epiphany/spec/CONTRACT_GENESIS_G1_INSTRUM...

12 KiB

Contract: genesis tranche G1 — CreateInstrument, and the from-empty spine

Repo root /home/jeans/Repos/active/epiphany. Governed by spec/RULING_GENESIS_PERSISTENCE.md (011c68a; identity sub-decision ruled at ec17d06) and spec/PLAN_GENESIS_OPS.md §4 (439e1e2), whose G1/G2/G3 ladder the user ratified 2026-07-24.

Execution model as every tranche on this track: Sonnet subagent, coordinator line-level review with independent mutation re-runs, user deep-dive at contract sign-off and final report. Mutation discipline throughout: anchor-assert before substituting, restore by reversing, never git checkout.

Parallel safety. The editor track owns epiphany-editor-core, epiphany-editor-gui, epiphany-layout-ir, epiphany-render-svg, the new epiphany-glyphs crate, every crates/epiphany-editor-gui/goldens/*.png, and spec/PLAN_EDITOR_APP.md + spec/CONTRACT_EDITOR_*.md. This packet touches none of them. Do not stage them; do not git add -A.


What this packet does, in one sentence

Adds one operation kind, CreateInstrument, so that a Score::empty(identity) plus an operation log can materialize a note-bearing score — and makes the from-empty path correct in the two ways the ruling requires.

Why it is this small

CreateInstrument is the single missing link between an empty score and a note. Score::empty (core/src/graph.rs:1747) yields Canvas::default() and empty vectors; from there the chain is

Score::empty → CreateInstrument (MISSING) → CreateStaff ✓ → CreateRegion ✓
             → CreateStaffInstance ✓ → CreateVoice ✓ → InsertEvent ✓

Every later arrow exists today with graph-aware preconditions. CreateStaff already requires a live Instrument (reduce.rs:3824-3833) — which nothing can currently create. That is the whole gap.

Instrument (core/src/graph.rs, fields id, name, range, abbreviation, sound_config, transposition, default_clef, default_staff_lines, unpitched_members) holds no outbound entity references. It is a root, so this operation needs no referential preconditions — only mint/idempotence.

Design pins

  1. No wire layout is designed. Instrument already has a Codec and already ships inside Score. Add Instrument to canonical_value! (core/src/codec.rs:3484) and encode the payload as push_lp_bytes(out, &self.instrument.canonical_bytes()) — the CreateStaffOp template verbatim (ops/src/payload.rs:1411). The generated decode_canonical already rejects non-canonical encodings, so strict-form enforcement is inherited, not written.
  2. Discriminants: kind 31, tag 31. Both spaces currently top out at 30 (TransposeInterval). They coincide here by accident, not by rule — the two spaces are independent and misaligned elsewhere (RespellPitch is kind 2 / tag 3; CreateVoice is kind 19 / tag 20). Append-only under req:binfmt:kind-discriminants.
  3. Name it CreateInstrument in both spaces, catalog name "create-instrument". The tag layer's older Create→Insert convention (InsertStaff for kind CreateStaff) is not followed: the two most recent additions, CreateVoice and CreateRepeatStructure, use Create in the tag, and a gratuitous homonym costs more than the convention buys.
  4. schema_major() is unconditionally 2. Instrument's major-2 appends (sound_config, default_clef, default_staff_lines) are mandatory, not Option-hidden, so no lower-major layout for this payload exists. Put it in the existing | OperationKind::CreateStaff(_) | OperationKind::SetMetadata(_) => 2 arm (payload.rs:219). Do not copy the value-dependent arm shape used by CreateRegion/SetStaffLayout.
  5. No accept-set change. max_supported_major(OperationEnvelopeBlock) is already 2 (bundle.rs:69) and stays 2. Do not touch epiphany-bundle. The raise to 3 belongs to G2 (SetTuningContext) and to nothing else.
  6. Reduction follows create_staff exactly (reduce.rs:3794-3855): live + byte-identical value → NoOp{AlreadyApplied}; live + differing value → NoOp{PreconditionFailedUnderReduction{RecreateContentMismatch}}; tombstoned → NoOp{TargetTombstoned}; otherwise mint_container + Applied.
  7. No DeleteInstrument. CreateStaff ships today with no DeleteStaff; the ruling states full CRUD is not automatically owed. Delete coverage is G3's design work.

The two cross-cutting items

These are the reason G1 is not merely "one more kind".

  1. instrument_values must exist and be seeded. TypedObjectId::Instrument liveness is already seeded from the base (reduce.rs:1319-1321), but there is no value map, so re-carry idempotence against a base instrument has nothing to compare. Add instrument_values: BTreeMap<InstrumentId, Instrument> mirroring staff_values at every one of its sites: the two struct decls (:914, :1004), the init (:1282), the base seed (beside :1327), and the snapshot/restore pair (:7234, :7270). Missing the snapshot/restore pair is the silent failure here — it would surface only under undo/replay.
  2. The identity cursor (ruling §3). From-empty reduction sets next_counter to 1 + max(counter) over ids authored by the reducing replica in the log, leaving the seed untouched when that replica authored none. This is the first time reduction writes identity at allepiphany-ops has no .identity reference today. Note Score::identity is an authoring cursor reduction has never advanced; under from-empty it would otherwise sit at the seed while the log already holds that replica's ids at 0..N, so minting from a reduced score re-issues used counters. Nothing catches that: invariant 11 (core/src/invariants.rs:1746-1752) checks only the reserved replica, never the counter.
  3. From-empty must reduce with a graph. Use reduce_operation_set_onto(&set, &Score::empty(identity)) (reduce.rs:617), never reduce_operation_set(&set) (:612). Graph-aware preconditions are skipped in the base-free mode by design — it has no universe to check against (reduce.rs:3721, :3824). A from-empty document through the base-free entry point silently loses referential enforcement. Document this at both entry points.

Touch points

Derived by enumerating every CreateStaff site. Twelve non-test surfaces — two of which the plan under-counted (v0.rs, migrate.rs):

# File Site (CreateStaff analogue)
1 core/src/codec.rs canonical_value! — add Instrument (:3484)
2 ops/src/payload.rs CreateInstrumentOp struct + CanonicalEncode (:1400, :1411)
3 ops/src/payload.rs OperationKind variant (:177)
4 ops/src/payload.rs schema_major() → 2 arm (:219)
5 ops/src/payload.rs discriminant() → 31 (:280)
6 ops/src/payload.rs tag() (:323) + encode dispatch (:367)
7 ops/src/payload.rs operation_kind_tag_vocabulary! entry (:440) — compile-enforced
8 ops/src/envdecode.rs discriminant-31 decode (:542) + tag→kind (:830)
9 ops/src/v0.rs V0OperationKind variant (:96)
10 ops/src/migrate.rs both directions (:171, :325)
11 ops/src/reduce.rs dispatch (:2632) + create_instrument + pin 8's map
12 ops/src/textproj_kind.rs production (:185) + parse (:470)
13 ops/src/fuzz.rs generator arm (:214)
14 ops/src/vectors.rs a decode vector for the new payload
15 spec/operation_catalog.tex §CreateInstrument

The tag vocabulary macro (#7) is a single source of truth: the decoder, fuzz corpus, conformance vectors, and edit-barrier round-trip all read PAYLOAD_FREE, and a variant missing an entry fails to compile. It exists because Push 4a added TransposeInterval to a hand-written match and nothing else, and four hand-maintained lists stayed green while the decoder rejected its own encoding. OperationKind::discriminant() (#5) has no such guard — it is hand-written.

Tests + minimum mutations

Every test must be mutation-verified: re-introduce the bug it exists to catch and show it dies. A test that cannot see its own bug is not a test.

  • (i1) the spine reaches a note. From Score::empty(identity), operations only — CreateInstrumentCreateStaffCreateRegionCreateStaffInstanceCreateVoiceInsertEvent — materializes a note-bearing Score. No fixture, no base. This is the packet's load-bearing test and the ruling's acceptance criterion 1. Mutation: drop the CreateInstrument envelope → CreateStaff must NoOp{TargetMissing} and no note appears.
  • (i2) re-carry idempotence. The same CreateInstrument twice → AlreadyApplied, and the state is byte-identical. Mutation: make the second carry differ in one field → RecreateContentMismatch.
  • (i3) re-carry against a base instrument — the pin-8 case. Reduce onto a base that already contains the instrument. Mutation: skip seeding instrument_values → the byte-identical re-carry is misreported as a mismatch.
  • (i4) order independence. Two replicas applying a concurrent genesis-era set in opposite delivery orders converge to byte-identical MaterializedState (ruling acceptance criterion 2; the existing pattern is reduce.rs:10031). Mutation: perturb one op's reduction order.
  • (i5) the identity cursor. Mint from a reduced-from-empty score and assert the id collides with nothing in the log. Mutation: leave next_counter at the seed → collision.
  • (i6) base-free loses enforcement. Assert explicitly that reduce_operation_set (base-free) does not enforce the instrument precondition while reduce_operation_set_onto does. This test documents a designed asymmetry rather than guarding a bug — state that in its doc comment so a later reader does not "fix" it.
  • (i7) minimal stamping. CreateInstrument.schema_major() == 2 unconditionally — including for an instrument whose optional fields are all None. Extend schema_majors_follow_the_minimal_stamping_rule (reduce.rs:10386). Mutation: make the arm value-dependent → dies.
  • (i8) decode vector pinned to literal bytes, not round-trip. Round-trip locking cannot see a self-consistent encoder/decoder reorder — that is the 3b-i lesson, where a swap applied to both halves passed 1283 tests and 8/8 conformance.
  • (i9) text-projection round-trip for the new kind, matching the existing per-kind coverage.

Blast radius

crates/epiphany-core/src/codec.rs (one canonical_value! line) and its DECISIONS.md; crates/epiphany-ops/src/{payload,envdecode,v0,migrate,reduce,textproj_kind,fuzz,vectors}.rs and its DECISIONS.md; spec/operation_catalog.tex (+ its rebuilt PDF — every other .tex commit carries one). Nothing else. No epiphany-bundle, no binary_format.tex, no editor-track crate, no golden re-blessing.

Gate (report actual output, never stale numbers)

cargo fmt --check; cargo clippy --workspace --all-targets at 0 warnings; cargo test --workspace at 0 failed; cargo test --doc at 0 failed; conformance 8/8; requirement_labels 6/6 with its three observed counts reported as seen (they were 212/282/282 at 439e1e2 and will move with the catalog section). Plus:

  • max_supported_major(OperationEnvelopeBlock) is still 2 — assert it, and state it in the report.
  • No crates/epiphany-editor-gui/goldens/*.png byte changes.

What I will verify independently before committing

Build to survive this. I re-run every mutation myself, and I check specifically: that #5's hand-written discriminant match and #7's macro entry agree; that pin 8's snapshot/restore pair (:7234/:7270) was not missed; that i1 fails for the stated reason rather than incidentally; that the accept-set really did not move; and that no claim in the report is copied forward from this contract rather than observed — the recurring failure mode on this project is a plausible claim propagating because nobody re-derived it.

Report

Files + summary, exact asserted values, every mutation with kill evidence, gate output verbatim, deviations flagged explicitly. If any pin here turns out to be wrong, say so rather than working around it silently.