epiphany/spec/RULING_GENESIS_PERSISTENCE.md

8.5 KiB

Ruling: the operation set absorbs genesis

Ratified 2026-07-24. Resolves spec/PLAN_EDITOR_APP.md §Ruling B blocker (i) — canonical graph-state persistence across genesis and pruning. The evidence is spec/ANALYSIS_GENESIS_PERSISTENCE.md, which stays as analysis; this document is the decision and its constraints.

Execution belongs to the Push-4b-class coordinated spec+core+ops track, not to the editor track. The editor track consumes it and does not block on it (§3.7's standing posture).


1. The decision

Every mutable field of Score becomes operation-authored. The Pass-12 K8 ratification — "genesis is outside the operation set", binary_format.tex:2420 — is reversed. A document is Score::empty(identity) plus its envelope log; the instrument → staff → staff instance → voice → event chain is authorable end to end.

There is no genesis block. No new chunk role, no manifest field, no write-once payload, no immutability rule, and no merge or fail-closed rule for a non-CRDT canonical blob. That machinery was the price of leaving genesis outside the operation set, and this ruling declines to pay it. Genesis becomes the first few operations of an ordinary log, converging under the rules already in force.

Rationale, in one line: genesis state is edited — instruments are added, page geometry is changed, temperaments are chosen — and every alternative made those edits either single-writer, unmergeable, or impossible. Concurrency is a first-order product commitment (§3.4), so the exception was not worth institutionalising in the format.

What was considered and rejected

  • A — a canonical genesis block. Rejected: it fixes genesis but leaves the music unreconstructable after a prune, and a non-op canonical payload has no merge rule, forcing either single-writer genesis or a fail-closed sync rule. Considered in a staged form (immutable block + settings ops) and rejected once it became clear the block buys nothing that B does not.
  • C — the canonical base carries graph values. Not rejected — deferred, and still required. See §4.
  • D — scope-limit T1b. Rejected as a destination; unnecessary as a sequencing device once B is committed to.

2. Scope

Two existing reduction patterns cover the whole tranche. Neither is new semantics; both are templates already proven in reduce.rs.

Settings — LWW field-overwrite, the SetMetadata pattern (reduce.rs:2713, seeded for value-restoring undo at :1357):

Field Operation
canvas.layout_defaults SetCanvasLayoutDefaults
tuning_context SetTuningContext
spelling_precedence SetSpellingPrecedence

Entities — set-union mint with byte-identical re-carry idempotence, the CreateStaff pattern (reduce.rs:3850, graph-aware preconditions at :3824):

Field Family
instruments CreateInstrument
staff_groups CreateStaffGroup
parts CreatePartDefinition
analysis_layers CreateAnalysisLayer
views CreateView
StaffInstance.measures CreateMeasure

Delete and modify coverage per family is the tranche contract's design work, guided by the existing precedent (Group 3's "mint + empty-only delete" for containers; CreateStaff lands today with no DeleteStaff, so full CRUD is not automatically owed). What is owed is that every family's referential preconditions are graph-aware, in the shape CreateStaff already uses:

  • 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).

Measures: authored, not derived

Ruled 2026-07-24. TimeAnchor::Measure { id, .. } means cross-cutting structures anchor to measure ids; deriving measures from the metric grid would make their identity a function of the meter, so every time-signature change would re-derive a different measure set and orphan the anchors pointing into it. Authored ids survive a meter change.

Accepted cost: measure/meter consistency becomes an authoring obligation backed by a graph invariant, not a model guarantee. The invariant belongs in the tranche.

Out of scope, with reasons

  • identity — not an authored field; see §3, which must be settled first.
  • decomposition_attachmentsderived, not authored. The prepass creates it (core/src/prepass.rs:382); reduction only ever retains (reduce.rs:2342). It leaves the table rather than gaining operations. Flagged for ratification with the tranche.

3. The one open sub-decision, and it blocks specification

identity must be dispositioned before the tranche can be specified.

IdentityContext { replica_id, next_counter } is replica-scoped by construction (ids.rs:711-717) yet lives on Score, which the codec encodes (codec.rs:2766, :3223). Today the value is inherited from a shared base, which masks the tension. Under this ruling reduction runs onto Score::empty(identity), so whoever opens the document chooses it — and two replicas reducing an identical log then produce Scores differing in an encoded field while the music is identical.

Options: move it to the manifest (document-scoped, recommended); keep it on Score but exclude it from the canonical encoding; or accept replica-dependent Score bytes and confine byte-equality claims to MaterializedState. Not ruled here.


4. Standing constraints

Pruning MUST NOT be implemented until disposition C lands. This ruling makes documents openable, editable, and collaborative; it does not make them prunable. A prune deletes the covered envelopes and installs a MaterializedState base carrying no graph values, so the score is unreconstructable afterward — and MaterializedState.effects are outcomes, not payloads, so nothing rebuilds it. Pruning is unimplemented today (no fn prune; opset.rs:7 calls it out-of-scope-for-v0), which is why this costs nothing now and would cost everything later. C — the canonical base carrying graph values, the same checkpoint T4b needs for incremental materialization — remains required before any pruning implementation.

The from-empty path must reduce with a graph, not base-free. Reduction has two modes, and graph-aware preconditions are skipped in the base-free one because it has no universe to check against (reduce.rs:3822, :3721). Score::empty(identity) is a graph, so new_onto with an empty score enforces every precondition from the first operation; the base-free mode does not. A from-empty document reduced through the wrong entry point silently loses referential enforcement. Name this in the tranche and test it.

One accept-set raise, spent once. OperationEnvelopeBlock is capped at major 2 (bundle.rs:69) because no operation payload embeds these types. The first new kind that does raises it to 3; every later kind in that major is free. The new kinds therefore land as one batch, not dribbled out per need. Note this is a different major from Push-4b's schema major 3, which is the Score/Snapshot-role wire that tranche 3b-i opened and permanently froze — there is no free ride between them.

Additive discriminants. New kinds extend past TransposeInterval under the existing convention (req:binfmt:kind-discriminants); canonical bytes for existing types do not move.


5. Acceptance

  • A document created empty and given only operations materializes a note-bearing Score — the full chain, no fixture, no base.
  • Two replicas applying concurrent genesis-era operations in any delivery order converge to byte-identical MaterializedState.
  • Opening such a document from a bundle reaches a note, which is what unblocks T1b.
  • Existing canonical bytes, goldens, and conformance gates unmoved except where the accept-set raise is the deliberate change.

6. What this unblocks

spec/PLAN_EDITOR_APP.md §Ruling B blocker (i) is resolved. Blocker (ii) — the version-aware envelope decoder — is unaffected and still open; its residual is one bounded ops packet. T1b's remaining runway is therefore blocker (ii) plus the Ruling-D ownership API, and the lease/save/single-writer machinery can be contracted in parallel with this tranche, since it does not depend on genesis being authorable.

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