ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_ledger.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Fail-closed review ledger binding every suppression site to a decision.
4
5Three committed authorities drive the ledger:
6
7* ``.github/suppression-review-rationales.yml`` -- the closed rationale
8 vocabulary. Each category names its allowed ledger state, concrete
9 applicability criteria, required evidence kinds, and an optional
10 revalidation trigger. Categories are vocabulary, never approval.
11* ``.github/suppression-review-ledger.tsv`` -- exactly one row per live
12 occurrence: ``site_id binding_sha256 state rationale_id batch_id
13 evidence_ref``. States: ``unreviewed`` (bootstrap only), ``retain``,
14 ``fix-required``, ``resolved``, ``superseded``.
15* ``.github/suppression-review-batches.yml`` -- one record per review batch:
16 authority, date, identity schema, assigned row count, and the digest of
17 the batch's ordered ledger rows.
18
19The gate rejects live sites missing from the ledger, ledger rows whose site
20is gone, binding drift under ``retain``, duplicate identities, unknown
21categories/states/batches, unsorted rows, batch count or digest mismatches,
22blank rationale or evidence on reviewed rows, active ``unreviewed`` or
23``fix-required`` rows, and identity-schema drift. Only a ``retain`` row with
24an exact binding match marks a row approved; nothing here generates
25approval.
26"""
27
28from __future__ import annotations
29
30import hashlib
31import re
32from collections.abc import Hashable
33from dataclasses import dataclass, replace
34from pathlib import Path
35
36import yaml
37from suppression_identity import IDENTITY_SCHEMA_VERSION
38from suppression_model import Finding, Inventory
39
40RATIONALES_PATH = ".github/suppression-review-rationales.yml"
41LEDGER_PATH = ".github/suppression-review-ledger.tsv"
42BATCHES_PATH = ".github/suppression-review-batches.yml"
43LEDGER_COLUMNS = 6
44LEDGER_STATES = frozenset({"unreviewed", "retain", "fix-required", "resolved", "superseded"})
45ACTIVE_STATES = frozenset({"unreviewed", "retain", "fix-required"})
46_HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
47_SAMPLE_LIMIT = 5
48
49
50@dataclass(frozen=True)
51class LedgerRow:
52 """One reviewed occurrence decision."""
53
54 line: int
55 site_id: str
56 binding_sha256: str
57 state: str
58 rationale_id: str
59 batch_id: str
60 evidence_ref: str
61
62
63class DuplicateKeySafeLoader(yaml.SafeLoader):
64 """Safe YAML loader that rejects a repeated mapping key instead of dropping it.
65
66 PyYAML's mapping construction keeps the LAST value bound to a repeated
67 key and reports nothing, so a second ``id:`` inside a batch record, or a
68 second top-level ``batches:`` block, silently replaces the reviewed
69 authority before any application validation runs. Rejecting the document
70 at parse time is the only place that decision is still visible.
71 """
72
73 def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict:
74 """Construct one mapping, rejecting any explicitly repeated key.
75
76 Merge keys are skipped, so YAML's documented ``<<`` override semantics
77 keep working: only keys written twice in the same mapping are refused.
78 """
79 seen: set[object] = set()
80 for key_node, _value_node in node.value:
81 if key_node.tag == "tag:yaml.org,2002:merge":
82 continue
83 key = self.construct_object(key_node, deep=deep)
84 if not isinstance(key, Hashable):
85 continue
86 if key in seen:
87 context = "while constructing a mapping"
88 problem = f"duplicate key {key!r}"
89 raise yaml.constructor.ConstructorError(
90 context, node.start_mark, problem, key_node.start_mark
91 )
92 seen.add(key)
93 return super().construct_mapping(node, deep=deep)
94
95
96def _load_document(text: str) -> object:
97 """Parse one YAML document with duplicate keys rejected at parse time."""
98 loader = DuplicateKeySafeLoader(text)
99 try:
100 return loader.get_single_data()
101 finally:
102 loader.dispose()
103
104
105def _load_yaml(root: Path, rel: str) -> tuple[dict | None, Finding | None]:
106 """Load one committed YAML authority, failing closed on any defect."""
107 try:
108 data = _load_document((root / rel).read_text(encoding="utf-8"))
109 except OSError:
110 return None, Finding("missing-review-ledger", f"{rel} is absent", rel)
111 except yaml.YAMLError as exc:
112 return None, Finding("malformed-review-ledger", str(exc), rel)
113 if not isinstance(data, dict):
114 return None, Finding("malformed-review-ledger", "document is not a mapping", rel)
115 return data, None
116
117
118def load_rationales(root: Path) -> tuple[dict[str, dict], list[Finding]]:
119 """Parse the rationale vocabulary and validate every category contract."""
120 data, problem = _load_yaml(root, RATIONALES_PATH)
121 if problem is not None:
122 return {}, [problem]
123 findings: list[Finding] = []
124 categories = data.get("categories")
125 if not isinstance(categories, dict) or not categories:
126 return {}, [Finding("malformed-review-ledger", "no categories", RATIONALES_PATH)]
127 result: dict[str, dict] = {}
128 for name, spec in categories.items():
129 if not isinstance(spec, dict):
130 findings.append(
131 Finding(
132 "malformed-review-ledger", f"category {name} is not a mapping", RATIONALES_PATH
133 )
134 )
135 continue
136 state = spec.get("state")
137 applicability = spec.get("applicability")
138 evidence = spec.get("evidence")
139 if (
140 state not in LEDGER_STATES - {"unreviewed"}
141 or not isinstance(applicability, str)
142 or not applicability.strip()
143 or not isinstance(evidence, list)
144 or not evidence
145 ):
146 findings.append(
147 Finding(
148 "malformed-review-ledger",
149 f"category {name} needs a reviewed state, applicability, and evidence kinds",
150 RATIONALES_PATH,
151 )
152 )
153 continue
154 result[name] = spec
155 return result, findings
156
157
158def load_ledger(root: Path) -> tuple[list[LedgerRow], list[Finding]]:
159 """Parse the ledger rows, rejecting malformed shape or ordering."""
160 try:
161 text = (root / LEDGER_PATH).read_text(encoding="utf-8")
162 except OSError:
163 return [], [Finding("missing-review-ledger", f"{LEDGER_PATH} is absent", LEDGER_PATH)]
164 findings: list[Finding] = []
165 rows: list[LedgerRow] = []
166 lines = text.splitlines()
167 expected_header = "site_id\tbinding_sha256\tstate\trationale_id\tbatch_id\tevidence_ref"
168 if not lines or lines[0] != expected_header:
169 findings.append(Finding("malformed-review-ledger", "missing header row", LEDGER_PATH, 1))
170 return [], findings
171 for line_no, raw in enumerate(lines[1:], start=2):
172 parts = raw.split("\t")
173 if len(parts) != LEDGER_COLUMNS:
174 findings.append(
175 Finding("malformed-review-ledger", "wrong column count", LEDGER_PATH, line_no)
176 )
177 continue
178 row = LedgerRow(line_no, *parts)
179 if not _HEX64_RE.match(row.site_id) or not _HEX64_RE.match(row.binding_sha256):
180 findings.append(
181 Finding("malformed-review-ledger", "identity is not 64 hex", LEDGER_PATH, line_no)
182 )
183 continue
184 if row.state not in LEDGER_STATES:
185 findings.append(
186 Finding(
187 "malformed-review-ledger", f"unknown state {row.state}", LEDGER_PATH, line_no
188 )
189 )
190 continue
191 rows.append(row)
192 ordered = [row.site_id for row in rows]
193 if ordered != sorted(ordered):
194 findings.append(
195 Finding("malformed-review-ledger", "rows are not sorted by site_id", LEDGER_PATH)
196 )
197 seen: set[str] = set()
198 for row in rows:
199 if row.site_id in seen:
200 findings.append(
201 Finding(
202 "ledger-duplicate-site", f"duplicate site {row.site_id}", LEDGER_PATH, row.line
203 )
204 )
205 seen.add(row.site_id)
206 return rows, findings
207
208
209def load_batches(root: Path) -> tuple[dict[str, dict], list[Finding]]:
210 """Parse the batch records and validate their schema fields."""
211 data, problem = _load_yaml(root, BATCHES_PATH)
212 if problem is not None:
213 return {}, [problem]
214 findings: list[Finding] = []
215 batches = data.get("batches")
216 if not isinstance(batches, list):
217 return {}, [Finding("malformed-review-ledger", "no batches list", BATCHES_PATH)]
218 result: dict[str, dict] = {}
219 required = ("id", "authority", "date", "identity_schema", "assigned_rows", "rows_sha256")
220 for record in batches:
221 if not isinstance(record, dict) or any(key not in record for key in required):
222 findings.append(
223 Finding("malformed-review-ledger", "batch record missing fields", BATCHES_PATH)
224 )
225 continue
226 if record["identity_schema"] != IDENTITY_SCHEMA_VERSION:
227 findings.append(
228 Finding(
229 "ledger-schema-mismatch",
230 f"batch {record['id']} reviewed under {record['identity_schema']}; "
231 f"live schema is {IDENTITY_SCHEMA_VERSION}",
232 BATCHES_PATH,
233 )
234 )
235 identity = str(record["id"])
236 if identity in result:
237 findings.append(
238 Finding(
239 "ledger-duplicate-batch",
240 f"batch identity {identity} is recorded more than once",
241 BATCHES_PATH,
242 )
243 )
244 continue
245 result[identity] = record
246 return result, findings
247
248
249def _sample(values: list[str]) -> str:
250 """Render a bounded sample list for one aggregate finding."""
251 shown = ", ".join(values[:_SAMPLE_LIMIT])
252 more = len(values) - min(len(values), _SAMPLE_LIMIT)
253 return shown + (f" (+{more} more)" if more > 0 else "")
254
255
256def _batch_findings(rows: list[LedgerRow], batches: dict[str, dict]) -> list[Finding]:
257 """Verify per-batch row counts and ordered-row digests."""
258 findings: list[Finding] = []
259 by_batch: dict[str, list[LedgerRow]] = {}
260 for row in rows:
261 if row.state == "unreviewed":
262 continue
263 if row.batch_id not in batches:
264 findings.append(
265 Finding(
266 "ledger-unknown-reference",
267 f"unknown batch {row.batch_id or '(blank)'}",
268 LEDGER_PATH,
269 row.line,
270 )
271 )
272 continue
273 by_batch.setdefault(row.batch_id, []).append(row)
274 for batch_id, record in batches.items():
275 members = by_batch.get(batch_id, [])
276 if len(members) != record["assigned_rows"]:
277 findings.append(
278 Finding(
279 "ledger-batch-mismatch",
280 f"batch {batch_id} has {len(members)} rows; record says "
281 f"{record['assigned_rows']}",
282 BATCHES_PATH,
283 )
284 )
285 payload = "\n".join(
286 f"{row.site_id}\t{row.binding_sha256}\t{row.state}"
287 f"\t{row.rationale_id}\t{row.evidence_ref}"
288 for row in members
289 ).encode("utf-8")
290 digest = hashlib.sha256(payload).hexdigest()
291 if digest != record["rows_sha256"]:
292 findings.append(
293 Finding(
294 "ledger-batch-mismatch",
295 f"batch {batch_id} rows digest {digest} != recorded {record['rows_sha256']}",
296 BATCHES_PATH,
297 )
298 )
299 return findings
300
301
302def _portable_evidence_values(row: LedgerRow) -> tuple[dict[str, str], Finding | None]:
303 """Parse the three required portable-test evidence fields."""
304 required = ("test-name", "passing-counterpart", "registered-gate")
305 values: dict[str, str] = {}
306 duplicates: set[str] = set()
307 for part in row.evidence_ref.split():
308 key, separator, value = part.partition(":")
309 if key not in required:
310 continue
311 if key in values:
312 duplicates.add(key)
313 if separator and value:
314 values[key] = value
315 missing = [key for key in required if not values.get(key)]
316 if missing or duplicates:
317 detail = []
318 if missing:
319 detail.append(f"missing {', '.join(missing)}")
320 if duplicates:
321 detail.append(f"duplicate {', '.join(sorted(duplicates))}")
322 return {}, Finding(
323 "malformed-review-ledger",
324 "portable prerequisite evidence is incomplete: " + "; ".join(detail),
325 LEDGER_PATH,
326 row.line,
327 )
328 return values, None
329
330
331def _test_reference_is_live(root: Path, reference: str) -> bool:
332 """Return whether ``path::function`` identifies a current test function."""
333 target, separator, symbol = reference.partition("::")
334 try:
335 text = (root / target).read_text(encoding="utf-8")
336 except OSError:
337 return False
338 return bool(
339 separator
340 and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", symbol)
341 and re.search(rf"\bdef\s+{re.escape(symbol)}\s*\‍(", text)
342 )
343
344
345def _portable_gate_fails_closed(root: Path) -> bool:
346 """Return whether the work-harness gate forbids prerequisite skips."""
347 try:
348 gate_text = (root / "scripts/ci/gates/tests.sh").read_text(encoding="utf-8")
349 helper_text = (root / "scripts/dev/work/tests/fixtures/work_testlib.py").read_text(
350 encoding="utf-8"
351 )
352 except OSError:
353 return False
354 gate_match = re.search(r"gate_work_harness\‍(\‍) \‍((.*?)^\‍)", gate_text, re.MULTILINE | re.DOTALL)
355 gate_body = gate_match.group(1) if gate_match is not None else ""
356 gate_contract = (
357 "require_cmd bash" in gate_body
358 and "require_cmd sh" in gate_body
359 and "RA8_WORK_HARNESS_REGISTERED_GATE=1" in gate_body
360 and "scripts/dev/work/src/work.py --selftest" in gate_body
361 )
362 helper_contract = (
363 'os.environ.get(REGISTERED_GATE_ENV) == "1"' in helper_text
364 and "raise RuntimeError(message)" in helper_text
365 )
366 return gate_contract and helper_contract
367
368
369def _portable_test_evidence_findings(row: LedgerRow, root: Path) -> list[Finding]:
370 """Validate the fail-closed evidence contract for a portable test skip."""
371 values, malformed = _portable_evidence_values(row)
372 if malformed is not None:
373 return [malformed]
374
375 findings = [
376 Finding(
377 "ledger-unknown-reference",
378 f"portable prerequisite {key} does not name a live test: {values[key]}",
379 LEDGER_PATH,
380 row.line,
381 )
382 for key in ("test-name", "passing-counterpart")
383 if not _test_reference_is_live(root, values[key])
384 ]
385
386 gate = values["registered-gate"]
387 if gate != "work-harness":
388 findings.append(
389 Finding(
390 "ledger-unknown-reference",
391 f"portable prerequisite names unknown registered gate {gate}",
392 LEDGER_PATH,
393 row.line,
394 )
395 )
396 return findings
397 if not _portable_gate_fails_closed(root):
398 findings.append(
399 Finding(
400 "ledger-invalid-gate-contract",
401 "work-harness gate can skip a portable prerequisite instead of failing closed",
402 LEDGER_PATH,
403 row.line,
404 )
405 )
406 return findings
407
408
409def _reviewed_row_findings(
410 row: LedgerRow, rationales: dict[str, dict], root: Path
411) -> list[Finding]:
412 """Validate rationale, state compatibility, and evidence on one row."""
413 findings: list[Finding] = []
414 if row.state == "unreviewed":
415 if row.rationale_id or row.batch_id:
416 findings.append(
417 Finding(
418 "malformed-review-ledger",
419 "unreviewed rows carry no rationale or batch",
420 LEDGER_PATH,
421 row.line,
422 )
423 )
424 return findings
425 spec = rationales.get(row.rationale_id)
426 if spec is None:
427 findings.append(
428 Finding(
429 "ledger-unknown-reference",
430 f"unknown rationale {row.rationale_id or '(blank)'}",
431 LEDGER_PATH,
432 row.line,
433 )
434 )
435 return findings
436 completed = row.state in {"resolved", "superseded"} and spec["state"] == "fix-required"
437 if spec["state"] != row.state and not completed:
438 findings.append(
439 Finding(
440 "ledger-state-conflict",
441 f"rationale {row.rationale_id} allows state {spec['state']}, row says {row.state}",
442 LEDGER_PATH,
443 row.line,
444 )
445 )
446 if not row.evidence_ref.strip():
447 findings.append(
448 Finding(
449 "malformed-review-ledger", "reviewed row has blank evidence", LEDGER_PATH, row.line
450 )
451 )
452 if row.rationale_id == "portable-test-prerequisite-boundary":
453 findings.extend(_portable_test_evidence_findings(row, root))
454 return findings
455
456
457def _reconcile_row(inventory: Inventory, live: dict[str, int], row: LedgerRow) -> list[Finding]:
458 """Reconcile one ledger row against the live inventory, fail closed."""
459 findings: list[Finding] = []
460 index = live.get(row.site_id)
461 if row.state in ACTIVE_STATES:
462 if index is None:
463 findings.append(
464 Finding(
465 "ledger-stale-site",
466 f"{row.state} site no longer exists: {row.site_id}",
467 LEDGER_PATH,
468 row.line,
469 )
470 )
471 elif row.state == "retain":
472 item = inventory.suppressions[index]
473 if item.binding_sha256 != row.binding_sha256:
474 findings.append(
475 Finding(
476 "ledger-binding-mismatch",
477 f"retained site {row.site_id} content changed "
478 f"({item.path}: reviewed {row.binding_sha256[:12]}, "
479 f"live {item.binding_sha256[:12]})",
480 LEDGER_PATH,
481 row.line,
482 )
483 )
484 else:
485 inventory.suppressions[index] = replace(item, disposition="approved")
486 elif index is not None:
487 findings.append(
488 Finding(
489 "ledger-resolved-still-present",
490 f"{row.state} site is still present: {row.site_id}",
491 LEDGER_PATH,
492 row.line,
493 )
494 )
495 if row.state == "superseded":
496 replacements = [
497 part.removeprefix("replaced-by:")
498 for part in row.evidence_ref.split()
499 if part.startswith("replaced-by:")
500 ]
501 if not replacements or any(rep not in live for rep in replacements):
502 findings.append(
503 Finding(
504 "ledger-unknown-reference",
505 f"superseded site {row.site_id} names no live replacement",
506 LEDGER_PATH,
507 row.line,
508 )
509 )
510 return findings
511
512
513def apply_ledger(inventory: Inventory, root: Path) -> None:
514 """Reconcile the committed ledger against the live inventory, fail closed."""
515 rationales, findings = load_rationales(root)
516 rows, row_findings = load_ledger(root)
517 batches, batch_findings = load_batches(root)
518 findings.extend(row_findings)
519 findings.extend(batch_findings)
520 if any(item.code == "missing-review-ledger" for item in findings):
521 inventory.findings.extend(findings)
522 return
523 findings.extend(_batch_findings(rows, batches))
524 for row in rows:
525 findings.extend(_reviewed_row_findings(row, rationales, root))
526 live = {item.site_id: index for index, item in enumerate(inventory.suppressions)}
527 by_site = {row.site_id: row for row in rows}
528 missing = sorted(site for site in live if site not in by_site)
529 if missing:
530 findings.append(
531 Finding(
532 "ledger-missing-site",
533 f"{len(missing)} live suppression(s) absent from the ledger: {_sample(missing)}",
534 )
535 )
536 unreviewed = [row for row in rows if row.state == "unreviewed"]
537 if unreviewed:
538 findings.append(
539 Finding(
540 "ledger-unreviewed",
541 f"{len(unreviewed)} ledger row(s) await review: "
542 f"{_sample([row.site_id for row in unreviewed])}",
543 )
544 )
545 fix_required = [row for row in rows if row.state == "fix-required"]
546 if fix_required:
547 findings.append(
548 Finding(
549 "ledger-fix-required",
550 f"{len(fix_required)} site(s) carry an unremediated fix decision: "
551 f"{_sample([row.site_id for row in fix_required])}",
552 )
553 )
554 for row in rows:
555 findings.extend(_reconcile_row(inventory, live, row))
556 inventory.findings.extend(findings)
557
558
559def candidate_rows(inventory: Inventory) -> list[str]:
560 """Emit bootstrap candidates for live sites the ledger does not know.
561
562 Candidates are always ``unreviewed``: generation can never approve.
563 """
564 return [
565 f"{item.site_id}\t{item.binding_sha256}\tunreviewed\t\t\t"
566 for item in sorted(inventory.suppressions, key=lambda row: row.site_id)
567 ]
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157