epiphany/spec/PLAN_GENESIS_OPS.md

9.8 KiB

Plan — the genesis operation tranche: scope, ladder, and open rulings

Governed by spec/RULING_GENESIS_PERSISTENCE.md (ratified 2026-07-24, 011c68a; identity sub-decision ruled 2026-07-24, ec17d06). That ruling reverses Pass-12 K8 and makes every mutable field of Score operation-authored. This plan is the execution scope: what the tranche touches, in what order, and which questions must be answered before a dispatch contract can be written.

Status: scoped, not dispatched. §6 lists what needs ratification first.


1. The mechanism, and why the tranche is tractable

Every one of the nine surfaces carries a Score field whose type already has a Codec in epiphany-core. Codec is pub(crate), so operations cannot use it — but they do not need to. The established seam is CanonicalValue (codec.rs:3443), and the template is one line:

impl CanonicalEncode for CreateStaffOp {
    fn encode_canonical(&self, out: &mut Vec<u8>) {
        push_lp_bytes(out, &self.staff.canonical_bytes());   // payload.rs:1411
    }
}

canonical_value! (codec.rs:3455) implements the trait by delegating to the existing Codec, introducing no new byte layout, and its generated decode_canonical already does decode → finish() → re-encode → reject on mismatch. So every new operation payload gets strict canonical-form enforcement for free, on the same seam the decode-vector corpus uses.

Concretely: the tranche adds the eight remaining carried types to canonical_value! and writes one push_lp_bytes line per op. It does not design wire layouts. Every layout it carries is already frozen and already shipping inside Score.

2. Two facts that de-risk this, and one that does not

The tag layer is now safe. operation_kind_tag_vocabulary! (payload.rs:440) is a single source of truth generating the discriminant map, its inverse, and PAYLOAD_FREE — which the decoder, fuzz corpus, conformance vectors, and edit-barrier round-trip all read. A variant added to OperationKindTag without an entry fails to compile. This macro exists because Push 4a added TransposeInterval to a hand-written match and nothing else: the decoder rejected its own encoding, an edit barrier naming it could not be reopened, and four hand-maintained lists — two asserting the tag was unknown — stayed green.

Canonical-form enforcement is inherited, not written. Per §1.

But OperationKind::discriminant() is still a hand-written match (payload.rs:253), and it is a different space from the tag space. They are not aligned and must not be assumed so — RespellPitch is kind 2 and tag 3; InsertEvent is 0 in both. The tag space reserves 16 for Registered; the kind space does not. Both are append-only under req:binfmt:kind-discriminants. Next free: kind 31, tag 31.

3. The nine surfaces, and what each costs

Only one of the nine forces the accept-set raise. Verified by reading each carried type's fields for mandatory (non-Option) appends above major 0:

Surface Op Carried type schema_major() Raise?
instruments CreateInstrument Instrument 2 (mandatory sound_config, default_clef) no
canvas.layout_defaults SetCanvasLayoutDefaults CanvasLayoutDefaults 0 no
spelling_precedence SetSpellingPrecedence SpellingPrecedence 0 no
staff_groups CreateStaffGroup StaffGroup 0 no
parts CreatePartDefinition PartDefinition 0 no
analysis_layers CreateAnalysisLayer AnalysisLayer 0 no
views CreateView ViewDefinition 0 no
StaffInstance.measures CreateMeasure Measure 0 no
tuning_context SetTuningContext ScoreTuningContext 3 YES

The op-block accept-set is already 2 (bundle.rs:69), so CreateInstrument at major 2 costs nothing — it sits exactly where CreateStaff already sits. SetTuningContext is the sole surface that drags OperationEnvelopeBlock to 3.

Each op touches roughly thirteen places: the op struct, the OperationKind variant, discriminant(), tag(), schema_major(), CanonicalEncode, the tag vocabulary entry, the envdecode.rs decode arm and its validation, the reduce.rs arm, both textproj_kind.rs arms (production :217, parse :523), validate.rs, vectors.rs, the fuzz/valuegen.rs generators, plus operation_catalog.tex. Nine surfaces at thirteen touch points is not one dispatch.

4. The ladder

G1 — the spine (CreateInstrument only)

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

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

Every arrow but the first already exists and already has graph-aware preconditions. So one operation satisfies the ruling's acceptance criteria 1 and 3 — a document created empty and given only operations materializes a note-bearing Score, and opening it from a bundle reaches a note, which is what unblocks T1b.

G1 also carries the two constraints that are not per-surface:

  • The identity cursor. 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 (ruling §3). This is the first time reduction writes identity at all — epiphany-ops has no .identity reference today. Wants a test that mints from a reduced score and asserts no collision with the log.
  • From-empty must reduce with a graph. new_onto with an empty score enforces preconditions from the first operation; the base-free mode skips them by design, because it has no universe to check against (reduce.rs:3721, :3824). A from-empty document through the wrong entry point silently loses referential enforcement. Name it and test it.

No accept-set raise. No wire change. Highest value per unit of risk in the whole tranche.

G2 — the three settings setters

SetCanvasLayoutDefaults, SetSpellingPrecedence, SetTuningContext on the SetMetadata LWW pattern (reduce.rs:2713), seeded for value-restoring undo (:1357). This is where the one accept-set raise is spent: bundle.rs's max_supported_major(OperationEnvelopeBlock) moves 2 → 3.

Note bundle.rs:58 documents the current cap with the tuning-context rationale in prose — "no operation payload embeds the tuning context, so no op block is ever born at v3". That comment becomes false the moment this lands and must move with the cap. Same for DECISIONS.md's superseded prohibition, which is already marked.

P13-S13 closes here.

G3 — the remaining entity families

CreateStaffGroup, CreatePartDefinition, CreateAnalysisLayer, CreateView, CreateMeasure, on the CreateStaff set-union mint pattern (reduce.rs:3850) with byte-identical re-carry idempotence, plus whatever delete/modify coverage §6.1 rules owed. Graph-aware referential preconditions per the ruling §2:

  • CreateStaffGroup.members, CreatePartDefinition.staves → live Staffs
  • CreateView.active_layers → live AnalysisLayers
  • CreateMeasure → a live StaffInstance
  • deleting an entity with live dependents → refuse (container-not-empty)

CreateMeasure is shaped differently from its five siblings: measures is nested on StaffInstance (graph.rs:611), not a Score-level vector, so its precondition reaches three levels down through canvas.regions[].staff_instances(). Consider splitting it out if G3 runs long.

5. Traps

  1. Two unaligned discriminant spaces, one macro-guarded and one not (§2).
  2. bundle.rs's cap comment is prose that encodes a rationale, not just a number. Moving the number without the prose leaves a confident falsehood in the file that most directly governs accept-sets.
  3. Minimal stamping is a pure function of the value. CreateInstrument is unconditionally 2 (its major-2 appends are not Options), unlike CreateRegion/SetStaffLayout, which are value-dependent. Do not copy the value-dependent arm shape.
  4. Round-trip locking cannot see a self-consistent reorder. The lesson of 3b-i: a swap applied to both codec halves passed 1283 tests and 8/8 conformance. New payloads want decode-vector entries pinned to literal bytes, not just round-trip tests.
  5. Score::empty seeds tuning_context with a default, so SetTuningContext's reduction must distinguish "never authored" from "authored to the default value" if undo is to restore correctly.

6. Open rulings — needed before a dispatch contract

  1. Delete/modify coverage per family. The ruling leaves this as the contract's design work and explicitly declines to assume full CRUD: CreateStaff ships today with no DeleteStaff. Group 3's precedent is "mint + empty-only delete" for containers. Which families get deletes in G3?
  2. decomposition_attachments. The ruling calls it derived, not authored — the prepass creates it (prepass.rs:382) and reduction only ever retains (reduce.rs:2342, its sole mention). It leaves the eight-field table rather than gaining operations. Flagged for ratification with the tranche. Verified: the citation is accurate.
  3. The measure/meter invariant. Measures are authored, not derived (ruling §2), so measure/meter consistency becomes an authoring obligation backed by a graph invariant. That invariant needs specifying — it belongs in G3.
  4. Ladder shape. G1/G2/G3 as above, or a different cut.

Related: spec/RULING_GENESIS_PERSISTENCE.md, spec/ANALYSIS_GENESIS_PERSISTENCE.md, spec/PLAN_EDITOR_APP.md §Ruling B / §3.7, spec/PLAN_PUSH4B_TUNING.md (the tranche mold), spec/PASS13_CANDIDATES.md (S13).