|
| 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}"] |
0 commit comments