epiphany/spec/CONTRACT_GENESIS_G3A_UNDO_R...

26 KiB
Raw Blame History

Contract: the G3a undo repair, and the Binary Format chronology restoration

Status: RATIFIED 2026-07-29. Two packets, two commits. Pins A3 (ungated ledger/map guards), A6 (no restoration lookup while those fields have no write chain — revisit only if a modifying operation is introduced), and A7 (retaining the maps is safe given A4's live-state filter and object-first recreate refusal) were ratified explicitly. Mutation budget ratified at twenty-two for Packet A and three for Packet B. Governs: the defect that blocks G3a sign-off (Packet A), and P13-S17 (Packet B). Predecessor: spec/CONTRACT_GENESIS_G3A_ENTITIES.md, landed at 6c5e69f.


§0. What was actually verified

Every claim below was read out of the code, not relayed.

The tombstone branch is reachable. mint_container (crates/epiphany-ops/src/reduce.rs:3922) calls note_minted (:7848), which pushes the object into tx_minted whenever self.current_tx is set. undo_transaction (:5288) reads tx_minted and tombstone_undo_targets (:5387) writes ObjectState::Tombstoned into objects for every target. All four G3a reducers reach mint_container: create_staff_group :4283, create_part_definition :4340, create_analysis_layer :4384, create_view :4437. create_instrument :4214 and create_staff :4165 do the same.

Therefore crates/epiphany-ops/DECISIONS.md:1986 — "ObjectState::Tombstoned is unreachable for any of them through the public operation API today" — is false, and was false for CreateStaff and CreateInstrument before this rung. It is a pre-existing wrong claim that G3a inherited and repeated.

The graph keeps ghosts. materialize_graph_tombstones (:2737) walks targets at :2768 with arms for Pitch, Voice, Slur, Tie, Beam, Spanner, RepeatStructure, Staff, TimeSignature, then _ => {} at :2810. There is no arm for StaffGroup, PartDefinition, AnalysisLayer, View, or Instrument. The ledger tombstones; Score.staff_groups / .parts / .analysis_layers / .views / .instruments keep the value. Ledger and graph disagree — the exact failure the Spanner arm's own comment at :2793 records as having been fixed once already.

The strand guard is silent on the new families. undo_strand_block (:5458) matches Staff and TimeSignature, then _ => None at :5500. Undoing a StaffGroup mint while a live Staff.group (crates/epiphany-core/src/graph.rs:825) names it strands the reference; likewise AnalysisLayerViewDefinition.active_layers (graph.rs:1652) and InstrumentStaff.instrument (graph.rs:817).

PartDefinition and View need no guard: nothing in the graph references them. Their own fields (PartDefinition.staves graph.rs:1636, ViewDefinition.active_layers) are outbound, and removing the holder strands nothing.

Count check. spec/PLAN_GMINOR_SCHEMA_MINOR.md:198 says "all ten kind/tag pairs." The epoch table at :170:180 lists 2427 (4), 2829 (2), 30 (1), 31 (1), 3233 (2), 34 (1), 3538 (4) = fifteen.

Binary Format chronology. spec/binary_format.tex history rows: G2a 0.12.0 (:3599), G-minor 0.13.0 (:3628), G3a 0.14.0 (:3643). The ladder order is G1 → G2a → G-minor → G2b → G3a, so G2b is missing between 0.13.0 and G3a's row, and the accept-set raise OperationEnvelopeBlock 2→3 that G2b performed is recorded nowhere in the revision history — G3a's row at :3657 merely observes the block "stays at 3 where genesis tranche G2b left it."

G1 has no standalone history row, by design. It is recorded retroactively inside the G2a row: the principal marker is at :3599 (0.12.0 --- Genesis tranche G2a), and G1 appears nested at :3603 as "(CreateInstrument, genesis tranche G1 --- landed at 3b09595 with no matching entry here)". The row is explicit that G1 never got its own entry. Pin B6 is scoped accordingly.


PACKET A — the undo repair

§A1. Pins

Pin A1 — five graph-removal arms. In materialize_graph_tombstones (reduce.rs:2768), before the _ => {} catch-all, add:

Target Removal
TypedObjectId::StaffGroup(id) score.staff_groups.retain(|v| v.id != *id)
TypedObjectId::PartDefinition(id) score.parts.retain(|v| v.id != *id)
TypedObjectId::AnalysisLayer(id) score.analysis_layers.retain(|v| v.id != *id)
TypedObjectId::View(id) score.views.retain(|v| v.id != *id)
TypedObjectId::Instrument(id) score.instruments.retain(|v| v.id != *id)

Each mirrors the existing Staff (:2804) and TimeSignature (:2807) arms exactly. No new RepairRecord: tombstone_undo_targets already pushes one CascadeDeleted per target at :5427 before calling this function, and none of the existing arms push more.

Pin A2 — three inbound-reference guards. In undo_strand_block (:5458), before _ => None:

Target Blocked by Read from
StaffGroup(g) a live Staff whose group == Some(g) self.staff_values
AnalysisLayer(l) a live ViewDefinition whose active_layers contains l self.view_values
Instrument(i) a live Staff whose instrument == i self.staff_values

Return Some((*target, referencer_obj)) on the first match in map order.

Pin A3 — the guards are ledger-based and ungated. They read the carried-value maps (staff_values :1001, view_values :1018), not self.graph, and are not wrapped in if self.graph.is_some().

Rationale, and it is a real decision: the create-side referential preconditions are graph-gated (create_staff:4137, create_staff_group:4266, create_view:4420) because base-free reduction has no universe to resolve against. The undo side is different — the carried-value maps are populated by mints regardless of graph presence and are seeded from base when there is one, so under base-free reduction the ledger still knows that a live staff names this group. Gating the guard would let base-free undo strand a reference the ledger can plainly see. The two existing guards (instance_staff, meter_change_chain) are likewise ungated. Stronger than the create side, and strictly safer.

Pin A4 — liveness is read from objects, not from map presence. The value maps are insert-only; tombstone_undo_targets never removes from them. A guard that treated map presence as liveness would let an already-tombstoned staff block an unrelated undo forever. Each guard MUST require matches!(self.objects.get(&referencer_obj), Some(ObjectState::Live)).

Pin A5 — same-transaction referencers do not block. Each guard MUST also require !targets.contains(&referencer_obj), matching the Staff guard's !targets.contains(&iobj) at :5471 and the doc comment at :5456. A transaction that mints a group and a staff in it must be undoable whole.

Pin A6 — restorations is not consulted by the three new guards. The TimeSignature guard consults it (:5483) because MeterChange has a write chain that undo may restore to a prior value. Staff.group, Staff.instrument and ViewDefinition.active_layers have no modify operation at all — that is the standing §1.1 condition — so no restoration can change the prospective post-undo value. The implementer MUST NOT cargo-cult the restoration lookup; its presence would be dead code asserting a write chain that does not exist.

Pin A7 — the value maps are not pruned on undo. tombstone_undo_targets leaves staff_group_values and siblings untouched, matching the existing staff_values / time_signature_values / instrument_values precedent. This is safe because every create reducer consults self.objects before its value map. The match self.objects.get(..) lines, not the function declarations: create_staff_group :4241, create_part_definition :4298, create_analysis_layer :4356, create_view :4395, create_staff :4109, create_instrument :4186. A re-create after undo therefore hits Some(ObjectState::Tombstoned)TargetTombstoned and never reaches the stale value. Row family u4 signs that ordering, and it is the only family that does — a re-create assertion bolted onto u1 would be unsigned regression coverage, since u1's arm-deletion mutation cannot reach it (§A3).

Pin A8 — Instrument is in scope as collateral. It is the same root cause, it already has normative undo semantics, and leaving it would mean knowingly shipping a fifth instance of a defect this packet exists to fix.

Pin A9 — no new OperationKind, tag, epoch, discriminant, wire layout, accept-set, or companion version. This packet changes reducer behaviour only. spec/binary_format.tex is not touched by Packet A. If any of those surfaces appears in the diff, the packet is wrong.

Pin A10 — DECISIONS.md correction of record. Replace the false paragraph at crates/epiphany-ops/DECISIONS.md:1986 with a correction that states plainly: the branch is reachable via UndoTransaction over a transaction containing the mint; it was reachable for CreateStaff and CreateInstrument before G3a; the claim as written was wrong; and the guards and removal arms this packet adds are what make it correct. Do not silently delete it — the wrong claim was signed off and the record must say so.

§A2. Touch table (Packet A)

# File Change
1 crates/epiphany-ops/src/reduce.rs five arms in materialize_graph_tombstones (pin A1)
2 crates/epiphany-ops/src/reduce.rs three arms in undo_strand_block (pins A2A6)
3 crates/epiphany-ops/src/reduce.rs doc comment on undo_strand_block :5452 naming the three new blocks
4 crates/epiphany-ops/src/reduce.rs test rows u1au1e, u2au2c, u2bf-ac, u2tomb-ac, u3au3c, u4au4e
5 crates/epiphany-ops/DECISIONS.md pin A10 correction; record the five arms, three guards, and pin A3's ruling
6 spec/PLAN_GENESIS_OPS.md:25 "commit pending" → 6c5e69f; note the undo repair rides after it
7 spec/PLAN_GMINOR_SCHEMA_MINOR.md:190 "introducing commit pending" → 6c5e69f
8 spec/PLAN_GMINOR_SCHEMA_MINOR.md:198 "all ten kind/tag pairs" → "all fifteen kind/tag pairs"
9 spec/CONTRACT_GENESIS_G3A_UNDO_REPAIR.md this file — status line DRAFT → RATIFIED, dated, naming the ratifying decision on pins A3/A6/A7

Row 9 exists because the contract governing a commit must land with it. The file is currently untracked; without this row it would sit outside both packets while §A2 says "nothing else" and §E permits staging only touch-table files — a rule that excluded the rule-book. It joins Packet A.

Nothing else. No epiphany-bundle, no epiphany-core, no spec .tex, no editor-track file, no spikes/ entry.

§A3. Test rows

Every row names the mutation that MUST be observed to fail. Reasoning that a mutation "would fail" does not sign a row — the G3a rung already produced two such unsigned claims, both of which I had to run myself.

Twenty-two mutations in Packet A, three in Packet B: twenty-five total. The six row families below are u1 (5), u2 (3), u2bf (3), u2tomb (3), u3 (3), u4 (5).

u1au1e — removal and tombstoning (5 mutations)

One row per family: StaffGroup, PartDefinition, AnalysisLayer, View, Instrument. Each mints the object inside a declared transaction against a non-empty base, undoes the transaction with StrictInverse, and asserts:

  • (i) the value is gone from the corresponding Score vector;
  • (ii) objects reports ObjectState::Tombstoned for its TypedObjectId.

Killing mutation, five independent runs: delete that family's arm from pin A1's table. Assertion (i) must fail. Deleting one arm must not be observed to kill another family's row — run them one at a time and record five separate observations.

These rows do not sign pin A7. A re-create assertion may be included as regression coverage, but the arm-deletion mutation says nothing about it; the ordering pin is signed by u4 below, and nowhere else.

u2au2c — a live outside referencer blocks the undo (3 mutations)

Graph-aware (reduce_operation_set_onto). In each, transaction T mints the referent; a separate, non-transactional op mints the referencer; undo T under StrictInverse MUST be Conflicted and the referent MUST remain in its Score vector.

Row T mints Outside op mints
u2a StaffGroup g Staff s with group: Some(g)
u2b AnalysisLayer l ViewDefinition v with active_layers[l]
u2c Instrument i Staff s with instrument: i

Killing mutation, three independent runs: delete that family's arm from pin A2. The effect becomes Applied and the reference strands.

u2bf-au2bf-c — the guards hold base-free (3 mutations) — signs pin A3

The same three shapes reduced through reduce_operation_set (reduce.rs:674), with no base Score. Assert the effect is Conflicted. There is no graph to assert against — MaterializedState carries no score — so these rows assert the effect and the objects state only.

Base-free reduction skips every create-side referential precondition (create_staff:4137, create_view:4420), so the referencer mints unconditionally and its value lands in staff_values / view_values regardless. That is exactly the situation pin A3 exists to cover: the ledger plainly knows the reference, and the undo must refuse.

Killing mutation, three independent runs: wrap that family's guard arm in if self.graph.is_some() { … } else { None }. The u2bf row must go red while its u2 counterpart stays green — that divergence is the whole signature. Record both observations per run.

u2tomb-au2tomb-c — a tombstoned referencer does not block (3 mutations) — signs pin A4

Graph-aware. Two transactions, undone in reverse order:

  1. T1 mints the referent (g / l / i).
  2. T2 mints the referencer naming it (s / v / s).
  3. Undo T2 — the referencer is tombstoned in objects, leaves its Score vector, and by pin A7 its value is retained in staff_values / view_values.
  4. Undo T1 — MUST be Applied (or AppliedWithRepair), and the referent MUST leave its Score vector.

Step 3 is what creates the state pin A4 guards against: a value-map entry present while its objects state is Tombstoned.

Killing mutation, three independent runs: delete the matches!(self.objects.get(&…), Some(ObjectState::Live)) conjunct from that family's guard. The dead referencer blocks, step 4 becomes Conflicted, and the row goes red.

u3au3c — same-transaction teardown is allowed (3 mutations) — signs pin A5

The mirror of u2au2c: put both the referent and the referencer inside T. Undo T MUST be Applied (or AppliedWithRepair), and both values MUST leave their Score vectors.

Killing mutation, three independent runs: drop the !targets.contains(…) conjunct from that family's guard. The undo becomes Conflicted.

u4au4e — objects outranks the retained value map (5 mutations) — signs pin A7

One row per family. Take u1's state (minted in T, T undone, so the object is Tombstoned while its value map still holds the value by pin A7), then apply a byte-identical re-carry of the original Create…. It MUST yield NoOpReason::TargetTombstonednot AlreadyApplied.

Byte-identical is the load-bearing choice: it is the one carried value for which the retained map would return identical == true and produce AlreadyApplied, so it is the only re-carry that can distinguish the two orderings. A differing re-carry would yield a precondition no-op either way and sign nothing.

Killing mutation, five independent runs: in that family's reducer, make the Some(ObjectState::Tombstoned { .. }) arm fall through to the value-map identity check instead of returning TargetTombstoned — i.e. reorder so the retained value overrides the tombstone. The row must observe AlreadyApplied and go red.

Row-construction note

create_staff preconditions instrument liveness at :4137 and group liveness at :4149; create_view preconditions layer liveness at :4420. All are graph-gated, so they bind in the u2 / u2tomb / u3 / u4 rows and are skipped in u2bf.

  • u2a, u3a, u2tomb-a need a base carrying a live Instrument: the referencing Staff requires one, and the transaction under test mints a StaffGroup, not an instrument.
  • u2c, u3c, u2tomb-c need no base instrument — the instrument the staff names is the one their own transaction mints.
  • u2b, u3b, u2tomb-b need no base entity at all; AnalysisLayer and ViewDefinition reference nothing outside the pair.

In every graph-aware row the referent must be minted before the referencing op in accepted order. A base-ingested instrument is Live in objects through seed_from_graph without any mint.

Anti-traps

A mutation that does not compile signs nothing. A mutation in an op that runs before the state under assertion cannot reach it — the t8b defect from G3a review. A mutation that leaves the row green signs nothing, and must be reported as such rather than reasoned around. Confirm each mutation produces a red test, then restore by editing the source back, never by git checkout or git stash.

§A4. Gate (Packet A)

  • cargo test --workspace — full pass, count reported and compared to the 1429 baseline at 6c5e69f, with the delta explained by the new rows.
  • cargo clippy --workspace --all-targets — zero warnings.
  • cargo fmt --check — clean.
  • Whitespace, per §C — git diff --cached --check after staging, then the scoped committed-range check after the commit.
  • All twenty-two Packet A mutations observed red and restored, each reported with its actual failing test name. u2bf additionally reports its paired u2 row staying green.

PACKET B — P13-S17, the Binary Format chronology

Separate commit. The stack is unpublished, so the true chronology can be restored rather than patched over.

§B1. Pins

Pin B1 — file P13-S17 in spec/PASS13_CANDIDATES.md: Binary Format revision history omitted genesis tranche G2b; the accept-set raise OperationEnvelopeBlock 2→3 reached the normative tables but never the history. Record that G2b's own contract touch row 27 required "version, Revision History row" and that the rung was signed off without it — the gate did not catch a documentation MUST because nothing tests the history.

P13-S17 lands RESOLVED, in the same commit that files it. Its ledger disposition is not left open: pins B2B4 restore the chronology and pins B5B6 add the guard that makes the omission recurrence-detectable, so the entry is filed and closed by Packet B itself. It is filed rather than merely fixed because the candidate ledger is the record of how the gate failed, and a silent repair would erase that. Contrast P13-S15 and P13-S16, which remain open by design because their fixes are sequenced to later rungs.

Pin B2 — restore the chronology. Ladder order is G1 → G2a → G-minor → G2b → G3a, so:

Document version Event Where
0.12.0 G2a binary_format.tex:3599, unchanged
0.13.0 G-minor :3628, unchanged
0.14.0 G2b — new row inserted after the G-minor row
0.15.0 G3a :3643, renumbered from 0.14.0

The title line at :243 moves to 0.15.0 with a description matching G3a (it already names G3a; only the number changes).

Pin B3 — the G2b row must state what G2b actually did. Read spec/CONTRACT_GENESIS_G2B_TUNING.md and commit 13c3d2f and write the row from them, not from memory. It must name at minimum: OperationKind / OperationKindTag 34 (SetTuningContext), epoch 10, the payload as the five-field subset epiphany_core::TuningContextSettings rather than the full graph type, and — the omission that motivates this packet — the accept-set raise OperationEnvelopeBlock 2→3, the first accept-set move since G2a explicitly recorded staying at 2.

Pin B4 — regenerate spec/binary_format.pdf from the amended source, using the repository's existing build path.

Pin B5 — a scoped history guard, in epiphany-testkit. A new test that reads spec/binary_format.tex and slices to the \chapter{Revision History} section only. Follow the loading precedent in crates/epiphany-testkit/tests/requirement_labels.rs (std::fs from a path relative to the manifest dir) or text_projection_grammar.rs's include_str! — either, but state which.

Pin B6 — presence of a rung name is not enough, and version numbers are forbidden. Two constraints that pull against each other, and the guard must satisfy both.

Why bare name-presence fails: "G2b" already occurs inside the Revision History chapter at binary_format.tex:3657, in the G3a row's sentence "OperationEnvelopeBlock stays at 3 where genesis tranche G2b left it." A guard asserting only that "G2b" appears in the slice stays green after the new row is deleted. It would be born dead.

Why version literals are also wrong: encoding "0.14.0" would pin a number this packet is itself moving, and the next chronology correction would have to edit the guard — the stale hand-maintained parallel list failure (PLAN_GMINOR_SCHEMA_MINOR.md:156).

Which rungs have principal markers: G2a, G-minor, G2b, G3a — not G1. G1 has no standalone row and this packet does not authorize inventing one (pin B2 lists exactly four rows, one of them new). G1 is recorded retroactively inside the G2a row at :3603, and that row states outright that G1 landed "with no matching entry here." A guard demanding a G1 principal marker would be born red against a document B2 leaves correct — the guard would be wrong, not the spec.

The guard MUST therefore assert:

  1. a distinct principal marker for each of G2a, G-minor, G2b, G3a, matching the row form — the rung name immediately preceded by the row's --- separator, e.g. --- Genesis tranche G2b. Prose mentions cannot satisfy this: :3657 says "where genesis tranche G2b left it" and :3603 says "genesis tranche G1 --- landed at", where the separator follows the name rather than preceding it.
  2. ordering — marker offsets strictly increasing within the slice: G2a < G-minor < G2b < G3a.
  3. at least one B3-specific content anchor for the G2b row — SetTuningContext, discriminant 34, and the accept-set raise to OperationEnvelopeBlock 3 — searched only within the G2b row segment, i.e. the span from the G2b marker to the next marker (or the slice end). Unbounded searching would let G3a's row, which names both OperationEnvelopeBlock 3 and G2b, satisfy the anchor after the G2b row is deleted — reintroducing the exact hole this pin exists to close.

G1 is deliberately unguarded. Record that in the test's own comment, citing :3603, so a later reader does not "fix" the omission by adding a fifth marker assertion and rediscovering this contradiction.

Killing mutations, three independent runs: (a) delete the newly added G2b row entirely — the marker assertion goes red, and it must be confirmed that a name-only guard would have stayed green here, which is the finding this pin encodes; (b) strip the accept-set-raise clause from the G2b row, leaving its marker — the content anchor goes red; (c) move the G2b row after the G3a row — the ordering assertion goes red.

All three MUST be observed. Packet B therefore carries three mutations, not one.

§B2. Touch table (Packet B)

# File Change
1 spec/PASS13_CANDIDATES.md P13-S17 (pin B1)
2 spec/binary_format.tex new G2b row; G3a 0.14.0 → 0.15.0; title :243 → 0.15.0
3 spec/binary_format.pdf regenerated
4 crates/epiphany-testkit/tests/ the scoped history guard (pins B5B6)

Nothing else. Packet B touches no reducer and no crate but the testkit.

§B3. Gate (Packet B)

  • Full workspace test, clippy, fmt, and the §C whitespace checks as in §A4.
  • All three pin-B5/B6 mutations observed red and restored, including the confirmation under (a) that a name-only guard would have stayed green.
  • The regenerated PDF's title page reads 0.15.0.

§C. Whitespace checking — the hole this packet closes

git diff --check -- crates/ spec/ is what the G3a rung used, and it is blind to untracked files: it reported clean while this very contract sat untracked. It is also blind to what is already staged.

Per packet, in order:

  1. Stage the touch-table files explicitly. Never git add -A.
  2. git diff --cached --check — catches whitespace in exactly what is about to be committed, untracked-and-now-staged files included.
  3. Commit.
  4. git diff --check <parent>..HEAD -- crates/ spec/ — a scoped committed-range check confirming the landed commit is clean.

Step 4 is deliberately path-scoped. The unpushed range fails git diff --check at spikes/editor-toolkit/round1-oracle/ORACLE_SUMMARY.md:147, an editor-track file outside this packet's authorization. Scoping keeps that pre-existing failure from masking a real one in crates/ or spec/, and keeps this packet from being tempted to "fix" a file it must not touch.


§D. Report requirements (both packets)

State, per packet: the test count before and after; every mutation with the observed failing test name; anything found that the contract did not anticipate. If a pin turns out to be wrong or unsatisfiable, stop and say so rather than working around it — pin A3 and pin A6 are rulings, not suggestions, and pin A6 in particular forbids an addition that would look like diligence.

§E. Boundary — unchanged and absolute

These MUST NOT be read, written, or staged: spec/PLAN_EDITOR_APP.md, spec/CONTRACT_EDITOR_*.md, spec/ANALYSIS_GENESIS_PERSISTENCE.md, spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md, spec/DRAFT_T4_FIXTURE_RECIPE.md, crates/epiphany-editor-gui/goldens/*.png, crates/epiphany-render-svg/**, crates/epiphany-glyphs/**, crates/epiphany-testkit/benches/editor_pipeline.rs, the entire spikes/ tree, the unstaged root Cargo.toml change, and .claude/worktrees/.

The narrow editor authorization granted for the G3a packet (epiphany-editor-core/src/barriers.rs, epiphany-layout-ir/src/barrier.rs) was spent by that packet and does not carry forward. This packet authorizes no editor-crate change of any kind.

Stage only the files in the two touch tables, explicitly. Never git add -A.