# Contract: the Text Projection document layer Repo root `/home/jeans/Repos/active/epiphany`. Read this in full before writing a line. The plan is `spec/PLAN_TEXTPROJ_DOCUMENT.md`; both its rulings are granted and this contract states them as law. The two layers below you are done and are your model: `epiphany-core`'s `textvalue*.rs` (Chapter-5 values) and `epiphany-ops`'s `textproj_*.rs` (Chapter-6 operations). Read the latter — the document layer is the same shape. ## Grammar-directed, and free functions `req:textproj:operation-vocabulary` established that the grammar's productions govern, not the mechanical value rule. The document lines are the same: find your production in `spec/text_projection.tex`'s Grammar chapter and implement exactly what it says. **Do not implement `TextValue` for bundle types.** `epiphany-bundle` does not depend on `epiphany-core`, so the trait is foreign and so is the type — the orphan rule forbids it from `epiphany-textproj`. You do not need it: no document-line production contains a `value` position; every one bottoms out in `bytes`, `integer`, `bool`, `string`, `option`, or a closed vocabulary. Write **free functions** — `fn project_profile(&ProfileDeclaration) -> Sexp`, `fn parse_profile(&Sexp) -> Result`. Use `epiphany_core::textvalue::{Sexp, read_sexp, TextError}` for the machinery. Do **not** add `epiphany-core` to `epiphany-bundle`'s dependencies. The bundle is a container format and its independence from the music model is deliberate. ## Blobs: emit none, and reject them on parse A blob is canonical **iff** referenced by a canonical operation or by canonical reduced state (`req:textproj:canonical-blobs`; `core_spec` §"Canonical and Non-Canonical Manifest Roots"). Nothing in `epiphany-core` or `epiphany-ops` references a `BlobId` — verified by source scan — so **no blob is canonical today** and the correct projection of every real bundle emits zero `(blob ...)` lines. Three obligations, and they are not the obvious ones: 1. **Emit side.** Implement the reachability predicate as a real function that today provably returns the empty set. **Never project `manifest.blob_roots`** — that is the plausible wrong answer: one line, looks right, emits non-canonical blobs in violation of the requirement. Even `manifest.rs`'s own doc comment says "canonical `blob_roots`" — the subset, not the field. 2. **Parse side: reject.** Document-level validation **MUST reject any `(blob ...)` line** as unreferenced, with a test asserting the rejection. A blob-bearing text at this companion version is necessarily non-canonical. Accepting one would stage a blob into the bundle that the next projection silently drops, which loses data *and* falsifies `project(serialize(parse(T))) == T` for that text. Forward compatibility is owned by header-version gating, not by leniency here. Line-level `project`/`parse` of the `(blob ...)` production must still be written and unit-tested in **both** directions against synthetic data, so that when the predicate becomes non-empty both sides are ready at once. 3. **A trip-wire.** A test that source-scans `crates/epiphany-core/src` and `crates/epiphany-ops/src` for the token `BlobId` and fails if it appears — same family as the existing drift locks. Its failure message must name the reachability predicate and tell the finder that both the emit side and the parse-side rejection now need their real implementation. ## The header carries exactly one version The header names the version of the companion the text conforms to. The parser **accepts exactly one**: the companion version this crate implements. Anything else is a rejection at line one. Multi-version acceptance and migrate-on-read for text are future spec decisions — do not improvise them. Define the version once, as a constant, and lock it against the companion with a test that reads `spec/text_projection.tex` and asserts the title version matches. A constant that silently disagrees with the document it claims conformance to is the whole failure mode this project keeps paying for. ## Section order is normative `projection ::= header document lineage? profile* extension* canonical-base? blob* envelope*` is a **sequence**, not a set. A repeated or out-of-order section is a rejection. A parser that accepts lines in any order and sorts them is normalizing, which `req:textproj:strict-parse` forbids. ## What must not appear (`req:textproj:derive-or-carry`) * **Physical attributes** — `offset`, `compressed_length`, `compression`, `uncompressed_length`. A serializer chooses them freely. * **Derivable identities** — `ChunkId`, `ContentHash`, `BlobId`. Re-derived from content. * The **one** identity carried verbatim is `SnapshotId`, which has nothing to derive from. * **Non-canonical accelerators are not projected**: `operation_index_root`, `acceleration_snapshots`, `text_projection_root`, `integrity_root`, `operation_block_summaries`. A bundle that round-trips through text comes back without them. That is correct; say so in a doc comment, because it reads as data loss and is not. ## `derived-ordering` sorts by projected form `req:textproj:derived-ordering` applies to exactly two sequences: the `(blob ...)` lines and an extension's preserved chunk roots. Order **and de-duplicate** them by the **UTF-8 bytes of their rendered form** — not by any binary key, because the binary key reads the offset, and a chunk's file position must not decide the text's order. Every other sequence keeps the binary order. ## Traps * **The extension line has six fields** in ratified declaration order: id, version, required, chunks, affected-kinds, barriers. `affected_object_kinds` and `edit_barriers` are **opaque byte strings**, never structured — the `Vec`-is-not-a-sequence trap that bit the operation layer lives here too. Each preserved chunk root projects as kind + schema_version + **uncompressed payload**, never as a `ChunkRef`. * **`ProfileId::Custom`** carries a 16-byte registry id and is the one profile that is not a bare symbol. * **Two `WallClockDuration` types exist**, one in `epiphany-core` and one in `epiphany-bundle`. The retention policy uses the bundle's. * **`canonical-base` carries the root chunk's uncompressed payload inline**, as a single byte string, plus the `SnapshotId` verbatim. * **Envelopes are emitted in `canonical_reduction_order`** (`epiphany_ops::canonical_reduction_order`, public and exported). ## Style Match the surrounding code. Doc comments on every public item and every non-obvious decision — especially *why* a parse rejects rather than normalizes. `rustfmt` clean, no `clippy` warnings, no `#[allow(...)]`, no `unwrap`/`expect` on a parse path. Touch only the file you are told to create; do not run `cargo fmt --all`. ## Verification The standing gate: `cargo fmt --all --check`; `cargo clippy --workspace --all-targets` → 0; full workspace tests; `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0; `cargo run -q -p epiphany-testkit --example conformance_suite`; zero golden churn. Plus the three this project learned the hard way: 1. **Mutation-verify every check you write.** Assert the anchor is present, delete or invert the check, confirm a *named* test of yours fails, restore. Report per check, with the actual command output. A survivor is a finding — say so. 2. **Exercise every outbound normalization.** For every value you normalize on the way out, construct a non-normalized input and prove the normalization happens. `derived-ordering` is exactly this shape: build a document with duplicate blobs and out-of-order chunk roots *in memory* and prove the projection collapses and orders them. Five such normalizations shipped untested in the operation layer because every fixture was already sorted. 3. **Assert your suite's own reach.** A round-trip suite that never exercises an extension, a canonical base, or a multi-envelope document proves far less than its green tick suggests. Count what you covered and assert the counts. Report the actual commands and their actual output. A previous agent reported "verification passes" when errors did point into its own file.