# Contract: the Text Projection operation layer Repo root `/home/jeans/Repos/active/epiphany`. Read this in full before writing a line. The plan it implements is `spec/PLAN_TEXTPROJ_OPS.md`. ## The ruling: this layer is grammar-directed `req:textproj:value-projection` — the mechanical struct/enum/newtype rule — does **not** govern the operation vocabulary. It governs exactly the `value` positions *inside* the grammar's productions. The grammar is in `spec/text_projection.tex`, Chapter "Grammar", in the single `lstlisting` block. **It is the specification for your work.** Find your production, implement exactly what it says. Where it says `value`, call `TextValue`; where it says anything else, follow the production. Three consequences you will meet immediately: * An operation kind **inlines** its `*Op` payload record. `(insert-event #x0a )` — *not* `(insert-event (insert-event-op #x0a ))`. The record exists so each enum variant can name a type; the binary form adds no bytes for it (`OperationKind`'s encoding writes the tag then delegates), so the text adds no wrapper either. * Envelope-level productions use the grammar's names, not kebabbed Rust names: `envelope`, `stamp`, `causal`, `undo`. * `stamp` flattens `HybridLogicalClock` into three arguments. That is the grammar's shape and it is deliberate. ## Where field order comes from Every operation payload type has an explicit `impl CanonicalEncode for T { fn encode_canonical(&self, …) }` in `crates/epiphany-ops/src/payload.rs` (or `undo.rs` / `conflict.rs`). It reads its fields in order. **That order is the ratified declaration order — mirror it exactly.** Never infer an order from the struct declaration, a doc comment, or the grammar's argument names; read `encode_canonical`. `crates/epiphany-ops/src/envdecode.rs` is the binary *inverse* and is the best cross-reference in the repo: it already decodes every one of these in wire order. ## Strict parsing is the point `req:textproj:strict-parse`: a parser MUST reject text that is not the canonical projection of the value it denotes. It MUST NOT normalize — not whitespace, not case, not an out-of-order sequence, not a duplicate in a set-typed field. The rules that follow from that, learned expensively on the core layer: * **If a constructor normalizes, check before constructing.** Returning the normalized value *is* accepting the bad input. * **If a constructor only validates** (rejects rather than adjusts), the `None` it returns is the whole of the strictness. Do **not** add a re-project-and-compare guard as a backstop: on the core layer four such guards were written, mutation-tested, found unable to fire, and removed. A check that cannot fail is worse than no check — it invites weakening the real one. * **Order-preserving `Vec`s need per-site checks.** A guard that re-projects cannot see an order it faithfully preserves. * **Exercise every outbound normalization.** For every value normalized on the way out, construct a non-normalized input and prove that normalization happens. Five such normalizations were written across two agents and all five were untestable by omission because every fixture was already sorted. A projection that emits text its own parser rejects is a defect ordinary round-trip tests cannot see. ### The six order-constrained sequences Each one's encoder normalizes, so `project` must apply the same normalization and `parse` must reject text that is not already in that order. | field | rule | note | |---|---|---| | `TransposeOp.targets` | **non-decreasing multiset** | duplicates are **legal**; the wire form is frozen. Reject only a strict *decrease*. | | `TransposeIntervalOp.targets` | strictly increasing | a `CanonicalSet`, which is a type alias for `BTreeSet`; the existing core impl already does this check | | `ChangeRegionTimeModelOp.declared_incompatible` | non-decreasing | encoder applies `sorted_canonical` | | `TupletCompensation::RewriteTuplets.tuplets` | non-decreasing | encoder applies `sorted_canonical` | | `TupletCompensation::CascadeDeleteTuplets.tuplets` | non-decreasing | shares one encoder arm with `RewriteTuplets`, but is a **separate** arm in the projector | | `PositionRemapping::Reassign` entries | non-decreasing by `EventId` | encoder does `sort_by_key(|(e, _)| e.canonical_bytes())` | **This table said "three" until an agent found the other three.** The scoping script behind it searched `pub struct` bodies for sequence fields and never looked inside enum variants, so every constrained sequence living in an enum was invisible to it. If you are looking for sites of some kind, say out loud what shapes your search can and cannot see before you trust its answer. Getting `TransposeOp` backwards — rejecting a duplicate — silently breaks a frozen operation's replay. It is the single highest-risk line in this work. ## Names are generated, never spelled `OperationKindTag::catalog_name()` is production code, generated by `operation_kind_tag_vocabulary!` from the same list that generates the wire discriminant and the decoder. **Use it.** Writing `"insert-event"` as a literal creates a list parallel to the enum — the exact shape that has cost this project four bugs, most recently a kind that encoded to tag 30 and whose own decoder rejected it. For the same reason, `parse` must dispatch through an **exhaustive `match` over `OperationKindTag`** (no `_` arm), so a kind added to the vocabulary and not to the projector fails to compile. Every `match` in a `project` is likewise exhaustive with no `_` arm. ## Style * Match the surrounding code. Doc comments on every `impl` and every non-obvious decision — especially *why* a parse rejects rather than normalizes. No comment that merely restates the next line. * `rustfmt` clean. No `clippy` warnings. No `#[allow(...)]`. * No `unwrap`/`expect` on a parse path, except where an invariant was just checked — and then name the check in the message. * Touch **only** the file you are told to create. Do not edit `payload.rs`, `envelope.rs`, `textvalue*.rs`, or another agent's file. * Do **not** run `cargo fmt --all` — it rewrites files other agents hold. Run `rustfmt --edition 2021 crates/epiphany-ops/src/`. ## How to verify, and how not to mislead yourself Sibling files may be empty stubs while you work, so the crate may not link. That is expected. Your file is done when **no compiler error points into it**: ``` cargo check -p epiphany-ops --all-targets 2>&1 | grep -n '' ``` must print nothing. Errors located in other files, or in `payload.rs` from a macro expansion, are not yours. A previous agent on this project reported "verification passes" when errors did in fact point into its own file. **Paste the actual command and its actual output in your report.** If the crate does not link, say so plainly and say your tests were therefore not executed. ### Tests you must write Under `#[cfg(test)] mod tests` in your own file: 1. **Round-trip** for every production you implement: build a value, `project()`, `render()`, `read_sexp()`, `parse()`, assert equal to the original, and assert the re-rendered text is byte-identical. 2. **Rejection** for every strictness rule you implement — a text that is well-formed but not canonical must be rejected, not normalized. Assert the rejection, not the message. 3. **Mutation-verify every order check and every guard you write.** For each: assert the anchor text is present, delete or invert the check, confirm a named test of yours fails, restore. A `str.replace` that matches nothing looks exactly like a passing test. **Report the result per check, with output.** If a check survives deletion, say so — that is a finding, not a failure, and it means the check is dead or the test is blind. Use `epiphany_core::textvalue::read_sexp` to parse test text.