epiphany/spec/CONTRACT_TEXTPROJ_DOCUMENT.md

8.1 KiB

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 functionsfn 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 attributesoffset, compressed_length, compression, uncompressed_length. A serializer chooses them freely.
  • Derivable identitiesChunkId, 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.