epiphany/crates/epiphany-ops/DECISIONS.md

144 KiB
Raw Permalink Blame History

epiphany-ops — decisions and Pass 11 candidates

This file records (a) the implementation decisions the QUICKSTART asked each agent to make once and document, and (b) the ambiguities discovered while building epiphany-ops, batched as Pass 11 candidates for the spec rather than improvised in code (QUICKSTART, Process notes: "Ambiguities go into a batch, not into code … Don't open Pass 11 until you have at least three such items batched.").

RATIFIED (Pass 11, 2026-06-21). The ops-layer Pass 11 candidates have been ratified into core_spec.tex — see spec/PASS11_RATIFICATION_LOG.md. Highlights: C2 adopted (IntegrityAnomalyId = derive_system_id(MUSCSANM,…), with MUSCSANM promoted to a reserved built-in tag); C3 decided (field-collision tags the winner Conflicted); C4 adopted + lifted to spec (the >2-way, partial-overlap promotion generalization); C7 fixed (zero-based DVV floor made normative); C9 decided (TransactionCategory/ObjectKind core vocabularies pinned); C10 decided (added ResolutionAction::Dismiss so Dismissed is reachable). C1/C5/C6/C8 stay deferred to their tracks.

Implementation decisions (QUICKSTART "Decisions you'll need to make")

  1. Replica ID entropy / event-arena / chunk store — N/A to this crate (Agents A, B, D). epiphany-ops consumes Agent B's identifier family and never mints graph identifiers itself, except the deterministic system-derived ones (promoted voices via Agent B's derive_promoted_voice_id, and the content-derived ConflictId / IntegrityAnomalyId).
  2. Async or sync — sync only. No async traits anywhere (decision 4).
  3. MSRV — workspace 1.77 (decision 5). No exotic features. unsafe forbidden crate-wide (#![forbid(unsafe_code)]).
  4. Canonical iteration is structural. Every collection that feeds canonical output is a BTreeMap/BTreeSet or a vector put into the normative order before encoding (Appendix D §"Ordered Iteration"). The determinism fuzzer is the tripwire: it reduces each random set in several acceptance orders and asserts byte-identical materialized state, which would fail the moment a HashMap iteration order leaked in.

Scope boundary: framework in full, representative operations

Chapter 6 specifies the operation framework and a representative selection of operations; the full catalog of ~6080 primitives is an explicit open question (§6.11, marked \openquestion) deferred to the Operation Catalog companion. This crate mirrors that division exactly:

  • Implemented in full (the framework): operation identity/stamps, the HLC and its monotonicity rule, DVV causal context, the order-independent OperationSlot model and acceptance pipeline, the canonical reduction order, the four-phase lifecycle, effects with the typed PreconditionFailureReason, conflict records with content-derived ids, the conflict registry, integrity anomalies kept separate from conflicts, transactions with the descriptor-precedence rule, re-anchoring, LWW discipline, validation modes, and forward undo.
  • Representative (the §6.10 set): InsertEvent, DeleteEvent, RespellPitch, CreateCrossCutting, ChangeRegionTimeModel, SetUserSystemBreak, DeclareTransaction, plus ResolveConflict and UndoTransaction. Together they exercise every reduction discipline the chapter defines (position-keyed insert + voice promotion; delete-wins + tombstone + re-anchor; field-overwrite + conflict; set-union; structural migration; LWW; atomic transactions). The remaining catalog kinds are an additive future change behind the existing OperationKind enum.

MaterializedState is the canonical bookkeeping Chapter 6 owns — the effect log, conflict registry, anomaly register, object existence/tombstones, spellings, and LWW fields. M2 adds OperationSet::reduce_onto(&Score), which seeds those indices from a canonical base and returns the corresponding Agent B graph. Insert/delete, voice promotion, supported reference-level cross-cutting values, system breaks, migration checks, transaction rollback, and undo mutate that graph. The base-free reduce() remains the operation-set convergence API.

M2a (Group 1) — event & pitch leaf-field ops: graph materialization

The Group-1 leaf-field ops (ModifyEvent, Transpose, InsertIdentifiedPitch, DeleteIdentifiedPitch, ModifyIdentifiedPitch) reuse M1's reduction disciplines unchanged; the canonical MaterializedState records only their effect-log entry and — for the field-overwrite ops — a StructuralFieldCollision on a concurrent differing write. Their resolved values live in the graph (reduce_onto), which is derived state, not a second canonical store, so the two decisions below are graph-materialization-only: the bookkeeping projection, and therefore convergence/determinism, is unaffected. Both exist because an in-place EventArena::get_mut edit bypasses insert's well-formedness guard, so the reducer must keep the graph Chapter-5-valid itself (otherwise the malformed state surfaces only later, in check_invariants). Both are exercised at scale by run_graph_convergence (criterion 1, now emitting these kinds) and pinned by targeted reduce_onto tests in tests/graph_reduction.rs.

  • A note and a rest are the same slot under pitch add/remove (note↔rest conversion). DeleteIdentifiedPitch of a single-pitch note's only pitch degrades the event to a Rest of the same id/voice/position/duration rather than leaving an empty pitched event — Chapter 5 forbids the empty chord ("use Rest for the no-pitch case", ArenaError::EmptyPitchedEvent). InsertIdentifiedPitch into a rest is the dual: the rest becomes a one-pitch note. This preserves the ops' disciplines (delete-wins / mint) and keeps the graph consistent with the bookkeeping (which tombstones / mints the pitch object either way). Rejected alternative: a "would empty the event" precondition failure — it needs a new PreconditionFailureReason against a ratified set, breaks delete-wins, and is a worse fit than the editor-natural "deleting a note's last pitch leaves a rest". For the spec: the Operation Catalog §"Insert/Delete identified pitch" (M2e) ratifies this note↔rest equivalence normatively.

  • ModifyEvent materializes metric placement changes (trim/move). A ModifyEvent whose payload moves a metric event (different Musical position or duration) is now applied to the graph and the owning voice is re-sorted by ascending position (id-tiebroken — the same order an insert maintains), so invariant 3 (VoiceEventsSortedNonOverlap) is preserved. This is the make-room enabler: a "pencil" insert trims the events it overlaps. To keep invariant 3, modify_event first checks a placement precondition read from voice_occupancy (the canonical, graph-independent placement index, so reduce() and reduce_onto() agree): a move with a non-positive span, or one that would overlap another live event in the voice, is refused as a clean NoOp(EventDurationInvalid) rather than skipped silently (which would log a clean op that never took effect). A materialized move updates voice_occupancy too, so a later insert sees the freed/changed span. A non-metric placement change is still deferred (re-sorting a non-metric voice is out of scope), and a malformed (empty) pitched replacement is still skipped; same-placement field edits apply as before, preserving voice membership. The LWW bookkeeping records the modify in every case. For the spec: the catalog §ModifyEvent (M2e) prototype boundary moves from "placement-change deferral" to "metric placement-change materialization with an invariant-3 precondition"; partial trimming of a tuplet member, and non-metric moves, remain later refinements.

DeleteEvent re-anchoring: the graph follows the ledger

A DeleteEvent that tombstones an event runs the re-anchoring rule table (Chapter 6 §6.5) over the cross-cutting structures that referenced it. The ledger decision (reanchor_for_tombstone) and the graph mutation (materialize_graph_delete) MUST agree on whether a structure survives — else an object is Live in MaterializedState but gone from the graph (or vice versa), a faithfulness gap the graph convergence gate (criterion 1) would surface.

  • Slurs and spanners re-anchor, not unconditionally drop. The ledger keeps a slur/spanner Live while ≥1 endpoint survives (re-anchored) and cascade-deletes only when none does. materialize_graph_delete now mirrors that exactly: an endpoint-deleted slur/spanner re-anchors onto its surviving endpoint (the structure stays in the graph) and is removed only when no endpoint survives. Previously the graph removed any slur touching the deleted event regardless of the ledger's re-anchor — the divergence this entry fixes. Ties (cascade) and beams (truncate-while-≥2-members, else cascade) were already consistent and are unchanged.
  • A re-anchored two-endpoint structure collapses onto the survivor. With only two endpoints, the sole survivor is the other endpoint, so re-anchoring sets both to it (a degenerate (B, B) slur / spanner). This is reference-clean — the cross-cutting invariant requires only that endpoints reference live events, not that they differ — but musically a stand-in. A proximity-aware target (the note that took the deleted one's place) needs resolved positions and is deferred (P11-C5; nearest_survivor is the lexicographic stand-in).
  • Base-score spanners are now indexed for re-anchoring. seed_from_graph records each seeded spanner's event-anchored endpoints in structures (as it already did for slurs/ties/beams), so a base spanner re-anchors through the same rule as a created one rather than being left dangling.
  • A CascadeDeleteTuplets also drops decompositions that named the tuplet. A notated decomposition component records its tuplet by id (NotatedComponent.tuplet), so once the cascade removes the tuplet structure, any attachment naming it would dangle — check_invariants flags it as a cross-cutting reference that no longer resolves (invariant 6). materialize_graph_delete therefore prunes, in the same step, every decomposition attachment whose components reference a removed tuplet. Those members are tombstoned in the same cascade, so the decomposition has nothing left to describe. This is what lets the editor's atomic tuplet overwrite (a pencil insert over a triplet member removes the whole triplet) leave an invariant-valid graph even when a member carried an in-tuplet decomposition.

This consistency is what lets graph_edit_session create cross-cutting structures and delete their endpoints, giving the Group-2 CRUD ops (and slur re-anchoring) real at-scale coverage under criterion 1 + check_invariants.

M2c (Group 3) — structural containers: empty-only delete

The structural-container CRUD ops (CreateRegion/DeleteRegion, CreateStaffInstance/DeleteStaffInstance, CreateVoice/DeleteVoice) mint an empty container (set-union creation) and tombstone an empty one. Two scoping calls the user made fixed the shape: the slice covers structural container CRUD (not the document root / canvas / global staves — those stay K1), and delete semantics are empty-only (precondition).

  • A delete of a non-empty container is a precondition no-op (ContainerNotEmpty), not a cascade. The caller deletes contents first. Rejected alternative: a cascading delete that tombstones the live children transitively — it conflates two intents (remove this container vs. remove its contents), and a cascade's re-anchoring interactions (a deleted voice's events feeding the cross-cutting re-anchoring table) are a strictly larger design than the slice needs. The empty-only gate is the conservative floor a cascade could later build on. Catalog §Structural Containers (M2e) states this normatively.
  • A create must carry an empty container, too — for every typed child object, not just the structural hierarchy. create_region / create_staff_instance / create_voice reject (ContainerNotEmpty) a carried value bearing any nested object with a distinct TypedObjectId: a region's staff instances, barline-alignment groups, or graphic objects; a staff instance's voices or measures; a voice's events. Graph creation clones the full value into the score, so a carried child would materialize an object the reducer never mints in its objects bookkeeping — a graph/ledger faithfulness gap. The check reads the carried value only, so reduce() and reduce_onto() agree. (Review-driven: the first cut checked only the hierarchy vectors; barline_alignment_groups / graphic_objects / measures were added after.)
  • Live-child sets are tracked in the reducer, not re-derived from the graph. region_instances: RegionId → {StaffInstanceId} and instance_voices: StaffInstanceId → {VoiceId} (a voice's live events are read from voice_occupancy) are maintained by the create/delete materializers and seeded by seed_from_graph, so the empty-only precondition is decided identically in the base-free reduce() and graph-aware reduce_onto().
  • Graph creation maintains the region staff extent. An empty staff-based region with a freshly-created staff instance would violate RegionExtents (the staff extent must list exactly the manifested staves) unless the extent is updated as instances are added/removed; the create/delete materializers do so, and valuegen::region carries a far-future time extent so a fresh region does not overlap an existing one once a staff instance lands.

M2d (Group 4) — score settings: per-op discipline (review-hardened)

The score-settings ops (SetMetadata, SetMetricGrid, SetUserPageBreak) are all field overwrites, but they do not share one discipline; the M2d review (closed in commit d93baac) pinned each to the discipline its catalog classification names. Recorded here because the review changed code/tests.

  • SetMetadata is an advisory LWW — no conflict. The catalog (§set-user-system-break "LWW advisory") and core_spec already classified metadata this way; the first implementation wrongly raised a StructuralFieldCollision on a concurrent differing write, which could make a clean concurrent metadata edit turn MaterializedState::is_clean() false. It now silently last-writer-wins in canonical order (graph singleton overwrite, always Applied, no working slot). Direction chosen: fix the implementation to match the already-correct spec, not the reverse.
  • SetMetricGrid is a structural overwrite with two preconditions. It keys a StructuralFieldCollision (field metric_grid) on concurrent differing grids (kept), and (a) preconditions the region live and staff-based — a FreeGraphic region has no metric-grid slot — and (b) rejects a grid whose meter sequence names a time signature that is not a live object (the Chapter-5 invariant forbids installing such a grid). Both checks read only base-free indices.
  • SetUserPageBreak mirrors SetUserSystemBreak exactly, under the canonical LWW key. Both now (i) share the live-and-staff-based precondition via a staff_based_regions index, so for any region represented in reducer state reduce() and reduce_onto() reach the same missing / tombstoned / FreeGraphic verdict — that is, regions an op stream creates or deletes. (reduce_onto(base) additionally seeds the base regions into that state, which a base-free reduce() does not see, so a layout op targeting a live base region can still apply under reduce_onto and no-op under reduce; the corpus exercises only op-created regions, where the two agree.) Both also (ii) materialize the graph break under the anchor's resolved musical position (apply_break_lww + resolved_anchor_position): any existing anchor resolving to the same position is dropped before the new one is added, so the graph break list stays in lockstep with the resolved-position-keyed ledger map. The system-break fix is a sibling parity change, not new M2d scope.
  • Performance. The dedicated 10K-envelope reducer micro-bench (criterion 5) is Agent F's worklist F1; the M2 value-typed ops are already exercised at 10K·scale by the conformance suite's reduction-determinism and convergence gates, which emit every M2 kind. No criterion bench is added under Agent K.

Pass 11 candidates (ambiguities for the spec, not resolved in code)

P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints

RESOLVED (Phase 2, Agent K — Operation Catalog, M1). The representative payloads are now value-typed: InsertEventOp { staff_instance, event: Event }, RespellPitchOp { pitch, spelling: PitchSpelling }, CreateCrossCuttingOp { structure: CrossCuttingValue }, ChangeRegionTimeModelOp { …, new_time_model: RegionTimeModel }, SetUserSystemBreakOp { …, anchor: TimeAnchor }, and TupletCompensation::ReplaceWithRest { rest: Rest }. They serialize by framing each value's epiphany_core::CanonicalValue bytes behind a u32 length prefix — the ratified byte-convention baseline (Pass 11 item 1.8, req:format:codec-conventions), introducing no new layout (the K↔J seam; see epiphany-core/DECISIONS.md). Graph-aware reduction now materializes the real event/structure rather than the C4 placeholder described below. reduce_onto's reduction rules are unchanged — only the field read-sites moved onto the value. The v0 identifier-only shapes are frozen in src/v0.rs as the migration regression guard, and src/migrate.rs lifts a v0 envelope to v1 deterministically and equivalence-preservingly (migrate_v0_envelope(v0, context: &Score); epiphany-testkit::migration is the merge gate). The full K0 set + the literal wire layout (Binary Format companion, Agent J) follow; the remainder of this entry is the historical v0 rationale. See P12-K1 below and spec/operation_catalog.tex.

Chapter 6's payload structs embed rich graph values (InsertEventOp { event: Event }, RespellPitchOp { new_spelling: PitchSpelling }, …), but the canonical wire encoding of those graph value types is itself deferred to the Binary Format companion (Agent B's P11-4: epiphany-core canonically encodes only identifiers and the scalar time types). An OperationEnvelope must be hashable today — the EnvelopeHash and slot equivocation both need canonical bytes — so this crate's payloads carry the reduction-relevant identifiers and canonical scalar coordinates, plus a ContentHash fingerprint where the reduction needs only equality (a respelling). Graph-aware reduction materializes this projection as deterministic C4 pitches (or a rest when no pitch ids are present); those placeholders do not claim to recover musical values absent from the payload. For the spec: pin the payload schemas (the Operation Catalog companion) and the canonical encoding (the Binary Format companion); when they land, the structs regain their full value fields without changing the reduction. The trigger will be a failing cross-crate round-trip test, per the QUICKSTART process notes.

P11-C2 — IntegrityAnomalyId derivation is unspecified

Chapter 5 gives IntegrityAnomaly an IntegrityAnomalyId but does not pin how it is derived. Because anomalies are deterministic facts of reduction, the id must be content-derived (two replicas must agree). This crate derives it as derive_system_id(MUSCSANM, kind.canonical_bytes()) in the SYSTEM_DERIVED namespace — the same discipline Chapter 5 uses for system identifiers, with a new MUSCSANM extension system tag. For the spec: confirm the derivation (and whether a built-in MUSCS… tag should be reserved for anomalies, as MUSCSVCE/ MUSCSPCH are for voices/pitches).

P11-C3 — which participant's effect carries Conflicted in a field collision

For concurrent differing RespellPitches, the spec pins the conflict record (kind StructuralFieldCollision, with winner/loser and the loser's spelling) and says the later-in-canonical-order operation wins and materializes. It does not pin which participant's OperationEffect is tagged Conflicted. This crate tags the winner (the later op, which materializes and whose processing created the record) Conflicted, and leaves the earlier op's already-recorded Applied effect in place. The outcome is order-independent because canonical order is fixed. For the spec: pin the per-operation effect tag for a field collision (and whether the superseded loser should retroactively read NoOp{SupersededByLaterOperation}).

P11-C4 — voice-promotion derivation inputs and the >2-collision generalization

Invariant 18's promoted-voice derivation takes (staff instance, original voice, winning op, losing op). M2 expanded VoiceOrigin::SystemPromoted to retain both operation ids, so Agent B verifies the exact Agent C derivation. This crate resolves promotion in an order-independent pre-pass: bucket inserts by voice, walk them by OperationId, keep a non-overlapping set in the original voice, and promote each concurrent overlapping loser to derive_promoted_voice_id(staff_instance, voice, winner, loser). This applies the InsertEvent invariant to partial interval overlaps as well as identical start positions. The op carries its staff_instance explicitly (a full reducer recovers it from the voice's container). One open point remains for the spec: define the >2-way collision case — the spec describes a pairwise rule; this crate uses the first lower-id overlapping operation retained in the original voice as the winner for each promotion.

P11-C5 — "nearest surviving anchor" needs resolved positions

The re-anchoring total order (Chapter 6 §6.5) ranks surviving candidates by containment proximity, then absolute time distance, then direction, then id. The prototype does not yet track resolved positions/time per object, so nearest_survivor uses a deterministic stand-in: the lexicographically- smallest surviving endpoint. The structure of the rule table (Tie → cascade-delete, Comment → orphan, Beam → truncate, Slur/Spanner → reanchor-or- cascade) is implemented faithfully; only the metric "nearest" is approximated. For the spec: no change needed — this resolves once the graph mutation phase tracks positions; recorded so the approximation is explicit.

Push-3 update (2026-07): the four-key "nearest" metric is now implemented (nearest_live_event + containment_rank, over the canonical ledger indices) and drives the marker and graphic-gesture rows plus the slur/spanner Reanchored reason; see "Re-anchoring rule-table completion" below. For slurs/spanners the table itself prescribes surviving-endpoint collapse, so nearest_survivor legitimately remains the endpoint-set minimum (proximity- aware re-targeting beyond the endpoints stays the table's own "deferred refinement"). Wall-clock distance in proportional regions remains unimplemented — the occupancy index is metric-only — so a wall-clock referent falls to the kind's declared failure action.

P11-C6 — time-model compatibility is computed when a graph is available

ChangeRegionTimeModel retains a declared_incompatible list for base-free reduction. Graph-aware reduction additionally derives incompatibilities from every event's actual coordinate variants and mapping coverage, refusing any migration that would violate Agent B's coordinate discipline. Concurrent same-region migrations conflict; causally-later migrations are reevaluated against the first migration's graph. For the spec: the rich migration payload still belongs to the Operation Catalog.

P11-C7 — DVV contiguous ranges use the zero-based operation-counter floor

The DVV's contiguous vector[r] = n asserts predecessors (r, 0..=n) exist, matching the operation-id and causal-context documentation. Reduction finds the first absent id in every asserted range and holds the dependent pending; dots and vector coverage of known equivocated/excluded ids remain direct blocking signals, with transitive propagation to dependents. The range check walks known ids rather than expanding 0..=n, so a sparse context with a very high counter does not cause proportional work. For the spec: explicitly retain the zero-based per-replica counter floor in the normative DVV definition.

P11-C8 — forward undo is modeled via minted-object compensation

The spec defines undo as a forward compensating edit (StrictInverse / BestEffort / Cascade) computed against the current materialized state. Without the full graph-mutation phase, this crate models the compensation as tombstoning the objects the target transaction minted: StrictInverse conflicts if any such object was already tombstoned/modified; BestEffort tombstones the survivors; Cascade is treated as StrictInverse over the same set (dependent-closure undo is deferred with the rest of the catalog). Graph-aware reduction also removes those event, pitch, promoted-voice, and supported cross-cutting mints from the live graph and records graph tombstones. For the spec: this is faithful to the "content-equivalence to pre-target state" definition for insert-shaped transactions; the inverse of every catalog primitive is the Operation Catalog's job.

P11-C9 — local minimal enums for open vocabularies

TransactionCategory (spec: open, "used by UIs and analytics") and ObjectKind (used by SystemIdentifierCollision) are given minimal core enums with a Registered(…) escape: TransactionCategory ∈ {NoteEntry, Structural, Layout, Import, Registered}, and ObjectKind ∈ {Voice, Pitch, Registered} (only the kinds the spec actually derives into the system namespace). For the spec: ratify or extend these sets.

P11-C10 — ResolveConflict Dismissed has no distinct payload

The spec distinguishes Resolved from Dismissed resolution states but provides a single ResolveConflictPayload { target, action }. This crate maps every applied resolve to Resolved { action }; Dismissed is reachable as a state but is not authored by a representative op. For the spec: define how a ResolveConflict selects Dismissed (a distinct action, or a separate payload). Phase 2 update: the Operation Catalog (Ch. ResolveConflict) records that ResolutionAction::Dismiss is the action that selects Dismissed; resolved.

Pass 12 candidates (Agent K — Operation Catalog)

P12-K1 — a v0 RespellPitch fingerprint is not invertible to a spelling

The v0 RespellPitchOp carried a ContentHash fingerprint of the new spelling, not the PitchSpelling. The v0→v1 migration (src/migrate.rs) must reconstruct the value, but a fingerprint cannot be inverted without a side table. The migration recovers the spelling from the score-graph context — an explicit per-pitch spelling attachment (SpellingScope::Pitch + SpellingDirective::Explicit) whose canonical bytes hash to the fingerprint — and, when the context lacks it, returns MigrationError::Irreversible (the bundle opens read-only, per the QUICKSTART migration contract). This is the one representative payload that is not self-contained under migration. For Pass 12: confirm the read-only fallback is the intended long-term disposition (vs. requiring a richer v0 corpus that preserves spelling pre-images). Recorded in spec/PASS12_BATCH.md and spec/operation_catalog.tex (§RespellPitch, §Migration).

Provisional canonical encoding (mirrors Agent B's P11-4)

The composite Chapter 6 types use a concrete, reversible canonical byte form (little-endian integers; u32 length prefixes on every variable-length part; NFC + length-prefixed text; single-byte discriminants) so that envelope hashing, conflict-id derivation, and the materialized-state bytes are testable now. This is deterministic and unambiguous but provisional: when the Binary Format companion lands, reconcile encode.rs and the per-type CanonicalEncode impls with it. A failing cross-crate round-trip test is the trigger.

Ratified (2026-07-02): spec/binary_format.tex v0.1.0 Chapter 6 pins this crate's wire forms exactly as implemented — the envelope field order and its normative id-leads property, the OperationPayload (0..=3) and OperationKind (0..=23, append-only) discriminant tables, per-payload framing, OperationKindTag's separate space, the 28-byte stamp, the DVV layout, and the full effects/conflict/anomaly/MaterializedState vocabulary. The reconciliation trigger never fired: the companion was transcribed from this crate and its golden anchors.

Spec-compliance audit follow-up (2026-07, Push 1)

Four reduction-semantics fixes closing MUST-level gaps the six-agent spec audit confirmed, plus one parity check. Each changes canonical effect bytes only in the scenario it fixes; all determinism gates (permutation invariance, 10k fuzz, migration equivalence) stay green.

  • Transpose skips tombstoned targets. The catalog (§Transpose, re-anchoring) says "tombstoned targets are skipped (the transpose applies only to live pitches)"; the code refused the whole operation on any dead target. Now: missing target → whole-op TargetMissing refusal (a dangling reference is malformed authorship, unchanged); tombstoned targets are skipped and the live remainder shifts (Applied); all-tombstoned degenerates to NoOp { TargetTombstoned } (byte-identical to the old behavior for that sub-case). The skip is not recorded as a repair: no compensating change is performed on the skipped target.

  • Marker re-anchoring is a recorded repair. Chapter 6 §Re-Anchoring: "Re-anchoring actions MUST be recorded as RepairRecord entries in the triggering operation's effect." The graph materializer re-pointed event-anchored markers to their region start silently; materialize_graph_delete now returns those re-anchors as RepairKind::Reanchored { …, reason: ExplicitFallback } records (ExplicitFallback because region-start is the documented P11-C5 stand-in for the table's "nearest event in same staff instance" metric, which stays deferred). Delete and undo effects carry them. Bookkeeping-only reduce() cannot see markers (they are graph state), so the records appear only under graph-aware reduction — same documented asymmetry class as base-only regions.

  • System-derived counter collision check (Chapter 5 §"System-Derived Counter Collisions" — the audit's top reduction MUST gap). The reducer now keeps a mint registry (ObjectKind, counter) → (canonical inputs, minting op) seeded from the base graph (SystemPromoted voices via the MUSCSVCE preimage, SYSTEM_DERIVED pitches via canonical_pitch_bytes, now public in epiphany-core for exactly this) and checked by a pre-walk over the canonical order before any effect is applied. Prospective mints are the promotion pre-pass assignments plus SYSTEM_DERIVED pitches carried by minting payloads (InsertEvent, InsertIdentifiedPitch). On the first differing-inputs collision: a SystemIdentifierCollision anomaly is recorded (both input sets retained), reduction does not continue past the collision point, and the earlier occupant op is held too, so neither input set occupies the collided counter. Held ops surface in pending under the new PendingReason::HaltedBySystemCollision { at } (discriminant 4, additive); transactions with a held member are wholly held, and causal dependents hold behind them (DependsOnPending). Scope decisions, made deliberately: (a) the pre-walk is conservative — a claimed mint participates even if the op would fail an unrelated apply-time precondition, since two input sets contending for one counter is a structural identity failure regardless of which contender materializes; (b) a base occupant (owner = None) cannot be evicted by this reduction and is left to diagnostic recovery; (c) in-place content rewrites of a live system pitch (ModifyEvent / ModifyIdentifiedPitch) are not treated as mints — that is Invariant-11 territory. For Pass 12: should reduction refuse content modification of a SYSTEM_DERIVED pitch outright (it silently invalidates the id's content-derivation)? Reader-side diagnostic-recovery gating on a bundle whose state carries this anomaly is bundle/editor work, tracked with the edit-barrier seam.

  • ResolveConflict meta-conflict names both resolvers. The spec requires a true conflict's caused_by to name "at least two" operations; the meta-conflict for a differing later resolve had winner == loser == self and one cause. Now: winner = the earlier resolver (its action stands), loser = the later differing resolver, caused_by = both. affected_objects stays empty — a conflict record has no TypedObjectId kind, so the contested conflict cannot be named there (spec-side gap, Pass-12-adjacent). The related asymmetries (a causally-later resolve cannot supersede; a differing resolve against Dismissed reads AlreadyApplied) are spec-gray and deliberately unchanged.

  • Base-free pitch-id freshness. insert_event now refuses a carried pitch id that already exists in canonical state under bookkeeping-only reduce() too (the graph-aware precondition already did, with the same TargetTombstoned reason), closing one base-free/graph-aware divergence from the audit. Placed after the graph precondition so graph-aware effect bytes are unchanged.

Reserved effect vocabulary (OperationEffect::TombstonedTarget, NoOpReason::SupersededByLaterOperation, PositionOutsideRegion, PitchSpaceMismatch, ReanchorResult) is now annotated as reserved at the type definitions with the condition under which each becomes load-bearing.

Re-anchoring rule-table completion (2026-07, Push 3)

The remaining referent rows of the re-anchoring rule table (core_spec §"The Re-Anchoring Rule Table") are now implemented: marker, cue event, comment, analytical annotation, and graphic gesture — together with the four-key "nearest" total ordering (§"Total Ordering for Nearest") they depend on. No discriminant was added or renumbered anywhere; every record uses the ratified RepairKind/ReanchorReason vocabulary.

  • The four-key "nearest" is computed from the canonical ledger indices. nearest_live_event takes the strict lexicographic minimum of (containment proximity, absolute rational time distance from the referent's resolved position, forward-before-backward, ascending EventId — whose numeric order is its canonical 16-byte order) over live events within the kind's declared proximity bound. Proximity (containment_rank: same voice 0, staff instance 1, staff 2, region 3, canvas 4) reads instance_voices, the new instance_staff map (seeded + maintained by CreateStaffInstance), and region_instances; distance/direction read voice_occupancy. All are base-free indices, so reduce() and reduce_onto() rank identically wherever both can represent a scenario. Occupancy is metric-only, so wall-clock distance (proportional regions) is a deferred refinement: a wall-clock referent finds no candidate and falls to the kind's declared failure action.
  • Where the rows run: the graph arm, one decision per row. None of the five kinds is creatable by an operation — they exist only in seeded base graphs — so their rows run in reanchor_event_referents, called from materialize_graph_delete (hence from both the DeleteEvent path and undo's materialize_graph_tombstones). Each row's ledger record and graph mutation are decided together, in canonical id order, keeping "the graph follows the ledger" (above) intact; reanchor_for_tombstone explicitly skips these kinds so no row is double-recorded. The referents are indexed in the same structures map as slurs/ties/beams/spanners (markers by event anchor, comments/annotations by event-anchored annotation anchors, gestures by Events/Range event references, cues — keyed by their own EventId — by their source lists); the four creatable kinds' create/modify/delete paths are unchanged.
  • Marker: nearest event in the same staff instance replaces the Push-1 region-start fallback. The re-anchored anchor keeps its offset (the survivor shares the staff instance, hence the region and its offset discipline), and the recorded reason names the achieved proximity rank (SameVoiceNearer when the survivor shares the voice). On failure the marker orphans: kept live in ledger and graph with RepairKind::Orphaned; the graph anchor degrades to the containing region's start purely as reference hygiene (invariant 10 rejects a dangling event anchor) — that form is no longer presented as a re-anchor choice.
  • Cue event: plain-text cascade on any source deletion. Deleting any event in a live cue's source cascade-deletes the cue — ledger tombstone, graph removal, CascadeDeleted repair — in the same reduction step. The cascaded cue is itself a tombstoned event, so the full pass runs over its own referents transitively (a cue-of-a-cue cascades along; ties/slurs on the cue re-anchor through the ordinary ledger arm). The rationale-vs-action tension for multi-source cues ("no source is meaningless" suggests truncate-while- any-survives) is deliberately not resolved in code — proposed Pass-12 row.
  • Comment: orphan, with deterministic anchor hygiene. The ledger records Orphaned and the comment survives everywhere; because invariant 10 rejects dangling anchors, the graph anchor degrades deterministically — an Event anchor to AnnotationAnchor::Region(containing region), a dead Range endpoint to the region edge on its side (start → Start, end → End).
  • Analytical annotation: extent-preserving range reconstruction, else orphan. An event-anchored annotation whose event dies re-anchors to AnnotationAnchor::Range with both endpoints as region-start Musical offsets covering the event's exact span (positions are region-relative, so the resolved extent is preserved); recorded as Reanchored { to: Region(region), reason: ExplicitFallback } (the row is a declared fallback, not a proximity choice). Range-anchored annotations get the same treatment per dead endpoint (event position plus any musical anchor offset). A wall-clock or indeterminate span is not expressible as a stored region-relative range — the annotation orphans with the comment's anchor hygiene; the expressibility gap is a proposed Pass-12 row.
  • Graphic gesture: nearest re-target / truncate / orphan. Events references to the dead event re-target to the nearest survivor in the same staff instance (Reanchored, reason per achieved rank); with no candidate the reference drops — SpannerTruncated { removed_members: [event] } while references remain (the vocabulary's own definition, "lost members but enough remained", fits), Orphaned when the list empties (gesture kept, user content). Range anchoring "truncates": a dead endpoint moves to its region edge (start → Start, end → End, offset zero) with Reanchored { to: Region, reason: ExplicitFallback } — the least-surprising deterministic reading of the table's one-word action; proposed Pass-12 row. Free is never indexed (table: "no action").
  • Slur/spanner Reanchored reason is computed, not hardcoded. The surviving-endpoint collapse itself is unchanged, but the reason now names the survivor's actual containment rank relative to the tombstoned endpoint (containment_rank over the same ledger indices; SameVoiceNearer remains the default when neither side has an indexed metric placement). Rank 4 (same canvas) has no ratified ReanchorReason variant, so it is recorded as ExplicitFallback rather than appending a discriminant — spec-vocabulary question, proposed Pass-12 row.
  • Known asymmetries, kept deliberately. (a) The five rows fire only under graph-aware reduction — the same documented asymmetry class as the Push-1 marker fix (base-free reduce() cannot see these kinds at all). (b) The tie/beam/slur/spanner ledger arm still does not run in the undo path (pre-existing gap, unchanged by this slice; the graph-only rows do run there via materialize_graph_delete).

ResolveEquivocation + validation modes (2026-07, Push 3)

Two Push-3 items land together: the ResolveEquivocation meta-operation (operation_catalog §"ResolveEquivocation", ratified this pass) and the validation-mode seam (core_spec §"Validation Modes"). One payload discriminant was appended (OperationPayload::ResolveEquivocation = 3); the ratified 0..=2 and every other discriminant table are untouched. Canonical reduction changes only for scenarios containing a valid resolve of an equivocated slot — everything else reduces byte-identically (the extended equivocation fuzz plus the unchanged run_equivocation_fuzz gate this).

  • Promotion is a set-level pre-pass, not a walk step. The catalog's rule ("when the operation set holds an Equivocated slot for target and chosen names one of its candidates, the slot reduces as if it had always been Single") conditions on the operation set, so Reducer::run resolves it in step 1b, before pending computation and ordering: among Single-slot, non-quarantined resolves whose (target, chosen) is valid, the smallest reduction tuple — the same total HLC order canonical_reduction_order selects ready ops by — governs. The chosen candidate envelope then joins the reducible set at its own canonical position: it flows through compute_pending (its own causal gaps still hold it), transactions, voice promotion, and the walk exactly like a native Single, and dependents that were DependsOnEquivocated unblock. A resolved slot records no OperationSlotEquivocated anomaly; losing candidates stay only in the opset's diagnostic candidate store.
  • No OperationSet API extension was needed. The promotion needs the candidate envelope, and OperationSet::candidate(hash) already exposes the retained diagnostic store (the CRDT property forbids dropping candidates, so every hash in an Equivocated slot resolves). accept transitions are untouched — the opset still holds the slot as Equivocated; the promoted view lives only in the reducer (promoted_singles, consulted by env_of so concurrency checks see the slot "as if always Single").
  • Resolve effects mirror resolve_conflict. Governing resolve → Applied; later resolve naming the same candidate → NoOp(AlreadyApplied); later valid resolve naming a differing candidate → StructuralFieldCollision on FieldPath("equivocation_resolution") with winner = governing, loser = later, both in caused_by, affected_objects empty (a slot is not a TypedObjectId) → Conflicted. A resolve whose target is absent, holds a Single slot, or whose chosen names no candidate is a precondition no-op reusing TargetMissing — the named target/candidate pair does not exist — rather than a new appended reason.
  • Deliberate single-pass simplifications (proposed Pass-12 rows). (a) A promoted candidate that is itself a ResolveEquivocation does not govern a further promotion (no cascade/fixpoint; the catalog names none). (b) The promoted candidate is not re-subjected to HLC-monotonicity segmentation (quarantine detection runs over native singles first; "as if it had always been Single" vs. the quarantine pass ordering is a spec question). (c) A resolve held pending by its own causal gaps still governs promotion — the set-level rule needs no walk position — while its recorded effect stays subject to the ordinary pending rules.
  • v0 migration. v0 predates the entry, so V0OperationPayload gains a ResolveEquivocation variant carried verbatim — the same "v1-native, round-trip by identity" treatment as the Group 14 kinds. project_v1_to_v0 stays total; migrate_v0_envelope maps it back by identity (deterministic, trivially equivalence-preserving).
  • Golden locks added. operation_kind_wire_discriminants_are_golden pins all 24 OperationKind literals (0..=23), operation_payload_discriminants_are_golden pins the payload union (0..=3 incl. the appended variant), and resolve_equivocation_payload_encodes_target_then_hash pins the 16+32-byte layout — the append-only discipline is now enforced by test, not convention.
  • Validation modes: the reducer is replay mode. src/validate.rs adds ValidationMode { Authoring, Replay } and advisory_violations(kind, score). reduce/reduce_onto enforce exactly the invariant preconditions in every context; authoring enforcement happens in epiphany-editor-core, which runs advisory_violations against its current materialized score before minting and refuses with a new EditorError::AdvisoryViolation (no envelope enters the log). Canonical reduction behavior and bytes are untouched by the mode machinery; AdvisoryViolation is deliberately non-canonical (no encoding, no discriminants — it never enters effects or state).
  • Advisory inventory (core_spec §6.10). Implemented: InsertEvent / ModifyEvent duration-not-crossing-region-boundary (the span straddles the region's musical end bound — resolvable when the extent's end anchor is region-start-anchored with a Musical offset, the same sound-but-incomplete discipline as Region::overlaps_in_time), and CreateCrossCutting(Slur) not-spanning-a-region-boundary (endpoint events resolve to different regions). Blocked on the truncated data model, documented in the module docs: InsertEvent pitch-within-instrument-range (Instrument carries no range field; staged to the Binary Format companion) and the Slur rule's "unless explicitly permitted by region configuration" (no such flag on Region; spanning is treated as never permitted until it lands). A wall-clock or symbolic region extent yields no musical bound, so the boundary check passes vacuously there (deferred tempo/measure resolution, P11-C5).

Edit-barrier bridge: OperationKindTag decode (2026-07, Push 3)

  • OperationKindTag gains its decode mirror. The tag had CanonicalEncode only (the discriminant byte, plus the registry id's 16 big-endian bytes for Registered); the edit-barrier blob codec (epiphany-layout-ir, the barrier owner) needs the inverse to read prohibited_operation_kinds back out of a manifest declaration. CanonicalDecode now decodes exactly the encoder's image: variable width (1 byte, or 17 for Registered), length mismatches and trailing bytes rejected, and an unknown discriminant rejected — via DecodeError::MalformedDomainTag, the same unknown-discriminant rejection TypedObjectId::decode_canonical already uses, so no new error variant and no change to the determinism crate. No encoding changed and no discriminant was appended; operation_kind_tag_decode_mirrors_encode_exactly / operation_kind_tag_decode_rejects_malformed_bytes pin the contract.

Subquadratic canonical_reduction_order (2026-07, the F1 → K fix)

  • The defect (F surfaces, K fixes). The testkit's F1 bench (crates/epiphany-testkit/benches/reduction.rs, Chapter 10: > 10,000 envelopes/s cold) documented canonical_reduction_order as O(n²) twice over: a literal double loop over all (predecessor, successor) pairs to build indegrees, and a full ready-scan per emission. Measured pre-fix: ~155K / ~12.5K / ~1.7K env/s at 1K / 10K / 50K envelopes (50K ≈ 29 s per cold reduce — the documented xfail row).
  • The algorithm: term decomposition + monotone thresholds, never pairs. Materializing edges is inherently quadratic for the common chain shape (every DVV floor covers the whole replica prefix, so covered pairs are Θ(n²)); the rewrite therefore never enumerates pairs. Each causal-context entry becomes one requirement term over the present set:
    • Vector floor (r, n) — covers exactly the present envelopes of replica r with counter <= n (zero-based floor, P11-C7): a prefix of the replica's lane sorted by (counter, slice index). The term is satisfied when the lane's emission frontier (first unemitted slot, monotone) passes the prefix; if the floor covers the envelope's own id, only the self-pair is exempt, so that term instead reads "frontier at own slot and second frontier past the prefix" (both monotone). Terms park in per-lane BTreeMaps keyed by the threshold slot and are drained exactly once as the frontiers advance.
    • Explicit dot — covers exactly the present envelopes bearing that id; satisfied when the id's unemitted multiplicity reaches 0 (1 for a self-dot, which covers only duplicate-id twins). An envelope is ready when its unsatisfied-term count reaches zero; ready envelopes sit in a BinaryHeap keyed by (reduction tuple, slice index). A pre-sorted (tuple, index) list with a cursor supplies the malformed- cycle fallback (heap empty, envelopes remain). Total work is O((n + Σ|context|) log n).
  • Why the order is byte-identical (the consensus argument). The order is defined by: edge p → s iff p != s and s.context.covers(p.id); ready iff every present covered predecessor emitted; emit the minimum (reduction tuple, slice index) among ready (slice index reproduces min_by_key's first-minimum rule — reachable only under duplicate-id tuple ties); if none is ready, the same minimum over all unemitted (cycle break). The rewrite computes the same readiness predicate: each term is satisfied iff all envelopes it covers are emitted, every covered predecessor is covered by at least one term, and terms cover only covered predecessors — so "all terms satisfied" ⇔ "all covered predecessors emitted", including the exempt self-pair (a floor/dot naming the envelope's own id) and envelopes covered by both a dot and a floor (the conjunction needs no per-pair dedup; the redundant term is harmless). Absent context entries (unknown replicas, floors below every present counter, absent dots) yield no term, exactly as they yield no edge. Same edge relation, same ready set, same total order on ready ⇒ the same emission sequence, element for element.
  • The retained oracle + regression gate. The pre-fix implementation is kept verbatim as canonical_reduction_order_reference (#[cfg(test)]), and three property tests assert element-for-element pointer equality of both orders (the strictest check — it distinguishes byte-identical twins): canonical_order_matches_reference_on_fuzz_sets (the crate's well-formed fuzz generator, 250 seeds × 2 slice orders), ..._on_adversarial_sets (400 randomized hostile sets: self-covering floors, dots to present/absent/own ids, duplicate ids with tied stamps, SYSTEM_DERIVED replicas, stamps contradicting the causal edges, empty contexts), and ..._on_directed_shapes (a 2,000-envelope full-coverage chain with descending stamps, self-covering and dot-only chains, dot 2-/3-cycles, mutual-floor cycles, twin permutations). Mutation-checked: breaking the self-exemption or the self-dot threshold fails the suite.
  • Measured post-fix (same bench, dev profile 2026-07): ~674K / ~257K / ~87K env/s at 1K / 10K / 50K — the 50K row cleared its budget by ~8.7x, the gate printed its XPASS promotion notice, and the row was flipped to Pass in the same change (see epiphany-testkit/DECISIONS.md F1).

Phase-3 first tranche: staff/meter/tempo/layout ops + value-restoring undo (2026-07)

The ratified catalog text (operation_catalog §CreateStaff, §"Meter and Tempo Overwrites", §SetStaffLayout, and the rewritten §UndoTransaction) is the contract for this tranche. Wire facts are strictly additive: OperationKind discriminants CreateStaff = 24, SetTimeSignature = 25, SetTempoSegment = 26, SetStaffLayout = 27; OperationKindTag discriminants InsertStaff = 24 (Create→Insert tag naming, cf. InsertRegion), SetTimeSignature = 25, SetTempoSegment = 26, SetStaffLayout = 27; PreconditionFailureReason::TempoMapMalformed = 11. No other discriminant table changed. Decisions the catalog left to the implementation:

  • The differing-value re-create / re-carry precondition reason is TargetMissing. §CreateStaff makes a byte-identical re-create idempotent (AlreadyApplied) and a differing value under a live id "a precondition no-op", without naming a reason; the reducer reuses TargetMissing (the generic dangling/unusable-target reason ModifyCrossCutting's malformed branch already uses) rather than minting a new discriminant. The same discipline (and reason) applies to the TimeSignature value a SetTimeSignature carries. A tombstoned id refuses as TargetTombstoned.
  • Tempo-map well-formedness is checked against the chain state, under the coarse resolved-position key. The resulting-map precondition (TempoMapMalformed) evaluates the scope's prospective segment list built from the tempo write chains (base-seeded under reduce_onto), ordered by the same resolved_anchor_position key the LWW slot uses: carried-start / key agreement, per-shape end data (a non-constant shape needs end + end_tempo; a constant end_tempo must equal start_tempo — the graph invariant checker's rules), resolved ends ≥ their start and ≤ the next key. Removals cannot malform a map (the gap rule holds the prior tempo) and always apply.
  • Graph normalization on removal. A meter removal that empties a grid the meter writes themselves created normalizes the region's default_metric_grid back to None (unless a whole-grid write or the base holds a grid independently); a tempo-segment removal that leaves a region's local map empty and initial-less normalizes local_tempo_map to None (an empty local map would shadow the score map rather than fall back to it). Both keep "create on set" and "remove last" inverses of each other.
  • CreateStaffInstance now preconditions a live staff — graph-aware only. §CreateStaff makes the check normative now that staves are mintable; like the insert preconditions, it is enforced when a graph is present (base-free reduction has no staff universe), which keeps every existing seeded-base scenario vacuously green. CreateStaff's own instrument/group resolution preconditions are graph-aware for the same reason.

Value-restoring undo: the write-chain design

Every LWW overwrite family now maintains, per key, the canonical-order write chain WriteChain<V> { base: Option<V>, writes: Vec<(op, tx, value)> } (replacing the former last_* last-writer maps; each chain's last write is still the LWW concurrent-differing comparison point). Families: event modify, identified-pitch modify, respell, cross-cutting modify, metadata, metric grid, meter change (new), tempo segment (new), staff layout (new), and the user system/page breaks. The reducer applies operations in canonical order, so appends are inherently canonical and every verdict below is permutation-invariant by construction (pinned by undo_restoration_is_permutation_invariant and the convergence gates).

  • Seeding. seed_from_graph seeds each chain's base with the base-graph value (events, pitch values, cross-cutting values, metadata, grids, meter changes, tempo segments, staff-instance layout fields, break anchors, and explicit user-chosen per-pitch spelling attachments). Mints seed the chains they create state for (InsertEvent → event + pitch chains, InsertIdentifiedPitch → pitch chain, CreateCrossCutting → structure chain, CreateRegion → grid/meter/break/tempo chains from the carried content, CreateStaffInstance → layout chain), so a chain-predecessor is defined identically under reduce() and reduce_onto() for op-minted objects. Only base objects in base-free reduction lack seeds — the established API-divergence caveat (see M2d) applies unchanged.
  • Undo verdict per written key (WriteChain::undo_verdict): if the target transaction's write is still the key's last, restore the chain-predecessor — the latest non-member write, else the base value, else absence; if a later writer superseded it, StrictInverse refuses the whole undo with a TransactionConflict (caused_by = undo + the canonically-first superseding writer) while BestEffort restores the still-last keys and skips the superseded. Keys whose owning object is tombstoned — or is itself one of the transaction's mints, about to be tombstoned by the same undo — are skipped entirely (no live slot to restore; not a conflict).
  • Absence semantics per key. Optional slots restore literal absence: the grid clears, the meter change / tempo segment / break is removed (with the normalizations above). The canonical bookkeeping maps (spellings, breaks, page_breaks) return to key-absence whenever the predecessor is the base value — base state lives in the graph, not the operational ledger — while the graph restores the base value (spelling attachment, break anchor). Always-valued families (event, pitch, cross-cutting, metadata, staff layout) with no known predecessor (reachable only base-free, for pre-horizon objects) restore nothing — a bookkeeping-only outcome.
  • Effects. A fully clean compensation is Applied; AppliedWithRepair carries only the minted-object tombstone repairs (CascadeDeleted), per the catalog — restorations add no repair vocabulary. A transaction that minted nothing and wrote nothing this reduction knows of stays the TargetMissing no-op (the pre-tranche behavior); a transaction that did write chains is now genuinely undoable — the sanctioned semantic change to previously-TargetMissing overwrite-transaction undos.
  • Mixed transactions compose both passes; StrictInverse refuses the whole undo if either part fails (a tombstoned mint keeps the pre-existing TombstonedTarget conflict; a superseded key raises the TransactionConflict above), and the refusal applies nothing. Two new strand guards protect the mint pass: a minted Staff still manifested by a live non-member instance, and a minted TimeSignature still referenced by a meter change that survives the restoration pass, refuse under StrictInverse (a TransactionConflict naming the blocked object and its referencer — reusing ratified conflict vocabulary rather than minting a new kind; a dedicated "stranded reference" kind is a Pass-12 question) and are skipped (left live) under BestEffort.
  • Undo-of-undo (pinned). A restoration that restores a value enters the key's chain as a new write by the undo operation. Consequences, both pinned by tests: (i) undoing the first undo's own enclosing transaction restores the value the first undo removed (undo_of_undo_restores_the_pre_undo_value); (ii) a second undo of the same target transaction finds the key superseded by the first undo — Conflicted under StrictInverse, skipped under BestEffort (a_second_undo_of_the_same_transaction_sees_the_first_as_superseding). An absence restoration is not representable as a chain write and leaves the chain unchanged, so repeating it is idempotent — the one asymmetry, accepted until a chain-native absence entry is worth its weight.
  • Deferred residue (unchanged from the catalog's "Still deferred"): delete resurrection (P11-C8), Transpose inversion (P12-K2) — so a transpose between a write and its undo is not re-applied on top of the restored value (the chain predecessor wins; transpose composition is not a chain write) — and Cascade's dependent closure (Cascade remains StrictInverse over the same set).

Proposed Pass-12 rows from this tranche: (1) the TargetMissing reuse for differing-value re-creates/re-carries (vs. a dedicated reason discriminant); (2) a conflict-kind vocabulary for undo strand-blocks (live-reference tombstone refusal) instead of TransactionConflict reuse; (3) whether an undo's chain write should carry a distinguished provenance so a second undo of the same transaction could be defined as idempotent rather than conflicting; (4) P12-C5 stands as filed (the decomposition pre-pass still honors only the first governing meter — the reduction semantics are pinned here and tested under a_mid_region_meter_change_reduces_cleanly_p12_c5).

Pass 12 G-pass (2026-07-07): the K and C rows are ratified

All open K/C rows are retired (dispositions in spec/PASS12_RATIFICATION_LOG.md, "G-pass tranche"). Adopted as implemented: K1 (migration read-only fallback is long-term), K4 (earliest resolve governs universally — no supersede; re-resolution is a future dedicated op; no Conflict object kind), K6 (single-pass promotion, quarantined resolves never govern, pending resolves govern set-level, TargetMissing reuse kept), K10 (TransactionConflict reuse blessed for strand-blocks), K11 (undo idempotence asymmetry is normative), K12 (cross-region slur = AND), C1 (cue cascades on any source death), C2 (Range truncate = region edge, zero offset), C3 (annotation orphaning sanctioned), C5 (folded into H4's single-meter bound). Deferred with named sites: K2 (tuning catalog; prototype pinned as declared v1 behavior, replacement = payload schema-major), K5 (Profile Conformance companion; v1 profiles declare no selection function). Decided with code to land in the G-pass code tranche: K3 refuse SYSTEM_DERIVED intrinsic-content rewrites (PreconditionFailureReason::SystemDerivedContentImmutable, discriminant 12), K9 differing-value re-creates get RecreateContentMismatch (13), C4 rank-4 re-anchors get ReanchorReason::SameCanvasNearer (6; 5 was already DeclaredByExtension). K8 retired: genesis is outside the operation set (catalog K1 slots removed, core Ch5 states it). Catalog 0.5.0 → 0.6.0; Binary Format 0.3.0 → 0.4.0.

G-pass code-tranche review findings (2026-07-07, all fixed before commit)

A high-effort review of the code tranche confirmed three correctness findings, each fixed with a regression test:

  1. Transpose bypassed P12-K3 — it shifted a SYSTEM_DERIVED pitch's alteration in place, desynchronizing content from the id's derivation inputs and making the K3 verdict depend on where a snapshot was cut (a full replay's registry holds the mint content; a post-transpose snapshot's holds the shifted content). Fixed: system-derived targets are skipped like tombstoned ones (id-namespace filter, so base-free and graph-aware reduction agree); an all-system-derived transpose reduces as SystemDerivedContentImmutable. Catalog §Transpose updated.
  2. Unestablished rank-4 re-anchors were relabeled SameCanvasNearercontainment_rank returns 4 both for the established same-canvas case and as the fallthrough when a voice's placement is unresolvable; the P12-C4 append must not launder the latter into a positive proximity claim. Fixed: recording routes through rank_reason, which downgrades an unestablished 4 to the honest ExplicitFallback; selection order is unchanged (the rank still compares as 4).
  3. Vocabulary generators lagged the appends — testkit precondition_failure_reason/reanchor_reason never emitted discriminants 12/13/6, so fuzz gates could not catch a renumbering regression. Fixed.

Pass-13 candidate (P13-K1) — resolved (Pass 13, 2026-07-08; user: "reject the introduction"). The K3 verdict for a system pitch introduced by a ModifyEvent replacement value (never minted — the collision pre-walk excludes ModifyEvent) differed across a snapshot cut: in-session the pitch was not Live (slipped through — system_mints had no entry, so the identity check saw nothing); after a snapshot re-seeded objects + registry from the base graph, the same modify read SystemDerivedContentImmutable.

Resolved by rejecting the introduction: modify_event now refuses a replacement that carries a never-minted system-derived pitch id (replica SYSTEM_DERIVED, absent-or-not-Live in objects) with TargetMissing, before the P12-K3 identity check. The verdict no longer depends on the registry — the pitch is not live in objects in either frame — so both frames refuse identically, closing the asymmetry. Scoped to the system namespace (where the asymmetry lives: only system pitches are re-seeded as system mints): a user-replica pitch carries no namespace claim and has no snapshot asymmetry, so ModifyEvent may still introduce user pitch content (the concurrent-modify tests rely on it). Locked by a_modify_event_introducing_a_never_minted_system_pitch_is_refused_p13_k1.

Schema major 2: minimal stamping (landed with core Phase B, deliberately)

The live codec change makes CrossCutting/Staff/Metadata payload bytes v2 immediately, so the honest stamps land in the SAME commit rather than a later phase (the major-1 D1/D2 byte-shim approach would have needed v1 shims for nine transitively-embedded types, all throwaway). OperationKind::schema_major implements the ratified minimal-stamping rule (Binary Format §Schema Major 2): Create/ModifyCrossCutting, CreateStaff, SetMetadata → always 2 (mandatory appends); CreateRegion → 2 iff a carried staff instance bears Some(staff_lines_override) else 1; CreateStaffInstance/ SetStaffLayout → 2 iff Some(override) else 0 (None encodes byte-identically to the prior major). Locked by schema_majors_follow_the_minimal_stamping_rule. the_canonical_base_is_byte_identical_across_data_model_majors golden-locks a seeded reduction's MaterializedState bytes — the companion's SHOULD that the canonical base never moves across data-model majors.

Review sharpening — the op-payload migrate-on-read deferral, stated precisely: with the live codecs at v2, op-payload BYTES from a pre-major-2 build (a persisted bundle whose CrossCutting/Staff/Metadata blocks were stamped major 0 under the old per-kind rule) have no in-build decoder — the frozen v1 layer covers whole-Score snapshots only, while binary_format's migration table ratifies "a v0/v1 op block migrates on read". This is ACCEPTABLE TODAY because (a) no production corpus exists (local repo, test bundles only), and (b) no code path decodes op-envelope bytes back to values (the bundle treats block bytes opaquely; reduction runs on in-memory envelopes; the opindex reads only the leading id). The moment a consumer byte-reconstructs op payloads (bundle replay of foreign documents, the P2 ops-decoder fuzzer, MusicXML round-trip tooling), it MUST bring the op-payload migrate-on-read primitive with it — per-type frozen v1 payload decoders keyed by the block's stamped major. Tracked as the standing Phase-C remainder in the Push-2 plan.

Schema major 2, Phase D — the repeat-authoring pair (code tranche)

Stamping. The minimal-stamping per-payload table (see "Schema major 2: minimal stamping" above) gains the pair: CreateRepeatStructure ⇒ always 2 — born at v2; the carried RepeatStructure's kind/voltas are unconditional fields, so even the migration-default value has no lower-major layout — and DeleteRepeatStructure0 (a bare identifier is a major-0 layout; the kind discriminant itself is a schema-minor vocabulary append, the Phase-3 mechanism — the stamp is always minimal stamping over the payload, never a property of the append).

Set-union without value comparison. A repeat create of a live id reads AlreadyApplied — the cross-cutting discipline. RecreateContentMismatch stays scoped to CreateStaff + the carried TimeSignature (the two sites that retain the carried value for comparison); extending it here was considered and declined: repeats mirror create_cross_cutting, whose family the catalog places them beside.

The all-anchors-live precondition covers every event-referencing anchor site — start/end, DaCapo/DalSegno jump targets, volta spans — via RepeatStructure::anchor_sites() (core), THE single site-set walk. Reduction (ledger + graph), the editor barrier seam, the invariant anchor walk, and core's cross-reference index all consume it, after review found five hand-rolled copies plus a SIXTH, stale one: indexes.rs had never learned the Phase-B kind/volta anchors (start/end only, contradicting its own every-referenced-object doc). Fixed with a regression test. Plain field walks get no exhaustive-match protection; the shared method is the guard.

Survivor selection. "Nearest surviving anchor" is realized as the deterministic identifier-order minimum over the structure's surviving endpoints — for two-endpoint slurs/spanners that was the vacuous tie-break; for multi-site repeats it is load-bearing, so the rule-table row and the catalog now say so explicitly, with proximity-aware (four-key) selection deferred exactly as the spanner row defers per-kind proximity bounds.

Spanner ghost fix (pre-existing). materialize_graph_tombstones never removed an undone Spanner mint from the graph (Slur/Tie/Beam were handled); the walk gains the Spanner and RepeatStructure arms, regression-locked in undoing_a_repeat_or_spanner_create_removes_it_from_the_graph.

Canonical-base pin, honestly. The 200-envelope blake3 was re-pinned (the gen_payload modulus shift moved the whole seeded stream); review found the seeded corpus's repeat creates all no-op, so that pin cannot detect a repeat-value leak into the base. The dedicated the_canonical_base_embeds_no_repeat_values test covers the property surgically: two reductions differing only in the created repeat's v2 content must produce byte-identical bases.

Pass-13 candidates filed (batch now open, see spec/PASS13_CANDIDATES.md): P13-D1 — undo-driven event tombstones run the graph-side re-anchor/cascade (materialize_graph_tombstonesmaterialize_graph_delete) but never the ledger-side reanchor_for_tombstone: a structure whose anchors are all undo-tombstoned leaves the graph while staying Live in objects, with no RepairRecord — a pre-existing class (slurs/spanners identically) that the repeat row now also exhibits; contradicts Ch6's same-step RepairRecord MUST under an undo-driven tombstone. P13-D2 — the cue-cascade recursion re-anchors against the triggering event before that event's own tombstone lands in objects, so a structure anchored on {X, cue-of-X} can record 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).

P13-D2 resolved (Pass 13, 2026-07-08). delete_event tombstoned the event in objects after materialize_graph_delete — but that graph pass cascades a cue among the event's referents, running reanchor_for_tombstone over the cue's own referents while the source event is still Live. A slur bridging {X, cue-of-X} therefore re-anchored onto X (recording Reanchored{to: X}) and then cascade-deleted when X's tombstone landed a line later — a contradictory same-effect trail (candidate: "plausible by code trace, unexecuted"; now executed — reverting the fix reproduces exactly that two-record trail). Fixed by tombstoning the event in objects before the graph delete, matching the conventions cascade_cue and tombstone_undo_targets already follow (both tombstone before their graph delete — which is why the undo path never had this bug). The bridging slur now sees X already dead during the cue cascade and cascades once. Locked by deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_bridging_slur_p13_d2.

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 kinds, now also true of the delete; a reader hitting the unknown discriminant cannot attribute the failure to version skew from the stamp alone. A bundle-writer design item for the next bundle tranche.

Follow-up (user review): the anchor-site set, consumed WHOLLY

Post-commit review found the Phase-D unification incomplete in exactly two places that still collapsed anchor_sites() to events: (1) the mint precondition validated only TimeAnchor::Event targets, so a create whose start named a missing REGION (or a volta span a missing MEASURE) minted a dangling repeat straight past CrossCuttingRefsResolve — fixed with anchor_object_refs (events + measures + regions; wall-clock references nothing), deterministic across both reduction modes since the base seed registers regions and measures in objects; and (2) the editor barrier containment derived only from event locations, so a repeat anchored solely to a protected region carried a default context and bypassed a region-scoped barrier — fixed with repeat_context (first object-referencing site in anchor order binds: event/measure → (region, instance); bare region anchor → the region). Both regression-locked. The referent INDEX stays event-only by design (the rule table repairs event tombstones; the spanner discipline).

P13-D3 filed: CreateCrossCutting has the same mint-time shape for SPANNERS — CrossCuttingValue::endpoints() filters to events, so a spanner anchored to a missing region/measure mints dangling past the invariant (anchor_target_exists checks spanner anchors at all three kinds), and non-event referent tombstones (a DeleteRegion under a region-anchored spanner or repeat) re-anchor nothing.

P13-D3 resolved (Pass 13, 2026-07-08; user: "fix the mint only"). The MINT is fixed: CrossCuttingValue::anchor_object_refs() returns the full anchor object set (events + a spanner's measure/region anchors; wall-clock references nothing), and create_cross_cutting's liveness precondition now checks it — so a spanner anchored to a missing region/measure is refused (TargetMissing) rather than minted dangling, exactly as the repeat mint's anchor_object_refs fix does. Deterministic across both reduction modes (the base seed registers regions and measures in objects). endpoints() stays event-only — it feeds the re-anchoring referent index, and non-event referent re-anchoring stays deferred, ratified events-only (the spanner discipline; extending the referent index to region/measure tombstones is the larger change the user parked). Regression: create_cross_cutting_spanner_preconditions_region_measure_anchors.

Push 4a — the transpose algebra (design gate, 2026-07-09)

TransposeOp { targets: Vec<PitchId>, chromatic_steps: i32 } was pinned in Pass 12 (P12-K2) as a prototype whose repair would be "a payload schema-major landing with the Chapter 4 tuning catalog". An audit reopened it. Both halves of that pin were wrong, and the operation had more defects than the pin admitted.

What is actually broken. Measured, not inferred — a probe through EditorSession on a C4:

chromatic_steps result
+1 C#4
+12 C4 with alteration: 12 — six double-sharps, not C5
+128 alteration: 127, silently clamped, reports Applied
targets: [p, p], +1 alteration: 2

Also: a non-Cmn position is left untouched while the operation reports Applied. And transpose(1000) then transpose(-1000) lands on alteration: -128, so the operation is not invertible.

Nothing downstream is at fault. prepass::accidental_ids faithfully renders alteration: 12 as six double-sharps; the engraver draws what it is handed. The whole defect is in what Transpose means.

The false coupling, and its limit. Pitch has two orthogonal fields: scale_position and acoustic. Transposition adds an interval to a scale position; tuning decides what frequency that position sounds at. Push 4a therefore needed no tuning resolver for the built-in cmn-12 arithmetic. It also corrected the claim that every PreconditionFailureReason::PitchSpaceMismatch needed the Chapter 4 catalog: detecting a non-Cmn position reads only its discriminant.

P13-S2 later exposed the boundary of that result. Generalized Cmn transposition is space-relative, so its chromatic cardinality and nominal map do require pitch-space registry resolution. Until Push 4b lands that resolver, the core refuses a Cmn position outside provable built-in cmn-12, and TransposeInterval maps that distinct capability refusal to the existing PitchSpaceMismatch (6) effect. Push 4 therefore remains split: 4a owns the operation algebra and frozen replay semantics; 4b owns structural resolution, not a second transposition rule.

Four ratified calls (user, 2026-07-09).

  1. Split 4a / 4b. Above.

  2. A new operation kind; freeze the old. An operation is history. A replica replaying a stored Transpose must reconstruct the state its author saw, so a "corrected" reduction rule would silently rewrite every score that used one. Transpose (wire disc 9) keeps its exact semantics — saturation, nominal-blindness, duplicate-sensitivity — now written down as normative replay semantics rather than as apologies. TransposeInterval takes wire disc 30 and is what authoring emits.

    This turned out to be cheap: appending an OperationKind discriminant at ≥ 30 is a schema minor (req:binfmt:kind-discriminants, the Phase-3 precedent), and every constituent of the new payload — a sequence of ids and two i32s — is a major-0 layout, so it stamps major 0 under minimal stamping. No schema major 3, no migration, no stored operation changes meaning. (I told the user it would cost a major before checking. It does not.)

  3. Diatonic + chromatic interval. Reuses TranspositionInterval, which already existed in graph.rs at schema major 2 for Instrument's written-versus-sounding interval, is already codec'd, and is byte-for-byte the pair required. Minting a new Interval struct would have been a second normative listing of one type — the drift hazard P13-I1 just closed. The spec now declares it once, in Chapter 2 where transposition lives, and Chapter 5 references it.

  4. Atomic refusal. A target that cannot be faithfully transposed — non-Cmn position, AbsoluteHz realization, or an alteration'/octave' that does not fit — refuses the whole operation, changing nothing. Never saturate, never partially apply. A chord transposed except for one note is not a transposed chord.

    Refusal is distinguished from skipping. Tombstoned and SYSTEM_DERIVED targets are still skipped, exactly as for Transpose. A deleted pitch is not an untransposable pitch; it is a pitch the operation has nothing to say about. An AbsoluteHz pitch, by contrast, declares its own frequency: it can be respelled or resounded, but a transposition claiming both does one and lies about the other.

targets becomes a set at the type level. CanonicalSet<PitchId> (a BTreeSet), not a Vec plus a dedup() someone can forget. PitchId: Ord is its canonical byte order (locked by ids.rs), so the set iterates in canonical order for free. This is not a convergence bug in the old op — every replica replaying [p, p] double-transposes identically — it is a canonicalization bug: the catalog said set, sorted_canonical sorts without deduplicating, and the reduction is multiset-sensitive.

Timing matters here. No operation-payload decoder exists yet (only OperationKindTag implements CanonicalDecode), so making targets a set is free today. Once the decoder lands — Push 5, the decode-fuzz P2 tranche — any dedup normalization would silently change the meaning of stored operations. Push 4a blocks Push 5, and that is why.

Spelling propagation. Core Chapter 2 already requires that operations which transpose a pitch produce SpellingSource::Propagated attachments. Transpose produces none, and RespellPitch can only materialize UserChosen — so an authored spelling survives a transposition still pinned to the notehead it was authored against. TransposeInterval MUST emit the propagated attachment; the interval's diatonic component already determines the nominal, and the attachment is how that determination reaches the engraved layer.

Spec: req:pitch:transposition (core Ch2, the algebra and the three refusals), req:opcat:transpose-frozen, req:opcat:transpose-interval-targets, req:opcat:transpose-interval-atomic, req:opcat:transpose-interval-spelling. Operation Catalog 0.7.0 → 0.8.0, Binary Format 0.6.0 → 0.7.0 (disc 30; the new seq^⇑ strictly-increasing notation). New PreconditionFailureReason: AcousticRealizationPinned (14), TranspositionOutOfRange (15); PitchSpaceMismatch (6) un-reserved.

The two existing transpose tests are false locks. Verified by mutation: gutting graph_transpose_pitch entirely leaves transpose_skips_tombstoned_targets_and_shifts_the_live_ones and transpose_skips_system_derived_targets_p12_k3 both green. They call base-free reduce(), where graph is None and the function never runs, and they assert only OperationEffect. Only editor-core's undo_and_redo_a_transpose — three crates away — catches it.

The fix was not to rewrite them, which the design gate wrongly promised. What they assert — skip-tombstoned, skip-system-derived, refuse-missing — are effect-log properties, and base-free reduction is the right place to assert them. The defect was the first one's name: it claimed to check that the live target "shifts" and checked no such thing. So:

  • renamed to transpose_effect_log_skips_tombstoned_targets_and_refuses_a_missing_one, with its scope written into the test;
  • the_frozen_transpose_skips_a_tombstoned_target_and_shifts_the_live_sibling now makes the original claim, against reduce_onto and an asserted pitch;
  • the_frozen_transpose_keeps_its_saturating_alteration_semantics locks the three pinned defects (octave-blind, saturating, multiset) as replay semantics. Mutation-verified by "helpfully" repairing graph_transpose_pitch to use the real algebra: the test fails, which is the point — it is a guard against rewriting history, not against a bug.

Consequences elsewhere.

  • EditorSession::transpose_selection now takes a TranspositionInterval. A scalar cannot distinguish "up an octave" (7, 12) from "C with twelve sharps" (0, 12), which is P12-K2. The "+1 semitone" key became alter_selection(±1) — a chromatic alteration with no diatonic motion, which is what that key always meant and what the old operation always did. TransposeOp is consequently unused in editor-core's lib: the compiler now enforces "never authored".
  • fuzz::gen_payload gained arm 27 and below(27) became below(28), which reshuffles the seeded stream, so the_canonical_base_is_byte_identical_... was re-pinned. Consciously, per that test's own instruction, and for the same reason as the Phase-D re-pin. Nothing leaked: canonical_bytes embeds effects, conflicts, and anomalies, never payload values.
  • The frozen Transpose keeps its fuzz coverage (arm 6) and its corpus authoring in testkit. It must reduce correctly forever, and a generator is now the only thing that will ever produce one.

Base-free reduction. The refusal reads pitch values, which exist only under reduce_onto. It is therefore a graph-aware-only precondition that passes base-free — the convention modify_identified_pitch's system-derived rewrite check already established and documents ("unverifiable and passes, like the other graph-aware-only preconditions"). It also writes nothing base-free, so the two modes agree on objects, and on the effect log for every operation whose targets are all transposable — the only case base-free reduction can distinguish.

Push 4a follow-up — the three gaps a green gate did not catch (2026-07-09)

An audit reopened P12-K2 after Push 4a landed. All three findings reproduced. The focused suites passed throughout, which is the point: they were coverage gaps, not regressions.

1. Extreme intervals panicked instead of refusing. Pitch::transposed did its arithmetic in i32 while TranspositionInterval's components are i32, so diatonic_steps = i32::MAX overflowed 12 * new_octave. The comment above it said "widen before arithmetic" — it widened i8 to i32, which is exactly not wide enough. inverse() negated i32::MIN. Now i64 throughout, and inverse() returns Option.

I checked whether this was worse than a panic, since epiphany-core is a library and a downstream release profile has overflow-checks off. A 10.5M-case sweep of wrapping-vs-exact arithmetic found zero inputs where wrapping produced a wrong Ok rather than a refusal. So: a panic, not silent corruption. The audit's characterisation was right and my instinct was wrong.

2. The Propagated attachment met the requirement and missed its purpose. Default precedence ranks UserChosen and Imported above Propagated (SpellingPrecedence::default), so the attachment a transpose writes is outranked exactly when it matters. Reproduced: a C4 the author spelled "C", sharpened to C♯4, resolves to Authored(UserChosen) with accidentals: []. The notehead draws a C natural for a pitch sounding C♯ — the accidental disappears. The Push-4a test could not see it because it deliberately started with no attachment.

Ratified fix: authored spellings are moved, not left and not discarded, by transposing their nominal — the nominal is what carries the enharmonic decision. B♯3 (sounding C4) up a fifth is F𝄪4 (sounding G4), not G. Source, priority and layer are preserved: a transposed UserChosen spelling is still the user's choice. An authored spelling that cannot be written at the transposed position refuses the whole operation, resolved before any write.

3. Value-restoring undo did not restore either transpose. Neither kind recorded into pitch_modify_chain; UndoTransaction(StrictInverse) reduced to NoOp(TargetMissing) and left the pitch shifted. EditorSession::undo works because it re-materializes from a truncated log, an entirely separate mechanism.

The behaviour gap was pre-existing — the frozen Transpose behaves the same, and the pre-Push-4a catalog said so honestly ("an inverse-interval undo is a Phase-3 refinement, P11-C8"). What was new was my false claim that the write chain handled it, written into the catalog for both kinds.

Ratified fix, and the interesting part: TransposeInterval becomes undoable; the frozen Transpose stays un-undoable, permanently. Making Transpose record into the chain would not change its own reduction rule, but it would change what a stored {Transpose, UndoTransaction} history replays to — from "the pitch stays shifted" to "the pitch returns". That is a change in what an existing document means, which is the one thing the freeze forbids. So the freeze bites, and gives another reason never to author the old kind. the_frozen_transpose_is_not_undoable_and_that_is_frozen_too guards it, verified by the inverse mutation (making it undoable fails the test).

The new chain. transposed_spelling_chain: BTreeMap<PitchId, WriteChain<Vec<SpellingAttachment>>> holds the engraved-layer, pitch-scoped, explicit attachment set per pitch, so undo restores the moved authored attachments and removes the propagated one together. Deliberately not respell_chain: RespellPitch owns that, its last write is the LWW working state its conflict detection reads, and folding transposes in would make a concurrent respell conflict with a transpose and move the canonical bytes of every existing history.

Two false locks, found by mutation, not by the gate.

  • The enharmonic test spelled a C♯4 pitch as "C♯", so the authored nominal coincided with the pitch's own and re-inference gave the same answer. Replacing spelling.transposed(..) with simplest_spelling(..) passed. Rewritten around B♯3-sounding-C4, it fails as it must.
  • My own mutation harness restored the file between two tests, so the second ran unmutated and "passed". A mutation that no-ops looks exactly like a test that passes — the trap recorded after Push 4a, hit again in a new form.

P13-S3 — a shared undo key with one writer (2026-07-09)

The engraved_spelling_chain added to make TransposeInterval undoable was written only by the transpose. But RespellPitch mutates the same graph attachments, and a chain with one writer is wrong in both directions:

  • Prior respell erased. respell → [tx: transpose] → StrictInverse undo returned Applied, restored the pitch to C4, and removed every attachment. The respell was an operation, not part of the base, so it existed only as a chain write — and the transpose's chain had never seen it, so its predecessor was absence.
  • Later respell wiped. [tx: transpose] → respell → StrictInverse undo returned Applied and erased the newer authoring, because the respell was invisible to the chain and so did not register as a superseding writer. The catalog's general rule is that a later canonical writer supersedes a strict undo.
  • Best-effort split the unit. With the spelling set superseded but the pitch value not, BestEffort restored the pre-transpose pitch and left a spelling authored against the transposed one attached to it.

Fix. Both operations record on the shared key (record_engraved_spellings), and the pitch value and its spelling set undo as one unit: if either half is superseded, neither is restored. StrictInverse already refuses on any supersession, so the coupling only bites for BestEffort.

The physical separation stands, and my earlier note was only half right. I kept engraved_spelling_chain distinct from respell_chain because the latter is RespellPitch's LWW working state, read by its concurrent-differing conflict detection — folding transposes in would make a concurrent respell conflict with a transpose and would move the canonical bytes of every existing history. That reasoning holds. What it did not license was letting one operation own the key. Two physical chains, two responsibilities (respell_chain: the ledger spelling and the LWW verdict; engraved_spelling_chain: the graph attachments), but every writer of the attachments records on the attachment chain.

Recording is gated on self.graph.is_some(), so base-free reduction is byte-unchanged and the seeded fuzz corpus's canonical-base digest does not move.

Spec: req:opcat:spelling-set-chain. Mutation-verified: removing the respell's record fails all three new tests; removing the coupling fails the best-effort one with the pitch back at C4 and the spelling still at C-sharp. Also covered: both permutations of a concurrent respell/transpose reduce to identical bytes.

P13-S3 follow-up: the coupling is broader than the transpose, on purpose

A review of the S3 fix caught the reducer's comment overclaiming. It said the coupling "cannot mis-fire on an unrelated operation" because ModifyIdentifiedPitch never writes the spelling set. True of the operation, irrelevant to the unit: the coupling is keyed on the pitch, and on which keys the transaction wrote. A transaction whose members write the two halves separately is coupled exactly the same way.

The editor's "move note" is precisely that — ModifyIdentifiedPitch (the value) plus RespellPitch (the spelling set), in one transaction (editor-core::apply_transaction). Measured:

later respell? policy undo effect pitch spelling
no BestEffort Applied restored to C4 attachment gone
yes BestEffort Applied stays D4 the later E stands
yes StrictInverse Conflicted stays D4 the later E stands

The middle row is the coupling firing on a non-transpose pair, and it is correct: restoring the pitch to C4 while the engraved spelling reads E — authored against the moved pitch — is exactly the stale-notehead defect the coupling exists to prevent. So the breadth is intentional and now stated in req:opcat:spelling-set-chain rather than left to be inferred.

Three regressions: full undo when nothing supersedes (guards against over-coupling), best-effort skipping the pair when a later respell supersedes, and strict undo conflicting. Mutation-verified: removing the coupling fails only the middle one, restoring the pitch to C4 with the spelling still reading E.

Push 5 / P2 — the ops decode surface (2026-07-09)

The operation layer exposes exactly two byte-decode surfaces: MaterializedState::decode_canonical and OperationKindTag::decode_canonical. Operation payloads have no decoder — OperationKind is encode-only — so nothing here can yet accept a duplicate TransposeInterval target. When such a decoder lands it inherits the wire table's seq^⇑ rule: reject a duplicate, never normalize it away.

No defect found in the decoder. It already carries the P1 hardening: a whole-state re-encode-and-compare guard, and Vec::with_capacity(n.min(1024)) at every count site, so the unbounded-allocation and soft-DoS classes P1 fixed in core do not apply. 2M adversarial inputs across four seeds, ~2s each.

But the P1 design note — "the guard is complete-by-construction, it cannot miss a lenient codec" — is only half true, and this is the finding worth keeping. Decode has two layers, and they catch disjoint things:

  • The whole-state guard catches every field the decoder normalizes. The BTreeMaps (objects, spellings, breaks, page_breaks) re-sort and de-duplicate, so a non-canonical encoding of them cannot survive a round trip.
  • It is blind to order-preserving Vec fields. A reordered anomalies or pending list re-encodes to exactly the bytes it came from, so the guard sees identity and accepts. Only the per-site windows(2).all(<) checks reject them. Same for a conflict record's caused_by / affected_objects, which ConflictRecord::encode_canonical writes verbatim.

Measured, not reasoned: removing both per-site Vec order checks leaves a 40K input injectivity sweep green. An injectivity fuzzer structurally cannot see this class — it asserts bytes → value → bytes identity, which is exactly what a missing order check preserves. The per-site checks are load-bearing and were locked by nothing.

(effects is a Vec with no order check, correctly: its canonical order is reduction order, which a decoder cannot recompute. Two orderings are two different states, so injectivity is not at stake.)

Delivered. fuzz::run_decode_fuzz (no-panic + injectivity over both surfaces), returning a DecodeFuzzCoverage the smoke tests assert on — a decode fuzzer that never reaches a decoder's accept path proves only the absence of a panic. Plus deterministic tests for each layer: an_out_of_order_objects_map_is_rejected_by_the_whole_state_guard (guard), an_out_of_order_anomaly_list_is_rejected and a_reordered_pending_list_is_rejected (per-site, invisible to the fuzzer). Each was mutation-verified against the exact check it locks.

Corpus depth is a property, not luck. A fixed list of envelope-set sizes reduces to states with no conflicts, anomalies, pending, or spellings — the very branches that hold every canonical-order check. Measured: 6 of 12 seeds failed to produce all four. build_decode_corpus now draws until covered and asserts it.

Push 5 / P4 — the cross-implementation decode corpus, and what it caught

spec/vectors/decode_vectors.txt: 37 committed byte strings across five surfaces (ops.materialized_state, ops.operation_kind_tag, bundle.manifest, bundle.operation_index, bundle.block), each with its normative accept/reject verdict. The reference implementation's fuzzers prove its own decoders self-consistent, which says nothing about whether a foreign decoder agrees with the format. This is what one is checked against.

It found a real defect on its first run. OperationKindTag::TransposeInterval encoded to [30] and its own decoder rejected it: Push 4a added the variant to discriminant() and never to decode_canonical. OperationKindTag is what edit barriers persist, so a barrier prohibiting TransposeInterval could be written and never read back — silent data loss on reopen.

Four things should have caught it and did not.

  1. operation_kind_tag_decode_mirrors_encode_exactly enumerated discriminants ((0u8..30).map(decode_canonical)), so it started from bytes the decoder already knew and structurally could not notice a variant the decoder was missing. It now enumerates variants, from a single all_tags() list, with a completeness check in both directions.
  2. every_normative_operation_tag_has_a_distinct_canonical_discriminant's hand-written variant list also omitted it. Same list now.
  3. operation_kind_tag_decode_rejects_malformed_bytes asserted tag 30 is rejected, and epiphany-layout-ir's decode_rejects_unknown_discriminants asserted the same at the barrier surface. Both were locking the bug in place, and made it look deliberate. Both now name 31.
  4. The P2 decode fuzzer fed valid corpus bytes to the tag decoder and tallied the failure as a rejection, like any garbage input. It never asserted that an unmutated corpus entry decodes. Both fuzzers now do, as a pre-pass.

The harness had the same disease as the code. check originally collapsed "rejected" with "accepted but does not re-encode", so a decoder that silently normalizes non-canonical bytes passed the reject vectors it was written to catch — verified: removing the whole-state guard and restoring the lenient compression codec both left the corpus green. check now returns Ok(injective) for accept and Err for reject, and the two are never conflated: silently normalizing non-canonical bytes IS accepting them. With that fixed, all four defect mutations fail the corpus, each naming its class.

The corpus pins one vector per class this repository has shipped a bug in: non-canonical-map-order (a re-encode guard catches it; no per-site check exists), non-canonical-vec-order (only a per-site check catches it; a guard is blind), lenient-sub-codec (a guard masked it in the manifest; the index had none), plus trailing-bytes, truncated, unknown-discriminant, count-exceeds-remaining. the_corpus_pins_every_class_we_have_shipped_a_bug_in fails if one goes missing.

Gated in the conformance suite as [7d], and drift-locked: the committed file must equal vectors::render(), so a wire-format change lands in the diff.

P4 follow-up: one production-owned tag vocabulary, and the corpus ratified

Two review findings, both real.

1. The tag-omission failure could recur. After the P4 fix there were still four hand-maintained lists of tags — a test-local all_tags(), a barrier test spelling 0u8..=30, a fuzz corpus naming five, a vector corpus naming four — plus a malformed-bytes test asserting 31 rejects. A future tag 31 added to discriminant() and omitted from decode_canonical() would have left every one of them green, and the malformed test would have locked it, exactly as the 30 version did.

operation_kind_tag_vocabulary! is now the single source: it generates discriminant, from_discriminant, and OperationKindTag::PAYLOAD_FREE from one list. The generated discriminant match is exhaustive over the enum, so a variant added to OperationKindTag and not to the macro fails to compile (verified). Everything downstream reads PAYLOAD_FREE: the decoder, the fuzz corpus (all 31 tags, not five), the conformance vectors (an accept vector per tag, so 65 vectors not 37), and the edit-barrier round-trip. Every "one past the vocabulary" constant is computed, never spelled — a spelled constant is the trap that springs on whoever appends the next tag.

Verified end to end by adding a hypothetical tag 31:

  • to the enum only → compile error;
  • to the enum and the macro → compiles, decodes, and every derived check passes because they read PAYLOAD_FREE — while the committed corpus's drift lock and its stale "one past the vocabulary" reject vector both fail, forcing the new vectors into the diff.

There is no path where a new tag leaves everything green.

2. The corpus called itself normative; the spec said it was deferred. Binary Format's §"About This Companion" listed the cross-implementation decoder test among things the document does not cover, and its Golden Anchor Registry called it "the deferred conformance harness". Both now ratify it: req:binfmt:decode-vectors and §"The Decode Vector Corpus" state that a conforming decoder MUST accept every accept vector, MUST reject every reject vector, and MUST re-encode an accepted value to its own bytes — and that accepting a reject vector and then normalizing it is accepting it. Binary Format 0.8.0 → 0.9.0. The wire-format fuzzer stays an implementation deliverable. The corpus header now cites the requirement rather than asserting one.

Push 5 / P5 — the missing inverse: bytes back to an operation envelope

Text Projection is blocked on this, but the blocker was bigger than the task.

The format was write-only for operations. A bundle's envelope blocks decoded to opaque byte strings; OperationKind had an encoder and no decoder; nothing in the workspace called decode_block outside epiphany-bundle itself; and nothing anywhere reconstructed an OperationEnvelope. Chapter 6 holds that a score's canonical state is the set of operations committed to it — so a bundle could be written and its score could never be reopened. The envelope's byte layout was fully pinned (binary_format.tex §"Operation Envelope Encoding"); nobody wrote the inverse.

epiphany_ops::decode_envelope is that inverse, and the first thing built on it is testkit/tests/bundle_reopen.rs: create a bundle from 400 generated envelopes, commit, take the bytes, reopen from nothing but bytes, decode every envelope, rebuild the OperationSet, reduce — and get the same canonical state. That test could not have been written before.

Strictness, in two layers, per the P2 lesson.

  • A whole-envelope re-encode-and-compare guard. Sound here because every sequence in this encoding is normalized by its encoder (sorted_canonical, BTreeSet, BTreeMap), so non-canonical bytes cannot survive a round trip.
  • Per-site checks where the rule deserves its own error, and where a future encoder change must not silently relax it: TransposeInterval.targets is a set (seq^⇑, strictly increasing — a duplicate rejected, never absorbed by the BTreeSet it collects into), and the frozen Transpose.targets is a multiset (non-decreasing, duplicates preserved). This is the rule Push 4a wrote into the wire table and left for whoever built this decoder; it is now enforced.
  • A bounded count(): a declared count past the bytes remaining is rejected before it can drive an allocation.

Coverage, measured rather than assumed — again. The obvious oracle (gen_envelope_set, 4,000 envelopes) reaches only 28 of 31 kinds and 1 of 4 payload variants. ChangeRegionTimeModel, DeclareTransaction, Registered, and all three meta payloads were untouched — which happen to hold the trickiest decoders (PositionRemapping, NFC strings, ResolutionAction, EnvelopeHash). every_operation_kind_and_payload_round_trips drives an exhaustive match on OperationKindTag, so the compiler forces a sample for every future kind.

Mutations verified. Removing the seq^⇑ check still rejects (the guard is a genuine backstop for that field) but with the wrong error — so the per-site check earns its place on the error, not the verdict. Removing the whole-envelope guard leaves every round-trip test green, because round-trips only ever feed canonical bytes; an_unsorted_sequence_is_rejected_by_the_whole_envelope_guard is the test that locks it, and it fails under that mutation.

A trap worth remembering. PitchId::new(ReplicaId(7), 1) and OperationId::new(ReplicaId(7), 1) have identical canonical bytes — typed ids share their byte form. A byte-patching test that searches for an id finds the envelope's own leading id first. Patch by framing, or from the end.

Push 4b tranche 1 — the transpose reduction's own proof of life (2026-07-22)

Core's Pitch::transposed learned to resolve cmn-24 structurally (the tranche retires the P13-S2 "cmn-12" string guard). But the plan's stated proof of life — a cmn-24 pitch transposing end-to-end — failed one layer up, in this crate, and the tranche's own test suite could not see it.

resolve_transposed_spellings computed the transposed 12-TET semitone with a ? before the loop that looks for an authored spelling to rewrite. The semitone is 12-chromatic-only (the spelling pre-pass has not changed), so it is None for cmn-24 — and the early ? therefore refused every cmn-24 transpose, including one with no spelling to rewrite at all. Measured directly: a spelling-less cmn-24 pitch reduced to NoOp { TranspositionOutOfRange }, value untouched.

This is P13-S3-shaped: latent in code, correct-looking, and made reachable — not introduced — the moment the space it gates on started resolving. Before the tranche, current.transposed() refused cmn-24 earlier, so control never reached this ?. The same shape as the shared-undo-key defect above, and the same lesson: a capability landing upstream can turn a dormant downstream branch live, and only re-deriving the end-to-end behaviour catches it.

Fix: move the ? to point of use, inside the loop. The semitone is needed only to rewrite an authored spelling; computed where it is consumed, a spelling-less cmn-24 transposes, and one with an authored spelling still refuses — the spelling pre-pass genuinely cannot write a 24-chromatic accidental, so refusing beats leaving it stale. Two tests lock the two halves, each mutation-killed by a different mutation: hoisting the ? back kills the transposes-when-empty test; swallowing it with unwrap_or(0) kills the refuses-when-authored test.

Not a frozen-replay violation. TransposeInterval (disc 30) is the live operation whose capability may widen; the frozen one is Transpose (disc 9), untouched. Widening what TransposeInterval can transpose is the tranche's ratified purpose, and the version skew it implies (an old replica refuses what a new one applies) is the tranche's property, not this fix's — the fix only makes the operation layer agree with the core layer the tranche already moved.

Genesis tranche G1 — CreateInstrument, kind 31 / tag 31 (2026-07-24)

spec/CONTRACT_GENESIS_G1_INSTRUMENT.md lands the first rung of the genesis ladder (spec/PLAN_GENESIS_OPS.md §4): CreateInstrument mints an Instrument on the score root, set-union discipline identical to create_staff (reduce.rs), minus the graph-aware reference-resolution block — Instrument holds no outbound entity reference, so there is nothing for one to check. All fifteen touch points landed as specified: codec.rs's canonical_value! (one line — Instrument already had a Codec and, because struct_codec! generates both, already had TextValue too, so the Text Projection touch point cost nothing extra); payload.rs's CreateInstrumentOp + the five mechanical sites (OperationKind variant, schema_major, discriminant, tag, encode dispatch, tag-vocabulary entry); envdecode.rs's decode arm and sample_kind sample; v0.rs/migrate.rs's identity round-trip (born past v0, no lossy projection); reduce.rs's dispatch + create_instrument + instrument_values (pin 8, six sites: both struct decls, init, the base seed, and the snapshot/restore pair); textproj_kind.rs's production/parse; fuzz.rs's generator arm 28 (rng.below(28)below(29)); vectors.rs's decode vector; operation_catalog.tex's new section.

The identity cursor (pin 9) is gated on a pristine-base check the naive version got backwards. The first attempt compared self.graph against Score::empty(...) at the end of run(), after every spine operation had already populated it — always false, so the cursor never advanced (the i5 test caught this immediately: next_counter stayed 0, not the expected 12). The fix captures from_empty_base in new_onto, from the pristine base argument, before graph is cloned and mutated. This is also the mechanism that keeps the fix scoped to from-empty reduction only: reduce_operation_set_onto is the same entry point epiphany-editor-core calls on every edit (EditorSession::materialize), against a populated base, so an unconditional cursor-write there would have been a silent behavioral change to a crate this packet may not touch. Gating on from_empty_base makes onto-reduction against a populated base byte-for-byte unchanged — verified, not assumed, since editor-core is one of the crates the boundary below made un-editable, so its own test suite is the only check available and it does not run in this tree (see below).

The cursor's derivation walks two sources, not one. next_counter is a single counter shared by every identifier kind (IdentityContext::take_counter), so scoping the fix to InstrumentId alone would have been wrong the moment a spine mixes kinds — which i1's own chain does (CreateStaff's StaffId, CreateVoice's VoiceId, ...). derive_identity_cursor takes the max over (a) every OperationId the op_set has ever accepted, via OperationSet::slots() — keyed by OperationId, so its key set is exactly "every id in the log" regardless of slot state (Single, Equivocated, held/pending) — and (b) every entity id in the final self.objects, decoded generically from TypedObjectId's canonical form (2-byte discriminant + 16-byte (replica, counter), for every variant but Registered) rather than a new per-kind accessor. This reads TypedObjectId::canonical_bytes() — already public — instead of adding a replica()/counter() method to epiphany-core::ids, which would have been a new public surface outside this packet's blast radius. Not fully general (a losing equivocation candidate's entity ids, as opposed to its OperationId, are invisible to this — no test in the required nine reaches that case, and closing it would need the same kind of new core-side accessor this design avoids); flagged rather than silently assumed complete.

Boundary conflict: OperationKind is exhaustively matched outside this

packet's blast radius (2026-07-24)

The contract's blast radius lists only epiphany-core/src/codec.rs and eight epiphany-ops/src/*.rs files, and its "Parallel safety" note claims "this packet touches none of" epiphany-editor-core, epiphany-editor-gui, epiphany-layout-ir, epiphany-render-svg, epiphany-glyphs. That claim does not survive contact with the compiler: OperationKind gains a variant, and Rust's exhaustiveness check finds every downstream consumer.

crates/epiphany-editor-core/src/barriers.rs:316, subjects_of, an exhaustive match kind: &OperationKind deriving edit-barrier subjects — fails to compile without a new arm (CreateStaff's arm, one line, is the exact analogue: an instrument is a score-root object with no regional containment, same as a staff). Because epiphany-testkit, epiphany-engrave, epiphany-render-svg, and epiphany-editor-gui all depend on epiphany-editor-core (non-optionally), this one missing arm blocks the entire rest of the workspace from compiling — including epiphany-testkit, which owns requirement_labels, the decode-vector corpus regeneration/verification, and the conformance suite. cargo test --workspace and cargo clippy --workspace --all-targets both fail at this single error and never reach a single test.

crates/epiphany-layout-ir/src/barrier.rs:1112, decode_rejects_unknown_discriminants, hardcodes 31u8 as "one past the [OperationKindTag] vocabulary" — a runtime test failure (the crate compiles fine), now stale because 31 is CreateInstrument. This is not a new failure mode: the test's own comment records it happened once already ("This assertion named 30 until Push 5 / P4 — by which time 30 was TransposeInterval, so the test was pinning a bug"). The fix each time is mechanical: bump the literal to the new one-past-the-vocabulary value (32) and its expected tag: 31 to tag: 32.

Both files are on this packet's explicit do-not-touch list (a parallel agent owns them in a separate worktree), so neither was edited — an initial, incorrect edit to barriers.rs (made before re-deriving the boundary against the actual compiler output) was caught and reverted before landing; git status on both crates is clean in the delivered tree. Consequently: cargo test --workspace, cargo clippy --workspace --all-targets, the conformance suite, and requirement_labels could not be executed against the full workspace in the delivered state — only against the seven crates that do not depend on epiphany-editor-core (epiphany-determinism/epiphany-core/epiphany-bundle/epiphany-ops/ epiphany-textproj/epiphany-layout-ir/epiphany-glyphs), all of which are green (epiphany-layout-ir excepted, for the one stale-vocabulary test above). This is reported to the coordinator rather than worked around; see the packet's final report for the exact reproduction commands and error text.

Genesis tranche G2a — SetCanvasLayoutDefaults / SetSpellingPrecedence,

kind 32/33, tag 32/33 (2026-07-28)

spec/CONTRACT_GENESIS_G2A_SETTINGS.md lands the second rung of the genesis ladder: two more Score fields become operation-authored, both on the SetMetadata LWW pattern (reduce.rs) — advisory last-writer-wins, no conflict, no idempotence short-circuit, seeded from the base for value-restoring undo. Unlike G1's CreateInstrument, both new kinds are schema major 0 unconditionally: neither carried type (CanvasLayoutDefaults, SpellingPrecedence) has ever had more than one wire layout as a standalone value, so both fall through OperationKind::schema_major's existing _ => 0 catch-all with no new arm — pin 3's explicit trap (adding them to the => 2 arm alongside SetMetadata would be the bug, since SetMetadata sits there for a property of ScoreMetadata, not of the LWW pattern itself). Verified by mutation: temporarily joining the => 2 arm kills both schema_majors_follow_the_minimal_stamping_rule (reduce.rs) and a dedicated epiphany-testkit assertion.

This packet does not touch epiphany-bundle, and the boundary crossing this time is exactly the three sites the contract namedbarriers.rs, layout-ir/src/barrier.rs, and text_projection_grammar.rs — no wider than scoped, unlike G1's discovery that the crossing itself was mis-scoped in the contract. Both new kinds join the existing SetMetadata(_) | DeclareTransaction(_) score-wide arm in barriers.rs::subjects_of (they are score-level field overwrites with no resolvable region or object, exactly like SetMetadata); the layout-ir "one past the vocabulary" literal moves 32→34; the testkit grammar kind-count literal moves 32→34.

ops/src/envdecode.rs::sample_kind and ops/src/textproj_kind.rs::parse are both match statements exhaustive over OperationKindTag (compile-enforced, unlike OperationKind::discriminant()), so both needed a new arm per kind — correctly named by the contract's touch-point table (envdecode.rs's "tag→kind (:819)" is exactly sample_kind; textproj_kind.rs's "parse (:443)" is exactly this match), verified against the tree rather than assumed. Both compiled to a hard error the moment the new OperationKindTag variants existed without a corresponding arm, so neither site could have been missed silently.

Two more hand-maintained generator sites went stale by two tranches, not one, confirming the contract's row-28/29 framing: testkit/src/ generators.rs::operation_payload's rng.below(30) bound had never grown past the repeat pair (28/29), so TransposeInterval (kind 30, Push-4a debt) and CreateInstrument (kind 31, G1 debt) were absent from every corpus this generator feeds, alongside this packet's own new kinds. Widened to rng.below(34) with all four appended; testkit/src/ layout_stub.rs::gen_operation_kind_tag was rewritten to derive its built-in draw from OperationKindTag::PAYLOAD_FREE (excluding Registered, appended explicitly) rather than extended by hand — the structural fix the contract's blast-radius note asked for, following the in-crate all_tags() precedent (payload.rs). Five of the six hand-maintained sites the contract's blast-radius note lists remain manual for the next append; only row 29 was convertible to a derivation.

Test s7's three-times-wrong framing is now four-times right. The contract's own text records that two earlier framings of "transaction rollback discards the write, not just the field" could not see their own mutation. The third framing — author inside a failed transaction, write again in a second transaction, undo the second, and assert the restored value is the pre-failure predecessor rather than the rolled-back write — was mutation-verified live: omitting canvas_layout_defaults_chain from Reducer::restore (keeping it in snapshot) makes the test observe the rolled-back transaction's value survive the undo instead of the genuine predecessor. Confirmed to fail for exactly that reason, not incidentally.

The canonical-base re-pin (the_canonical_base_is_byte_identical_across_ data_model_majors) is a corpus shift, not a value leak — verified by construction, not by re-running and hoping: MaterializedState embeds no Score field value for any setting, SetMetadata included, and both new kinds are schema major 0 unconditionally, so there is no schema-major surface for a leak to appear on even before checking the hash. gen_payload (fuzz.rs) widened from rng.below(29) to rng.below(31) with the two new arms appended, reshuffling the seeded RNG stream exactly as at Phase D, Push 4a, and G1; the digest moved and was re-pinned with that reasoning recorded in the test's own comment.

Genesis tranche G2b — SetTuningContext, kind/tag 34, the accept-set raise

(2026-07-28)

spec/CONTRACT_GENESIS_G2B_TUNING.md lands the third rung: the sole genesis payload born at schema major 3, and the one surface among the nine settings/creates that drags OperationEnvelopeBlock's accept-set from 2 to 3. Its carried type is not the full ScoreTuningContext but the new epiphany_core::TuningContextSettings — the authored subset of exactly the five wire-bearing fields; see that crate's DECISIONS.md for why (the never-authored / authored-to-default divergence a full-value payload would have introduced).

set_tuning_context copies set_metadata structurally — advisory LWW, no conflict, no idempotence short-circuit — but writes onto score.tuning_context field by field rather than by whole-struct assignment, because the payload carries a different (narrower) type than the field it targets: accidental_extensions is left untouched in both the ordinary apply path and undo's restoration-apply path. This is the one place in this rung's reduction code that could not be a literal copy-paste of set_metadata/ set_canvas_layout_defaults/set_spelling_precedence — those three write score.<field> = op.<value>.clone() in one line because the carried type is the field's type; here it is a strict subset, so the assignment is five lines, one per subset field, deliberately omitting accidental_extensions.

Pin 5, verified rather than assumed. An earlier draft of the governing contract required undo to distinguish "never authored" from "authored-to-default" — spec/PLAN_GENESIS_OPS.md §5 trap 5, withdrawn 2026-07-28 before this packet started. tuning_context_chain is seeded from Score::empty's default in seed_from_graph, exactly as metadata_chain is, so the first undo of a SetTuningContext write yields Restore(Some(Predecessor::Base(seeded))) regardless of whether the seed (or the write) happened to equal the type default. Test t7 asserts the two cases (default seed, non-default seed) restore identically — proving the distinction unobservable, not merely failing to test for it.

schema_major() gains a real, unconditional arm returning 3 — the opposite of G2a's "no arm" pin, because ScoreTuningContext's smufl/overrides appends are mandatory (not Option-hidden), so no lower-major layout for this payload exists (a CreateInstrument-shaped arm, not a CreateRegion-shaped one).

The accept-set raise is entirely epiphany-bundle's change (see that crate's DECISIONS.md); this crate's contribution is making SetTuningContext the value that requires it.

Boundary crossings, budgeted as the contract's one-time authorization. editor-core/src/barriers.rs::subjects_of gains kind 34 in the same score-wide SetMetadata(_) | DeclareTransaction(_) | ... arm G2a's two kinds joined — a score-level field overwrite with no resolvable region or object. layout-ir/src/barrier.rs's "one past the vocabulary" literal moves 34→35. testkit/src/generators.rs's rng.below bound and testkit/tests/text_projection_grammar.rs's kind count both move by one. testkit/src/layout_stub.rs's derived PAYLOAD_FREE-based draw needed no edit — confirmed, not assumed, per the contract's explicit ask (touch row 21) — because it derives from the vocabulary macro rather than being hand-extended.

Decode vector pinned to literal bytes (vectors.rs, set_tuning_context_envelope_decode_vector_is_pinned_to_literal_bytes), not merely round-tripped — the plan's trap 4 (a self-consistent discriminant reorder once passed 1283 tests and 8/8 conformance). Mutation-verified: a one-byte corruption of the pinned discriminant produces InvalidTag { kind: "OperationKind", tag: 99 }, observed directly rather than inferred.

No pruning or compaction is implemented, enabled, or prepared here — pin 9's explicit non-goal. The op log is now the sole canonical carrier of the tuning context (the canonical base embeds no graph value for any Score field), which is exactly why a future prune must stay blocked on disposition C; this packet adds no fn prune and no scaffolding toward one.

Genesis tranche G3a — CreateStaffGroup/CreatePartDefinition/

CreateAnalysisLayer/CreateView, kinds/tags 3538 (2026-07-29)

spec/CONTRACT_GENESIS_G3A_ENTITIES.md lands the fourth rung: the four remaining root-level Score entity mints, closing the from-empty satisfiability gap G1 opened (CreateStaff's group precondition was unsatisfiable under from-empty reduction until a StaffGroup could be minted). All four ride create_staff's set-union mint discipline exactly (fresh id mints; byte-identical re-carry is idempotent; a differing value under a live id is RecreateContentMismatch; a tombstoned id is TargetTombstoned), all at schema major 0 (pin 2: no schema_major() arm — the catch-all _ => 0 is correct, G2a's shape not G1/G2b's), all at epoch 11.

Pin 4a: four more carried-value maps, 28 touch points. staff_group_values, part_definition_values, analysis_layer_values, view_values join staff_values/time_signature_values/instrument_values at the same seven sites each: reducer state declaration, WorkingSnapshot declaration, initialization, base seeding (seed_from_graph — the site that fails silently without it, per G1's documented instrument_values hazard), mint insertion, snapshot, restore. Site 4 is load-bearing and separately mutation-tested (t13, four sub-mutations, one per family) against a dedicated base-recarry test (t5b) that a from-empty-only re-carry test (t5) structurally cannot exercise.

Correction of record (spec/CONTRACT_GENESIS_G3A_UNDO_REPAIR.md, Packet A, 2026-07-29). The paragraph that originally stood here claimed: "No delete exists for any of these six mint-only families (CreateStaff, CreateInstrument, and now the four G3a kinds), so ObjectState::Tombstoned is unreachable for any of them through the public operation API today." That claim is false, and it was signed off in error. It is not being silently deleted; this paragraph replaces it and says so.

The branch is reachable, with no delete operation required: mint the object inside a declared transaction, then carry UndoTransaction (reduce.rs:5288) against that transaction under StrictInverse or BestEffort. tombstone_undo_targets (reduce.rs:5387) writes ObjectState::Tombstoned into objects for every minted target this way — CreateStaff and CreateInstrument included, and this was already true before G3a landed. G3a's four kinds inherited the same reachable branch and the same unexamined wrong claim; none of the six families needed a dedicated delete operation to reach Tombstoned, only an undo of the transaction that minted them.

The claim's real defect was upstream of G3a: undoing a StaffGroup, PartDefinition, AnalysisLayer, or View mint tombstoned the object in the ledger but left its value sitting in the corresponding Score vector (no removal arm in materialize_graph_tombstones), and undoing a StaffGroup, AnalysisLayer, or Instrument mint could strand a live Staff.group / ViewDefinition.active_layers / Staff.instrument reference (no guard in undo_strand_block). G3a shipped without sign-off coverage for a branch that was live and broken. spec/CONTRACT_GENESIS_G3A_UNDO_REPAIR.md Packet A closes both gaps:

  • Five removal arms in materialize_graph_tombstones (pin A1): StaffGroupscore.staff_groups, PartDefinitionscore.parts, AnalysisLayerscore.analysis_layers, Viewscore.views, Instrumentscore.instruments — each mirrors the pre-existing Staff / TimeSignature arms exactly.
  • Three inbound-reference guards in undo_strand_block (pins A2A6): StaffGroup(g) blocked by a live Staff whose group == Some(g), AnalysisLayer(l) blocked by a live ViewDefinition whose active_layers contains l, Instrument(i) blocked by a live Staff whose instrument == i. Each reads the carried-value maps (staff_values/view_values), not self.graph, and is deliberately ungated (pin A3, ratified ruling): the create-side referential preconditions are graph-gated because base-free reduction has no universe to resolve against, but the ledger's carried-value maps are populated by mints regardless of graph presence — so gating the undo-side guard would let base-free undo strand a reference the ledger can plainly see. Each guard requires the referencer to be ObjectState::Live (pin A4 — an already-tombstoned referencer never blocks) and excludes referencers minted in the same transaction being undone (pin A5 — a transaction that mints both sides is undoable whole). restorations is deliberately not consulted by these three guards (pin A6, ratified ruling): unlike MeterChange, none of Staff.group, Staff.instrument, or ViewDefinition.active_layers has a modify operation, so no write chain's restoration could ever change the prospective post-undo value — adding a lookup here would be dead code asserting a write chain that does not exist.

objects outranks the retained value maps on re-create (pin A7): every create reducer already checks self.objects before its value map, so a re-create after undo hits ObjectState::Tombstoned and returns TargetTombstoned, never reaching the stale retained value — verified by row family u4 (u4au4e)'s byte-identical re-carry mutation (falling the Tombstoned arm through to the value-map identity check misreports AlreadyApplied instead).

§1.1, disposition B — the Staff.group/StaffGroup.members authority ruling, and why create_staff_group does the least possible thing. CreateStaffGroup carries members exactly as given — no normalization, no validation beyond the graph-aware liveness loop (pin 4) — and reduction never writes StaffGroup.members from any other operation. Staff.group is the sole authority; StaffGroup.members is a stored, non-authoritative, denormalized projection that both the missing-member and spurious-member stale forms are permitted to reach (t8b, two independent mutations: one in create_staff proving disposition A's maintenance rule is not implemented, one in create_staff_group proving non-empty members is not normalized away). Filed as P13-S16; disposition A (the maintenance/enforcement fix) is sequenced after G3b.

Referential preconditions, graph-aware only, mirroring create_staff exactly. CreateStaffGroup.members and CreatePartDefinition.staves each precondition every named id resolves to a live Staff; CreateView. active_layers preconditions every named id resolves to a live AnalysisLayer; CreateAnalysisLayer has no outbound reference and needs no precondition at all — the CreateInstrument shape, not the CreateStaff shape. All three loops are independently mutation-tested (t6, three sub-mutations) and independently shown unenforced base-free (t7). Pin 5: these preconditions were verified against invariant 10's existing check body (core/src/invariants.rs:1120:1159), which already resolved all three reference classes before this rung — G3a's own contribution to invariant 10 is the doc-comment reconciliation (pin 6, doc-only, no enum entry, no discriminant, no behaviour change), asserted structurally by a grep guard (t12) rather than by inspection.

t8, narrowed per §1.1. From empty, CreateInstrumentCreateStaffGroupCreateStaff(group: Some(g)) reaches a note with the staff's group precondition satisfied — this closes the satisfiability half only. It does not claim a bidirectionally consistent group is authorable; t8b pins both permitted stale forms as the honest complement.

Decode vectors pinned to literal bytes (vectors.rs, four new tests mirroring the G1/G2a/G2b precedent), not merely round-tripped. One genuine finding during construction: valuegen::analysis_layer's original name format ("analysis-layer-{n}") happened to be exactly 16 bytes for small counters — the same width as AnalysisLayerId's canonical encoding — which made a struct_codec! field swap on the two-field AnalysisLayer type byte-invisible (decode-under-swap re-encodes to the identical original bytes, since both fields are equal-width length-prefixed leaves). Fixed by shortening the name format ("layer-{n}"); documented in both the helper and the corresponding literal-byte test so the hazard does not recur silently. All four field-swap mutations (StaffGroup, PartDefinition, AnalysisLayer, ViewDefinition) were then observed to kill their own vector, independently, and only their own.

Boundary crossings, budgeted as the contract's one-time authorization. editor-core/src/barriers.rs::subjects_of gains four arms — each names only the object it mints with EditContext::default(), exactly CreateStaff's and CreateInstrument's shape, regardless of whether the carried value holds an outbound reference. layout-ir/src/barrier.rs's "one past the vocabulary" literal moves 35→39. testkit/src/generators.rs's rng.below bound moves 35→39 with four new arms and a widened operation_payload_emits_every_ appended_kind coverage assertion. testkit/tests/text_projection_grammar.rs's kind count moves 35→39. testkit/src/layout_stub.rs's prose range comment moves 30..=34→30..=38 (the derived PAYLOAD_FREE-based draw itself needed no edit, same as G2b).

the_canonical_base_is_byte_identical_across_data_model_majors re-pinned again, same reasoning as every prior tranche: gen_payload gained four arms and its bounded draw widened, reshuffling the whole seeded RNG stream. Nothing leaked — all four kinds are schema major 0 unconditionally and MaterializedState embeds no Score field value for any of the four carried types.

No pruning or compaction is implemented, enabled, or prepared here — pin 9's explicit non-goal, restated: G3a adds four more authored families to the surface a future prune must not discard.

Genesis tranche G3b — CreateMeasure, kind/tag 39, closing the genesis

ladder (2026-07-29/30, packets 13a: e64a4b7, b92023a, b622498,

314cd7a, acd7a76)

spec/CONTRACT_GENESIS_G3B_MEASURE.md lands the fifth and final rung (spec/PLAN_GENESIS_OPS.md §4: G1 → G2a → G-minor → G2b → G3a → G3b). Unlike every G3a family, Measure is a nested container child of StaffInstance (graph.rs:611), not a Score-root vector, so CreateMeasure rides CreateStaffInstance's parent-carrying shape (payload.rs:1510, reducer reduce.rs:4026) rather than the four G3a families' bare-value shape: CreateMeasureOp { instance: StaffInstanceId, measure: Measure }, measure_id() returning self.measure.id. Measure is schema major 0 (struct_codec! is a plain unversioned walk and its only non-scalar field, TimeAnchor, has a hand-written Codec with no version branching), so schema_major() gains no arm and OperationEnvelopeBlock stays at 3 where G2b left it.

The carried-value map must carry the owning instance (pin 5): measure_values: BTreeMap<MeasureId, (StaffInstanceId, Measure)>, not a bare Measure map, because Measure has no back-pointer to its parent — the graph-removal arm and the per-instance ordering check both need it. Threaded through the same seven sites every carried-value map uses (declaration, WorkingSnapshot declaration, initialization, base seed in seed_from_graph, mint insertion, snapshot, restore). Set-union discipline: byte-identical re-carry under the same parent is AlreadyApplied; a differing value, or the same measure under a different parent, is RecreateContentMismatch — the parent is part of identity here.

Three new PreconditionFailureReason variants, all epoch 12: MeasureMeterMismatch (16), MeasureOutOfOrder (17), and MeasureOrderUnverifiable (18). create_measure's append-only discipline (pin 9, reduce.rs:5407ff) checks, in order: the parent StaffInstance is live (ungated); a resolving time_signature names a live TimeSignature and every non-WallClock referent of start resolves (graph-aware only); the carried start is comparable to the current last live measure's start and strictly after it (MeasureOutOfOrder, or MeasureOrderUnverifiable if incomparable); agreement against the effective grid's active signature (MeasureMeterMismatch); and the boundary distance from the predecessor equals the governing measure_duration() (MeasureMeterMismatch, or MeasureOrderUnverifiable if the delta is not computable). Clauses 1 (ordering) and 3 (boundary distance) are vacuous for an instance's first measure — no predecessor to compare against. Clause 2 (agreement) has no predecessor dependency and runs on a first measure exactly as on any other: a pickup declaring a signature that disagrees with the governing grid is refused by clause 2 alone, first measure or not. And clause 3's exemption is narrower than "vacuous" suggests: the pickup's own successor is measured against the governing signature's full measure_duration(), not the pickup's own (shorter) length, so create_measure refuses that successor too. This is the pickup/anacrusis deferral filed as P13-S19, open by design (core DECISIONS.md files the invariant-side mirror, correctly scoped to "never flagged by the boundary clause" — not never flagged at all). CreateMeasure fails closed on every incomputable case; graph invariant 20 (core DECISIONS.md) abstains on the identical cases — deliberately opposite postures, not an inconsistency.

The effective-grid oracle, and the instance-grid ledger the reducer previously lacked (pin 6c). StaffInstance.local_metric_grid overrides the region default, but the reducer retained no instance-local grid state before this rung, so base-free reduction could not distinguish an override from an inherited default. Disposition (A) is ratified: an instance_grid: BTreeMap<StaffInstanceId, Option<MetricGrid>> ledger (the same seven sites), seeded by create_staff_instance and by seed_from_graph, plus a shared oracle that reconstructs the inherited grid from metric_grid_chain (whole-grid writes) layered under meter_change_chain (per-key changes) in canonical write order, folding in prospective undo restorations, and run identically in both reduction modes — so graph-aware and base-free reduction never disagree on a case this rung can decide. Selection over a partially-comparable meter_sequence follows pin 6c exactly: an incomparable change makes selection indeterminate even from an otherwise-empty candidate set (an incomparable change is unplaced, not absent, and might have governed); a genuinely empty candidate set is vacuous, not a violation; multiple mutually-incomparable maxima are indeterminate, never resolved by document order or id.

Two repair commits, both found by tests the white-box coverage could not see (b92023a, b622498). Base-free c3 ordering originally trusted OperationId's Ord (replica, counter — authoring identity) instead of the minters' canonical reduction-order stamps, and never checked the two measures shared a live StaffInstance. An empty effective grid originally refused instead of abstaining, because agreement collapsed None into a disagreeing Unique and boundary distance collapsed None into Indeterminate; they are now separate outcomes. Simultaneous prospective whole-grid and per-key restorations had no tie-break (Recency::Prospective being a unit variant made both compare equal), repaired to let the per-key write govern on equal recency within the prospective family — matching how undo_transaction actually records a whole-grid restoration before its meter-change restoration under the same undo. The second repair rebuilt the regression fixture itself: it never ran its own transaction, so compute_pending's missing-vector-predecessor rule (envelope counters jumping 3 → 10 → 11 → 12 → 20) left every member permanently pending and the test's "prediction" was checked only against itself, never against the materialized graph.

Undo coverage: the graph-removal arm, and the Measure strand guard across all SEVEN inbound surface classes (pin 10, packet 3a acd7a76). materialize_graph_tombstones gains a TypedObjectId::Measure(id) arm that reaches the owning instance through measure_values's carried parent — no search needed. The strand guard cannot be written against self.structures: that index is event-only by construction (the base-seed walk filters through anchor_event_refs, which drops every Measure/Region/WallClock anchor), so a guard against it is born green — rewriting it that way was tried and breaks fifteen tests, proving the point rather than merely asserting it. The seven surfaces, each with its own owner rule: spanner (cross_cutting_modify_chain, owned by the structure), repeat (repeat_values, owned by the repeat structure — see below), another measure's start (measure_values, owned by that measure), meter change (meter_change_chain, owned by the region), system break (break_chain, owned by the region), page break (page_break_chain, owned by the region), and tempo segment (tempo_segment_chain, keyed (Option<RegionId>, MusicalPosition) — a Some(region) entry is owned by that region; score-level None entries are genuinely ownerless and block on the reference itself). TempoSegment carries two anchor sites, start and optional end (tempo.rs:109), both guarded independently. Five of the seven surfaces are real WriteChains and restoration-aware in both directions (a restoration reinstating a reference blocks; one removing a reference does not); repeat_values and measure_values are immutable value maps — nothing ever rewrites a live repeat's or measure's anchors in place, so restoration-awareness is N/A for exactly those two.

RepeatStructure needed its own ledger, not the cross-cutting chain (pin 10.4). CrossCuttingValue is Tie | Slur | Beam | SpannerRepeatStructure is not a variant, so cross_cutting_modify_chain structurally cannot hold one. repeat_values: BTreeMap<RepeatStructureId, RepeatStructure> carries it instead, at the same seven sites, with no delete site — retention matches the ratified project discipline (no value map is ever pruned; every reader consults self.objects first) and is what makes the tombstoned-referencer test row reachable at all: a tombstoned repeat's value stays in the map and still names the measure, so the guard can be shown not blocking on a corpse only if the value is still there to check.

Also closes a hole that predates G3b. undo_strand_block's existing TimeSignature arm consulted only meter_change_chain; a minted TimeSignature still named by a live Measure.time_signature was not checked and could be stranded by an undo. measure_values is an immutable value map, so this extension is ungated exactly like the pre-existing StaffGroup/AnalysisLayer/Instrument guards.

SetMetricGrid/SetTimeSignature now preserve invariant 20, both clauses, on both forward paths and both undo restoration policies, with every check running before any mint (set_time_signature previously minted its carried TimeSignature before writing the meter change; a prospective refusal appended afterward would have leaked a minted signature from a non-transactional operation with no undo to reclaim it — restructured so every check precedes the mint, and tested that a refusal leaves no residue in objects, any carried-value map, or the graph). Restoration safety is evaluated in aggregate, not per-restoration: a single undo can collect a whole-grid restoration and one or more meter-change restorations together, and individually-unsafe restorations can be jointly safe (or the reverse). StrictInverse/Cascade evaluate the prospective post-undo state with the whole restoration set applied and conflict if unsafe; BestEffort applies the maximal safe subset under a documented deterministic canonical-order greedy (consider restorations in canonical order, admit each one that keeps the accumulated state clean, skip the rest — "maximal" under this rule, not the set-theoretic maximum, which is not uniquely defined).

The cross-crate anchor-relation agreement test, and the hook that makes it possible. epiphany-ops depends on epiphany-core, never the reverse, so invariant 20 (epiphany-core) cannot reuse this crate's private Reducer::anchors_comparable_order / Reducer::anchor_musical_delta, and implements the identical pin 6/6b relation a second time over the graph alone. Two implementations of one normative relation is a divergence hazard, so this crate exports a narrow oracle hook, measure_anchor_relation_for_agreement_test (reduce.rs:703, re-exported from lib.rs), whose only sanctioned use is epiphany-testkit's g3b_measure_anchor_agreement.rs, which drives a table of anchor pairs through both this hook and core's measure_anchor_relation and asserts they agree — comparable-or-not, ordering when comparable, and musical delta. It is not a general-purpose anchor API. This crate's DECISIONS.md entry and core's document the same pair of hooks from each side.

No epiphany-bundle change of any kind, mirroring G3a: Measure's one wire layout never gained a schema_major() arm, so the accept-set never moved.