17 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: G1 landed (3b09595, CI green) via
spec/CONTRACT_GENESIS_G1_INSTRUMENT.md. G2a contracted
(spec/CONTRACT_GENESIS_G2A_SETTINGS.md); G2b and G3 scoped, not contracted.
§6 lists what still needs ratification.
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_counterto1 + 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 writesidentityat all —epiphany-opshas no.identityreference 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_ontowith 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 settings setters, split in two
All three ride the SetMetadata LWW pattern (reduce.rs:2814), seeded for
value-restoring undo (:1385). But they do not sit at the same major, and
only one of them is a compatibility event. Verified 2026-07-28 against the
working tree:
SetSpellingPrecedence→SpellingPrecedence. Major 0: the frozendecode_v0_score/decode_v1_score/decode_v2_scorewalks and the liveCodecall read this field through plainCodec::dec(codec.rs:2673,:3249,:3372) — it has never been versioned.SetCanvasLayoutDefaults→CanvasLayoutDefaults. Major 0. The type is labelled "schema major 1" (graph.rs:836), but the versioning lives in the containingCanvaswalk, not the leaf:dec_canvas_v0(codec.rs:2775) default-fills the whole field whileenc_canvas_v1(:3158) writes it through the liveCodec. As a standalone payload it has exactly one layout.SetTuningContext→ScoreTuningContext. Major 3, and unconditionally so.enc_tuning_context_v2(codec.rs:3293) writes three fields; the liveCodecwrites five. A defaultsmufland an emptyoverridesstill append bytes, so there is no value for which a lower-major layout exists — this is aCreateInstrument-shaped arm, not aCreateRegion-shaped one.
So the split is not tidiness. Two of the three move no wire bound at all, and
the accept-set raise is a one-way door: once a block can be born at v3, an
older reader meeting one preserves the bundle read-only (bundle/src/error.rs:257).
Spending that in the same packet as two major-0 leaf setters buries it.
G2a — SetCanvasLayoutDefaults + SetSpellingPrecedence. Both land in
schema_major()'s catch-all _ => 0 arm (payload.rs:262) with no arm
added; adding them to the => 2 arm would be the bug. No epiphany-bundle
change of any kind.
G2b — SetTuningContext alone, carrying the raise, the bundle.rs prose,
and the S13 close. 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". G2b is precisely what falsifies
that sentence, so the comment must move with the number. Same for
DECISIONS.md's superseded prohibition, which is already marked.
The cost of splitting, stated honestly: each packet appends kind
productions to the text-projection grammar, and a kind append is a
document-surface change (the G1 precedent). So the companion bumps twice —
0.8.0 → 0.9.0 → 0.10.0 — and each bump re-sweeps five live version sites in
text_projection.tex plus a changelog row, re-flips the negative
superseded_companion_version vector, and regenerates the vector corpora.
That is mechanical and pre-1.0; it is the cheaper of the two risks.
P13-S13 closes at G2b — and on the metadata precedent, not on the canonical
base. The base cannot carry a v3 tuning context: it is role-bound to major 0
(mis_stamped_canonical_base, bundle.rs:866). It does not need to. The
canonical base is a MaterializedState (reduce.rs:504) — effects, conflicts,
anomalies, objects, spellings, breaks, page-breaks, pending — which embeds no
graph values for any field, including metadata, op-authored since M2d and
durable purely through its operations. S13's claim was "no canonical carrier
embeds it at all: no operation authors it". G2b makes an operation author it,
and the op log is canonical.
What that sharpens. The standing prohibition on pruning (blocked on disposition C) stops being a performance concern the moment G2b lands: pruning would then discard authored genesis state, not merely re-derivable state. G2b's contract must state this as an explicit non-goal.
G2b holdout — accidental_extensions, and why a naïve full-value
SetTuningContext would be wrong. ScoreTuningContext's Codec
deliberately drops accidental_extensions on encode and default-fills it
to Vec::new() on decode (core/src/codec.rs:1939) — the field is staged out
of schema major 3 and lands at a later one. Meanwhile OperationSet::accept
stores the authored envelope as an object, not as bytes
(ops/src/opset.rs:70). So a SetTuningContext carrying a non-empty
accidental_extensions would reduce with those extensions present on the
authoring replica, and reduce without them on any replica that received the
document through serialization — a silent divergence between a live session and
the same document reloaded.
canonical_value! does not catch this. Its generated decode_canonical
compares bytes (decode → finish() → re-encode → reject on mismatch); it
never compares against the originating value, so a field that never reached the
bytes is invisible to it. G2b needs a normalization-or-subset pin before
dispatch — either the payload carries a wire-complete subset type, or the
operation normalizes the field away at construction and refuses a non-empty
one. Decide that in the G2b contract, not in its implementation. Does not block
G2a.
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→ liveStaffsCreateView.active_layers→ liveAnalysisLayersCreateMeasure→ a liveStaffInstance- 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
- Two unaligned discriminant spaces, one macro-guarded and one not (§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.- Minimal stamping is a pure function of the value.
CreateInstrumentis unconditionally 2 (its major-2 appends are notOptions), unlikeCreateRegion/SetStaffLayout, which are value-dependent. Do not copy the value-dependent arm shape. - 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.
Withdrawn 2026-07-28 — this is not a trap, andScore::emptyseedstuning_contextwith a default, soSetTuningContext's reduction must distinguish "never authored" from "authored to the default value" if undo is to restore correctly.SetMetadataalready proves it.Score::emptyseedsmetadatawith a default exactly as it seedstuning_context(graph.rs:1749,:1757), and the base ingest then runsmetadata_chain.seed(score.metadata.clone())(reduce.rs:1385) under a comment stating the purpose outright: the score-level LWW chains seed with the base values so a value-restoring undo of the first operational write restores the pre-operational state. Since from-empty reduces ontoScore::empty(trap-free only throughreduce_operation_set_onto— G1 pin 10), the seed runs, and undoing the first write yieldsRestore(Some(Predecessor::Base(default))). Restoring the default is correct in both the never-authored and the authored-to-default case, so the distinction is unobservable and must stay so. ThePredecessor::Basevs::Writedistinction earns its keep only for the canonical bookkeeping families (spellings,breaks,page_breaks,reduce.rs:707), where a base predecessor returns a map key to absence.ScoreTuningContextis an always-valuedScorefield, like metadata: there is no absent state to return to. All three G2 setters copyset_metadatastructurally.- Adding an
OperationKindvariant is NOT containable to core + ops — the G1 lesson, and the one claim this plan previously got wrong. Rust exhaustiveness forces an arm inepiphany-editor-core'ssubjects_of(barriers.rs:437), and becauseepiphany-testkitdepends on editor-core, a missing arm blocks conformance andrequirement_labels— the gate cannot run at all. Three further sites bake in a literal that only surfaces once the workspace compiles:layout-ir/src/barrier.rs:1105(a tag "one past the vocabulary"),testkit/tests/text_projection_grammar.rs:307(a hardcoded kind count), andtextproj/src/vectors.rs(a negative vector whose "wrong version" is the one each bump moves to). Every G2/G3 contract MUST enumerate these and budget the boundary crossing up front.
6. Open rulings — needed before a dispatch contract
- Delete/modify coverage per family. The ruling leaves this as the
contract's design work and explicitly declines to assume full CRUD:
CreateStaffships today with noDeleteStaff. Group 3's precedent is "mint + empty-only delete" for containers. Which families get deletes in G3? 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.- 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.
Ladder shape. G1/G2/G3 as above, or a different cut.Ratified 2026-07-24, and amended 2026-07-28: G2 splits into G2a (the two major-0 setters) and G2b (SetTuningContextalone, carrying the accept-set raise and the S13 close). See §4.
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).