ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_rebind.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Sanctioned rebind of a reviewed suppression binding after a harmless move.
5
6A durable ``site_id`` survives line movement, but the reviewed
7``binding_sha256`` commits to line-derived evidence such as
8``decision-line:410``. Deleting an unrelated include above a reviewed marker
9therefore invalidates its binding without changing the reviewed target.
10Hand-editing the ledger to repair the hash is forbidden; this module is the
11sanctioned mechanism instead.
12
13A rebind succeeds only when every identity-critical fact still matches: the
14site resolves exactly once in the live inventory and exactly once in the
15ledger, the ledger row is ``retain``, the reviewed prose still names the
16path, directive, and exact reason sentence, and the live binding recomputed
17with the old line numbers equals the reviewed binding (proving the sole
18difference is line movement). The tool then writes the new binding, refreshes
19line references in the evidence prose, and recomputes the owning batch
20digest. It never creates, approves, re-justifies, or re-dispositions a row,
21never touches unrelated rows, and refuses when the owning batch does not
22currently validate.
23"""
24
25from __future__ import annotations
26
27import argparse
28import hashlib
29import re
30import sys
31from dataclasses import dataclass, replace
32from pathlib import Path
33from typing import cast
34
35import suppression_rebind_selftest as rebind_selftest
36from suppression_c_control_scan import PAIRING_WINDOW
37from suppression_identity import binding_payload
38from suppression_ledger import LEDGER_COLUMNS, LedgerRow, load_ledger, load_rationales
39from suppression_model import Suppression
40from suppression_scan import scan_repository
41
42
43@dataclass(frozen=True)
44class RebindPlan:
45 """Reviewed move repair awaiting reviewer confirmation."""
46
47 site_id: str
48 path: str
49 batch_id: str
50 old_refs: tuple[str, ...]
51 new_refs: tuple[str, ...]
52 marker_old: str
53 marker_new: str
54 old_binding: str
55 new_binding: str
56 target: str
57 ledger_line: int
58
59
60def _decision_numbers(evidence: tuple[str, ...]) -> list[str]:
61 """Return decision-line numbers in evidence order."""
62 numbers: list[str] = []
63 for item in evidence:
64 match = re.fullmatch(r"decision-line:(\d+)", item)
65 if match is not None:
66 numbers.append(match.group(1))
67 return numbers
68
69
70def _prose_refs(evidence_ref: str, path: str) -> list[str]:
71 """Return `<path>:<digits>` line references in prose order."""
72 return re.findall(re.escape(path) + r":(\d+)", evidence_ref)
73
74
75def _mapped_scope(scope: str, mapping: dict[str, str]) -> str:
76 """Rewrite a decision-line scope through a new/old number mapping."""
77 match = re.fullmatch(r"decision-line:(\d+)", scope)
78 if match is not None and match.group(1) in mapping:
79 return f"decision-line:{mapping[match.group(1)]}"
80 return scope
81
82
83def _mapped_evidence(evidence: tuple[str, ...], mapping: dict[str, str]) -> tuple[str, ...]:
84 """Rewrite decision-line evidence through a new/old number mapping."""
85 rebuilt: list[str] = []
86 for item in evidence:
87 match = re.fullmatch(r"decision-line:(\d+)", item)
88 if match is not None and match.group(1) in mapping:
89 rebuilt.append(f"decision-line:{mapping[match.group(1)]}")
90 else:
91 rebuilt.append(item)
92 return tuple(rebuilt)
93
94
95def _recomputed_binding(record: Suppression, mapping: dict[str, str]) -> str:
96 """Recompute the binding with old line numbers restored."""
97 trial = replace(
98 record,
99 scope=_mapped_scope(record.scope, mapping),
100 evidence=_mapped_evidence(record.evidence, mapping),
101 )
102 return hashlib.sha256(binding_payload(trial, trial.anchor)).hexdigest()
103
104
105def _single_live(records: list[Suppression], site_id: str) -> tuple[Suppression | None, str]:
106 """Resolve exactly one live record or explain the refusal."""
107 live = [record for record in records if record.site_id == site_id]
108 if not live:
109 return None, f"unknown site_id {site_id}"
110 if len(live) > 1:
111 return None, f"ambiguous site_id {site_id}: {len(live)} live rows"
112 return live[0], ""
113
114
115def _single_row(rows: list[LedgerRow], site_id: str) -> tuple[LedgerRow | None, str]:
116 """Resolve exactly one ledger row or explain the refusal."""
117 hit = [row for row in rows if row.site_id == site_id]
118 if not hit:
119 return None, f"site_id {site_id} has no ledger row"
120 if len(hit) > 1:
121 return None, f"ambiguous site_id {site_id}: {len(hit)} ledger rows"
122 if hit[0].state != "retain":
123 return None, f"ledger state is {hit[0].state}; only retain rows rebind"
124 return hit[0], ""
125
126
127def _check_prose(record: Suppression, row: LedgerRow) -> str:
128 """Require reviewed prose to name the live semantic target."""
129 if record.path not in row.evidence_ref:
130 return "reviewed prose does not name the live path"
131 if record.directive not in row.evidence_ref and not (
132 record.family in row.evidence_ref and record.rule in row.evidence_ref
133 ):
134 return "reviewed prose does not name the live directive"
135 if record.reason not in row.evidence_ref:
136 return "reviewed prose does not contain the live reason"
137 return ""
138
139
140def _check_movement(
141 record: Suppression, row: LedgerRow
142) -> tuple[tuple[str, str, str, str] | None, str]:
143 """Prove the binding differs only by rigid line movement."""
144 new_decisions = _decision_numbers(record.evidence)
145 markers = _prose_refs(row.evidence_ref, record.path)
146 if len(new_decisions) != 1 or len(set(markers)) != 1:
147 return None, "line-reference counts do not align"
148 new_decision = new_decisions[0]
149 marker_old = markers[0]
150 floor = max(1, int(marker_old) - PAIRING_WINDOW)
151 hits: list[str] = []
152 for candidate in range(floor, int(marker_old) + PAIRING_WINDOW + 1):
153 mapping = {new_decision: str(candidate)}
154 if _recomputed_binding(record, mapping) == row.binding_sha256:
155 hits.append(str(candidate))
156 if not hits:
157 return None, "binding differs beyond line movement"
158 if len(hits) > 1:
159 return None, "old decision line ambiguous"
160 if record.line - int(marker_old) != int(new_decision) - int(hits[0]):
161 return None, "marker and decision shifts disagree"
162 return (marker_old, str(record.line), hits[0], new_decision), ""
163
164
165def _refresh_prose(evidence_ref: str, path: str, plan: RebindPlan) -> str:
166 """Rewrite the prose marker reference to the live marker line."""
167 token = re.compile(re.escape(path) + r":(\d+)")
168 old, new = plan.marker_old, plan.marker_new
169
170 def swap(match: re.Match[str]) -> str:
171 """Swap one prose reference for its planned successor."""
172 return f"{path}:{new}" if match.group(1) == old else match.group(0)
173
174 return token.sub(swap, evidence_ref)
175
176
177def plan_rebind(
178 records: list[Suppression], rows: list[LedgerRow], site_id: str
179) -> tuple[RebindPlan | None, str]:
180 """Plan a fail-closed rebind, or return the refusal reason."""
181 record, problem = _single_live(records, site_id)
182 if record is None:
183 return None, problem
184 row, problem = _single_row(rows, site_id)
185 if row is None:
186 return None, problem
187 if record.binding_sha256 == row.binding_sha256:
188 return None, "live binding already matches; nothing to rebind"
189 problem = _check_prose(record, row)
190 if problem:
191 return None, problem
192 found, problem = _check_movement(record, row)
193 if found is None:
194 return None, problem
195 marker_old, marker_new, old_decision, new_decision = found
196 return (
197 RebindPlan(
198 site_id=site_id,
199 path=record.path,
200 batch_id=row.batch_id,
201 old_refs=(old_decision,),
202 new_refs=(new_decision,),
203 marker_old=marker_old,
204 marker_new=marker_new,
205 old_binding=row.binding_sha256,
206 new_binding=record.binding_sha256,
207 target=f"{record.directive}: {record.reason}",
208 ledger_line=row.line,
209 ),
210 "",
211 )
212
213
214def _render_row(row: LedgerRow) -> str:
215 """Serialize one ledger row in committed column order."""
216 tab = "\t"
217 return (
218 f"{row.site_id}{tab}{row.binding_sha256}{tab}{row.state}"
219 f"{tab}{row.rationale_id}{tab}{row.batch_id}{tab}{row.evidence_ref}"
220 )
221
222
223def batch_digest(members: list[LedgerRow]) -> str:
224 """Recompute the ordered-row digest the gate validates."""
225 payload = "\n".join(
226 f"{item.site_id}\t{item.binding_sha256}\t{item.state}"
227 f"\t{item.rationale_id}\t{item.evidence_ref}"
228 for item in members
229 )
230 return hashlib.sha256(payload.encode("utf-8")).hexdigest()
231
232
233def _parse_ledger(
234 ledger_text: str, site_id: str
235) -> tuple[list[LedgerRow], list[str], LedgerRow | None, str]:
236 """Parse ledger text and resolve one row, or explain the refusal."""
237 lines = ledger_text.split("\n")
238 header = "site_id\tbinding_sha256\tstate\trationale_id\tbatch_id\tevidence_ref"
239 if not lines or lines[0] != header:
240 return [], [], None, "ledger header mismatch"
241 rows: list[LedgerRow] = []
242 for number, raw in enumerate(lines[1:], start=2):
243 if not raw.strip():
244 continue
245 parts = raw.split("\t")
246 if len(parts) != LEDGER_COLUMNS:
247 return [], [], None, f"ledger line {number} malformed"
248 rows.append(LedgerRow(number, *parts))
249 target = [row for row in rows if row.site_id == site_id]
250 if len(target) != 1:
251 return [], [], None, "ledger site ambiguous or missing"
252 return rows, lines, target[0], ""
253
254
255def _update_batch(
256 batches_text: str, batch_id: str, rows: list[LedgerRow], members: list[LedgerRow]
257) -> tuple[str, str]:
258 """Rewrite one batch digest after validating it, or explain the refusal."""
259 block = re.search(
260 r"(?m)^ - id: " + re.escape(batch_id) + r"\n(?:^ \S.*\n|\n|(?:^ .*\n)+)+",
261 batches_text,
262 )
263 if block is None:
264 return "", f"batch {batch_id} not found"
265 if len(re.findall(r"(?m)^ - id: " + re.escape(batch_id) + r"$", batches_text)) != 1:
266 return "", f"batch {batch_id} ambiguous"
267 current = batch_digest([item for item in rows if item.batch_id == batch_id])
268 recorded = re.search(r"(?m)^ rows_sha256: ([0-9a-f]{64})$", block.group(0))
269 if recorded is None or recorded.group(1) != current:
270 return "", f"batch {batch_id} does not currently validate"
271 fresh = batch_digest([item for item in members if item.batch_id == batch_id])
272 return (
273 batches_text[: block.start()]
274 + block.group(0).replace(recorded.group(1), fresh, 1)
275 + batches_text[block.end() :],
276 "",
277 )
278
279
280def apply_plan(ledger_text: str, batches_text: str, plan: RebindPlan) -> tuple[str, str] | str:
281 """Apply a plan to ledger/batches text, or return the refusal reason."""
282 rows, lines, row, problem = _parse_ledger(ledger_text, plan.site_id)
283 if problem:
284 return problem
285 if row is None:
286 return "ledger site missing after parse"
287 if row.binding_sha256 != plan.old_binding:
288 return "ledger binding changed since planning"
289 new_ref = _refresh_prose(row.evidence_ref, plan.path, plan)
290 members = [
291 LedgerRow(
292 row.line,
293 row.site_id,
294 plan.new_binding,
295 row.state,
296 row.rationale_id,
297 row.batch_id,
298 new_ref,
299 )
300 if item.site_id == plan.site_id
301 else item
302 for item in rows
303 ]
304 new_batches, problem = _update_batch(batches_text, plan.batch_id, rows, members)
305 if problem:
306 return problem
307 return _join_ledger(lines, members), new_batches
308
309
310def apply_retire(ledger_text: str, batches_text: str, plan: RetirePlan) -> tuple[str, str] | str:
311 """Apply a succession to ledger/batches text, or return the refusal reason."""
312 rows, lines, row, problem = _parse_ledger(ledger_text, plan.site_id)
313 if problem:
314 return problem
315 if row is None or row.state != "retain":
316 return "ledger row changed since planning"
317 ref = f"{row.evidence_ref} replaced-by:{plan.successor} succession: {plan.because}"
318 if f"replaced-by:{plan.successor}" not in ref.split():
319 return "retire wrote a malformed replaced-by link"
320 members = [
321 LedgerRow(
322 row.line,
323 row.site_id,
324 row.binding_sha256,
325 "superseded",
326 "superseded-identity-rebinding",
327 row.batch_id,
328 ref,
329 )
330 if item.site_id == plan.site_id
331 else item
332 for item in rows
333 ]
334 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
335 if problem:
336 return problem
337 return _join_ledger(lines, members), new_batches
338
339
340def apply_relink(ledger_text: str, batches_text: str, plan: RelinkPlan) -> tuple[str, str] | str:
341 """Apply a link refresh to ledger/batches text, or return the refusal reason."""
342 rows, lines, row, problem = _parse_ledger(ledger_text, plan.site_id)
343 if problem:
344 return problem
345 if row is None or row.state != "superseded":
346 return "ledger row changed since planning"
347 token = _recorded_token(row.evidence_ref, plan.old_target)
348 if not token or row.evidence_ref.count(token) != 1:
349 return "old target is not uniquely recorded in this row"
350 ref = row.evidence_ref.replace(token, f"replaced-by:{plan.new_target}", 1)
351 if f"replaced-by:{plan.new_target}" not in ref.split():
352 return "relink wrote a malformed replaced-by link"
353 members = [
354 LedgerRow(
355 row.line,
356 row.site_id,
357 row.binding_sha256,
358 row.state,
359 row.rationale_id,
360 row.batch_id,
361 ref,
362 )
363 if item.site_id == plan.site_id
364 else item
365 for item in rows
366 ]
367 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
368 if problem:
369 return problem
370 return _join_ledger(lines, members), new_batches
371
372
373def apply_restore(ledger_text: str, batches_text: str, plan: RestorePlan) -> tuple[str, str] | str:
374 """Apply a reinstatement to ledger/batches text, or return the refusal reason."""
375 rows, lines, row, problem = _parse_ledger(ledger_text, plan.site_id)
376 if problem:
377 return problem
378 if row is None or row.state != "resolved":
379 return "ledger row changed since planning"
380 ref = f"{row.evidence_ref} reinstated: {plan.because}"
381 members = [
382 LedgerRow(
383 row.line,
384 row.site_id,
385 row.binding_sha256,
386 "retain",
387 plan.rationale,
388 row.batch_id,
389 ref,
390 )
391 if item.site_id == plan.site_id
392 else item
393 for item in rows
394 ]
395 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
396 if problem:
397 return problem
398 return _join_ledger(lines, members), new_batches
399
400
401def _join_ledger(lines: list[str], members: list[LedgerRow]) -> str:
402 """Re-emit ledger text with member rows substituted in place."""
403 by_line = {item.line: _render_row(item) for item in members}
404 out = [lines[0]]
405 out.extend(by_line.get(number, lines[number - 1]) for number in range(2, len(lines) + 1))
406 text = "\n".join(out)
407 return text if text.endswith("\n") else text + "\n"
408
409
410def _repo_root() -> Path:
411 """Return the repository root housing this checker."""
412 return Path(__file__).resolve().parents[2]
413
414
415def _load_records(root: Path) -> tuple[list[Suppression], str]:
416 """Load live inventory records through the repository scanner."""
417 inventory, _ = scan_repository(root)
418 return inventory.suppressions, ""
419
420
421def _describe(plan: object) -> str:
422 """Render a dry-run presentation of one plan."""
423 if isinstance(plan, RebindPlan):
424 return "\n".join(
425 [
426 f"site_id: {plan.site_id}",
427 f"path: {plan.path}",
428 f"batch: {plan.batch_id}",
429 f"old marker: {plan.marker_old}",
430 f"new marker: {plan.marker_new}",
431 f"old line: {', '.join(plan.old_refs)}",
432 f"new line: {', '.join(plan.new_refs)}",
433 f"old binding: {plan.old_binding}",
434 f"new binding: {plan.new_binding}",
435 f"target: {plan.target}",
436 ]
437 )
438 if isinstance(plan, RetirePlan):
439 return "\n".join(
440 [
441 f"site_id: {plan.site_id}",
442 f"successor: {plan.successor}",
443 f"target: {plan.path} {plan.directive}",
444 f"batch: {plan.batch_id}",
445 f"because: {plan.because}",
446 ]
447 )
448 if isinstance(plan, RelinkPlan):
449 return "\n".join(
450 [
451 f"site_id: {plan.site_id}",
452 f"old target: {plan.old_target}",
453 f"new target: {plan.new_target}",
454 f"target: {plan.path} {plan.directive}",
455 f"batch: {plan.batch_id}",
456 ]
457 )
458 if isinstance(plan, RestorePlan):
459 return "\n".join(
460 [
461 f"site_id: {plan.site_id}",
462 f"rationale: {plan.rationale}",
463 f"batch: {plan.batch_id}",
464 f"because: {plan.because}",
465 ]
466 )
467 return f"unknown plan kind {type(plan)}"
468
469
470def selftest() -> int:
471 """Prove fail-closed ledger operations in both directions."""
472 failures = rebind_selftest.run_selftests()
473 if failures:
474 for failure in failures:
475 print(f"suppression_rebind selftest: FAILED -- {failure}", file=sys.stderr)
476 return 1
477 print("suppression_rebind selftest: all cases pass (both directions).")
478 return 0
479
480
481@dataclass(frozen=True)
482class RetirePlan:
483 """Reviewed succession for a stale retain row."""
484
485 site_id: str
486 successor: str
487 path: str
488 directive: str
489 batch_id: str
490 because: str
491 ledger_line: int
492
493
494@dataclass(frozen=True)
495class RelinkPlan:
496 """Reviewed successor refresh for a dangling replaced-by link."""
497
498 site_id: str
499 old_target: str
500 new_target: str
501 path: str
502 directive: str
503 batch_id: str
504 because: str
505 ledger_line: int
506
507
508@dataclass(frozen=True)
509class RestorePlan:
510 """Reviewed reinstatement of a resolved row whose site is live."""
511
512 site_id: str
513 rationale: str
514 batch_id: str
515 because: str
516 ledger_line: int
517
518
519def _evidence_coords(evidence_ref: str) -> list[tuple[str, str]]:
520 """Extract (path, directive) pairs named in reviewed prose."""
521 return re.findall(r"(scripts/[A-Za-z_/]+\.py)(?::\d+)? directive:([A-Za-z_0-9]+)", evidence_ref)
522
523
524def _replaced_by(evidence_ref: str) -> list[str]:
525 """Extract replaced-by link targets in prose order."""
526 return [
527 part.removeprefix("replaced-by:")
528 for part in evidence_ref.split()
529 if part.startswith("replaced-by:")
530 ]
531
532
533def _clean_text(value: str) -> str:
534 """Reject justification text that would break TSV structure."""
535 if not value.strip() or "\t" in value or "\n" in value:
536 return "justification must be non-blank single-line text"
537 return ""
538
539
540def _check_coords(coords: list[tuple[str, str]], path: str, directive: str, what: str) -> str:
541 """Require an explicit target claim to match reviewed prose coordinates."""
542 if coords and (path, directive) not in coords:
543 return f"{what} does not match reviewed prose coordinates"
544 return ""
545
546
547@dataclass(frozen=True)
548class RetireRequest:
549 """Human-reviewed succession mapping for one stale retain row."""
550
551 site_id: str
552 successor: str
553 path: str
554 directive: str
555 because: str
556
557
558@dataclass(frozen=True)
559class RelinkRequest:
560 """Human-reviewed link refresh for one dangling replaced-by target."""
561
562 site_id: str
563 old_target: str
564 new_target: str
565 path: str
566 directive: str
567 because: str
568
569
570@dataclass(frozen=True)
571class RestoreRequest:
572 """Human-reviewed reinstatement for one resolved row."""
573
574 site_id: str
575 rationale: str
576 because: str
577
578
579def _unique_row(rows: list[LedgerRow], site_id: str, want: str) -> tuple[LedgerRow | None, str]:
580 """Resolve exactly one ledger row in the wanted state."""
581 hit = [row for row in rows if row.site_id == site_id]
582 if len(hit) != 1:
583 return None, "ledger site ambiguous or missing"
584 if hit[0].state != want:
585 return None, f"ledger state is {hit[0].state}; want {want}"
586 return hit[0], ""
587
588
589def _unique_live(records: list[Suppression], site_id: str) -> tuple[Suppression | None, str]:
590 """Resolve exactly one live record."""
591 live = [record for record in records if record.site_id == site_id]
592 if len(live) != 1:
593 return None, "site unknown or ambiguous in live inventory"
594 return live[0], ""
595
596
597def _is_live(records: list[Suppression], site_id: str) -> bool:
598 """Return whether one site resolves in the live inventory."""
599 return any(record.site_id == site_id for record in records)
600
601
602def _check_successor(records: list[Suppression], successor: str, path: str, directive: str) -> str:
603 """Require a unique live successor matching the asserted target."""
604 live, problem = _unique_live(records, successor)
605 if live is None:
606 return problem
607 return _check_target(live, path, directive)
608
609
610def _check_rationale(rationales: dict[str, dict[str, object]], name: str) -> str:
611 """Require a known retain-graded rationale id."""
612 spec = rationales.get(name)
613 if not isinstance(spec, dict) or spec.get("state") != "retain":
614 return f"rationale {name} unknown or not retain-graded"
615 return ""
616
617
618def _check_target(live: Suppression, path: str, directive: str) -> str:
619 """Require a live successor to match the asserted target."""
620 if (live.path, live.directive) != (path, directive):
621 return "successor does not match the asserted target"
622 return ""
623
624
625def plan_retire(
626 records: list[Suppression], rows: list[LedgerRow], req: RetireRequest
627) -> tuple[RetirePlan | None, str]:
628 """Plan a fail-closed succession for a stale retain row."""
629 row, problem = _unique_row(rows, req.site_id, "retain")
630 if row is None:
631 return None, problem
632 if _is_live(records, req.site_id):
633 return None, "site is still live; use rebind or restore"
634 problem = _check_successor(records, req.successor, req.path, req.directive)
635 if problem:
636 return None, problem
637 problem = _check_coords(
638 _evidence_coords(row.evidence_ref), req.path, req.directive, "asserted target"
639 )
640 if problem:
641 return None, problem
642 problem = _clean_text(req.because)
643 if problem:
644 return None, problem
645 plan = RetirePlan(
646 req.site_id, req.successor, req.path, req.directive, row.batch_id, req.because, row.line
647 )
648 return plan, ""
649
650
651def _check_link_present(row: LedgerRow, old_target: str, new_target: str) -> str:
652 """Require a recorded, non-trivial link refresh."""
653 if old_target not in _replaced_by(row.evidence_ref):
654 return "old target is not a recorded link of this row"
655 if new_target == old_target:
656 return "new target repeats the dead link"
657 return ""
658
659
660def _check_old_dead(
661 records: list[Suppression], rows: list[LedgerRow], old_target: str, path: str, directive: str
662) -> str:
663 """Require a dead link target with a ledger row naming the same target."""
664 if _is_live(records, old_target):
665 return "old target is still live; link is not dangling"
666 old_row = next((item for item in rows if item.site_id == old_target), None)
667 if old_row is None:
668 return "old target has no ledger row for continuity check"
669 return _check_coords(_evidence_coords(old_row.evidence_ref), path, directive, "old target")
670
671
672def _recorded_token(evidence_ref: str, old_target: str) -> str:
673 """Find the prose token recording a link, tolerating legacy separators."""
674 want = f"replaced-by:{old_target}"
675 for token in evidence_ref.split():
676 if token == want:
677 return token
678 if token.startswith(want) and set(token[len(want) :]) <= {";"}:
679 return token
680 return ""
681
682
683def _check_link_prose(row: LedgerRow, req: RelinkRequest) -> str:
684 """Require a recorded, non-trivial link refresh."""
685 if not _recorded_token(row.evidence_ref, req.old_target):
686 return "old target is not a recorded link of this row"
687 if req.new_target == req.old_target:
688 return "new target repeats the dead link"
689 return ""
690
691
692def _check_old_dead(records: list[Suppression], rows: list[LedgerRow], req: RelinkRequest) -> str:
693 """Require a dead link target with a ledger row naming the same target."""
694 if _is_live(records, req.old_target):
695 return "old target is still live; link is not dangling"
696 old_row = next((item for item in rows if item.site_id == req.old_target), None)
697 if old_row is None:
698 return "old target has no ledger row for continuity check"
699 return _check_coords(_evidence_coords(old_row.evidence_ref), req.path, req.directive, "old")
700
701
702def plan_relink(
703 records: list[Suppression], rows: list[LedgerRow], req: RelinkRequest
704) -> tuple[RelinkPlan | None, str]:
705 """Plan a fail-closed successor refresh for a dangling link."""
706 row, problem = _unique_row(rows, req.site_id, "superseded")
707 if row is None:
708 return None, problem
709 problem = _check_link_prose(row, req)
710 if problem:
711 return None, problem
712 problem = _check_successor(records, req.new_target, req.path, req.directive)
713 if problem:
714 return None, problem
715 problem = _check_old_dead(records, rows, req)
716 if problem:
717 return None, problem
718 plan = RelinkPlan(
719 site_id=req.site_id,
720 old_target=req.old_target,
721 new_target=req.new_target,
722 path=req.path,
723 directive=req.directive,
724 batch_id=row.batch_id,
725 because=req.because,
726 ledger_line=row.line,
727 )
728 return plan, ""
729
730
731def _check_restore_live(
732 records: list[Suppression], site_id: str, row: LedgerRow
733) -> tuple[Suppression | None, str]:
734 """Require a live record carrying the reviewed binding."""
735 live, problem = _unique_live(records, site_id)
736 if live is None:
737 return None, problem
738 if live.binding_sha256 != row.binding_sha256:
739 return None, "live binding differs; needs new review, not restore"
740 return live, ""
741
742
743def plan_restore(
744 records: list[Suppression],
745 rows: list[LedgerRow],
746 rationales: dict[str, dict[str, object]],
747 req: RestoreRequest,
748) -> tuple[RestorePlan | None, str]:
749 """Plan a fail-closed reinstatement of a resolved row whose site is live."""
750 row, problem = _unique_row(rows, req.site_id, "resolved")
751 if row is None:
752 return None, problem
753 live, problem = _check_restore_live(records, req.site_id, row)
754 if live is None:
755 return None, problem
756 problem = _check_rationale(rationales, req.rationale)
757 if problem:
758 return None, problem
759 problem = _clean_text(req.because)
760 if problem:
761 return None, problem
762 return RestorePlan(req.site_id, req.rationale, row.batch_id, req.because, row.line), ""
763
764
765def _require_because(retires: list[str], restores: list[str], because: str | None) -> str:
766 """Require recorded justification for state-changing operations."""
767 if (retires or restores) and not because:
768 return "--because is required"
769 return ""
770
771
772def _split_spec(value: str, parts: int, kind: str) -> tuple[list[str], str]:
773 """Split one CLI op spec or report the shape violation."""
774 fields = value.split(":")
775 if len(fields) != parts or not all(fields):
776 return [], f"malformed --{kind} spec (want {parts} colon fields)"
777 return fields, ""
778
779
780@dataclass(frozen=True)
781class OpBatch:
782 """One CLI invocation worth of ledger operations."""
783
784 rebind: str | None
785 retires: list[str]
786 relinks: list[str]
787 restores: list[str]
788 rationale: str | None
789 because: str | None
790 write: bool
791
792
793def _plan_jobs(
794 records: list[Suppression],
795 rows: list[LedgerRow],
796 rationales: dict[str, dict[str, object]],
797 batch: OpBatch,
798) -> tuple[list[tuple[str, object]], str]:
799 """Plan every requested operation against one inventory."""
800 jobs: list[tuple[str, object]] = []
801 if batch.rebind is not None:
802 plan, problem = plan_rebind(records, rows, batch.rebind)
803 if plan is None:
804 return [], problem
805 jobs.append(("rebind", plan))
806 for adder in (
807 _plan_retires(records, rows, batch),
808 _plan_relinks(records, rows, batch),
809 _plan_restores(records, rows, rationales, batch),
810 ):
811 plans, problem = adder
812 if problem:
813 return [], problem
814 jobs.extend(plans)
815 return jobs, ""
816
817
818def _plan_retires(
819 records: list[Suppression], rows: list[LedgerRow], batch: OpBatch
820) -> tuple[list[tuple[str, object]], str]:
821 """Plan every requested succession."""
822 jobs: list[tuple[str, object]] = []
823 for spec in batch.retires:
824 fields, problem = _split_spec(spec, 4, "retire")
825 if problem:
826 return [], problem
827 site, successor, path, directive = fields
828 plan, problem = plan_retire(
829 records, rows, RetireRequest(site, successor, path, directive, batch.because or "")
830 )
831 if plan is None:
832 return [], problem
833 jobs.append(("retire", plan))
834 return jobs, ""
835
836
837def _plan_relinks(
838 records: list[Suppression], rows: list[LedgerRow], batch: OpBatch
839) -> tuple[list[tuple[str, object]], str]:
840 """Plan every requested link refresh."""
841 jobs: list[tuple[str, object]] = []
842 for spec in batch.relinks:
843 fields, problem = _split_spec(spec, 5, "relink")
844 if problem:
845 return [], problem
846 site, old, new, path, directive = fields
847 plan, problem = plan_relink(
848 records, rows, RelinkRequest(site, old, new, path, directive, batch.because or "")
849 )
850 if plan is None:
851 return [], problem
852 jobs.append(("relink", plan))
853 return jobs, ""
854
855
856def _plan_restores(
857 records: list[Suppression],
858 rows: list[LedgerRow],
859 rationales: dict[str, dict[str, object]],
860 batch: OpBatch,
861) -> tuple[list[tuple[str, object]], str]:
862 """Plan every requested reinstatement."""
863 jobs: list[tuple[str, object]] = []
864 for site in batch.restores:
865 plan, problem = plan_restore(
866 records,
867 rows,
868 rationales,
869 RestoreRequest(site, batch.rationale or "", batch.because or ""),
870 )
871 if plan is None:
872 return [], problem
873 jobs.append(("restore", plan))
874 return jobs, ""
875
876
877def _apply_jobs(root: Path, jobs: list[tuple[str, object]]) -> tuple[str, str] | str:
878 """Apply planned operations sequentially to ledger/batches text."""
879 ledger_path = root / ".github" / "suppression-review-ledger.tsv"
880 batches_path = root / ".github" / "suppression-review-batches.yml"
881 ledger_text = ledger_path.read_text(encoding="utf-8")
882 batches_text = batches_path.read_text(encoding="utf-8")
883 for kind, plan in jobs:
884 if kind == "rebind":
885 result = apply_plan(ledger_text, batches_text, cast(RebindPlan, plan))
886 elif kind == "retire":
887 result = apply_retire(ledger_text, batches_text, cast(RetirePlan, plan))
888 elif kind == "relink":
889 result = apply_relink(ledger_text, batches_text, cast(RelinkPlan, plan))
890 else:
891 result = apply_restore(ledger_text, batches_text, cast(RestorePlan, plan))
892 if isinstance(result, str):
893 return result
894 ledger_text, batches_text = result
895 return ledger_text, batches_text
896
897
898def _load_all(
899 root: Path, batch: OpBatch
900) -> tuple[tuple[list[Suppression], list[LedgerRow], dict[str, dict[str, object]]] | None, str]:
901 """Load inventory, ledger, and rationales for one batch."""
902 records, problem = _load_records(root)
903 if problem:
904 return None, problem
905 rows, findings = load_ledger(root)
906 if findings:
907 return None, "ledger does not parse"
908 rationales: dict[str, dict[str, object]] = {}
909 if batch.restores:
910 rationales, findings = load_rationales(root)
911 if findings:
912 return None, "rationales do not parse"
913 return (records, rows, rationales), ""
914
915
916def _run_ops(
917 root: Path,
918 batch: OpBatch,
919) -> int:
920 """Plan every requested operation against one inventory, then apply."""
921 problem = _require_because(batch.retires, batch.restores, batch.because)
922 if problem:
923 return _refuse(problem)
924 loaded, problem = _load_all(root, batch)
925 if loaded is None:
926 print(f"suppression_rebind: {problem}", file=sys.stderr)
927 return 2
928 records, rows, rationales = loaded
929 jobs, problem = _plan_jobs(records, rows, rationales, batch)
930 if problem:
931 return _refuse(problem)
932 for kind, plan in jobs:
933 print(f"--- {kind} ---")
934 print(_describe(plan))
935 if not batch.write:
936 return 0
937 result = _apply_jobs(root, jobs)
938 if isinstance(result, str):
939 return _refuse(result)
940 ledger_text, batches_text = result
941 ledger_path = root / ".github" / "suppression-review-ledger.tsv"
942 batches_path = root / ".github" / "suppression-review-batches.yml"
943 ledger_path.write_text(ledger_text, encoding="utf-8")
944 batches_path.write_text(batches_text, encoding="utf-8")
945 print("suppression_rebind: applied.")
946 return 0
947
948
949def _refuse(reason: str) -> int:
950 """Report one planning refusal."""
951 print(f"suppression_rebind: refused: {reason}", file=sys.stderr)
952 return 1
953
954
955def main(argv: list[str] | None = None) -> int:
956 """Offer dry-run review and fail-closed application of ledger operations."""
957 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
958 parser.add_argument("--selftest", action="store_true", help="Run self-tests.")
959 parser.add_argument("--site", help="Ledger site_id to rebind.")
960 parser.add_argument("--retire", action="append", default=[], help="SITE:NEW:PATH:DIRECTIVE.")
961 parser.add_argument(
962 "--relink", action="append", default=[], help="SITE:OLD:NEW:PATH:DIRECTIVE."
963 )
964 parser.add_argument(
965 "--restore", action="append", default=[], help="Ledger site_id to reinstate."
966 )
967 parser.add_argument("--rationale", help="Rationale id for --restore.")
968 parser.add_argument("--because", help="Justification recorded with retire/restore.")
969 parser.add_argument("--dry-run", action="store_true", help="Show the plan only.")
970 parser.add_argument("--apply", action="store_true", help="Write the rebind.")
971 args = parser.parse_args(argv)
972 if args.selftest:
973 return selftest()
974 if args.restore and not args.rationale:
975 parser.error("--restore requires --rationale")
976 if not args.site and not args.retire and not args.relink and not args.restore:
977 parser.error("--site, --retire, --relink, or --restore is required")
978 if not args.dry_run and not args.apply:
979 parser.error("--dry-run or --apply is required")
980 return _run_ops(
981 _repo_root(),
982 OpBatch(
983 args.site,
984 args.retire,
985 args.relink,
986 args.restore,
987 args.rationale,
988 args.because,
989 args.apply,
990 ),
991 )
992
993
994if __name__ == "__main__":
995 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298