The operation set absorbs genesis

Ratified 2026-07-24, resolving Ruling B blocker (i). Pass-12 K8 is reversed:
every mutable field of Score becomes operation-authored, and a document is
Score::empty(identity) plus its envelope log. The instrument, staff, staff
instance, voice, event chain is authorable end to end.

The decision removes machinery rather than adding it. Every alternative kept
genesis outside the operation set and then had to pay for that: a new chunk
role, a manifest field, an immutability rule, and a merge or fail-closed rule
for a canonical payload with no CRDT semantics. Genesis state is edited —
instruments get added, page geometry changes, temperaments are chosen — and
each alternative made those edits single-writer, unmergeable, or impossible.
Concurrency is a first-order product commitment, so the exception was not
worth institutionalising in the format.

Scope is nine surfaces over two templates already proven in reduce.rs: three
LWW settings setters on the SetMetadata pattern (canvas.layout_defaults,
tuning_context, spelling_precedence) and six entity mint families on the
CreateStaff pattern (instruments, staff_groups, parts, analysis_layers, views,
and StaffInstance.measures), each with graph-aware referential preconditions.
Delete and modify coverage is left to the tranche contract rather than assumed,
since CreateStaff itself ships today with no DeleteStaff.

Measures are ruled authored rather than derived. TimeAnchor::Measure carries a
measure id that cross-cutting structures anchor to, so deriving measures from
the metric grid would make their identity a function of the meter and every
time-signature change would orphan the anchors pointing into them. The cost
accepted is that measure/meter consistency becomes an authoring obligation
backed by a graph invariant.

Three constraints are written in rather than left implicit. Pruning may not be
implemented until the canonical base carries graph values: a prune installs a
MaterializedState base whose effects are outcomes, not payloads, so nothing
rebuilds the score afterward — silent and total, and free to prohibit now
because no prune exists to break. The from-empty path must reduce through
new_onto with an empty Score rather than base-free, because the base-free mode
skips graph-aware preconditions by design and would silently lose referential
enforcement from the first operation. And the OperationEnvelopeBlock accept-set
raise 2 to 3 is spent once, so the new kinds land as one batch — this is a
different major from Push 4b's schema major 3, the Score and Snapshot role wire
that tranche 3b-i froze, and there is no free ride between them.

The analysis is corrected in place rather than rewritten, so the evidence the
ruling rests on stays readable. Two amendments: Measure is a ninth uncovered
surface the original table missed by scoring canvas.regions at container
granularity, and identity is promoted from a stated question to a blocking one
— IdentityContext is replica-scoped yet lives on Score and is encoded, so under
from-empty reduction two replicas with an identical log produce Scores
differing in an encoded field while the music is identical. That disposition
blocks specification of the tranche and is deliberately not ruled here.

Execution belongs to the Push-4b-class coordinated track; the editor track
consumes it. T1b's lease, save, and single-writer machinery does not depend on
the tranche landing and may be contracted in parallel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 15:11:36 -04:00
parent dd33b34f08
commit 011c68a831
3 changed files with 251 additions and 12 deletions

View File

@ -1,5 +1,11 @@
# Analysis: canonical graph-state persistence across genesis and pruning
> **Ruled 2026-07-24 — see `spec/RULING_GENESIS_PERSISTENCE.md`.** Disposition
> **B** was taken: the operation set absorbs genesis. This document stays as
> analysis and is not rewritten to match, so the evidence the ruling rests on
> remains readable; the two amendments it carries (the `Measure` correction and
> the promotion of `identity` to blocking) are marked inline where they belong.
**Status: analysis, not a ruling.** This is the field-by-field `Score` table
that `spec/PLAN_EDITOR_APP.md` §Ruling B blocker (i) requires *before* the
blocker can be resolved, and that the T1b runway names as step (1). It
@ -73,6 +79,7 @@ without a minting operation.
|---|---|---|---|---|
| 1 | `metadata` | ✅ | `SetMetadata` (LWW) | `reduce.rs:2713`, `:5249` |
| 2 | `canvas.regions` | ✅ | `CreateRegion` / `DeleteRegion`, `ChangeRegionTimeModel`, `SetMetricGrid`, `SetTimeSignature`, `SetUserSystemBreak` / `SetUserPageBreak` | 29 sites incl. `:2656` |
| 2a | ↳ `StaffInstance.measures` | ❌ **none** | — see the correction below | `Measure {` built only in `testkit/src/fixtures.rs` |
| 3 | `canvas.layout_defaults` | ❌ **none** | — page size and margins | zero hits in `epiphany-ops` |
| 4 | `instruments` | ❌ **none** | — no `CreateInstrument` exists | read-only at `:1318` |
| 5 | `staves` | ✅ | `CreateStaff` (mint), tombstone removal | `:3850`, `:2560` |
@ -92,11 +99,27 @@ without a minting operation.
| 19 | `tombstoned_pitches` | ✅ derived | delete/tombstone paths | `:2526` |
| 20 | `tombstoned_events` | ✅ derived | delete/tombstone paths | 3 sites |
**Eight fields have no operation that can produce them** (3, 4, 6, 7, 10, 15,
16, 17, plus `identity` at 18), and one more (14) can only be pruned back,
never authored. Of the covered ones, several are covered *only in the
**Eight top-level fields have no operation that can produce them** (3, 4, 6, 7,
10, 15, 16, 17, plus `identity` at 18), and one more (14) can only be pruned
back, never authored. Of the covered ones, several are covered *only in the
graph-aware mode* — they write through `if let Some(score) = self.graph`.
> **Correction (2026-07-24, made while ruling on this analysis).** Row 2 scored
> `canvas.regions` op-covered at *container* granularity — regions, staff
> instances, voices — and that hid a ninth gap one level deeper. **`Measure` is
> authored by nothing**: no operation mints it, no reducer path writes it, and
> `Measure {` is constructed only in `testkit/src/fixtures.rs` (`:137`, `:235`,
> `:389`). `CreateStaffInstance` moreover *refuses* an instance carrying
> measures (`reduce.rs:3715`, container-not-empty), so they cannot enter at
> instance-mint either. A from-empty document therefore cannot have a measure,
> which puts this on the critical path rather than in the margins. Ruled
> **authored, not derived** (2026-07-24): `TimeAnchor::Measure { id, .. }` means
> cross-cutting structures anchor to measure ids, so deriving measures from the
> metric grid would make their identity a function of the meter and every
> time-signature change would orphan the anchors pointing into them. The cost
> accepted with that ruling is that measure/meter consistency becomes an
> authoring obligation backed by a graph invariant, not a model guarantee.
---
## 3. The three findings
@ -224,6 +247,15 @@ added to `Score` — which argues, again, for C.
2. **Is `identity` document state or session state?** It is on `Score` today
with no op coverage. If a document has one `IdentityContext` and each
session mints under its own replica id, the field's role needs stating.
**Promoted to blocking (2026-07-24):** under the ruled disposition,
reduction runs onto `Score::empty(identity)`, so whoever opens the document
chooses the value — and the codec *encodes* it (`codec.rs:2766`, `:3223`),
so two replicas reducing an identical log produce Scores differing in an
encoded field while the music is identical. `IdentityContext` is
`{replica_id, next_counter}`, replica-scoped by construction
(`ids.rs:711-717`). This must be dispositioned before the tranche can be
specified; the likely answer is that it belongs in the manifest rather than
the graph.
3. **`decomposition_attachments` and `spelling_precedence`** are consumed by
the prepass. Are they derived state that should be rebuilt rather than
persisted — in which case they leave this table — or authored state?

View File

@ -229,10 +229,11 @@ after the resolver tranche lands.
* **T1b — the document layer (post-resolver; contract after its blockers
resolve).** Rulings B and D: `EditorDocument` + single-writer enforcement
in `epiphany-bundle`. Its runway, in order: (1) the **graph-state
persistence decision** — how canonical graph state is persisted across
*both* genesis *and* pruning, driven by the field-by-field `Score` table
(§Ruling B, blocker i); a coordinated spec/core/ops/format decision, or an
explicit T1b scope limit to empty/metadata/region documents; (2) the
persistence decision — RESOLVED 2026-07-24**
(`spec/RULING_GENESIS_PERSISTENCE.md`): the operation set absorbs genesis,
and pruning is parked until the canonical base carries graph values. The
lease/save/single-writer machinery does **not** depend on that tranche
landing and may be contracted in parallel with it; (2) the
**versioned-decode disposition** — the migrate-on-read API in
`epiphany-ops`, preferred and eventually mandatory, or the enforceable
current-layout restriction (§Ruling B, blocker ii); (3) the **Ruling-D
@ -433,9 +434,14 @@ consumes them and never blocks on them — and the sequencing currency is the
**schema-major budget** ("a major is a budget to spend deliberately",
`PLAN_PUSH4B_TUNING.md`), not editor tranche numbers:
* **Genesis persistence (T1b blocker i, §Ruling B):** how genesis-only graph
data — instruments, canvas — is canonically persisted and travels with a
document. The one registry item that *does* gate an editor tranche.
* **Genesis persistence (T1b blocker i, §Ruling B) — RULED 2026-07-24,
tranche not yet dispatched.** `spec/RULING_GENESIS_PERSISTENCE.md`: the
operation set absorbs genesis, so this stops being "how is genesis-only data
persisted" and becomes a normal operation-vocabulary tranche — three LWW
settings setters and six entity mint families, one `OperationEnvelopeBlock`
accept-set raise spent once, Pass-12 K8 reversed. Blocked on the `identity`
disposition. The one registry item that *did* gate an editor tranche; the
gate is now the tranche's execution, not the decision.
* **Articulations, dynamics, ornaments are empty wire types.**
`ArticulationMark` / `DynamicMark` / `OrnamentMark` are unit structs
(`event.rs:49-57`) that already encode — giving them real payloads is a
@ -573,13 +579,28 @@ The T2 ruling the ladder reserved, now drafted:
Granting this ruling unblocks T2's W4 packet (copy/paste); W1W3 do not
depend on it.
### Ruling B — the document layer and persistence semantics — **REDRAFTED ×2 — NOT grantable yet**
### Ruling B — the document layer and persistence semantics — **REDRAFTED ×2 — blocker (i) RESOLVED 2026-07-24; blocker (ii) still open**
*(First draft withdrawn for violating the grow-only operation set. Second
draft amended by the 2026-07-23 second review. Two blockers stand between
draft amended by the 2026-07-23 second review. Two blockers stood between
this ruling and grant; both are outside the resolver-parallel blast radius,
which is why T1b sequences after the resolver.)*
> **Blocker (i) is RESOLVED — `spec/RULING_GENESIS_PERSISTENCE.md`, ratified
> 2026-07-24.** The field-by-field table this blocker demanded was produced as
> `spec/ANALYSIS_GENESIS_PERSISTENCE.md` (nine uncovered surfaces, not the
> handful this text guessed at: `canvas.layout_defaults`, `instruments`,
> `staff_groups`, `parts`, `tuning_context`, `spelling_precedence`,
> `analysis_layers`, `views`, and `StaffInstance.measures`, plus `identity`).
> The ruling takes **neither** (a)-as-written nor (b): the operation set
> **absorbs** genesis — Pass-12 K8 is reversed, there is no genesis block, and
> a document is `Score::empty(identity)` plus its envelope log. Execution is a
> Push-4b-class coordinated tranche; the editor track consumes it. Two
> constraints carry forward into T1b: **pruning may not be implemented until
> disposition C lands** (a `MaterializedState` base cannot rebuild a score), and
> `identity`'s disposition blocks specification of that tranche. The text below
> is kept as the record of what the blocker was.
**Blocker (i) — canonical graph-state persistence, across genesis AND
pruning.** Two halves of one question. *Genesis:* "every piece of content
enters as operations" is false for more than instruments — genesis is

View File

@ -0,0 +1,186 @@
# 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 `Staff`s;
* `CreateView.active_layers` → live `AnalysisLayer`s;
* `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_attachments`** — **derived, 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).*