Skip to content

Commit 20c9f40

Browse files
committed
feat(evidence): admission predicates + obligation inventory
1 parent c1f6d76 commit 20c9f40

3 files changed

Lines changed: 583 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""Evidence admission: 7 predicates gating lane ledgers for round comparison.
2+
3+
Row-blob canonicalization (PINNED -- E3 and P11Lab reuse verbatim):
4+
5+
- The row mapping is the EXACT ``LedgerRow`` mapping: every schema field of
6+
the row and nothing else. E1 models ``LedgerRow`` as a dataclass, so the
7+
mapping is ``{field.name: getattr(row, field.name) for field in
8+
fields(row)}`` with ``Outcome`` normalized to its string value (no
9+
``"record"`` discriminator, no enrichment, no defaults). ``Mapping`` rows
10+
are dumped as given.
11+
- Per-row blob: ``json.dumps(mapping, sort_keys=True, separators=(",", ":"),
12+
ensure_ascii=True).encode("utf-8")`` -- key order normalized by
13+
``sort_keys`` only; list values (e.g. ``flags``) keep their order.
14+
- Shard blob: the shard's row-blobs sorted byte-wise, joined by single
15+
``b"\\n"`` with NO trailing newline.
16+
- ``sha256``: hex digest of the shard blob. Empty shard = sha256 of ``b""``
17+
(``e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855``).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import hashlib
23+
import json
24+
from collections import Counter
25+
from collections.abc import Mapping, Sequence
26+
from dataclasses import dataclass, fields
27+
from typing import Any
28+
29+
from pkcs11_check.core.evidence.obligations import ObligationInventory
30+
from pkcs11_check.core.evidence.schema import LedgerHeader, LedgerRow, Outcome
31+
32+
#: sha256 of ``b""`` -- the pinned digest of an empty (zero-record) shard.
33+
EMPTY_SHARD_SHA256 = hashlib.sha256(b"").hexdigest()
34+
35+
36+
@dataclass(frozen=True)
37+
class ShardExpectation:
38+
"""Predeclared evidence for one shard: row count plus pinned blob digest."""
39+
40+
records: int
41+
sha256: str
42+
43+
44+
@dataclass(frozen=True)
45+
class PredeclaredRoster:
46+
"""The roster is the single authority for what evidence a lane must carry.
47+
48+
``required_identity`` names the header fields that must be non-empty for
49+
the ``provenance_present`` predicate.
50+
"""
51+
52+
lane: str
53+
shards: Mapping[str, ShardExpectation]
54+
required_identity: tuple[str, ...] = ("lane", "framework_sha")
55+
56+
57+
@dataclass(frozen=True)
58+
class AdmissionVerdict:
59+
"""Admission outcome: every failure names its predicate; warnings never fail."""
60+
61+
admitted: bool
62+
failures: list[str]
63+
warnings: list[str]
64+
65+
66+
def _as_mapping(row: LedgerRow | Mapping[str, Any]) -> dict[str, Any]:
67+
"""Return the EXACT row mapping: all schema fields, no enrichment.
68+
69+
``LedgerRow`` becomes its field mapping with ``Outcome`` normalized to
70+
its value, so dataclass and mapping rows hash byte-identically.
71+
"""
72+
if isinstance(row, Mapping):
73+
mapping = dict(row)
74+
else:
75+
mapping = {field.name: getattr(row, field.name) for field in fields(row)}
76+
outcome = mapping.get("outcome")
77+
if isinstance(outcome, Outcome):
78+
mapping["outcome"] = outcome.value
79+
return mapping
80+
81+
82+
def row_blob(row: LedgerRow | Mapping[str, Any]) -> bytes:
83+
"""Return the canonical per-row blob (pinned formula, see module docstring)."""
84+
return json.dumps(
85+
_as_mapping(row), sort_keys=True, separators=(",", ":"), ensure_ascii=True
86+
).encode("utf-8")
87+
88+
89+
def shard_digest(rows: Sequence[LedgerRow | Mapping[str, Any]]) -> str:
90+
"""Return the hex sha256 over a shard's sorted row-blobs.
91+
92+
Empty input hashes ``b""`` (the pinned empty-shard edge).
93+
"""
94+
return hashlib.sha256(b"\n".join(sorted(row_blob(row) for row in rows))).hexdigest()
95+
96+
97+
def _outcome_name(value: Any) -> str | None:
98+
"""Normalize a row outcome to its bucket name; None when missing/mistyped/empty."""
99+
if isinstance(value, Outcome):
100+
return value.value
101+
if isinstance(value, str) and value:
102+
return value
103+
return None
104+
105+
106+
def admit_lane(
107+
header: LedgerHeader,
108+
rows: Sequence[LedgerRow],
109+
roster: PredeclaredRoster,
110+
obligations: ObligationInventory,
111+
) -> AdmissionVerdict:
112+
"""Admit a lane ledger: ALL 7 predicates must pass for ``admitted=True``.
113+
114+
Pure function: no I/O. ``roster.lane`` is informational -- no predicate
115+
compares it to ``header.lane``; provenance checks required-identity
116+
non-emptiness only. Unselected catalog cases surface as
117+
``unselected_obligation`` warnings (sorted, warn-only).
118+
"""
119+
mappings = [_as_mapping(row) for row in rows]
120+
failures: list[str] = []
121+
failures.extend(_check_roster_complete(mappings, roster))
122+
failures.extend(_check_zero_malformed(header))
123+
failures.extend(_check_typed_fields(mappings))
124+
failures.extend(_check_session_framing(header, mappings))
125+
failures.extend(_check_occurrence_preserved(mappings))
126+
failures.extend(_check_observability_complete(header))
127+
failures.extend(_check_provenance_present(header, roster))
128+
warnings = [f"unselected_obligation: {case_id}" for case_id in sorted(obligations.unselected)]
129+
return AdmissionVerdict(admitted=not failures, failures=failures, warnings=warnings)
130+
131+
132+
def _check_roster_complete(mappings: list[dict[str, Any]], roster: PredeclaredRoster) -> list[str]:
133+
"""Every roster shard present with exact count + digest; no undeclared extras."""
134+
groups: dict[str, list[dict[str, Any]]] = {}
135+
for mapping in mappings:
136+
shard = mapping.get("shard_id")
137+
key = shard if isinstance(shard, str) else f"<non-str shard_id: {type(shard).__name__}>"
138+
groups.setdefault(key, []).append(mapping)
139+
problems: list[str] = []
140+
for shard_id, expected in roster.shards.items():
141+
if shard_id not in groups:
142+
if expected.records == 0:
143+
if EMPTY_SHARD_SHA256 != expected.sha256:
144+
problems.append(
145+
f"roster_complete: empty shard {shard_id!r} digest mismatch: "
146+
f"rows hash {EMPTY_SHARD_SHA256}, roster expects {expected.sha256}"
147+
)
148+
else:
149+
problems.append(
150+
f"roster_complete: roster shard {shard_id!r} missing from rows "
151+
"-- no partial credit"
152+
)
153+
continue
154+
present = groups[shard_id]
155+
if len(present) != expected.records:
156+
problems.append(
157+
f"roster_complete: shard {shard_id!r} has {len(present)} rows, "
158+
f"roster expects {expected.records}"
159+
)
160+
continue
161+
digest = shard_digest(present)
162+
if digest != expected.sha256:
163+
problems.append(
164+
f"roster_complete: shard {shard_id!r} digest mismatch: "
165+
f"rows hash {digest}, roster expects {expected.sha256}"
166+
)
167+
for shard_id in sorted(groups):
168+
if shard_id not in roster.shards:
169+
problems.append(
170+
f"roster_complete: undeclared extra shard {shard_id!r} "
171+
f"({len(groups[shard_id])} rows)"
172+
)
173+
return problems
174+
175+
176+
def _check_zero_malformed(header: LedgerHeader) -> list[str]:
177+
"""Any malformed ledger line fails admission."""
178+
if header.malformed_lines > 0:
179+
return [f"zero_malformed: header reports {header.malformed_lines} malformed line(s)"]
180+
return []
181+
182+
183+
def _check_typed_fields(mappings: list[dict[str, Any]]) -> list[str]:
184+
"""Every row carries present, correctly-typed outcome/node_id/phase/attempt."""
185+
bad: list[int] = []
186+
for index, mapping in enumerate(mappings):
187+
node_id = mapping.get("node_id")
188+
phase = mapping.get("phase")
189+
attempt = mapping.get("attempt")
190+
if (
191+
_outcome_name(mapping.get("outcome")) is None
192+
or not isinstance(node_id, str)
193+
or not node_id
194+
or not isinstance(phase, str)
195+
or not phase
196+
or type(attempt) is not int
197+
or attempt < 1
198+
):
199+
bad.append(index)
200+
if not bad:
201+
return []
202+
shown = ", ".join(f"row {index}" for index in bad[:10])
203+
extra = f" (+{len(bad) - 10} more)" if len(bad) > 10 else ""
204+
return [
205+
f"typed_fields: {len(bad)} row(s) with missing/mistyped "
206+
f"outcome/node_id/phase/attempt: {shown}{extra}"
207+
]
208+
209+
210+
def _check_session_framing(header: LedgerHeader, mappings: list[dict[str, Any]]) -> list[str]:
211+
"""Header counters agree with the row multiset, bucket-by-bucket plus total."""
212+
observed: Counter[str] = Counter()
213+
for mapping in mappings:
214+
name = _outcome_name(mapping.get("outcome"))
215+
observed[name if name is not None else "<untyped>"] += 1
216+
problems: list[str] = []
217+
int_values = 0
218+
for bucket, expected in header.counters.items():
219+
if type(expected) is not int:
220+
problems.append(
221+
f"session_framing: bucket {bucket!r} counter is not an int: {expected!r}"
222+
)
223+
continue
224+
int_values += 1
225+
got = observed.get(bucket, 0)
226+
if got != expected:
227+
problems.append(
228+
f"session_framing: bucket {bucket!r} header says {expected}, rows have {got}"
229+
)
230+
if int_values == len(header.counters) and sum(header.counters.values()) != len(mappings):
231+
problems.append(
232+
f"session_framing: header counters sum to {sum(header.counters.values())}, "
233+
f"rows number {len(mappings)}"
234+
)
235+
return problems
236+
237+
238+
def _check_occurrence_preserved(mappings: list[dict[str, Any]]) -> list[str]:
239+
"""Every (node_id, phase) group holds exactly attempts {1..N} of N rows."""
240+
groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
241+
for mapping in mappings:
242+
node_id = mapping.get("node_id")
243+
phase = mapping.get("phase")
244+
node_key = (
245+
node_id if isinstance(node_id, str) else f"<non-str node_id: {type(node_id).__name__}>"
246+
)
247+
phase_key = phase if isinstance(phase, str) else f"<non-str phase: {type(phase).__name__}>"
248+
groups.setdefault((node_key, phase_key), []).append(mapping)
249+
problems: list[str] = []
250+
for (node_id, phase), members in sorted(groups.items()):
251+
totals: list[Any] = [member.get("attempts_total") for member in members]
252+
if any(type(total) is not int for total in totals):
253+
problems.append(
254+
f"occurrence_preserved: group ({node_id!r}, {phase!r}) has non-int attempts_total"
255+
)
256+
continue
257+
first: int = totals[0]
258+
if any(total != first for total in totals):
259+
problems.append(
260+
f"occurrence_preserved: group ({node_id!r}, {phase!r}) disagrees on attempts_total"
261+
)
262+
continue
263+
attempts: list[Any] = [member.get("attempt") for member in members]
264+
if any(type(attempt) is not int for attempt in attempts) or sorted(attempts) != list(
265+
range(1, first + 1)
266+
):
267+
problems.append(
268+
f"occurrence_preserved: group ({node_id!r}, {phase!r}) has {len(members)} "
269+
f"row(s), attempts_total={first}, observed attempts={attempts!r}"
270+
)
271+
return problems
272+
273+
274+
def _check_observability_complete(header: LedgerHeader) -> list[str]:
275+
"""No incomplete observations survive to admission (missing key fails closed)."""
276+
incomplete = header.counters.get("incomplete")
277+
if incomplete is None or type(incomplete) is not int or incomplete > 0:
278+
detail = "missing" if incomplete is None else repr(incomplete)
279+
return [f"observability_complete: header 'incomplete' counter is {detail} (must be 0)"]
280+
return []
281+
282+
283+
def _check_provenance_present(header: LedgerHeader, roster: PredeclaredRoster) -> list[str]:
284+
"""Every roster-required identity field is non-empty in the header."""
285+
missing = []
286+
for name in roster.required_identity:
287+
value = getattr(header, name, None) if isinstance(name, str) else None
288+
if not isinstance(value, str) or not value:
289+
missing.append(name)
290+
if not missing:
291+
return []
292+
return [f"provenance_present: header identity field(s) empty or missing: {missing!r}"]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Obligation inventory: profile-selected case IDs plus the unselected remainder."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
10+
@dataclass(frozen=True)
11+
class ObligationInventory:
12+
"""Profile-selected obligations plus the catalog remainder.
13+
14+
``members``: the profile-selected case IDs — the admission obligations.
15+
``unselected``: catalog IDs the profile did NOT select. Admission
16+
surfaces these as ``unselected_obligation`` warnings (warn, never fail),
17+
so dropped coverage stays visible instead of silently absent.
18+
"""
19+
20+
members: frozenset[str]
21+
unselected: frozenset[str] = frozenset()
22+
23+
def __contains__(self, case_id: object) -> bool:
24+
"""Return True when ``case_id`` is a selected obligation member."""
25+
return case_id in self.members
26+
27+
28+
def compile_obligations(
29+
catalog: Mapping[str, Any], profile: Mapping[str, Any]
30+
) -> ObligationInventory:
31+
"""Build the inventory from a case catalog and a selecting profile.
32+
33+
``catalog`` maps every known case ID to its entry; ``profile`` selects
34+
via its exact ``"cases"`` key. Returns members = profile-selected IDs
35+
and unselected = catalog IDs minus the selection.
36+
37+
Unknown selected IDs (not in the catalog) raise ``ValueError``, never
38+
pass silently; so do a missing/non-list ``"cases"`` key and non-mapping
39+
inputs.
40+
"""
41+
if not isinstance(catalog, Mapping):
42+
raise ValueError(f"compile_obligations: catalog is {type(catalog).__name__}, not a mapping")
43+
if not isinstance(profile, Mapping):
44+
raise ValueError(f"compile_obligations: profile is {type(profile).__name__}, not a mapping")
45+
if "cases" not in profile:
46+
raise ValueError('compile_obligations: profile lacks the exact "cases" key')
47+
selected = profile["cases"]
48+
if not isinstance(selected, (list, tuple)) or not all(
49+
isinstance(case_id, str) for case_id in selected
50+
):
51+
raise ValueError('compile_obligations: profile "cases" is not a list of strings')
52+
unknown = sorted({case_id for case_id in selected if case_id not in catalog})
53+
if unknown:
54+
raise ValueError(f"compile_obligations: profile selects unknown case IDs: {unknown}")
55+
members = frozenset(selected)
56+
unselected = frozenset(key for key in catalog if isinstance(key, str) and key not in members)
57+
return ObligationInventory(members=members, unselected=unselected)

0 commit comments

Comments
 (0)