@@ -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+
16761750def _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 ,
0 commit comments