4"""Sanctioned rebind of a reviewed suppression binding after a harmless move.
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.
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
25from __future__
import annotations
31from dataclasses
import dataclass, replace
32from pathlib
import Path
33from typing
import cast
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
43@dataclass(frozen=True)
45 """Reviewed move repair awaiting reviewer confirmation."""
50 old_refs: tuple[str, ...]
51 new_refs: tuple[str, ...]
60def _decision_numbers(evidence: tuple[str, ...]) -> list[str]:
61 """Return decision-line numbers in evidence order."""
62 numbers: list[str] = []
64 match = re.fullmatch(
r"decision-line:(\d+)", item)
66 numbers.append(match.group(1))
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)
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)]}"
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] = []
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)]}")
95def _recomputed_binding(record: Suppression, mapping: dict[str, str]) -> str:
96 """Recompute the binding with old line numbers restored."""
99 scope=_mapped_scope(record.scope, mapping),
100 evidence=_mapped_evidence(record.evidence, mapping),
102 return hashlib.sha256(binding_payload(trial, trial.anchor)).hexdigest()
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]
109 return None, f
"unknown site_id {site_id}"
111 return None, f
"ambiguous site_id {site_id}: {len(live)} live rows"
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]
119 return None, f
"site_id {site_id} has no ledger row"
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"
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
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"
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)
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))
157 return None,
"binding differs beyond line movement"
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),
""
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
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)
174 return token.sub(swap, evidence_ref)
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)
184 row, problem = _single_row(rows, site_id)
187 if record.binding_sha256 == row.binding_sha256:
188 return None,
"live binding already matches; nothing to rebind"
189 problem = _check_prose(record, row)
192 found, problem = _check_movement(record, row)
195 marker_old, marker_new, old_decision, new_decision = found
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,
214def _render_row(row: LedgerRow) -> str:
215 """Serialize one ledger row in committed column order."""
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}"
223def batch_digest(members: list[LedgerRow]) -> str:
224 """Recompute the ordered-row digest the gate validates."""
226 f
"{item.site_id}\t{item.binding_sha256}\t{item.state}"
227 f
"\t{item.rationale_id}\t{item.evidence_ref}"
230 return hashlib.sha256(payload.encode(
"utf-8")).hexdigest()
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):
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]
251 return [], [],
None,
"ledger site ambiguous or missing"
252 return rows, lines, target[0],
""
256 batches_text: str, batch_id: str, rows: list[LedgerRow], members: list[LedgerRow]
258 """Rewrite one batch digest after validating it, or explain the refusal."""
260 r"(?m)^ - id: " + re.escape(batch_id) +
r"\n(?:^ \S.*\n|\n|(?:^ .*\n)+)+",
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])
273 batches_text[: block.start()]
274 + block.group(0).replace(recorded.group(1), fresh, 1)
275 + batches_text[block.end() :],
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)
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)
300 if item.site_id == plan.site_id
304 new_batches, problem = _update_batch(batches_text, plan.batch_id, rows, members)
307 return _join_ledger(lines, members), new_batches
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)
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"
326 "superseded-identity-rebinding",
330 if item.site_id == plan.site_id
334 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
337 return _join_ledger(lines, members), new_batches
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)
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"
363 if item.site_id == plan.site_id
367 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
370 return _join_ledger(lines, members), new_batches
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)
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}"
391 if item.site_id == plan.site_id
395 new_batches, problem = _update_batch(batches_text, row.batch_id, rows, members)
398 return _join_ledger(lines, members), new_batches
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}
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"
410def _repo_root() -> Path:
411 """Return the repository root housing this checker."""
412 return Path(__file__).resolve().parents[2]
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,
""
421def _describe(plan: object) -> str:
422 """Render a dry-run presentation of one plan."""
423 if isinstance(plan, RebindPlan):
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}",
438 if isinstance(plan, RetirePlan):
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}",
448 if isinstance(plan, RelinkPlan):
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}",
458 if isinstance(plan, RestorePlan):
461 f
"site_id: {plan.site_id}",
462 f
"rationale: {plan.rationale}",
463 f
"batch: {plan.batch_id}",
464 f
"because: {plan.because}",
467 return f
"unknown plan kind {type(plan)}"
470def selftest() -> int:
471 """Prove fail-closed ledger operations in both directions."""
472 failures = rebind_selftest.run_selftests()
474 for failure
in failures:
475 print(f
"suppression_rebind selftest: FAILED -- {failure}", file=sys.stderr)
477 print(
"suppression_rebind selftest: all cases pass (both directions).")
481@dataclass(frozen=True)
483 """Reviewed succession for a stale retain row."""
494@dataclass(frozen=True)
496 """Reviewed successor refresh for a dangling replaced-by link."""
508@dataclass(frozen=True)
510 """Reviewed reinstatement of a resolved row whose site is live."""
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)
524def _replaced_by(evidence_ref: str) -> list[str]:
525 """Extract replaced-by link targets in prose order."""
527 part.removeprefix(
"replaced-by:")
528 for part
in evidence_ref.split()
529 if part.startswith(
"replaced-by:")
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"
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"
547@dataclass(frozen=True)
549 """Human-reviewed succession mapping for one stale retain row."""
558@dataclass(frozen=True)
560 """Human-reviewed link refresh for one dangling replaced-by target."""
570@dataclass(frozen=True)
572 """Human-reviewed reinstatement for one resolved row."""
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]
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}"
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]
593 return None,
"site unknown or ambiguous in live inventory"
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)
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)
607 return _check_target(live, path, directive)
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"
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"
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")
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)
637 problem = _check_coords(
638 _evidence_coords(row.evidence_ref), req.path, req.directive,
"asserted target"
642 problem = _clean_text(req.because)
646 req.site_id, req.successor, req.path, req.directive, row.batch_id, req.because, row.line
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"
661 records: list[Suppression], rows: list[LedgerRow], old_target: str, path: str, directive: 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)
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")
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():
678 if token.startswith(want)
and set(token[len(want) :]) <= {
";"}:
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"
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)
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")
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")
709 problem = _check_link_prose(row, req)
712 problem = _check_successor(records, req.new_target, req.path, req.directive)
715 problem = _check_old_dead(records, rows, req)
720 old_target=req.old_target,
721 new_target=req.new_target,
723 directive=req.directive,
724 batch_id=row.batch_id,
726 ledger_line=row.line,
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)
738 if live.binding_sha256 != row.binding_sha256:
739 return None,
"live binding differs; needs new review, not restore"
744 records: list[Suppression],
745 rows: list[LedgerRow],
746 rationales: dict[str, dict[str, object]],
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")
753 live, problem = _check_restore_live(records, req.site_id, row)
756 problem = _check_rationale(rationales, req.rationale)
759 problem = _clean_text(req.because)
762 return RestorePlan(req.site_id, req.rationale, row.batch_id, req.because, row.line),
""
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"
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)"
780@dataclass(frozen=True)
782 """One CLI invocation worth of ledger operations."""
788 rationale: str |
None
794 records: list[Suppression],
795 rows: list[LedgerRow],
796 rationales: dict[str, dict[str, object]],
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)
805 jobs.append((
"rebind", plan))
807 _plan_retires(records, rows, batch),
808 _plan_relinks(records, rows, batch),
809 _plan_restores(records, rows, rationales, batch),
811 plans, problem = adder
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")
827 site, successor, path, directive = fields
828 plan, problem = plan_retire(
829 records, rows, RetireRequest(site, successor, path, directive, batch.because
or "")
833 jobs.append((
"retire", plan))
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")
846 site, old, new, path, directive = fields
847 plan, problem = plan_relink(
848 records, rows, RelinkRequest(site, old, new, path, directive, batch.because
or "")
852 jobs.append((
"relink", plan))
857 records: list[Suppression],
858 rows: list[LedgerRow],
859 rationales: dict[str, dict[str, object]],
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(
869 RestoreRequest(site, batch.rationale
or "", batch.because
or ""),
873 jobs.append((
"restore", plan))
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:
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))
891 result = apply_restore(ledger_text, batches_text, cast(RestorePlan, plan))
892 if isinstance(result, str):
894 ledger_text, batches_text = result
895 return ledger_text, batches_text
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)
905 rows, findings = load_ledger(root)
907 return None,
"ledger does not parse"
908 rationales: dict[str, dict[str, object]] = {}
910 rationales, findings = load_rationales(root)
912 return None,
"rationales do not parse"
913 return (records, rows, rationales),
""
920 """Plan every requested operation against one inventory, then apply."""
921 problem = _require_because(batch.retires, batch.restores, batch.because)
923 return _refuse(problem)
924 loaded, problem = _load_all(root, batch)
926 print(f
"suppression_rebind: {problem}", file=sys.stderr)
928 records, rows, rationales = loaded
929 jobs, problem = _plan_jobs(records, rows, rationales, batch)
931 return _refuse(problem)
932 for kind, plan
in jobs:
933 print(f
"--- {kind} ---")
934 print(_describe(plan))
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.")
949def _refuse(reason: str) -> int:
950 """Report one planning refusal."""
951 print(f
"suppression_rebind: refused: {reason}", file=sys.stderr)
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.")
962 "--relink", action=
"append", default=[], help=
"SITE:OLD:NEW:PATH:DIRECTIVE."
965 "--restore", action=
"append", default=[], help=
"Ledger site_id to reinstate."
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)
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")
994if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.