epiphany/crates/epiphany-bundle/DECISIONS.md

647 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# epiphany-bundle — 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-bundle`, 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 bundle-layer Pass 11 candidates have
> been ratified into `core_spec.tex` — see `spec/PASS11_RATIFICATION_LOG.md`.
> Highlights: D4 adopted (ChunkKind/ProfileId/CompressionAlgorithm discriminants);
> D5 adopted (`ManifestId` preimage, with `manifest_id` excluded); D1 fixed
> (equal-generation superblock rule → `DivergentSameGeneration`); D3 fixed (blob
> hashing is bare `MUSCBLOB‖payload`, spec contradiction removed); D6 fixed
> (`ProfileConstraints` defined with the required `RetentionPolicy`,
> first-declared multi-profile precedence). D2 (Binary Format companion) stays
> Track B, with the convention baseline ratified by core item 1.8.
## Implementation decisions (QUICKSTART "Decisions you'll need to make")
1. **Replica ID entropy source** — N/A to this crate (Agent B/`epiphany-core`).
2. **Event-arena storage** — N/A to this crate (Agent B).
3. **Chunk store backend for v0 — a positioned single-file `BlockStore`,
append-at-EOF.** The bundle *is* the file format, so chunks are addressed by
file offset within one file (the spec's `ChunkRef::offset`), not a side
`BTreeMap<ChunkId, Bytes>`. The `BlockStore` trait abstracts positioned
reads/writes plus an explicit durable `flush`; three implementations back it:
- `MemStore` — an in-memory byte image (`flush` is a no-op); the default v0
backing and the recovered-image reader.
- `FileStore` — a real file whose `flush` is `fsync` (the production
durability path; unix-only, via positioned `pread`/`pwrite`).
- `FaultStore` — the crash simulator behind the acceptance gate.
The QUICKSTART suggested an in-memory `BTreeMap` for v0 and deferring the
mmap'd file backend "until Agent D's crash fuzzer is green." The crash fuzzer
*is* this crate's gate, and it drives `FaultStore`; memory-mapping is left as
the deferred optimization (the format's chunk immutability makes it safe
later, per Chapter 8 §"Memory Mapping"). The body is allocated append-at-EOF,
which trivially satisfies "MUSTNOT overwrite any currently-reachable chunk."
4. **Async or sync — sync only.** No async traits anywhere; `BlockStore` is sync
(decision 4). A thin async wrapper crate can come later, as the QUICKSTART
suggests, without touching this type system.
5. **MSRV — workspace 1.77.** No exotic features. `std::io::Error::other`
(stable since 1.74) is used; `overflow-checks` stay on in release so offset and
length arithmetic faults loudly rather than wrapping.
### Additional local decisions
- **A prototype canonical byte layout precedes the Binary Format companion.**
Chapter 8 §"Binary Format Companion" defers the byte-level encoding to a
separate spec that does not yet exist (an explicit `openquestion`). To make the
atomic-commit, crash-recovery, and re-serialization guarantees testable now,
this crate defines a concrete, fixed-convention encoding: little-endian
integers (matching the spec's preimage convention), `u32`-length-prefixed
variable fields, `0`/`1` option-presence bytes, and the fixed prelude offsets
documented in the `header`/`superblock` module tables. This is the bundle's
analogue of `epiphany-core`'s P11-4 and is provisional — see P11-D2.
- **CRC-32C is hand-rolled (Castagnoli, table-driven `const fn`).** Chapter 8
specifies CRC-32C for the header and each superblock; a ~30-line table-driven
implementation avoids a second hashing dependency (the workspace keeps `blake3`
as the sole content-hash dependency) and pins the `"123456789" → 0xE3069283`
check vector.
- **Blobs hash bare; chunks/manifests hash structured.** Following Agent A's
`ContentHash::of_blob`, a blob id is `BLAKE3("MUSCBLOB" || payload)`. Non-blob
chunks use the structured Chapter 8 preimage
`domain || kind || schema || uncompressed_length || payload`, with the manifest
under `MUSCMANI` and all other kinds under `MUSCCHNK`. See P11-D3.
- **`commit_timestamp` is written as `0`.** It is advisory (selection is by
generation, never timestamp), and a fixed value keeps commits byte-reproducible
for the fuzzer. A real editor would stamp wall-clock time here.
- **Crash model.** `FaultStore` separates *live* (page-cache) bytes from *durable*
bytes; only a successful `flush` promotes live → durable, and a crash on a
`flush` may tear the most-recent (single in-flight) write to a prefix. This is
faithful because the protocol's flush points isolate dependencies: the
superblock-slot write is the *only* pending write at the commit-point flush, so
a torn superblock is the only torn write that can affect selection, and the CRC
catches it. Earlier-step torn writes only ever produce unreachable garbage.
- **Indeterminate commit-point flush poisons the bundle.** If the *final* flush
(the commit point) returns an error, the new superblock may or may not have
reached durable storage, so the on-disk active generation is unknown. `commit`
marks the in-memory bundle read-only and returns the error; the caller must
reopen from storage to resync. (Earlier flush errors are safe — the active slot
is untouched, so the bundle remains validly at the old generation.)
- **Reader resource limits.** Untrusted lengths (a superblock's
`manifest_length`, a manifest's chunk/blob lengths) are checked against
policy caps (`MAX_MANIFEST_BYTES`, `MAX_CHUNK_BYTES`, `MAX_BLOB_BYTES`, plus a
`BlobRef`'s own `declared_max`) *before* any allocation, so a large/sparse
hostile file cannot drive an OOM or a 32-bit truncation. The values are
generous v0 defaults; a production reader would make them configurable.
- **Writer/reader symmetry.** `create`/`commit` refuse to emit anything their
own `open` would reject: at least one declared profile; the canonical base's
profile declared and its reduction-version consistent; every canonical root
present and of the right kind/shape (operation roots decode and fit the
profile's max block size; the canonical base is a snapshot; every blob root
resolves and hash-verifies); the encoded manifest within `MAX_MANIFEST_BYTES`;
no initial canonical roots/blobs at `create`. After emitting, the in-memory
manifest is **normalized** (decoded back from its own canonical bytes), so
`bundle.manifest()` matches a reopen (duplicate roots already collapsed), and a
required extension makes even a freshly *created* bundle read-only. `open`
additionally checks the manifest lies in the body, its generation matches the
superblock, the superblock's profile is declared, and profile ids are distinct.
The *active* profile (and thus the max block size enforced on reads) is the one
the **selected superblock** names — a bundle opened under `Lite` reads under
`Lite`'s limits, not the canonical-first profile's.
- **Profile support model (emittability vs editability).** A profile is
*editable* (`Full`/`Lite`, exact major, block bound ≤ the reader's
`MAX_CHUNK_BYTES`), *understood but read-only* (`ReadOnly`), or *unsupported*
(`Custom` registry profiles, a mismatched major, or a block bound the reader
cannot allocate). **Emittability and editability are separate.** A bundle is
emittable as long as it declares at least one *understood* profile — so a sole
`ReadOnly` profile produces a valid read-only bundle (the spec describes
ReadOnly-produced bundles). The active profile a writer names prefers the
canonical-first *editable* profile (so `[ReadOnly, Lite]` is emitted under
`Lite`, editable), falling back to the first merely understood one (a sole
`ReadOnly` → read-only bundle). That selected declaration is computed once and
drives canonical-root limits, the superblock profile, and the live read-only
state, so commit-time validation cannot disagree with the reopened bundle.
`open` mirrors this: an understood profile
opens read-only-if-`ReadOnly`, an unsupported one opens read-only with an
`UnsupportedProfile` anomaly. A profile major must match exactly. The spec's
*SHOULD* upgrade-the-profile-on-first-edit is **deferred** — v0 opens a
`ReadOnly` bundle read-only rather than rewriting the profile.
- **Numeric `SemVer` ordering.** Declarations carrying a `SemVer` (profiles,
extensions) sort by an explicit numeric `(id, version)` key, not by encoded
bytes — the little-endian version integers would otherwise sort byte-wise
(`256.0.0` before `1.0.0`), violating Appendix D's "ascending by … semantic
version."
- **Blob media types** are validated as the narrow RFC 6838 §4.2 *restricted
name* `type/subtype` (ASCII, ≤127 chars per component, alphanumeric-first, the
restricted-name alphabet — not the broader HTTP token set) on both decode and
emit, keeping arbitrary or non-NFC bytes out of canonical manifests (ASCII is
already NFC).
- **Generation exhaustion** returns `BundleError::GenerationExhausted` rather
than overflow-panicking at `u64::MAX`.
- **Zstd chunk *reading* is supported; the write path stays uncompressed
(2026-07-01 spec-audit fix).** A spec audit flagged the read paths as the
file-format chapter's only exercised-path MUST violation: Chapter 8
§"Compression" requires conforming implementations to support *reading*
chunks "compressed with Zstandard at any level zstd defines", but
`read_and_verify_chunk`/`read_and_verify_blob` returned
`UnsupportedCompression` for anything but `None`. They now decompress
`Zstd` payloads (`Reserved` remains `UnsupportedCompression`; the writer
still emits only `None` — the QUICKSTART's compression deferral is about
the *write* path, which the spec leaves as MAY). Decisions taken:
- **Dependency: the `zstd` crate (libzstd bindings), not pure-Rust
`ruzstd`.** (1) `zstd::bulk::decompress_to_buffer` writes into a
caller-allocated buffer sized *exactly* from the declared
`uncompressed_length` — which is validated against the reader's
resource limits *before* allocation — so a hostile stream has a hard
output bound, and libzstd's decoding window is capped internally;
(2) libzstd is the reference implementation, battle-tested against
malformed frames, matching this crate's hostile-input posture;
(3) the workspace already requires a C toolchain (`blake3`'s `cc`
build), so pure Rust bought nothing here; (4) tests need an *encoder*
to produce fixtures and `ruzstd` is decode-only, so picking it would
have pulled `zstd` in anyway as a dev-dependency — two zstd
implementations in one build graph. The read-only mandate is enforced
at the call sites instead: production code never calls the encoder.
- **Length rule (spec: "reject chunks whose decompressed size
disagrees").** The output buffer is sized exactly by the declared
length: a stream that ends short yields a precise
`ChunkLengthMismatch`; one that would exceed the declaration hits
libzstd's destination-full error; malformed, truncated, and
trailing-garbage streams all fail — the latter three as the new typed
`BundleError::Decompression`. No path panics or allocates past the
declaration. Hashing (including the `id == hash` redundancy) is
unchanged and runs strictly *after* decompression, over the
uncompressed bytes — compression stays outside content identity.
- **The manifest stays mandatorily uncompressed** (§"Manifest
Encoding"). The superblock deliberately has no compression field, so
stored manifest bytes are the payload; an image whose manifest bytes
are compressed anyway fails to open (hash mismatch →
`NoValidSuperblock`, or, with a colluding hash over the compressed
bytes, a manifest decode failure). A `ChunkKind::Manifest` *chunk
reference* declaring compression is additionally refused outright with
the new typed `BundleError::CompressedManifest`, before any bytes are
read.
- `CompressionAlgorithm`'s golden-locked two-byte encoding
(`req:format:chunkkind-discriminants`) is untouched; the ratified
discriminants already modeled `Zstd { level } = 1`.
- **The operation index is implemented with a provisional, golden-locked
payload (Push-3).** Chapter 8 §"The Operation Index" defines the semantics —
an *optional, non-canonical* accelerator mapping each `OperationId` to the
`ChunkRef` of its enclosing block plus an offset within the block, O(log n)
lookup, absent → rebuild by scanning, present-but-corrupt-or-stale → MUST
reject and rebuild — but defers the byte format to the Binary Format
companion (P11-D2). Until that lands, `OperationIndex` encodes under this
crate's fixed codec conventions and the exact bytes are **golden-locked**
(`opindex::tests::payload_encoding_is_golden`), so a layout change breaks
deliberately:
```text
u32 block_count
block_count × ChunkRef — strictly ascending canonical order
(kind discriminant, hash, offset)
u32 entry_count
entry_count × { id: [u8;16], block: u32 LE, offset: u32 LE }
— strictly ascending by id bytes
```
`block` is an ordinal into the block vector; `offset` is the byte offset of
the envelope's **first content byte** within the block's *decoded*
(uncompressed) payload — exactly the coordinate `envelope_offsets` reports
(its `u32` length prefix sits at `offset - 4`). Decisions taken:
- **Layering: raw id bytes in the bundle, the peek in ops.** The bundle
stays semantics-free — entries key on the opaque 16 canonical id bytes.
That a canonical envelope *leads* with those bytes is an `epiphany-ops`
invariant, vouched for by ops' `peek_operation_id` (tested against
`encode_canonical`); builders pair it with the bundle's
`envelope_offsets` (which shares `decode_block`'s exact validation) to
produce index entries. The same "ops computes, bundle carries" split as
the block-summary metadata.
- **Reject, never normalize.** `OperationIndex::decode` rejects unsorted or
duplicated blocks or ids, a non-`OperationEnvelopeBlock` reference, an
out-of-range ordinal, and trailing bytes — the manifest decoder's
discipline, so accepted bytes are byte-stable. `build` rejects duplicate
ids (an `OperationId` occupies exactly one slot in one block) and
duplicate blocks at construction.
- **Staleness is coverage equality over full `ChunkRef`s.**
`OperationIndex::covers` is true iff the index's block set equals the
manifest's `operation_roots` set as *full references*, not just chunk
ids: `locate` hands out the index's stored refs for reading, so a ref
agreeing in hash but differing in any locator field (offset, lengths,
compression) is not the manifest's block and must count as stale rather
than steering reads elsewhere. `false` = stale → reject and rebuild.
- **A defective index is never bundle corruption** (Chapter 8 §"Canonical
and Non-Canonical Manifest Roots"). `Bundle::usable_operation_index`
packages the whole discipline: `Some` only for a declared, readable,
hash-intact, well-formed index covering the current operation roots;
`None` on *any* defect, meaning "rebuild by scanning all blocks".
`Bundle::read_operation_index` exposes the underlying failure for
diagnostics only. The testkit proves the boundary: a garbage or
byte-flipped index chunk leaves the bundle opening cleanly with all
canonical reads intact
(`bundle_harness::assert_corrupt_operation_index_is_not_bundle_corruption`).
- **The commit-time SHOULD is a builder, not a policy.** The spec says
writers SHOULD rebuild/update the index at commit when the operation set
has grown significantly. v0 deliberately ships the *mechanism*
`OperationIndex::build` from per-block `(id bytes, offset)` lists,
`StagedChunk::operation_index`, and the testkit's commit-time
rebuild-and-wire demonstration
(`bundle_harness::assert_operation_index_end_to_end` /
`scan_rebuild_operation_index`) — and no automatic "grown significantly"
heuristic; when/how often to refresh is editor policy layered above this
crate.
- **The write path stays uncompressed.** The spec's *MAY* compress
operation indexes is honored on the read side (an index chunk reads
through the same zstd-capable `read_chunk` path as any chunk); writing
compressed indexes is deferred with the rest of write-path compression.
## Known v0 limitations (deliberately deferred, not defects)
These are bounded by v0 scope (QUICKSTART "Don't do these" / "decisions you'll
need to make") rather than spec ambiguities. Each is honest about what is *not*
yet enforced so a later integration knows where to extend.
- **Retention/GC is a type, not an engine.** `RetentionPolicy` is modeled as the
QUICKSTART asks, and rollback over the two fixed slots is structurally
supported, but there is no retained-manifest catalog, deterministic retention
*selection*, GC reachability pass, or rollback *operation*. The spec itself
frames GC as *"a conservative, optional, deferred operation"* that *"MUSTNOT
run as part of a commit's critical path"* (Chapter 8 §"Garbage Collection and
Retention"), and the body is append-only in v0, so nothing is reclaimed yet —
manifests older than one generation are simply retained. A policy requesting
more than one retained manifest cannot be *honored for reclamation* until the
GC engine lands, but no manifest is *lost* either.
- **Content-address dedup is current-manifest scoped, not whole-history.** A
commit reuses a chunk (or blob) already referenced by the *active* manifest
rather than re-appending it. It does not dedup against older retained manifests
or unreferenced garbage, and it trusts the existing reference's location
without re-verifying the chunk (the chunk was verified when first committed and
is re-verified on read). Whole-history dedup needs the same body-wide content
index as the deferred GC engine.
- **Operation-envelope block summary metadata is carried (M4 follow-up).**
Chapter 8's `OperationEnvelopeBlock` carries `dvv_summary`, `min_stamp`, and
`max_stamp`. These are *semantic* — a DVV and `OperationStamp`s computed by
reading the envelopes, which belong to `epiphany-ops` (Agent C). The bundle
still treats a block as opaque envelope bytes and cannot compute them, but the
manifest now carries an `OperationBlockSummary { dvv_summary, min_stamp,
max_stamp }` per block, keyed by the block's `ChunkId`
(`Manifest::operation_block_summaries` / `operation_block_summary`), as
**opaque ops-supplied bytes** in canonical (ChunkId-ascending) order. This lets
a reader select or skip a block by causal frontier / stamp range without
decoding it. The C/D integration point — ops computes the summary, the bundle
carries it — is exercised end to end by Agent F
(`roundtrip::operation_block_summary` +
`assert_operation_block_summary_survives_storage`). `read_operation_block`
still enforces the chunk kind and the active profile's maximum block size.
- **Schema negotiation is major-gate only.** A canonical chunk or manifest at an
unsupported schema *major* is refused (`BundleError::UnsupportedSchemaVersion`);
v0 defines only schema `0.x`, so there is no minor-version back-compat matrix
to exercise yet. Non-canonical opaque chunks at unknown majors are carried
verbatim (they are never parsed).
- **Extensions: required → read-only; opaque preservation is now enforced.** An
unknown *required* extension forces read-only (v0 understands no extensions,
so all are unknown). Optional-extension `preserved_chunk_roots` are carried in
the manifest, and (M4 follow-up) `commit` now **enforces** preservation:
after the builder closure runs, every prior extension declaration the closure
did not itself re-declare (by `extension_id`) is carried forward verbatim, so
an extension-*unaware* writer cannot silently orphan an unknown extension's
roots; an extension-*aware* writer that re-declares its own id keeps control.
(Edit barriers / the unsafe-edit path are still not evaluated — barrier
operands `OperationKindTag`/`ObjectKind`/`EditBarrier` are owned by Agents
C/E.) The commit closure is also validated to never publish dangling or
mismatched *canonical* roots.
## Pass 11 candidates (ambiguities for the spec, not resolved in code)
### P11-D1 — Superblock selection has no tie-break for equal generations
Chapter 8 §"Superblock Selection" says *"the slot with the higher generation is
active"* but specifies no rule for two valid slots at the **same** generation
(which the QUICKSTART nonetheless lists as a scenario the harness must handle).
This crate resolves it deterministically: equal generation **and** equivalent
load-bearing fields (`manifest_hash`, `manifest_schema_version`,
`reduction_algorithm_version`, `profile_id` — the advisory `commit_timestamp`
and the physical manifest offset/length are excluded) → the slots are
equivalent, pick A; equal generation that differs in any of those → an
`IntegrityAnomaly::DivergentSameGeneration`, opened read-only (two different
committed states cannot share a generation under a conforming writer). The spec
should adopt or override this.
### P11-D2 — The Binary Format companion is not yet written
The concrete byte layout in this crate (prelude field offsets, integer
endianness, length-prefix widths, option/enum-discriminant encodings) is
provisional, standing in for the deferred Binary Format companion specification.
When that companion lands, reconcile this crate's `header`, `superblock`,
`chunk`, and `manifest` encodings with it (a failing cross-implementation
round-trip would be the trigger, per the QUICKSTART process notes). This is the
file-format analogue of `epiphany-core`'s P11-4.
> **Ratified (2026-07-02):** `spec/binary_format.tex` v0.1.0 Chapter 7 pins
> this crate's header (64-byte table), superblock (256-byte table), chunk
> framing and hash preimages, `ChunkRef`, block framing, the manifest body
> order with its sort/dedup rules, and the operation-index payload (P12-D1,
> `req:binfmt:opindex`) exactly as implemented and golden-locked here. The
> reconciliation trigger never fired: the companion was transcribed from this
> crate.
### P11-D3 — Blob hashing shape is ambiguous
Chapter 8 §"Blobs" says blobs are *"content-addressed identically to chunks
(BLAKE3 of uncompressed payload, with the `MUSCBLOB` domain tag)."* "Identically
to chunks" implies the structured preimage (which commits to kind, schema
version, and length); "BLAKE3 of uncompressed payload with the domain tag"
implies a bare `MUSCBLOB || payload`. The two disagree. This crate follows Agent
A's `ContentHash::of_blob` (bare `MUSCBLOB || payload`), the only spec content
hash documented as a bare `domain || payload`. The spec should state explicitly
whether a `BlobId` commits to a kind/schema/length or is bare.
### P11-D4 — Enum discriminant values entering canonical state are unspecified
`ChunkKind::canonical_bytes()` appears in the chunk hash preimage (Chapter 8
§"Domain-Separated Preimages"), so each `ChunkKind`'s numeric discriminant is
**normative** — yet Chapter 8 fixes only the *shape* of the preimage, not the
discriminant table (exactly the situation `epiphany-core` P11-1 flags for
`TypedObjectId`). This crate assigns `ChunkKind` discriminants by declaration
order (`OperationEnvelopeBlock = 0` … `Manifest = 8`), as a single byte, and
likewise fixes `ProfileId` (03) and `CompressionAlgorithm` (02) discriminants.
The spec should pin these, since the `ChunkKind` value in particular changes
content hashes.
### P11-D5 — `ManifestId` derivation inputs are undefined
Chapter 8 §"The Manifest" says *"Each commit produces a new `ManifestId`"* and the
deferred-types table assigns it the `MUSCMNIF` domain tag, but no derivation
preimage is given. This crate derives
`trunc128(BLAKE3("MUSCMNIF" || document_id || generation || manifest_body))`,
where `manifest_body` is the canonical manifest encoding with the `manifest_id`
field excluded (to avoid self-reference). The spec should fix the canonical
input list so two conforming writers derive identical manifest ids.
### P11-D6 — Where the `RetentionPolicy` lives is not shown
Chapter 8 §"Garbage Collection and Retention" requires that *"the active
conformance profile MUST declare a `RetentionPolicy`,"* but the
`ProfileDeclaration` / `ProfileConstraints` structs shown in §"Format Profiles"
do not include the field. This crate places `retention_policy` inside
`ProfileConstraints`. The spec should show the field explicitly (and confirm
whether a bundle declaring multiple profiles resolves retention from the first
declared profile, as this crate does).
## Schema major 2: op-block accept-set raised to [0, 2]
`max_supported_major(OperationEnvelopeBlock)` → 2 (same commit as the core
fills + ops stamps, so stamps never lag bytes); every other role stays at
major 0 — including the payload-polymorphic Snapshot: nothing stages an
acceleration full-`Score` snapshot yet, so its role gate waits for a real
producer (the core-side seam `decode_canonical_versioned` already handles
{0,1,2}). `SchemaVersion::V2` added; beyond-accept-set tests moved to
major 3.
### Follow-up (review): the snapshot role gets its producer + the base gets a role bound
A post-commit review caught the criterion-4 harness bypassing the versioned
snapshot contract (current-major `Score` bytes stamped V0 in the
`canonical_base` slot, decoded with the unversioned decoder). Fixed the
substantive way: the harness now stages a **properly-roled acceleration
snapshot** — `ChunkKind::Snapshot` stamped `SchemaVersion::for_major(2)`,
referenced from `Manifest::acceleration_snapshots`, decoded through
`Score::decode_canonical_versioned(bytes, root.schema_version.major)` — so
the schema-major snapshot contract is exercised end-to-end through the
bundle. Consequences: `max_supported_major(Snapshot)` → 2 (superseding the
"waits for a producer" note above), and because the per-kind gate no longer
implies it, the canonical-base-stays-major-0 rule is now enforced per ROLE
(`mis_stamped_canonical_base`, consulted at open and commit → read-only +
`UnsupportedCanonicalChunkMajor`, regression-locked). The `SnapshotId` in
the harness remains a hash-truncation stand-in (companion open question).
## Push 5 / P3 — the bundle wire, and a lenient sub-codec (2026-07-09)
A wire-decode fuzzer (`fuzz::run_wire_decode_fuzz`) over `Bundle::open`,
`Manifest::decode`, `OperationIndex::decode`, `decode_block`, and
`envelope_offsets`. The existing crash-recovery fuzzer corrupts the image the way
a *crash* does — torn writes at syscall boundaries. This one corrupts it the way
an attacker or a bit-rotted disk does: arbitrary bytes, anywhere.
**One real defect: `CompressionAlgorithm::None` ignored its parameter byte.**
`decode` read it and discarded it; `encode` writes `0`. So `[0, 0xFF]` and
`[0, 0]` both decoded to `None`, and the first re-encoded to the second — a
lenient, **non-injective** codec, inherited by every structure embedding a
`ChunkRef`.
Its visibility depended entirely on whether the embedder had a whole-value
re-encode guard:
- `Manifest::decode` **has** one, and it is total *by argument*: `manifest_id`
is derived from the body, so a body edit fails the id check and an id edit
fails the derivation; and `encode_body` sorts and deduplicates every vector,
so an out-of-order or duplicated encoding cannot round-trip. That is what
makes the guard complete here where `MaterializedState`'s is not (see
`epiphany-ops/DECISIONS.md` §"Push 5 / P2"). It caught this defect.
The accompanying test is exhaustive over every single-byte *replacement* of
one constructed manifest (each byte × the 255 other values) — evidence for the
argument, not a proof of totality, and blind to multi-byte perturbations. An
earlier revision of this record claimed "verified by exhaustive single-byte
perturbation" while the test actually tried three XOR deltas per byte. Caught
in review; the test now does what the sentence says, and the sentence no
longer carries the weight of the proof.
- `OperationIndex::decode` has **no** guard; it validates per-site instead. It
accepted both byte strings while its own doc promised to *"reject (never
normalizing) any non-canonical form"*. That promise was false.
Fixed at the source rather than papered over at the index: a non-zero `None`
parameter is now rejected, for every one of the 255 non-zero values. A sweep of
one `OperationIndex` payload (every byte × every value, plus an 8-byte
extreme-integer window) finds no remaining non-injective site in it.
**This contradicted ratified spec text**, which said the byte was *"present but
zero, and ignored on read"*. Escalated rather than fixed unilaterally; the user
ratified strict decode and the spec amendment. Core spec's clause is superseded;
Binary Format gains `req:binfmt:compression-none-parameter` and moves 0.7.0 →
0.8.0. No wire layout changed and no conforming writer emits a non-zero byte, so
this rejects only corrupt or adversarial input and no existing file changes
meaning.
**Coverage is the harness's job, again.** The fuzzer's first run reached the
operation index's accept path **zero times** — random bytes never decode as an
index — so every assertion under it was vacuous. It found the bug only after the
index corpus was built from real `OperationIndex::build` output. The smoke tests
now assert on a `WireFuzzCoverage` so that can never silently regress. 1.5M
inputs across five seeds, ~1s each, clean after the fix.
Three regressions. Restoring the leniency fails exactly two of them:
`compression_none_rejects_a_non_zero_parameter_byte` (the codec) and
`a_lenient_compression_byte_is_rejected_rather_than_normalized` (the index —
the surface that exposed it). The third,
`every_single_byte_replacement_of_a_manifest_is_rejected`, stays **green** under
that mutation, because the guard rejects the bytes whatever the sub-codec does.
That is not a weak test; it is the asymmetry, and it locks the guard rather than
the codec. A regression suite where every test fails on every mutation would be
telling us less, not more.
## Push 5 — Text Projection design gate (2026-07-09)
`spec/text_projection.tex` v0.1.0: the companion the core specification's
Chapter 8 §"Text Projection" delegates to and never had, and which the Binary
Format companion excludes as "the Text Projection companion's". No
implementation; this is the gate.
**The projection was blocked on P5 and is now unblocked.** Its normative
requirement is bidirectionality *with the binary form*, which needs bytes →
`OperationEnvelope`. That decoder did not exist until `3baf8d0`.
**Four ratified calls (user, 2026-07-09).**
1. **Reduced state is preserved by *determining* it**, never by a second literal
copy (`req:textproj:reduced-state-derived`). It is a deterministic function of
the operation set and the canonical base; a text carrying both would hold two
sources of truth for one fact, and nothing could stop them disagreeing. Core
spec's "all canonical reduced state" now carries that reading inline.
2. **A canonical base snapshot is inlined** as one opaque byte string
(`req:textproj:base-snapshot-inline`). This is the call with teeth: a base
exists so prior operations *need not be retained*, and where they are pruned
the base is derivable from nothing else — a reference-only projection of a
compacted document would be **lossy**, and the text would not determine its
document. Core spec permits "encoded compactly or referenced externally";
inline is the choice that keeps archival honest.
3. **Lowercase hex, everywhere** (`req:textproj:hex`). One rule; no alphabet or
padding to canonicalize; greppable. Base64 would buy a quarter of the bytes of
the one body nobody reads, and cost a second encoding plus a rule for which
applies where.
4. **One envelope per line** (`req:textproj:envelope-per-line`). The stated use
case is that merge conflicts surface at the envelope level; one line per
envelope makes a three-way merge conflict *exactly* an envelope conflict,
never a conflict inside one that yields an operation neither side wrote. It
also removes all indentation, so canonicality has nothing to hide in.
Readability is a pretty-printer's job, and a pretty-printer must not write its
output back and call it a projection.
**Strict parsing** (`req:textproj:strict-parse`) is stated in the same terms
P2P5 taught: normalizing non-canonical text *is* accepting it. The rationale
names both hazards this repo hit in binary — a re-encode guard is blind to
order-preserving sequences, and a guard on an outer value can mask a lenient
inner codec — and prescribes the same total defence: re-project and compare, *and*
check per-site the orders re-projection would restore.
**Conformance requires both directions** (`req:textproj:conformance`): a
projector alone cannot be checked.
**Known gap, stated in the document.** The grammar's atom productions and line
shapes are normative; `kind`, `action`, `policy`, `constraints`, `barrier` are
derived from the Operation Catalog and the wire table rather than spelled out.
That is the difference between a design gate and a finished companion, and it is
written into the companion rather than left for a reader to discover.
### Text Projection 0.2.0 — the gate reopened: canonical-manifest coverage
A review found the 0.1.0 companion **lossy for documents that are valid today**.
Its claim to preserve the manifest's canonical roots was false in three ways, and
all three had one cause I had not named.
- **A canonical blob had no representation.** `blob_roots` referenced by canonical
operations or reduced state are canonical roots (`core_spec` §"Canonical and
Non-Canonical Roots"), and the document structure had no blob line. An embedded
image, font, or recording would vanish from a projection *silently* — the
operations referencing it would still be there, pointing at a blob id the text
no longer contained.
- **An `ExtensionDeclaration` lost its semantic version and its
`affected_object_kinds`**, and left its `preserved_chunk_roots` undefined.
- **`ProfileId::Custom(ProfileRegistryId)` was unrepresentable**: the grammar
required a symbol where sixteen registry bytes are carried.
**The cause: `ChunkRef` and `BlobRef` are physical references.** Offset,
compressed length, compression — exactly what the projection may not preserve.
And they carry derivable identities — `ChunkId`, `ContentHash`, `BlobId` — which
it may not duplicate. Having no rule for that, I dropped the references entirely
and took their contents with them.
`req:textproj:derive-or-carry` states the rule, and it is the same rule
`req:textproj:reduced-state-derived` already applied one level up: **carry exactly
what the document does not determine, and nothing it does.** Physical attributes
never appear. Derivable identities never appear. Content and semantic attributes
always appear. The sole non-derivable identity in schema major 0 is `SnapshotId`,
which the Binary Format companion pins as opaque and forbids readers to derive —
an exception for a stated reason, not an oversight.
Consequently: `req:textproj:canonical-blobs`, `req:textproj:profile-id`,
`req:textproj:extension-declaration`, and `req:textproj:base-snapshot-inline`
extended to say what the inlined payload *is* (the canonical byte form of the
reduced state) and that the root `ChunkRef` and the `SnapshotRef.hash` are
**re-derived** from `hash(Snapshot, schema, payload)`, never read from the text.
**The gap started upstream.** `core_spec`'s own list of what the projection
preserves omitted canonical blobs while classifying `blob_roots` as canonical
roots — an inconsistency inside one document. Corrected there too, along with
withdrawing the permission to reference a base snapshot "externally", which the
inline ratification had already made untenable.
The 0.1.0 ratifications — reduced state derived, base inlined, hex, one envelope
per line, strict parsing — stand unchanged. Implementation stays deferred: the
companion is a gate, and a gate that is lossy is not one.
### Text Projection 0.3.0 — every production expanded; one rule for values
0.1.0 left `kind`, `action`, `policy`, `constraints` and `barrier`
derived-but-unwritten and said so. All are now written; the grammar has **no
undefined nonterminal** (machine-checked) and 31 operation-kind productions in
exact discriminant order, cross-checked against `envdecode.rs`.
**The one real decision: how embedded Chapter-5 values appear.** An operation
payload carries an `Event`, a `Pitch`, a `Region`, a `TimeSignature`. Three ways
to write them, and only one is safe:
- *Forty hand-written productions* would restate the entire Chapter-5 data model
in a second normative document — two normative listings of one struct, the
exact drift P13-I1 was opened to close.
- *Opaque canonical-value byte strings* would be lossless and zero-drift, but a
pitch would be unreadable without binary tooling, failing the core spec's own
"format inspection and debugging" use case.
- **One mechanical rule** (`req:textproj:value-projection`, ratified): a struct is
`(<type-name> <field>…)` with fields positional in the ratified declaration
order; a **newtype is transparent**, exactly as in the binary form; a tagged
union is `(<variant> <field>…)`; an option is `()` or `(some v)`; a sequence
keeps the binary form's order. **A rule cannot drift from what it reads.**
**Two leaf decisions follow from canonicality, not taste.** A rational is
`(ratio n d)` in lowest terms with the sign on the numerator. A `CanonicalF64` is
the byte string of its **eight canonical IEEE-754 bytes, never a decimal**
decimal float text is not canonically unique (shortest-round-trip and
17-significant-digit forms both round-trip; `-0.0` has two spellings), so a
decimal tempo would break `req:textproj:canonical-text` at the first tempo mark.
**Names follow semantics, not tags.** Operation kinds use the Operation Catalog's
section names (`create-region`, `create-staff`), not `OperationKindTag`'s
(`InsertRegion`, `InsertStaff`), which renamed three pairs for reasons internal to
the tag space.
**One deferral, stated as such.** `affected_object_kinds` and `edit_barriers` have
ratified structured shapes (`ObjectKind`, `EditBarrier`) *and* canonical byte
encodings, and the bundle stores them opaquely. At 0.3.0 the projection does the
same, on the principle that it interprets nothing the bundle does not. A later
revision may project them structurally; because their canonical bytes are
unchanged, that changes the text and not the document.
Also corrected: the `extension` line now writes its fields in the ratified
declaration order (`core_spec` §"Extension Declarations"), which had chunks before
kinds and barriers.
Still no implementation. The companion is now complete enough to implement against.