diff --git a/spec/CONTRACT_GENESIS_G1_INSTRUMENT.md b/spec/CONTRACT_GENESIS_G1_INSTRUMENT.md new file mode 100644 index 0000000..9fd2785 --- /dev/null +++ b/spec/CONTRACT_GENESIS_G1_INSTRUMENT.md @@ -0,0 +1,219 @@ +# 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". + +8. **`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` + 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. +9. **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 all** — + `epiphany-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. +10. **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 — `CreateInstrument` → `CreateStaff` → `CreateRegion` → + `CreateStaffInstance` → `CreateVoice` → `InsertEvent` — 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.