epiphany/spec/CONTRACT_TEXTPROJ_DOCUMENT.md

156 lines
8.1 KiB
Markdown

# 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<ProfileDeclaration, TextError>`. 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<u8>`-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.