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
- No wire layout is designed.
Instrumentalready has aCodecand already ships insideScore. AddInstrumenttocanonical_value!(core/src/codec.rs:3484) and encode the payload aspush_lp_bytes(out, &self.instrument.canonical_bytes())— theCreateStaffOptemplate verbatim (ops/src/payload.rs:1411). The generateddecode_canonicalalready rejects non-canonical encodings, so strict-form enforcement is inherited, not written. - Discriminants: kind
31, tag31. Both spaces currently top out at 30 (TransposeInterval). They coincide here by accident, not by rule — the two spaces are independent and misaligned elsewhere (RespellPitchis kind 2 / tag 3;CreateVoiceis kind 19 / tag 20). Append-only underreq:binfmt:kind-discriminants. - Name it
CreateInstrumentin both spaces, catalog name"create-instrument". The tag layer's older Create→Insert convention (InsertStafffor kindCreateStaff) is not followed: the two most recent additions,CreateVoiceandCreateRepeatStructure, useCreatein the tag, and a gratuitous homonym costs more than the convention buys. schema_major()is unconditionally2.Instrument's major-2 appends (sound_config,default_clef,default_staff_lines) are mandatory, notOption-hidden, so no lower-major layout for this payload exists. Put it in the existing| OperationKind::CreateStaff(_) | OperationKind::SetMetadata(_) => 2arm (payload.rs:219). Do not copy the value-dependent arm shape used byCreateRegion/SetStaffLayout.- No accept-set change.
max_supported_major(OperationEnvelopeBlock)is already 2 (bundle.rs:69) and stays 2. Do not touchepiphany-bundle. The raise to 3 belongs to G2 (SetTuningContext) and to nothing else. - Reduction follows
create_staffexactly (reduce.rs:3794-3855): live + byte-identical value →NoOp{AlreadyApplied}; live + differing value →NoOp{PreconditionFailedUnderReduction{RecreateContentMismatch}}; tombstoned →NoOp{TargetTombstoned}; otherwisemint_container+Applied. - No
DeleteInstrument.CreateStaffships today with noDeleteStaff; 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".
instrument_valuesmust exist and be seeded.TypedObjectId::Instrumentliveness 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. Addinstrument_values: BTreeMap<InstrumentId, Instrument>mirroringstaff_valuesat 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.- The identity cursor (ruling §3). From-empty reduction sets
next_counterto1 + 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 writesidentityat all —epiphany-opshas no.identityreference today. NoteScore::identityis 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 at0..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. - From-empty must reduce with a graph. Use
reduce_operation_set_onto(&set, &Score::empty(identity))(reduce.rs:617), neverreduce_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 —CreateInstrument→CreateStaff→CreateRegion→CreateStaffInstance→CreateVoice→InsertEvent— materializes a note-bearingScore. No fixture, no base. This is the packet's load-bearing test and the ruling's acceptance criterion 1. Mutation: drop theCreateInstrumentenvelope →CreateStaffmustNoOp{TargetMissing}and no note appears. - (i2) re-carry idempotence. The same
CreateInstrumenttwice →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 isreduce.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_counterat the seed → collision. - (i6) base-free loses enforcement. Assert explicitly that
reduce_operation_set(base-free) does not enforce the instrument precondition whilereduce_operation_set_ontodoes. 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() == 2unconditionally — including for an instrument whose optional fields are allNone. Extendschema_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/*.pngbyte 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.