Skip to content

Commit 2d02241

Browse files
authored
Merge pull request #644 from escoffier-labs/fix/638-redaction-polish
fix(redaction): idempotent anchor refresh and retirement digest cross-check
2 parents 97e87a5 + a6cfe3e commit 2d02241

2 files changed

Lines changed: 185 additions & 3 deletions

File tree

src/brigade/run_redaction.py

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,7 +1377,14 @@ def _validate_chained_anchors(
13771377
and record.get("rewritten_digest_retired_by") == resumable_operation_id
13781378
and current.get("parent_operation_id") == operation_id
13791379
)
1380-
if not (allowed_current or allowed_parent):
1380+
resumable_record = records.get(resumable_operation_id) if resumable_operation_id else None
1381+
allowed_retiring_child = (
1382+
current is not None
1383+
and current.get("phase") in {"cleanup-authorized", "cleaned"}
1384+
and resumable_record is not None
1385+
and resumable_record.get("rewritten_digest_retired_by") == operation_id
1386+
)
1387+
if not (allowed_current or allowed_parent or allowed_retiring_child):
13811388
raise RedactionError("redaction chained anchor verification failed")
13821389
missing = set(records) - seen
13831390
if missing:
@@ -1469,14 +1476,26 @@ def _refresh_chained_anchors(
14691476
rewritten.append(rewritten_event)
14701477
previous_digest = rewritten_event.event_digest
14711478
data = _canonical_event_bytes(rewritten)
1479+
current = _read_bounded_regular(
1480+
journal_path,
1481+
limit=run_checkpoint.MAX_JOURNAL_BYTES,
1482+
category="journal",
1483+
)
1484+
if current == data:
1485+
# Anchor payloads already match the record digests; skip journal and
1486+
# projection rewrites so a second verify/cleanup pass is a no-op.
1487+
return _digest(data)
14721488
_assert_active_owner(workspace, run_dir)
14731489
_replace_journal(journal_path, data)
14741490
snapshot = _load_json_object(run_dir / "run.json", limit=MAX_RUN_JSON_BYTES, category="run projection")
14751491
_replace_projection(run_dir, _projection(snapshot, rewritten))
14761492
return _digest(data)
14771493

14781494

1479-
def _validate_lineage_graph(records: Mapping[str, Mapping[str, Any]]) -> None:
1495+
def _validate_lineage_graph(
1496+
records: Mapping[str, Mapping[str, Any]],
1497+
record_digests: Mapping[str, str] | None = None,
1498+
) -> None:
14801499
if not records:
14811500
return
14821501
children: dict[str, list[str]] = {}
@@ -1498,6 +1517,13 @@ def _validate_lineage_graph(records: Mapping[str, Mapping[str, Any]]) -> None:
14981517
retired_by = records[parent].get("rewritten_digest_retired_by")
14991518
if retired_by is not None and retired_by != descendants[0]:
15001519
raise RedactionError("redaction lineage retirement mismatch")
1520+
# Digest cross-check applies to split/full retirement edges: the child
1521+
# must name the parent's current record digest after retirement rewrite.
1522+
if record_digests is not None and retired_by is not None:
1523+
child = records[descendants[0]]
1524+
expected = record_digests.get(parent)
1525+
if expected is None or child.get("parent_record_sha256") != expected:
1526+
raise RedactionError("redaction lineage digest mismatch")
15011527

15021528
tips = set(records) - set(children)
15031529
if len(tips) != 1:
@@ -1628,8 +1654,10 @@ def _load_operation_inventory(
16281654
if state_digest != record_digest or state_retired_by != record_retired_by:
16291655
if record_retired_by is not None and state_digest is not None:
16301656
split_retired_by = record_retired_by
1657+
split_digest = state_digest
16311658
elif state_retired_by is not None and record_digest is not None:
16321659
split_retired_by = state_retired_by
1660+
split_digest = record_digest
16331661
else:
16341662
raise RedactionError("redaction transaction state/record mismatch")
16351663
child_state = states.get(split_retired_by)
@@ -1641,6 +1669,9 @@ def _load_operation_inventory(
16411669
or child_state.get("phase") != "cleanup-authorized"
16421670
or child_state.get("parent_operation_id") != operation_id
16431671
or child_record.get("parent_operation_id") != operation_id
1672+
or child_record.get("parent_record_sha256") != record_digests.get(operation_id)
1673+
or not isinstance(split_digest, str)
1674+
or not re.fullmatch(r"[0-9a-f]{64}", split_digest)
16441675
):
16451676
raise RedactionError("redaction transaction state/record mismatch")
16461677
phase = state.get("phase")
@@ -1669,10 +1700,53 @@ def _load_operation_inventory(
16691700
raise RedactionError("redaction lineage parent reference is invalid")
16701701
elif phase not in {"cleanup-authorized", "cleaned"}:
16711702
raise RedactionError("redaction lineage child quarantine is missing")
1672-
_validate_lineage_graph(records)
1703+
_validate_lineage_graph(records, record_digests)
16731704
return records, states, record_digests
16741705

16751706

1707+
def _realign_child_parent_record_digest(
1708+
redactions_dir: Path,
1709+
*,
1710+
run_id: str,
1711+
parent_operation_id: str,
1712+
child_operation_id: str,
1713+
records: Mapping[str, Mapping[str, Any]],
1714+
) -> None:
1715+
"""Point a child record at the parent's current record digest."""
1716+
child_record = records.get(child_operation_id)
1717+
if child_record is None or child_record.get("parent_operation_id") != parent_operation_id:
1718+
raise RedactionError("redaction lineage retirement mismatch")
1719+
parent_record_path = redactions_dir / parent_operation_id / "record.json"
1720+
child_record_path = redactions_dir / child_operation_id / "record.json"
1721+
parent_record_digest = _redaction_record_sha256(parent_record_path)
1722+
if child_record.get("parent_record_sha256") == parent_record_digest:
1723+
return
1724+
_write_redaction_record(
1725+
child_record_path,
1726+
operation_id=child_operation_id,
1727+
run_id=run_id,
1728+
sequence_start=child_record["sequence_start"],
1729+
sequence_end=child_record["sequence_end"],
1730+
reason=child_record["reason_code"],
1731+
rewritten_sha256=child_record.get("rewritten_journal_sha256"),
1732+
quarantine_retained=child_record["quarantine_retained"],
1733+
parent_operation_id=parent_operation_id,
1734+
parent_record_sha256=parent_record_digest,
1735+
rewritten_digest_retired_by=child_record.get("rewritten_digest_retired_by"),
1736+
)
1737+
child_operation_dir = child_record_path.parent
1738+
child_fd = _open_directory_handle(child_operation_dir, category="redaction operation")
1739+
try:
1740+
_remove_operation_temps(
1741+
child_operation_dir,
1742+
child_fd,
1743+
patterns=(_RECORD_TEMP_RE,),
1744+
category="redaction lineage cleanup",
1745+
)
1746+
finally:
1747+
os.close(child_fd)
1748+
1749+
16761750
def _retire_rewritten_digest_aliases(
16771751
redactions_dir: Path,
16781752
*,
@@ -1717,6 +1791,15 @@ def _retire_rewritten_digest_aliases(
17171791
parent_record_sha256=record.get("parent_record_sha256"),
17181792
rewritten_digest_retired_by=retired_by_operation_id,
17191793
)
1794+
# Align the retiring child's parent_record_sha256 before the parent state
1795+
# write so a state-side crash still leaves a digest-consistent split.
1796+
_realign_child_parent_record_digest(
1797+
redactions_dir,
1798+
run_id=run_id,
1799+
parent_operation_id=parent_operation_id,
1800+
child_operation_id=retired_by_operation_id,
1801+
records=records,
1802+
)
17201803
if state.get("rewritten_digest_retired_by") != retired_by_operation_id:
17211804
_write_state(
17221805
state_path,
@@ -2547,6 +2630,12 @@ def cleanup_redaction_quarantine(
25472630
retired_by_operation_id=operation_id,
25482631
records=lineage_records,
25492632
)
2633+
if parent_operation_id is not None:
2634+
# Retirement rewrites the parent record and realigns this child's
2635+
# parent_record_sha256; refresh before the cleaned record write.
2636+
parent_record_sha256 = _redaction_record_sha256(
2637+
operation_dir.parent / parent_operation_id / "record.json"
2638+
)
25502639
_write_redaction_record(
25512640
record_path,
25522641
operation_id=operation_id,
@@ -2560,6 +2649,25 @@ def cleanup_redaction_quarantine(
25602649
parent_record_sha256=parent_record_sha256,
25612650
rewritten_digest_retired_by=rewritten_digest_retired_by,
25622651
)
2652+
if isinstance(rewritten_digest_retired_by, str):
2653+
# Cleaning a retired parent rewrites its record digest; keep the
2654+
# retiring child aligned for the lineage digest cross-check.
2655+
lineage_records = {
2656+
**lineage_records,
2657+
operation_id: {
2658+
**record,
2659+
"quarantine_retained": False,
2660+
"rewritten_journal_sha256": rewritten_sha256,
2661+
"rewritten_digest_retired_by": rewritten_digest_retired_by,
2662+
},
2663+
}
2664+
_realign_child_parent_record_digest(
2665+
operation_dir.parent,
2666+
run_id=resolved_run_dir.name,
2667+
parent_operation_id=operation_id,
2668+
child_operation_id=rewritten_digest_retired_by,
2669+
records=lineage_records,
2670+
)
25632671
_write_state(
25642672
state_path,
25652673
operation_id=operation_id,

tests/test_run_redaction.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,6 +1953,80 @@ def test_redaction_after_cleaned_parent_uses_active_anchor_reference(tmp_path):
19531953
assert second_record["parent_record_sha256"] == parent_anchor.payload["record_sha256"]
19541954

19551955

1956+
def test_second_cleanup_verify_pass_performs_zero_anchor_refresh_writes(tmp_path, monkeypatch):
1957+
run_dir, _, _ = _authority_run(tmp_path)
1958+
report = run_redaction.redact_journal(
1959+
run_dir,
1960+
sequence_start=2,
1961+
sequence_end=2,
1962+
reason=REASON_CODE,
1963+
operator_confirmed=True,
1964+
)
1965+
cleaned = run_redaction.cleanup_redaction_quarantine(
1966+
run_dir,
1967+
operation_id=report.operation_id,
1968+
operator_confirmed=True,
1969+
)
1970+
assert cleaned.cleaned is True
1971+
1972+
writes: list[str] = []
1973+
1974+
def track_journal(path, data):
1975+
writes.append("journal")
1976+
raise AssertionError("second verify pass must not rewrite the journal")
1977+
1978+
def track_projection(run_dir_arg, projection):
1979+
writes.append("projection")
1980+
raise AssertionError("second verify pass must not rewrite the projection")
1981+
1982+
monkeypatch.setattr(run_redaction, "_replace_journal", track_journal)
1983+
monkeypatch.setattr(run_redaction, "_replace_projection", track_projection)
1984+
1985+
again = run_redaction.cleanup_redaction_quarantine(
1986+
run_dir,
1987+
operation_id=report.operation_id,
1988+
operator_confirmed=True,
1989+
)
1990+
assert again.cleaned is True
1991+
assert writes == []
1992+
1993+
1994+
def test_tampered_split_retirement_record_fails_digest_cross_check(tmp_path):
1995+
run_dir, _, _ = _authority_run(tmp_path)
1996+
first = run_redaction.redact_journal(
1997+
run_dir,
1998+
sequence_start=2,
1999+
sequence_end=2,
2000+
reason=REASON_CODE,
2001+
operator_confirmed=True,
2002+
)
2003+
second = run_redaction.redact_journal(
2004+
run_dir,
2005+
sequence_start=4,
2006+
sequence_end=4,
2007+
reason="personal-data-exposure",
2008+
operator_confirmed=True,
2009+
)
2010+
run_redaction.cleanup_redaction_quarantine(
2011+
run_dir,
2012+
operation_id=second.operation_id,
2013+
operator_confirmed=True,
2014+
)
2015+
assert json.loads(first.record_path.read_text())["rewritten_digest_retired_by"] == second.operation_id
2016+
2017+
second_record = json.loads(second.record_path.read_text())
2018+
second_record["parent_record_sha256"] = "0" * 64
2019+
second.record_path.write_text(json.dumps(second_record, indent=2, sort_keys=True) + "\n")
2020+
2021+
with pytest.raises(run_redaction.RedactionError, match="digest mismatch") as excinfo:
2022+
run_redaction.cleanup_redaction_quarantine(
2023+
run_dir,
2024+
operation_id=first.operation_id,
2025+
operator_confirmed=True,
2026+
)
2027+
assert len(excinfo.value.diagnostic) <= run_events.MAX_DIAGNOSTIC_LEN
2028+
2029+
19562030
def test_redaction_inventory_rejects_tampered_parent_record_anchor_reference(tmp_path):
19572031
run_dir, _, _ = _authority_run(tmp_path)
19582032
run_redaction.redact_journal(

0 commit comments

Comments
 (0)