# epiphany-ops — decisions and Pass 11 candidates

This file records (a) the implementation decisions the QUICKSTART asked each
agent to make once and document, and (b) the ambiguities discovered while
building `epiphany-ops`, batched as **Pass 11 candidates** for the spec rather
than improvised in code (QUICKSTART, Process notes: *"Ambiguities go into a
batch, not into code … Don't open Pass 11 until you have at least three such
items batched."*).

> **RATIFIED (Pass 11, 2026-06-21).** The ops-layer Pass 11 candidates have been
> ratified into `core_spec.tex` — see `spec/PASS11_RATIFICATION_LOG.md`.
> Highlights: C2 adopted (`IntegrityAnomalyId` = `derive_system_id(MUSCSANM,…)`,
> with `MUSCSANM` promoted to a reserved built-in tag); C3 decided
> (field-collision tags the *winner* `Conflicted`); C4 adopted + lifted to spec
> (the >2-way, partial-overlap promotion generalization); C7 fixed (zero-based
> DVV floor made normative); C9 decided (`TransactionCategory`/`ObjectKind` core
> vocabularies pinned); C10 decided (added `ResolutionAction::Dismiss` so
> `Dismissed` is reachable). C1/C5/C6/C8 stay deferred to their tracks.

## Implementation decisions (QUICKSTART "Decisions you'll need to make")

1. **Replica ID entropy / event-arena / chunk store** — N/A to this crate
   (Agents A, B, D). `epiphany-ops` consumes Agent B's identifier family and
   never mints graph identifiers itself, except the deterministic system-derived
   ones (promoted voices via Agent B's `derive_promoted_voice_id`, and the
   content-derived `ConflictId` / `IntegrityAnomalyId`).
2. **Async or sync — sync only.** No async traits anywhere (decision 4).
3. **MSRV — workspace 1.77** (decision 5). No exotic features. `unsafe` forbidden
   crate-wide (`#![forbid(unsafe_code)]`).
4. **Canonical iteration is structural.** Every collection that feeds canonical
   output is a `BTreeMap`/`BTreeSet` or a vector put into the normative order
   before encoding (Appendix D §"Ordered Iteration"). The determinism fuzzer is
   the tripwire: it reduces each random set in several acceptance orders and
   asserts byte-identical materialized state, which would fail the moment a
   `HashMap` iteration order leaked in.

## Scope boundary: framework in full, representative operations

Chapter 6 specifies the *operation framework* and a *representative selection* of
operations; the full catalog of ~60–80 primitives is an **explicit open
question** (§6.11, marked `\openquestion`) deferred to the Operation Catalog
companion. This crate mirrors that division exactly:

- **Implemented in full (the framework):** operation identity/stamps, the HLC and
  its monotonicity rule, DVV causal context, the order-independent
  `OperationSlot` model and acceptance pipeline, the canonical reduction order,
  the four-phase lifecycle, effects with the typed `PreconditionFailureReason`,
  conflict records with content-derived ids, the conflict registry, integrity
  anomalies kept separate from conflicts, transactions with the
  descriptor-precedence rule, re-anchoring, LWW discipline, validation modes, and
  forward undo.
- **Representative (the §6.10 set):** `InsertEvent`, `DeleteEvent`,
  `RespellPitch`, `CreateCrossCutting`, `ChangeRegionTimeModel`,
  `SetUserSystemBreak`, `DeclareTransaction`, plus `ResolveConflict` and
  `UndoTransaction`. Together they exercise every reduction *discipline* the
  chapter defines (position-keyed insert + voice promotion; delete-wins +
  tombstone + re-anchor; field-overwrite + conflict; set-union; structural
  migration; LWW; atomic transactions). The remaining catalog kinds are an
  additive future change behind the existing `OperationKind` enum.

`MaterializedState` is the canonical bookkeeping Chapter 6 owns — the effect
log, conflict registry, anomaly register, object existence/tombstones,
spellings, and LWW fields. M2 adds `OperationSet::reduce_onto(&Score)`, which
seeds those indices from a canonical base and returns the corresponding Agent B
graph. Insert/delete, voice promotion, supported reference-level cross-cutting
values, system breaks, migration checks, transaction rollback, and undo mutate
that graph. The base-free `reduce()` remains the operation-set convergence API.

## M2a (Group 1) — event & pitch leaf-field ops: graph materialization

The Group-1 leaf-field ops (`ModifyEvent`, `Transpose`, `InsertIdentifiedPitch`,
`DeleteIdentifiedPitch`, `ModifyIdentifiedPitch`) reuse M1's reduction
disciplines unchanged; the canonical `MaterializedState` records only their
effect-log entry and — for the field-overwrite ops — a `StructuralFieldCollision`
on a concurrent differing write. Their resolved values live in the graph
(`reduce_onto`), which is derived state, not a second canonical store, so the two
decisions below are **graph-materialization-only**: the bookkeeping projection,
and therefore convergence/determinism, is unaffected. Both exist because an
in-place `EventArena::get_mut` edit bypasses `insert`'s well-formedness guard, so
the reducer must keep the graph Chapter-5-valid itself (otherwise the malformed
state surfaces only later, in `check_invariants`). Both are exercised at scale by
`run_graph_convergence` (criterion 1, now emitting these kinds) and pinned by
targeted `reduce_onto` tests in `tests/graph_reduction.rs`.

- **A note and a rest are the same slot under pitch add/remove (note↔rest
  conversion).** `DeleteIdentifiedPitch` of a single-pitch note's *only* pitch
  degrades the event to a `Rest` of the same id/voice/position/duration rather
  than leaving an empty pitched event — Chapter 5 forbids the empty chord ("use
  `Rest` for the no-pitch case", `ArenaError::EmptyPitchedEvent`).
  `InsertIdentifiedPitch` into a rest is the dual: the rest becomes a one-pitch
  note. This preserves the ops' disciplines (delete-wins / mint) and keeps the
  graph consistent with the bookkeeping (which tombstones / mints the pitch
  object either way). *Rejected alternative:* a "would empty the event"
  precondition failure — it needs a new `PreconditionFailureReason` against a
  ratified set, breaks delete-wins, and is a worse fit than the editor-natural
  "deleting a note's last pitch leaves a rest". **For the spec:** the Operation
  Catalog §"Insert/Delete identified pitch" (M2e) ratifies this note↔rest
  equivalence normatively.

- **`ModifyEvent` materializes metric placement changes (trim/move).** A
  `ModifyEvent` whose payload moves a *metric* event (different `Musical` position
  or duration) is now applied to the graph and the owning voice is **re-sorted**
  by ascending position (id-tiebroken — the same order an insert maintains), so
  invariant 3 (`VoiceEventsSortedNonOverlap`) is preserved. This is the make-room
  enabler: a "pencil" insert trims the events it overlaps. To keep invariant 3,
  `modify_event` first checks a **placement precondition** read from
  `voice_occupancy` (the canonical, graph-independent placement index, so
  `reduce()` and `reduce_onto()` agree): a move with a non-positive span, or one
  that would overlap another live event in the voice, is **refused** as a clean
  `NoOp(EventDurationInvalid)` rather than skipped silently (which would log a
  clean op that never took effect). A materialized move updates `voice_occupancy`
  too, so a later insert sees the freed/changed span. A *non-metric* placement
  change is still deferred (re-sorting a non-metric voice is out of scope), and a
  malformed (empty) pitched replacement is still skipped; same-placement field
  edits apply as before, preserving voice membership. The LWW bookkeeping records
  the modify in every case. **For the spec:** the catalog §ModifyEvent (M2e)
  prototype boundary moves from "placement-change deferral" to "metric
  placement-change materialization with an invariant-3 precondition"; partial
  trimming of a tuplet member, and non-metric moves, remain later refinements.

## DeleteEvent re-anchoring: the graph follows the ledger

A `DeleteEvent` that tombstones an event runs the re-anchoring rule table
(Chapter 6 §6.5) over the cross-cutting structures that referenced it. The
*ledger* decision (`reanchor_for_tombstone`) and the *graph* mutation
(`materialize_graph_delete`) MUST agree on whether a structure survives — else an
object is `Live` in `MaterializedState` but gone from the graph (or vice versa),
a faithfulness gap the graph convergence gate (criterion 1) would surface.

- **Slurs and spanners re-anchor, not unconditionally drop.** The ledger keeps a
  slur/spanner `Live` while ≥1 endpoint survives (re-anchored) and cascade-deletes
  only when none does. `materialize_graph_delete` now mirrors that exactly:
  an endpoint-deleted slur/spanner re-anchors onto its surviving endpoint (the
  structure stays in the graph) and is removed only when no endpoint survives.
  Previously the graph removed *any* slur touching the deleted event regardless of
  the ledger's re-anchor — the divergence this entry fixes. Ties (cascade) and
  beams (truncate-while-≥2-members, else cascade) were already consistent and are
  unchanged.
- **A re-anchored two-endpoint structure collapses onto the survivor.** With only
  two endpoints, the sole survivor *is* the other endpoint, so re-anchoring sets
  both to it (a degenerate `(B, B)` slur / spanner). This is reference-clean — the
  cross-cutting invariant requires only that endpoints reference *live* events, not
  that they differ — but musically a stand-in. A proximity-aware target (the note
  that took the deleted one's place) needs resolved positions and is deferred
  (P11-C5; `nearest_survivor` is the lexicographic stand-in).
- **Base-score spanners are now indexed for re-anchoring.** `seed_from_graph`
  records each seeded spanner's event-anchored endpoints in `structures` (as it
  already did for slurs/ties/beams), so a base spanner re-anchors through the same
  rule as a created one rather than being left dangling.

This consistency is what lets `graph_edit_session` create cross-cutting structures
and delete their endpoints, giving the Group-2 CRUD ops (and slur re-anchoring)
real at-scale coverage under criterion 1 + `check_invariants`.

## M2c (Group 3) — structural containers: empty-only delete

The structural-container CRUD ops (`CreateRegion`/`DeleteRegion`,
`CreateStaffInstance`/`DeleteStaffInstance`, `CreateVoice`/`DeleteVoice`) mint an
**empty** container (set-union creation) and tombstone an **empty** one. Two
scoping calls the user made fixed the shape: the slice covers *structural
container* CRUD (not the document root / canvas / global staves — those stay K1),
and delete semantics are **empty-only (precondition)**.

- **A delete of a non-empty container is a precondition no-op
  (`ContainerNotEmpty`), not a cascade.** The caller deletes contents first.
  *Rejected alternative:* a cascading delete that tombstones the live children
  transitively — it conflates two intents (remove this container vs. remove its
  contents), and a cascade's re-anchoring interactions (a deleted voice's events
  feeding the cross-cutting re-anchoring table) are a strictly larger design than
  the slice needs. The empty-only gate is the conservative floor a cascade could
  later build on. Catalog §Structural Containers (M2e) states this normatively.
- **A create must carry an empty container, too — for *every* typed child
  object, not just the structural hierarchy.** `create_region` /
  `create_staff_instance` / `create_voice` reject (`ContainerNotEmpty`) a carried
  value bearing any nested object with a distinct `TypedObjectId`: a region's
  staff instances, barline-alignment groups, or graphic objects; a staff
  instance's voices or measures; a voice's events. Graph creation clones the full
  value into the score, so a carried child would materialize an object the reducer
  never mints in its `objects` bookkeeping — a graph/ledger faithfulness gap. The
  check reads the carried value only, so `reduce()` and `reduce_onto()` agree.
  *(Review-driven: the first cut checked only the hierarchy vectors;
  `barline_alignment_groups` / `graphic_objects` / `measures` were added after.)*
- **Live-child sets are tracked in the reducer, not re-derived from the graph.**
  `region_instances: RegionId → {StaffInstanceId}` and `instance_voices:
  StaffInstanceId → {VoiceId}` (a voice's live events are read from
  `voice_occupancy`) are maintained by the create/delete materializers and seeded
  by `seed_from_graph`, so the empty-only precondition is decided identically in
  the base-free `reduce()` and graph-aware `reduce_onto()`.
- **Graph creation maintains the region staff extent.** An empty staff-based
  region with a freshly-created staff instance would violate `RegionExtents`
  (the staff extent must list exactly the manifested staves) unless the extent is
  updated as instances are added/removed; the create/delete materializers do so,
  and `valuegen::region` carries a far-future time extent so a fresh region does
  not overlap an existing one once a staff instance lands.

## M2d (Group 4) — score settings: per-op discipline (review-hardened)

The score-settings ops (`SetMetadata`, `SetMetricGrid`, `SetUserPageBreak`) are
all field overwrites, but they do **not** share one discipline; the M2d review
(closed in commit `d93baac`) pinned each to the discipline its catalog
classification names. Recorded here because the review changed code/tests.

- **`SetMetadata` is an advisory LWW — no conflict.** The catalog
  (§set-user-system-break "LWW advisory") and core_spec already classified
  metadata this way; the first implementation wrongly raised a
  `StructuralFieldCollision` on a concurrent differing write, which could make a
  clean concurrent metadata edit turn `MaterializedState::is_clean()` false. It
  now silently last-writer-wins in canonical order (graph singleton overwrite,
  always `Applied`, no working slot). *Direction chosen:* fix the implementation
  to match the already-correct spec, not the reverse.
- **`SetMetricGrid` is a structural overwrite with two preconditions.** It keys a
  `StructuralFieldCollision` (field `metric_grid`) on concurrent differing grids
  (kept), *and* (a) preconditions the region live **and staff-based** — a
  FreeGraphic region has no metric-grid slot — and (b) rejects a grid whose meter
  sequence names a time signature that is not a live object (the Chapter-5
  invariant forbids installing such a grid). Both checks read only base-free
  indices.
- **`SetUserPageBreak` mirrors `SetUserSystemBreak` exactly, under the canonical
  LWW key.** Both now (i) share the live-and-staff-based precondition via a
  `staff_based_regions` index, so for any region *represented in reducer state*
  `reduce()` and `reduce_onto()` reach the same missing / tombstoned / FreeGraphic
  verdict — that is, regions an op stream creates or deletes. (`reduce_onto(base)`
  additionally seeds the base regions into that state, which a base-free `reduce()`
  does not see, so a layout op targeting a live *base* region can still apply under
  `reduce_onto` and no-op under `reduce`; the corpus exercises only op-created
  regions, where the two agree.) Both also (ii) materialize the graph break
  under the anchor's **resolved musical position** (`apply_break_lww` +
  `resolved_anchor_position`): any existing anchor resolving to the same position
  is dropped before the new one is added, so the graph break list stays in lockstep
  with the resolved-position-keyed ledger map. The system-break fix is a sibling
  parity change, not new M2d scope.
- **Performance.** The dedicated 10K-envelope reducer micro-bench (criterion 5)
  is Agent F's worklist F1; the M2 value-typed ops are already exercised at
  10K·scale by the conformance suite's reduction-determinism and convergence
  gates, which emit every M2 kind. No criterion bench is added under Agent K.

## Pass 11 candidates (ambiguities for the spec, not resolved in code)

### P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints

> **RESOLVED (Phase 2, Agent K — Operation Catalog, M1).** The representative
> payloads are now **value-typed**: `InsertEventOp { staff_instance, event:
> Event }`, `RespellPitchOp { pitch, spelling: PitchSpelling }`,
> `CreateCrossCuttingOp { structure: CrossCuttingValue }`,
> `ChangeRegionTimeModelOp { …, new_time_model: RegionTimeModel }`,
> `SetUserSystemBreakOp { …, anchor: TimeAnchor }`, and
> `TupletCompensation::ReplaceWithRest { rest: Rest }`. They serialize by framing
> each value's `epiphany_core::CanonicalValue` bytes behind a `u32` length prefix
> — the ratified byte-convention baseline (Pass 11 item 1.8,
> `req:format:codec-conventions`), introducing no new layout (the K↔J seam; see
> `epiphany-core/DECISIONS.md`). Graph-aware reduction now materializes the
> **real** event/structure rather than the C4 placeholder described below.
> `reduce_onto`'s reduction rules are unchanged — only the field read-sites moved
> onto the value. The v0 identifier-only shapes are frozen in `src/v0.rs` as the
> migration regression guard, and `src/migrate.rs` lifts a v0 envelope to v1
> deterministically and equivalence-preservingly (`migrate_v0_envelope(v0,
> context: &Score)`; `epiphany-testkit::migration` is the merge gate). The full
> K0 set + the literal wire layout (Binary Format companion, Agent J) follow; the
> remainder of this entry is the historical v0 rationale. See `P12-K1` below and
> `spec/operation_catalog.tex`.

Chapter 6's payload structs embed rich graph values (`InsertEventOp { event:
Event }`, `RespellPitchOp { new_spelling: PitchSpelling }`, …), but the
*canonical wire encoding* of those graph value types is itself deferred to the
Binary Format companion (Agent B's P11-4: `epiphany-core` canonically encodes
only identifiers and the scalar time types). An `OperationEnvelope` must be
hashable **today** — the `EnvelopeHash` and slot equivocation both need canonical
bytes — so this crate's payloads carry the reduction-relevant *identifiers and
canonical scalar coordinates*, plus a `ContentHash` fingerprint where the
reduction needs only equality (a respelling). Graph-aware reduction materializes
this projection as deterministic C4 pitches (or a rest when no pitch ids are
present); those placeholders do not claim to recover musical values absent from
the payload. **For the spec:** pin the payload
schemas (the Operation Catalog companion) and the canonical encoding (the Binary
Format companion); when they land, the structs regain their full value fields
without changing the reduction. The trigger will be a failing cross-crate
round-trip test, per the QUICKSTART process notes.

### P11-C2 — `IntegrityAnomalyId` derivation is unspecified

Chapter 5 gives `IntegrityAnomaly` an `IntegrityAnomalyId` but does not pin how
it is derived. Because anomalies are deterministic facts of reduction, the id
must be content-derived (two replicas must agree). This crate derives it as
`derive_system_id(MUSCSANM, kind.canonical_bytes())` in the `SYSTEM_DERIVED`
namespace — the same discipline Chapter 5 uses for system identifiers, with a new
`MUSCSANM` extension system tag. **For the spec:** confirm the derivation (and
whether a built-in `MUSCS…` tag should be reserved for anomalies, as `MUSCSVCE`/
`MUSCSPCH` are for voices/pitches).

### P11-C3 — which participant's *effect* carries `Conflicted` in a field collision

For concurrent differing `RespellPitch`es, the spec pins the **conflict record**
(kind `StructuralFieldCollision`, with `winner`/`loser` and the loser's spelling)
and says the later-in-canonical-order operation wins and materializes. It does
not pin which participant's `OperationEffect` is tagged `Conflicted`. This crate
tags the **winner** (the later op, which materializes and whose processing
created the record) `Conflicted`, and leaves the earlier op's already-recorded
`Applied` effect in place. The outcome is order-independent because canonical
order is fixed. **For the spec:** pin the per-operation effect tag for a field
collision (and whether the superseded loser should retroactively read
`NoOp{SupersededByLaterOperation}`).

### P11-C4 — voice-promotion derivation inputs and the >2-collision generalization

Invariant 18's promoted-voice derivation takes *(staff instance, original voice,
winning op, losing op)*. M2 expanded `VoiceOrigin::SystemPromoted` to retain both
operation ids, so Agent B verifies the exact Agent C derivation. This crate resolves promotion
in an **order-independent pre-pass**: bucket inserts by voice, walk them by
`OperationId`, keep a non-overlapping set in the original voice, and promote
each concurrent overlapping loser to `derive_promoted_voice_id(staff_instance,
voice, winner, loser)`. This applies the InsertEvent invariant to partial
interval overlaps as well as identical start positions. The op carries its
`staff_instance` explicitly (a full reducer recovers it from the voice's
container). One open point remains for the spec: define the >2-way collision
case — the spec describes a pairwise rule; this crate uses the first lower-id overlapping
operation retained in the original voice as the winner for each promotion.

### P11-C5 — "nearest surviving anchor" needs resolved positions

The re-anchoring total order (Chapter 6 §6.5) ranks surviving candidates by
containment proximity, then absolute time distance, then direction, then id. The
prototype does not yet track resolved positions/time per object, so
`nearest_survivor` uses a deterministic **stand-in**: the lexicographically-
smallest surviving endpoint. The *structure* of the rule table (Tie →
cascade-delete, Comment → orphan, Beam → truncate, Slur/Spanner → reanchor-or-
cascade) is implemented faithfully; only the metric "nearest" is approximated.
**For the spec:** no change needed — this resolves once the graph mutation phase
tracks positions; recorded so the approximation is explicit.

### P11-C6 — time-model compatibility is computed when a graph is available

`ChangeRegionTimeModel` retains a `declared_incompatible` list for base-free
reduction. Graph-aware reduction additionally derives incompatibilities from
every event's actual coordinate variants and mapping coverage, refusing any
migration that would violate Agent B's coordinate discipline. Concurrent
same-region migrations conflict; causally-later migrations are reevaluated
against the first migration's graph. **For the spec:** the rich migration
payload still belongs to the Operation Catalog.

### P11-C7 — DVV contiguous ranges use the zero-based operation-counter floor

The DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist,
matching the operation-id and causal-context documentation. Reduction finds the
first absent id in every asserted range and holds the dependent pending; dots
and vector coverage of known equivocated/excluded ids remain direct blocking
signals, with transitive propagation to dependents. The range check walks known
ids rather than expanding `0..=n`, so a sparse context with a very high counter
does not cause proportional work. **For the spec:** explicitly retain the
zero-based per-replica counter floor in the normative DVV definition.

### P11-C8 — forward undo is modeled via minted-object compensation

The spec defines undo as a forward compensating edit (StrictInverse / BestEffort
/ Cascade) computed against the current materialized state. Without the full
graph-mutation phase, this crate models the compensation as tombstoning the
objects the target transaction *minted*: StrictInverse conflicts if any such
object was already tombstoned/modified; BestEffort tombstones the survivors;
Cascade is treated as StrictInverse over the same set (dependent-closure undo is
deferred with the rest of the catalog). Graph-aware reduction also removes those
event, pitch, promoted-voice, and supported cross-cutting mints from the live
graph and records graph tombstones. **For the spec:** this is faithful to the
"content-equivalence to pre-target state" definition for insert-shaped
transactions; the inverse of every catalog primitive is the Operation Catalog's
job.

### P11-C9 — local minimal enums for open vocabularies

`TransactionCategory` (spec: open, "used by UIs and analytics") and `ObjectKind`
(used by `SystemIdentifierCollision`) are given **minimal core enums with a
`Registered(…)` escape**: `TransactionCategory ∈ {NoteEntry, Structural, Layout,
Import, Registered}`, and `ObjectKind ∈ {Voice, Pitch, Registered}` (only the
kinds the spec actually derives into the system namespace). **For the spec:**
ratify or extend these sets.

### P11-C10 — `ResolveConflict` Dismissed has no distinct payload

The spec distinguishes `Resolved` from `Dismissed` resolution states but provides
a single `ResolveConflictPayload { target, action }`. This crate maps every
applied resolve to `Resolved { action }`; `Dismissed` is reachable as a state but
is not authored by a representative op. **For the spec:** define how a
`ResolveConflict` selects Dismissed (a distinct action, or a separate payload).
*Phase 2 update: the Operation Catalog (Ch. ResolveConflict) records that
`ResolutionAction::Dismiss` is the action that selects `Dismissed`; resolved.*

## Pass 12 candidates (Agent K — Operation Catalog)

### P12-K1 — a v0 `RespellPitch` fingerprint is not invertible to a spelling

The v0 `RespellPitchOp` carried a `ContentHash` *fingerprint* of the new
spelling, not the `PitchSpelling`. The v0→v1 migration (`src/migrate.rs`) must
reconstruct the value, but a fingerprint cannot be inverted without a side table.
The migration recovers the spelling from the score-graph **context** — an
explicit per-pitch spelling attachment (`SpellingScope::Pitch` +
`SpellingDirective::Explicit`) whose canonical bytes hash to the fingerprint —
and, when the context lacks it, returns `MigrationError::Irreversible` (the
bundle opens read-only, per the QUICKSTART migration contract). This is the one
representative payload that is not self-contained under migration. **For Pass
12:** confirm the read-only fallback is the intended long-term disposition (vs.
requiring a richer v0 corpus that preserves spelling pre-images). Recorded in
`spec/PASS12_BATCH.md` and `spec/operation_catalog.tex`
(§RespellPitch, §Migration).

## Provisional canonical encoding (mirrors Agent B's P11-4)

The composite Chapter 6 types use a concrete, reversible canonical byte form
(little-endian integers; `u32` length prefixes on every variable-length part;
NFC + length-prefixed text; single-byte discriminants) so that envelope hashing,
conflict-id derivation, and the materialized-state bytes are testable now. This
is deterministic and unambiguous but **provisional**: when the Binary Format
companion lands, reconcile `encode.rs` and the per-type `CanonicalEncode` impls
with it. A failing cross-crate round-trip test is the trigger.
