From 79c8e1da2e7af5ab1c455c7f1a9e476a87a53405 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 18:17:51 -0400 Subject: [PATCH] =?UTF-8?q?Pass=2013=20=E2=80=94=20P13-D1:=20run=20the=20l?= =?UTF-8?q?edger=20re-anchor=20for=20undo-driven=20event=20tombstones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An undo of an event-minting transaction tombstoned the events graph-side (materialize_graph_tombstones → materialize_graph_delete re-anchors/cascades a structure whose anchor died) but never ran the ledger-side reanchor_for_tombstone — so a structure orphaned by the undo left the graph while staying Live in objects, with no RepairRecord. Ch6's same-step RepairRecord MUST was unmet for undo-driven tombstones (slurs/spanners/repeats). Fix: tombstone_undo_targets now captures each event target's voice (before the graph half clears voice_occupancy) and runs reanchor_for_tombstone per event target after the graph half. The orphaned structure now cascades or re-anchors in objects with a same-step RepairRecord, agreeing with the already-updated graph — both use the same min-survivor rule, so they converge on existence and target. reanchor_for_tombstone gains a liveness guard (skip a non-Live structure) so the undo's own tombstoned mints — whose stale structures-index entries linger — aren't re-processed into duplicate repairs; the direct-delete path already drops tombstoned structures from the index, so the guard is a no-op there. canonical_bytes embeds both objects and the effect log, so this corrects the reduced state (an inconsistency never previously exercised — no existing test broke). Regression: undo_orphaning_a_pre_existing_slur_cascades_it_in_the_ ledger_p13_d1 (cascade + recorded repair + order-independent convergence). 939 tests, convergence/equivocation/conformance green. PASS13: P13-D1 resolved. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-ops/DECISIONS.md | 17 ++++ crates/epiphany-ops/src/reduce.rs | 138 ++++++++++++++++++++++++++++++ spec/PASS13_CANDIDATES.md | 2 +- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index d6f94f5..87fc436 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1081,6 +1081,23 @@ against the triggering event before that event's own tombstone lands in `Reanchored{to: X}` and `CascadeDeleted` in one effect (plausible by code trace, unexecuted). Neither is repeat-specific; neither is fixed here. +**P13-D1 resolved (Pass 13, 2026-07-08).** `tombstone_undo_targets` now runs +the ledger half after the graph half: it captures each event target's voice +(before `materialize_graph_tombstones` clears `voice_occupancy`), then calls +`reanchor_for_tombstone` per event target. A structure orphaned by an +undo-tombstoned anchor now cascades or re-anchors in `objects` with a +same-step `RepairRecord`, so the ledger and the already-updated graph agree — +both use the same `min`-survivor rule, so they converge on existence and +target. `reanchor_for_tombstone` gained a **liveness guard** (skip a +non-`Live` structure) so the undo's own tombstoned mints — whose stale +`structures` index entries linger — are not re-processed into duplicate +repairs; the direct-delete path drops a tombstoned structure from `structures`, +so the guard is a no-op there. Because `canonical_bytes` embeds both `objects` +and the effect log, this corrects the reduced state (an inconsistency that was +simply never exercised — no existing test broke). Locked by +`undo_orphaning_a_pre_existing_slur_cascades_it_in_the_ledger_p13_d1` +(cascade + recorded repair + order-independent convergence). + **Noted, not implemented:** no writer path derives a chunk schema *minor* from appended kind discriminants (a major-0 block carrying discriminant 29 stamps the same fixed minor as always) — pre-existing for the Phase-3 diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 929dc29..b33e72f 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -4655,6 +4655,31 @@ impl<'a> Reducer<'a> { targets: &[TypedObjectId], ) -> Vec { let mut repairs = Vec::new(); + // Each tombstoned event's voice, captured BEFORE the graph tombstone + // removes it from `voice_occupancy` — the containment-proximity key for + // the re-anchoring reasons below (mirrors `delete_event`). + let event_voices: Vec<(TypedObjectId, Option)> = targets + .iter() + .filter(|t| matches!(t, TypedObjectId::Event(_))) + .map(|t| { + let TypedObjectId::Event(id) = t else { + unreachable!() + }; + let voice = self + .voice_occupancy + .iter() + .find_map(|(voice, events)| { + events.iter().find(|(_, _, e)| e == id).map(|_| *voice) + }) + .or_else(|| { + self.graph + .as_ref() + .and_then(|score| score.events.get(*id)) + .map(Event::voice) + }); + (*t, voice) + }) + .collect(); for t in targets { let minter = self.minted_by.get(t).copied().unwrap_or(env.id); self.objects.insert( @@ -4670,6 +4695,22 @@ impl<'a> Reducer<'a> { }); } repairs.extend(self.materialize_graph_tombstones(env, targets)); + // P13-D1: mirror the graph re-anchoring in the LEDGER. The graph side + // (`materialize_graph_delete`, run above) silently re-anchors or + // cascade-deletes a structure whose anchor event was undo-tombstoned, + // on the standing assumption that `reanchor_for_tombstone` records the + // repair and updates `objects`. The direct-delete path pairs the two; + // the undo path historically ran only the graph half, leaving an + // orphaned structure Live in `objects` with no RepairRecord — a Ch6 + // same-step-recording MUST unmet for undo-driven tombstones. Running + // the ledger half here brings the two into agreement (the same + // `min`-survivor rule, so graph and ledger converge on existence and + // target). Every target is already tombstoned in `objects` above, so + // `surviving_endpoints` sees the full undo, and the liveness guard in + // `reanchor_for_tombstone` skips the target-structures themselves. + for (ev, voice) in event_voices { + self.reanchor_for_tombstone(env, ev, &mut repairs, voice); + } repairs } @@ -5821,6 +5862,16 @@ impl<'a> Reducer<'a> { .map(|(sid, _)| *sid) .collect(); for sid in referencing { + // Skip a structure that is no longer Live — it may already have + // been tombstoned by this same effect (an undo tombstones a whole + // transaction's mints together, and its stale index entry lingers) + // or cascaded by an earlier referent in this call. Re-processing it + // would push a duplicate repair. The direct-delete path never hits + // this (a tombstoned structure is dropped from `structures`), so the + // guard is a no-op there and load-bearing only for the undo path. + if !matches!(self.objects.get(&sid), Some(ObjectState::Live)) { + continue; + } match sid { TypedObjectId::Tie(_) => { // A tie's existence requires both endpoints: cascade-delete. @@ -9567,6 +9618,93 @@ mod tests { ); } + #[test] + fn undo_orphaning_a_pre_existing_slur_cascades_it_in_the_ledger_p13_d1() { + // P13-D1: a PRE-EXISTING structure whose every anchor event is + // undo-tombstoned must cascade in the LEDGER (`objects` → Tombstoned) + // with a same-step RepairRecord — not merely leave the graph while + // staying Live with no record (Ch6 §Re-Anchoring MUST). The slur is + // created OUTSIDE the undone transaction, so the undo ORPHANS it rather + // than tombstoning it directly. Before the fix the undo path ran only + // the graph half of the re-anchor, leaving `objects[slur]` Live and no + // repair recorded. + use epiphany_core::SlurId; + let tx = TransactionId::new(ReplicaId(1), 900); + let e1 = EventId::new(ReplicaId(1), 100); + let e2 = EventId::new(ReplicaId(1), 101); + let slur_id = SlurId::new(ReplicaId(9), 500); + let sid = TypedObjectId::Slur(slur_id); + + let insert_kind = |event: EventId, at: i64| { + OperationKind::InsertEvent(InsertEventOp { + staff_instance: StaffInstanceId::new(ReplicaId(9), 0), + event: crate::valuegen::insert_event_value( + event, + VoiceId::new(ReplicaId(9), 1), + pos(at), + epiphany_core::MusicalDuration::whole(), + &[], + ), + }) + }; + let slur_op = OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur_id, e1, e2)), + }); + let undo = undo_env(1, 4, 40, seen_r1(3), tx, UndoPolicy::StrictInverse); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + declare_transaction(1, 0, 10, CausalContext::new(), tx), + tx_member(1, 1, 11, seen_r1(0), tx, insert_kind(e1, 0)), + tx_member(1, 2, 12, seen_r1(1), tx, insert_kind(e2, 10)), + prim_env(1, 3, 13, seen_r1(2), slur_op), + undo.clone(), + ]); + let state = set.reduce(); + + // The orphaned slur cascades in the ledger (not left Live)… + assert!( + matches!( + state.objects.get(&sid), + Some(ObjectState::Tombstoned { .. }) + ), + "the orphaned slur cascades in the ledger, not left Live with no repair" + ); + // …and the undo records the same-step CascadeDeleted repair (Ch6 MUST). + let undo_records_cascade = matches!( + state.effects.iter().find(|(id, _)| *id == undo.id).map(|(_, e)| e), + Some(OperationEffect::AppliedWithRepair { repairs }) + if repairs.iter().any(|r| r.target == sid + && matches!(r.kind, RepairKind::CascadeDeleted)) + ); + assert!( + undo_records_cascade, + "the undo records the orphaned slur's cascade repair (Ch6 same-step MUST)" + ); + // Determinism: a permuted delivery reduces to identical bytes. + let mut permuted = OperationSet::new(); + permuted.accept_all(vec![ + undo.clone(), + prim_env( + 1, + 3, + 13, + seen_r1(2), + OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur_id, e1, e2)), + }), + ), + tx_member(1, 2, 12, seen_r1(1), tx, insert_kind(e2, 10)), + tx_member(1, 1, 11, seen_r1(0), tx, insert_kind(e1, 0)), + declare_transaction(1, 0, 10, CausalContext::new(), tx), + ]); + assert_eq!( + state.canonical_bytes(), + permuted.reduce().canonical_bytes(), + "the orphan-cascade converges regardless of delivery order" + ); + } + #[test] fn a_rank_four_reanchor_records_same_canvas_nearer_p12_c4() { // Pass 12 (P12-C4): the rank-4 (same-canvas) proximity survivor has diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index b58de43..65fad17 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -8,6 +8,6 @@ this file is the index, not the analysis. | Id | One-line statement | Filed in | Status | |---|---|---|---| | P13-K1 | The K3 verdict for a system pitch introduced by a ModifyEvent replacement value differs across a snapshot cut (in-session `TargetMissing` vs post-snapshot `SystemDerivedContentImmutable`); really "may ModifyEvent introduce never-minted pitch ids" | `crates/epiphany-ops/DECISIONS.md` (Pass-12 G-pass code tranche) | **resolved** (Pass 13: reject the introduction — modify_event refuses a never-minted system-derived pitch, verdict now snapshot-cut-invariant; user "reject the introduction") | -| P13-D1 | Undo-driven event tombstones run graph-side re-anchor/cascade but never ledger-side `reanchor_for_tombstone`: structures leave the graph while staying `Live`, no `RepairRecord` — Ch6's same-step recording MUST is unmet for undo-driven tombstones (pre-existing class: slurs/spanners; repeats now too) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | open | +| P13-D1 | Undo-driven event tombstones run graph-side re-anchor/cascade but never ledger-side `reanchor_for_tombstone`: structures leave the graph while staying `Live`, no `RepairRecord` — Ch6's same-step recording MUST is unmet for undo-driven tombstones (pre-existing class: slurs/spanners; repeats now too) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | **resolved** (Pass 13: `tombstone_undo_targets` runs the ledger re-anchor per event target; liveness guard; convergence-locked) | | P13-D2 | Cue-cascade recursion re-anchors against the triggering event before its tombstone lands in `objects`: a structure anchored on {X, cue-of-X} can record `Reanchored{to: X}` then `CascadeDeleted` in one effect (contradictory repair trail; plausible by code trace, unexecuted) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | open | | P13-D3 | `CreateCrossCutting` validates only event endpoints (`CrossCuttingValue::endpoints()`), so a SPANNER anchored to a missing region/measure mints dangling past `anchor_target_exists`; and non-event referent tombstones (`DeleteRegion` under a region-anchored spanner/repeat) re-anchor nothing — "every referenced endpoint is live" is events-only as implemented | `crates/epiphany-ops/DECISIONS.md` (Phase D follow-up) | **resolved** (Pass 13: mint fixed via `anchor_object_refs`; non-event referent re-anchoring ratified events-only, user "fix the mint only") |