Skip to content

Latest commit

 

History

History
167 lines (152 loc) · 116 KB

key-files.md

File metadata and controls

167 lines (152 loc) · 116 KB

Key files (load-bearing)

Detailed inventory of the project's load-bearing files. Read this when starting a non-trivial task; the top-level CLAUDE.md only carries the summary architecture and points here for the file-by-file map.

Core (load-bearing contracts)

  • src/core/types.ts — CipherSpec, StepNode, State variants, TraceFrame, StepDocumentation. Saved JSON references these shapes forever; changes here are breaking.
  • src/core/runtime.ts — the walk-and-trace engine. Pure given a registry.
  • src/core/registry.ts — StepRegistry. Maps stepType → { executor, doc }.
  • src/core/spec-mutations.ts — findStep, findStepAndParent (Slice 4: returns { node, parent, indexInParent } | null for ANY node kind — leaves AND groups AND iterates — so the visual editor can anchor inserts to groups), updateStepParams, updateAllStepsByType, insertStepAfter/insertStepBefore/removeStep/reorderStep (Slice 4 structural mutators; pure, reference-equality preserved on untouched branches, throw on missing anchor id; reorder is intra-parent only — cross-parent moves are removeStep + insertStepAfter), compareSpecs. Pure spec-in/spec-out. compareSpecs returns SpecParamDiff[] where each entry optionally carries scalar (before/after for primitive params) or cells (per-cell ParamCellDiff, 1D index or 2D row/col; flat-256 arrays auto-decompose to 16×16 like the S-box editor). Bare diffs with neither field intentionally pass through — describeDelta falls back to "X changed" for them.
  • src/core/format.ts — ByteFormat, formatByte/parseByte/formatBytes/parseBytes/parseBytesWithLength. Consumed by every byte-rendering site.
  • src/core/zq-vector.ts — the shared reading/writing rules for the zq-vec-*@1 family (ML-KEM P1): readZqVecParams (coeffBytes + littleEndian), readCoeff/writeCoeff (one loop and one index expression serve both byte orders, so reader and writer cannot drift apart), readZqModulus — which also checks residues actually FIT the declared element width, since a modulus of 70000 with 2-byte coefficients would otherwise lose its top bits with no error — plus zqElementCount and requireZqPort. ML-DSA P2 added readScalarPort, the reader for the wired constants (γ1, γ2, ω, bounds) that family keeps on ports. bigint throughout, because the modulus arrives on a port at whatever width it was wired at. P2 added readZqBitWidth, shared by the four steps carrying a d or an eta — one error message and one "must fit an element" rule, the same guard readZqModulus applies to q.
  • src/core/big-int-codec.ts — bytesToBigInt / bigIntToBytes (big-endian, fixed-width, throws on overflow). The arbitrary-width sibling of word-codec.ts's fixed 8/16/32/64-bit helpers; the single conversion seam the RSA big-integer primitives share (bigint math lives inside executors, exchanging Uint8Array at ports).
  • src/core/spec-shapes.ts — Static state-shape inference for a CipherSpec, added alongside the shapeContract field on StepDocumentation. Exports inferShapesAtAnchors(spec, registry): ReadonlyMap<string, StateShape> (returns the shape that exists immediately after each leaf/container completes; consumed by GraphView to decorate every data-drop-anchor element with a data-state-shape attribute) and validateShapes(spec, registry): GraphWarning[] (emits state-shape-mismatch for every leaf whose declared contract input disagrees with the inferred upstream shape — trace-free, fires before Run). Both share one walker that threads shape through groups (transparent — DFS through, children inherit parent shape) and iterates (two modes: a port-mode iterate — seedInput defined, every shipped multi-block spec — enters and exits as "bytes"; the legacy aux-mode branch keeps the historical matrix4x4-bytes in/out shape only for old saved documents). "any" and missing-contract leaves skip validation and pass shape through. Used by GraphView's rawWarnings memo (concatenated with validateGraph's trace-derived output) and by the shapesByAnchor memo that powers the drop-anchor greying CSS rules. Pure function; same inputs → same outputs.
  • src/core/graph.ts — deriveAuxGraph(trace, spec): CipherGraph plus GraphNode / ContainerNode / GraphEdge / EdgeKind types. Slice 9 added validateGraph(graph, trace): GraphWarning[] — pure structural analysis over the (graph, trace) pair. Four warning kinds in a discriminated union: "orphaned-read" (a step requested an aux key with no upstream producer — sourced from frame.auxReadMissing, which the runtime started populating in the same slice), "unused-write" (a frame wrote an aux key that no later frame reads — diffed against the producer-set of graph.edges), "cycle" (DFS over aux + state edges; acyclic by construction for any trace-derived graph, kept as defense-in-depth for future non-trace-synthesized edges), and "state-shape-mismatch" (a leaf's declared shapeContract.input disagrees with the spec-inferred upstream shape — produced by validateShapes in core/spec-shapes.ts, concatenated into the warning list in GraphView's rawWarnings memo; trace-free, fires the moment an incompatible step is dropped). All warnings carry the canonical (post :b{i} strip) stepId, deduped so multi-block iterates don't repeat the same logical issue N times. Today's shipped strict consumers (e.g. add-round-key) throw on missing aux rather than emit a frame, so orphan warnings won't trigger naturally until Slice 10's graceful aux primitives (aux-xor, aux-copy, aux-load) land — the validation plumbing is in place now so the visual editor can flag wiring problems as soon as those steps go in. The signature deviates slightly from the plan's validateGraph(graph): the trace is needed for orphaned-read detection (the graph deliberately stores only realized edges, not unfulfilled-read requests), and splitting it out keeps the graph itself a clean "what happened" snapshot. Pure function combining two passes. Aux pass (trace-derived): nodes/containers/rootIds from spec walk; edges from auxRead/auxWritten plus iterate-mediated synthesis (split-blocks → iterate → concat-blocks, since the runtime moves those aux values itself without recording them in any frame's auxWrites); blockSpan annotations on iterate-body nodes from frame.blockIndex. Strips :b{i} per-iteration suffixes during edge dedup (so 4-block ECB collapses to one logical edge per consumer, not four). State pass — RETIRED in Phase 5 / Slice 5.3e (2026-05-31): the spine is now composed entirely by inferPortEdges from each leaf's declared portInputs (port-flow-only); the description below is historical. inferStateEdges(spec) emitted one kind: "state" edge per DFS-consecutive pair of sibling leaves within each iterate-scope. Groups are TRANSPARENT (DFS through; the spine crosses round.k → round.k+1 boundaries — the pedagogical headline); iterates are OPAQUE (their body is its own scope, no spine edge crosses the boundary in either direction, matching the runtime's state = cloneState(blocks[i]) + aux[outBlocksAux] accumulation semantics — drawing a state edge across the iterate would double the aux edges that already capture the per-block handoff honestly). State edges carry the sentinel auxKey: "state", distinguishable from real aux keys; trace-derived edges are appended first, state edges last. Since state inference reads only the spec, the spine appears pre-run on the structural skeleton — the user sees what the cipher does the moment they load it. Feistel future: when a Feistel-style cipher (branching state via left/right halves) lands, the "DFS-consecutive leaves share state" assumption breaks; this pass needs revisiting along with any runtime-recorded lineage. Slice 6 added collapseGraph(graph, collapsedIds): CipherGraph — a pure view-time transform that runs AFTER deriveAuxGraph. Per the advisor: kept SEPARATE (not folded into derivation) so Slice 1's "graph is pure (spec, trace)" contract survives. Semantics: collapsed containers stay in containers (renderer draws them as chips) with childIds: []; leaves/containers under any collapsed ancestor disappear; edges' endpoints get remapped to the outermost collapsed ancestor on each endpoint's path; self-loops produced by remap are dropped; survivors dedup by (kind, from, to, auxKey) so the kind-defensive dedup key from commit 1 keeps state and aux edges with the same endpoints distinguishable. rootIds is filtered to still-resolvable ids. Empty collapsedIds short-circuits with reference equality. Commit 4 of the graph-readability sequence added replicateHighFanoutSources(graph, threshold, modes?): CipherGraph — a pure view-time transform that runs AFTER collapseGraph. Sources with > threshold outgoing kind: "aux" edges (counts state edges as zero — they're 1-to-1) get a replica node inserted next to each consumer, with edges rewritten to flow from the replica. Replica id: ${src}@->${consumer}. Replicas carry replicaOf pointing at the source's canonical stepId so click handlers can scrub to the source's trace frame. Original source nodes stay in nodes (linear-list click-to-scrub keeps working). One replica per (source, consumer) pair, even when multiple aux keys flow the same direction — the (1+N) tiny edges still beat (1) long edge. Layout: replicas inherit the consumer's containerPath, then are spliced into the parent container's childIds (or rootIds if root) immediately before the consumer. Commit 5 added per-source overrides via the optional third modes argument ({ [sourceId]: "always" | "never" }): "always" forces replication regardless of fanout count, "never" suppresses it even when the source's count exceeds the threshold. Absence of an entry is the implicit "auto" default (defer to the threshold). The transform itself honors "always" even when threshold <= 0 so unit tests can drive it in isolation; the GraphView enforces the master-switch semantic ("global off = no replicas regardless of overrides") by short-circuiting the call entirely when the toolbar checkbox is off. threshold <= 0 with no "always" overrides AND "no source over threshold" both short-circuit with reference equality. Slice 1 + Slice 6 of the 2D editor plan; commits 1, 2, 4, and 5 of the graph-readability sequence.
  • src/core/feistel-shape.ts — analyzeFeistelRound(group): FeistelRoundShape | null derives a Feistel round's structure (split / L-port / R-port / F-stack / fxor / recombine + the swap flag, read from the recombine's concat argument ORDER) PURELY from a port-mode group's wiring — no feistel-round kind, no spec tag. Cipher-agnostic (DES today; a future TEA/XTEA built split → F → xor → concat recognizes for free). findActiveFeistelRound + resolveFeistelRoundBytes back the linear-mode Feistel components (FeistelSwapDiagram etc.). Returns null for AES/SHA rounds + the outer rounds group.
  • src/core/feistel-layout.ts — pure geometry for the graph view's canonical Feistel round (DES canonical-representation feature, 2026-06-02). feistelRoundPlacement(shape, childIds, opts) assigns the textbook two-column slot offsets (split top-center; F-stack right column; fxor on the left L rail level with the last F leaf; recombine bottom-center; round-key replica right of the F column at its consumer's row) + the round body extent. feistelSwapWires(swap, recombineBox, splitBox, dx) returns the two inter-round carry half-wires named by VALUE (lxorf = L⊕F left rail, r = R right rail): on a swap they CROSS (R → next round's new_L, L⊕F → new_R, matching concat(R, L⊕F)), else straight (round 16). Consumed by GraphView's layoutNode Feistel branch + the swap overlay; the X is the rail-level picture (byte-level recombine → split is straight — the swap lives in the concat order) so each wire is LABELED.
  • src/core/twofish-diagram.ts — pure presentation model for the linear view's abstract Twofish round (2026-07-17), consumed only by src/ui/components/TwofishRoundDiagram.tsx. twofishDiagramModel(shape, group): TwofishDiagramModel | null takes a TwofishRoundShape (from its graph-side siblings twofish-shape.ts / twofish-layout.ts, which stay untouched) and derives the extra facts a picture needs but a layout doesn't: which input word each mix rail carries (railIndex, walked to the leaf reading the split directly), which PHT output each ⊕ consumes (fIndex — matched against shape.fIds, never assumed from rail order), each rotation's label in the PAPER's direction (ROL 8, not the builder's equivalent ROR 24), and swapSources / outputLabels — the 4-way rotation (R2′, R3′, R0, R1) read off the recombine's argument order. Returns null (diagram simply doesn't render) when a rewire leaves the round undrawable. Separate from twofish-shape.ts so the shipped graph layout can't regress.
  • src/core/chacha-shape.ts — analyzeChaChaDoubleRound(group): ChaChaDoubleRoundShape | null + findActiveChaChaQuarterRound(frame, spec) (2026-07-20). The third canonical-round recognizer, after the 2-way Feistel and Twofish's 4-rail. Its unit is the DOUBLE-ROUND group (98 leaves) and it returns the EIGHT quarter rounds inside — unlike the other two, where one group is one cell, because a quarter round takes four words and a group body is seeded with one value. It cannot use Twofish's backward-cone technique: the four diagonal quarter rounds consume the four column ones' outputs, so a cone from a diagonal anchor runs back through half the double round. It instead walks RFC 8439 §2.1's twelve-operation chain explicitly, backwards from the <<< 7 that ends each quarter round, cross-checking every leaf against a second path to it (e.g. a_cd1 is reached both from a_cd2's operand and from x_bc1's), so operand ORDER never matters and a rewired XOR still recognizes. A final partition gate requires the eight walks to tile the group's leaves exactly once with only the split and concat unclaimed — overlap, orphans or a changed rotation constant all return null and fall back to the generic stack. Wiring-derived throughout: no spec tag, no leaf ids, and no direction-awareness (ChaCha's encrypt and decrypt specs are structurally identical).
  • src/core/chacha-layout.ts — pure geometry for the graph view's canonical ChaCha20 double round (2026-07-20). chachaDoubleRoundPlacement(shape, childIds, opts) places each quarter round as a 3-wide × 4-tall block whose rows ARE RFC 8439 §2.1's four written lines (ops is already in RFC order, so index → (column, line) falls out), with the eight blocks in two tiers — the four column rounds above the four diagonal ones, which is §2.3.1's structure recovered from wiring. Split centered on top, concat centered at the bottom. Deliberately does not draw the permuted 16-word inter-tier crossing: aligning rails to a 4×4 matrix would spread each quarter round's rails apart and make its twelve-op chain zigzag — the tangle Twofish's swap-X was removed for. The rail-level picture is the linear view's job (chacha-diagram.ts). Consumed by GraphView's layoutNode via the chachaRounds param, parallel to feistelRounds/twofishRounds.
  • src/core/chacha-diagram.ts — pure presentation model for the linear view's ChaCha20 quarter round (2026-07-20), consumed only by src/ui/components/ChaChaQuarterRoundDiagram.tsx. chachaDiagramModel(round, quarterRoundIndex) adds the facts a picture needs but a layout doesn't: a written label per operation (a += b, d ^= a, d <<< 16) and — the payload — which of the sixteen STATE WORDS each rail carries, threaded forward from the split (split.outputN is word N; each quarter round writes its results back to the same four positions). That is what lets the diagram name the round as RFC 8439 §2.3.1 does, QUARTERROUND(0, 5, 10, 15) — the one fact distinguishing eight otherwise-identical quarter rounds. kind ("column" vs "diagonal") is likewise derived from whether every input reads the split, not from the index. Separate from chacha-shape.ts so the shipped graph layout can't regress (the Twofish precedent).
  • src/core/salsa-shape.ts — the ARX family's SECOND recognizer (2026-07-20), an adapter over analyzeArxDoubleRound with anchorBits: 18 plus Bernstein's own twelve-op walk. Rails a/b/c/d are y0..y3; each word is written exactly ONCE, by its line's XOR, which is the structural difference from ChaCha's repeatedly-mutated rails. Two things its walk needs that ChaCha's does not. (1) Salsa's terminal op is the XOR and the <<< 18 anchor FEEDS it, so xor18 is reached by a forward consumer scan over the group — miss it and each round claims eleven leaves and the partition gate refuses the whole double round. (2) add18's two operands are BOTH XORs, so they are told apart only by reaching through each to the rotate it consumed (xorLineBits) — the only unknown not pinned by an already-identified leaf. No positional forward check: add-mod-32@1/xor@1 are commutative and a swapped operand pair must still recognize.
  • src/core/arx-group.ts — the ARX family's recognition entry point (2026-07-27): analyzeArxGroup(group) (asks both analyzers — the two are mutually exclusive by construction, though not by anchor alone: a Salsa double round does contain eight <<< 7 rotations, and it is ChaCha's walk that declines), plus the two spec-wide walks over it — arxDoubleRoundsById(spec) (the canonical-layout map) and arxRoundNeverModes(spec) (the replication guard). Extracted from GraphView.tsx because both replication tests had grown local copies of the composition, so narrowing the shipped guard to one cipher would have left them green while the browser cell fell apart. It gets its own leaf module rather than joining arx-round-shape.ts (which both *-shape.ts files import — a cycle) or being exported from GraphView.tsx (a node-env test would pull in the whole Solid component). Tests import it; they never rebuild it.
  • src/core/salsa-diagram.ts — pure presentation model for the linear view's Salsa20 quarter round (2026-07-20). Adds the state-word indices (threaded by the shared deriveArxWordIndices) and Bernstein's written form, including the y→z rail naming so line 2 reads z1 + y0 exactly as the spec writes it. Its distinctive field is lane: the add and the rotate are tagged "scratch" because they touch no state word, and only the XOR gets a rail — a structural fact, not a rendering preference. kind is column / row (Bernstein's terms), and quadLabel prints the quad in RAIL ORDER, never sorted, because a column quarter round starts on the state's diagonal (quarterround(x5, x9, x13, x1)).
  • src/core/ntt-shape.ts — the canonical-layout family's fourth recognizer (2026-08-10), and the first whose container is an iterate rather than a StepGroup: an NTT layer's body is a port-mode loop run once per butterfly group. analyzeNttButterfly(iterate) matches the eight leaves by primitive type and binding — the split reading the body's own in port, the split reading chain, the aux-load-bytes@1 all three arithmetic leaves take modulus from — and resolves the DIRECTION from wiring: does the multiply consume the subtraction's output (Gentleman–Sande) or the subtraction consume the multiply's (Cooley–Tukey)? Every role, label and layout slot is keyed on that answer. The additions are checked as UNORDERED pairs and the subtractions positionally, because zq-vec-add@1 commutes and a swapped operand pair is a legal no-op edit (the Salsa precedent) while hi − lo reversed is not. SPLIT_LOW_PORT/SPLIT_HIGH_PORT read split-bytes@1's own output-naming contract, which is the anchor that tells low from high without asking the addition. A partition gate drops a rewired body to the generic layout. Also holds nttButterfliesById(spec) and nttButterflyNeverModes(spec) — no ntt-group.ts sibling, because arx-group.ts exists only to break a two-analyzer cycle and there is one analyzer here. Tests import these; they never rebuild them.
  • src/core/ntt-layout.ts — pure geometry for the graph view's canonical butterfly cell (2026-08-10). nttButterflyPlacement(shape, childIds, opts) places the eight leaves on a 4 × 5 grid: a ζ row along the top (the table read above the high rail, the rotation rightmost), then the split, the one mixing step, the two rails flanking the modulus, and the recombine. One slot table serves both butterflies — twist and diff share the mid slot because the two directions' role sets are disjoint exactly there, so the direction arrives pre-resolved and nothing here reads shape.kind. Two placements were derived by auditing rendered path geometry, not chosen: the modulus sits BETWEEN the rails (from outside them its wire to the far rail settles onto that rail's y while still inside the near rail's box), and the ζ pair gets its own row (otherwise the outgoing chain crosses the next layer's split and the outgoing coefficients cross this layer's rotation). Measured effect: wires crossing a box they don't terminate at go 34 → 1 forward and 47 → 1 inverse; canvas ~12,800 → ~6,600 px. Consumed by GraphView's layoutNode via the nttButterflies param, parallel to feistelRounds/twofishRounds/arxRounds.
  • src/core/ml-dsa-arith.ts — ML-DSA's rounding rules as plain number functions (FIPS 204 Algorithms 35–40): modPm, power2Round, decompose (with the wrap-round special case, reported as wrapped), highBits/lowBits, makeHint/useHint, checkGamma2 (refuses a γ2 that does not tile q − 1), plus the port helpers the ml-dsa.* executors share (readMlDsaModulus, readResidues, coeffNumbers/packNumbers, flagByte, countBytes). One implementation for executors and narrators; the tests' oracles are derived differently on purpose. Plan: docs/plans/refactored-petting-tide.md P2.
  • src/core/ntt-diagram.ts — pure presentation models for both lattice linear-view diagrams (2026-08-10), consumed by NttButterflyDiagram.tsx and ZqBaseCaseMulDiagram.tsx. findActiveNttButterfly(frame, spec) walks the frame's container path for an iterate that analyzes; nttButterflyDiagramModel(...) emits ordered columns whose ORDER differs by direction — the twiddle box sits before the crossing going forward and after it coming back, which makes this the first direction-aware diagram since Twofish's (ChaCha's and Salsa's are direction-blind because those specs are structurally identical). Its payload is the layer's pairing distance and "group i of N", the NTT's equivalent of the ARX diagrams' state-word indices; N must be counted within the layer's own iterateScopeKey scope — seven sibling iterates run 1, 2, 4 … 64 groups. baseCaseMulDiagramModel(frame, spec) covers zq-base-case-mul@1, deriving the pair count from the frame's own input width and marking the one product term that folded back from X² via X² = γ.
  • src/core/document.ts — CipherDocument file format wrapper + serializeDocument(doc): string (stable key order — alphabetical at every level; arrays preserve order) + parseDocument(text): { ok: true, doc } | { ok: false, error }. Hand-written TS types so optional fields are field?: T rather than Zod's inferred field?: T | undefined (matters under exactOptionalPropertyTypes). The wrapper holds a required spec: CipherSpec plus three optional sidecars: layout?: LayoutSpec (graph positions, collapsed groups, flow direction), session?: SessionSnapshot (mode + cipher + cipherMode + padding + byteFormat + optional inputBytes/keyBytes), metadata?: DocumentMetadata (name, createdAt, appVersion). schemaVersion is the migration anchor — CURRENT_SCHEMA_VERSION = 3, ACCEPTED_SCHEMA_VERSIONS = [1, 2, 3]; older versions migrate forward in parseDocument, and a pre-check gives a friendly forward/back-compat error for any unsupported version before the full Zod schema runs. Slice 3 of the 2D editor plan; unblocks Slice 5 (save/load UI) and Slice 7 (URL share).
  • src/core/document-schema.ts — Zod schemas backing document.ts. Strictness split: wrapper layers (CipherDocument / LayoutSpec / SessionSnapshot / DocumentMetadata) are .strict() so unknown fields force a schemaVersion bump; CipherSpec + StepNode stay loose so per-step extensions can land without forcing a migration. StepNode uses z.discriminatedUnion("kind", [StepLeafSchema, StepGroupSchema, IterateGroupSchema]) for pinpoint error paths; recursion lives in the children fields via z.lazy(() => StepNodeSchema) so each per-kind ZodObject keeps its concrete type and satisfies z.discriminatedUnion's per-option constraint without casts. Also exports CIPHER_IDS / CIPHER_MODES / PADDING_SCHEMES / BYTE_FORMATS value tuples (mirrored from ui/stores/* via type-only imports + satisfies readonly Cipher[] bottom check + Exclude<...> top check) so adding a new variant to those unions surfaces here at tsc time.
  • src/version.ts — APP_VERSION: string re-exported from package.json (via import pkg from "../package.json" with { type: "json" }; works under resolveJsonModule: true + verbatimModuleSyntax: true). Single source of truth so a release bump is a one-file edit. Consumed by the App-shell footer (informational) and by buildSaveText in App.tsx to stamp metadata.appVersion into exported documents on the session-on branch only (spec-only saves remain byte-stable so the planned URL-share invariant survives). See docs/versioning.md for the release process.

Ciphers

  • src/ciphers/default-registry.ts — wires every step type. Adding a new step means editing this.

  • src/ciphers/aes-128.ts, src/ciphers/aes-128-decrypt.ts — AES-128 forward + inverse specs.

  • src/ciphers/aes-192.ts, src/ciphers/aes-192-decrypt.ts — AES-192 (Nk=6, Nr=12). Reuses every AES-128 step type; only ROUNDS and inputs.key.byteLength differ.

  • src/ciphers/aes-256.ts, src/ciphers/aes-256-decrypt.ts — AES-256 (Nk=8, Nr=14). Same shape as AES-192; the aes.key-expansion@1 step has an Nk>6 branch that fires only here.

  • src/ciphers/aes-constants.ts — AES_SBOX, AES_INV_SBOX, AES_RCON, AES_MIX_MATRIX, AES_INV_MIX_MATRIX, AES_SHIFT_ROWS, AES_INV_SHIFT_ROWS. Rcon table is long enough for all three key sizes.

  • src/ciphers/aes-round-builder-native.ts — the port-native AES round-body builder (buildAesEncryptBodyNative(rounds, blockSource) / buildAesDecryptBodyNative(...), scaffolding-suppression B1, 2026-05-29): SubBytes / ShiftRows / MixColumns / AddRoundKey composed from byte-substitute@1 / permute@1 / gf-matrix-multiply@1 / xor-with-aux@1. The single point of truth for per-round construction, used by the single-block specs and — via aes-core.ts — by the generic mode builders. (Replaced the legacy aes-round-builder.ts, deleted with the matrix primitives in Phase 5.)

  • src/ciphers/chacha20.ts — ChaCha20 (RFC 8439), the first STREAM cipher and the only cipher with no BlockCipherCore. Builds both directions from one function (buildChaCha20Spec(direction)) because encrypt and decrypt are structurally identical — the message meets only an XOR. Contains the block function (16-word state, ten grouped double rounds, feed-forward, little-endian serialization) and its own iterate with the counter on the cross-iteration carry. The four LE↔BE permute@1 crossings are the only places byte order is touched; read the file header before editing any of them.

  • src/ciphers/aes-key-schedule-builder-native.ts — the K1 key-schedule decomposition (2026-06-01): unrolls AES key expansion into per-word port-native frames (per-group Rcon constant-load@1; RotWord = permute@1, SubWord = byte-substitute@1, word-XOR = xor@1; byte-slice@1 repack) with a single meta-bearing aes.publish-round-keys@1 tail that writes aux["roundKey.N"] byte-identically to the old monolith (B-minimal — consumers untouched). bumpKeyExpansion rebuilds it for duplicate-round. Speck / Serpent / DES have sibling *-key-schedule-builder-native.ts files (K2/K3/K4a) following the same pattern.

  • src/ciphers/block-cipher-core.ts — the cipher-agnostic block-cipher contract (Phase A, 2026-07-17): BlockCipherCore (id / displayName / familyName / blockByteLength / keyByteLength / buildKeySchedule() / buildEncryptBody(seed) / buildDecryptBody(seed)) + BlockMode + the shared port() helper + CipherDirection (which used to be mis-homed in aes-ecb-builder.ts). The seam that makes modes cost N ciphers + M modes, not N × M. id is a string, NOT the Cipher union — nothing in src/ciphers/ imports from src/ui/, and a mode builder that enumerates the cipher list is the very thing this eliminates. Designed against CTR (forward-cipher-only, no padding), so both bodies are exposed independently and padding is a per-mode flag.

  • src/ciphers/aes-core.ts — AES as a BlockCipherCore (aesCore(variant)), plus the AesVariant type. The only place the mode machine learns AES's specifics (16-byte block, 10/12/14 rounds, 16/24/32-byte keys). A thin re-expression of the existing native builders — no AES logic is reimplemented. Adding a cipher to every mode = writing a sibling of this file.

  • src/ciphers/blowfish-core.ts — Blowfish as a BlockCipherCore (Phase C, 2026-07-17). The promised sibling of aes-core.ts, and the file that proves the seam: the first core whose block is not 16 bytes, so it is the first to actually exercise the block-size-generic mode/padding/IV arithmetic Phase B introduced (everything before it could have hidden a stray hardcoded 16). Wraps buildKeySetup + buildBlowfishBody from the spec builder; no cipher logic is reimplemented. Registering it is what gives Blowfish ECB/CBC and the padding overlay — "has a core" is the single gate for both.

  • src/ciphers/twofish-core.ts — Twofish as a BlockCipherCore (2026-07-20), the last core: with it every block cipher runs every mode. Wraps buildTwofishBody + buildTwofishKeySchedule; no cipher logic reimplemented. Its 16-byte block adds no block-size confidence (that is AES's width), but it nests the app's most structurally unusual body — the 4-rail round, recognized by shape in twofish-shape.ts — one scope deeper inside the mode's iterate. Seed-threading was one binding (the input-whitening head), which was only possible because every subkey reaches its round through aux (twofish.K.*) rather than a port edge into the key-setup group; a port edge would throw across the iterate's scope boundary.

  • src/ui/stores/block-cipher-cores.ts — the Cipher-keyed core registry (Phase B, 2026-07-17): blockCipherCoreFor / blockByteLengthFor / hasBlockCipherCore over a Partial<Record<Cipher, BlockCipherCore>> (every block cipher since Twofish landed 2026-07-20 — AES ×3 + Speck ×2 + Blowfish + Serpent ×3 + DES + Twofish; kept Partial because absence stays meaningful for the next cipher added). The consumption-layer half of the seam — block-cipher-core.ts deliberately doesn't know the Cipher union, so this is where a selected cipher maps to a core. Absence is meaningful, not unfinished: a cipher is absent until its body is seed-parameterized, so presence answers "can this cipher run a mode / take padding?". Its own module (not hung off spec.ts) because padding.ts + App.tsx read it and spec.ts already imports padding.ts — a cycle otherwise.

  • src/ciphers/modes/ecb.ts + aes-128-ecb.ts + aes-128-ecb-decrypt.ts — multi-block ECB, written once for every cipher: buildEcbSpec(core, direction) takes a BlockCipherCore and knows nothing else. (Replaced the AES-specific aes-ecb-builder.ts, deleted in Phase A; the generated specs are byte-identical — verified by a deep-equal parity gate over all 12 variant × mode × direction combinations.) The spec shape is [core.buildKeySchedule(), iterate{…core body…}] — a byte-native port-mode iterate (seedInput = $input or the pad step's output, blockByteLength = the core's, bodyOutput = the core body's explicit exit); the old split-blocks / compute-block-count / concat-blocks boundary steps were retired with the matrix iterate in Phase 5 / Slice 5.1 (2026-05-30). applyPaddingScheme prepends pkcs7-pad (or sibling) on encrypt and appends pkcs7-unpad on decrypt around the outside. Only AES-128 has dedicated spec FILES; AES-192/256's ECB+CBC are generated from their core in stores/spec.ts (modesFromCore) — a file per (cipher × mode × direction) is the N×M explosion this seam removes, and AES-128 keeps its files only because ~35 modules import them, some by reference.

  • src/ciphers/modes/cbc.ts + aes-128-cbc.ts + aes-128-cbc-decrypt.ts — multi-block CBC, written once for every cipher: buildCbcSpec(core, direction). (Replaced the AES-specific aes-cbc-builder.ts, deleted in Phase A.) Adds a pre-loop fetch-iv (aux-load-bytes@1 reading aux["iv"], one block wide) and chains through the port-mode iterate's chainInput / chainFeedback rather than an aux slot. The encrypt/decrypt asymmetry is the load-bearing detail and lives entirely in the spec: encrypt is cbc-xor → body, feeding the chain from the body's output (C_i); decrypt is body → cbc-xor, feeding the chain from the raw input (port(it,"in") = C_i), because on decrypt the ciphertext is what arrives, not what leaves. Both narration blocks interpolate core.familyName ("AES", not "AES-128" — the variant is irrelevant to how a mode chains blocks).

  • src/ciphers/modes/ctr.ts — CTR, written once for every cipher: buildCtrSpec(core, direction). The mode that stops using the block cipher as a block cipher — it encrypts the counter to make keystream and XORs that with the message, so the body is seeded from the iterate's chain port and the message block never enters the cipher at all. The counter reuses CBC's cross-iteration carry (chainInput = fetch-iv = T₀, chainFeedback = ctr-increment) and advances via a visible increment-counter@1 frame. Encrypt and decrypt build structurally identical specs (both call core.buildEncryptBody) because XOR is self-inverse; direction changes only the id, name, and prose. Partial final blocks (2026-07-20): the iterate sets allowPartialFinalBlock, so a message may end mid-block and the final iteration gets a short in; ctr-trim (truncate-to-reference@1) then trims the full-width keystream to that block's width so xor@1's equal-length rule is met. No padding overlay is ever spliced into a CTR spec — buildCanonicalPair passes no block width for it.

  • src/ciphers/modes/cfb.ts — CFB, written once for every cipher: buildCfbSpec(core, direction). The mode between CTR and CBC: like CTR it encrypts a feedback register (seeded from the iterate's chain port) and XORs the keystream with the message, so the forward body runs in both directions and no padding is engaged; like CBC that register holds the previous ciphertext block, so blocks are serially dependent. Its encrypt and decrypt specs differ in exactly one wire — chainFeedback is cfb-xor.output on encrypt (the ciphertext we emit) and the iterate's raw in on decrypt (the ciphertext that arrived). Get both wrong in a matched way and the round-trip still passes, which is why tests/cfb-all-cores-kat.test.ts leans on node:crypto's aes-*-cfb (CFB128 — not -cfb8/-cfb1), an ECB-rebuilt keystream, and the error-propagation shape instead. Ragged tail as CTR's (allowPartialFinalBlock + cfb-trim). Cost no new step type — every leaf already existed. Scoped to full-block CFB.

  • src/ciphers/modes/ofb.ts — OFB, written once for every cipher: buildOfbSpec(core, direction). CFB's wiring with one wire moved and the direction branch deleted: chainFeedback is the core body's own output (O_j = E(O_{j-1})), so the keystream depends only on key + IV and is identical in both directions — encrypt and decrypt are structurally the same spec, as CTR's are. Deliberately the untrimmed keystream.output, not ofb-trim.output: the recurrence is defined on whole blocks, and though the two are byte-indistinguishable (nothing reads the final iteration's feedback — verified by perturbation, 132/132 still pass), the full-width wire is what the trace should show. Round-trip proves nothing here — one spec used both ways round-trips by construction — so tests/ofb-all-cores-kat.test.ts asserts it last and leans on node:crypto's aes-*-ofb (no -ofb8 variant exists), an ECB-rebuilt keystream chain, the three-way block-0-equal/block-1-differs contrast against CTR and CFB, and OFB's zero error propagation (one corrupt byte damages one byte — CFB's same test asserts two damaged blocks). Ragged tail as CTR's/CFB's. Cost no new step type and no new predicate — only a fifth arm on the two CFB extracted.

  • src/steps/truncate-to-reference.ts — truncate-to-reference@1, CTR's ragged-tail trim: keeps input[0 .. reference.length], reading reference's length only (never its bytes). No params — the target width depends on the message the user typed, so it changes per keystroke and byte-slice@1 (whose length is a spec-time param) cannot serve. Throws when reference is wider than input. Has an exact provenance fn (identity prefix) and a per-frame value-aware narrator in src/ui/narration/truncate.tsx that names the discarded keystream bytes — the sentence explaining why CTR's ciphertext matches its plaintext length, which static narrationOverride markdown cannot branch to produce.

  • src/ciphers/speck-32-64-builder.ts + speck-32-64-{be,le}{,-decrypt}.ts — Speck32/64 spec factory + four canonical specs (BE encrypt, BE decrypt, LE encrypt, LE decrypt). The four files differ only by the (byteOrder, direction) pair passed to the builder; the builder bakes the per-leaf params and the decrypt round-key reversal. Block size is 4 bytes, key is 8 bytes, 22 rounds, no padding overlay support.

  • src/ciphers/serpent-spec-builder.ts + serpent-round-builder.ts + serpent-{128,192,256}{,-decrypt}.ts — Serpent SP-network spec factory + six canonical specs (all three key sizes × encrypt/decrypt). The six files differ only by the (keyByteLength, direction) pair passed to buildSerpentSpec; the round body is identical across all three variants because Serpent internally pads shorter keys to 256 bits as a key-schedule preamble. Block size 16 bytes, 32 rounds, single-block only today (multi-block ECB/CBC/CTR is a future phase that will plug into the existing iterate primitive the same way AES-128-ECB does). The cipher runs in standard form (visible IP/FP, per-nibble S-box, table-based LT) — the equivalent bitslice form (no IP/FP, column-wise S-box, word-equation LT) is mathematically the same but pedagogically opaque.

  • src/ciphers/rsa.ts — RSA (textbook), the first public-key cipher. buildRsaSpec(direction, W) builds an encrypt/decrypt spec whose key-gen leaves (mul/sub deriving n, φ, and a traced extended-Euclid loop deriving d = e⁻¹ mod φ — Phase 4, below) compute the key from the editable p, q, e cipherConstants and live in a default-expanded collapsible "Key Generation" group (Phase 2). Because port flow can't cross a group scope, the group's rsa.publish-key-params@1 tail (src/steps/publish-key-params.ts, the one hybrid/meta step) mirrors this direction's key material into aux — the B-minimal cross-scope export the four key schedules use; each direction publishes only the key its ladder consumes (keys param: public {rsa.n, rsa.e} on encrypt, private {rsa.n, rsa.d} on decrypt, so no aux value is written-but-unread) — and the unrolled W·8-rung square-and-multiply ladder (mod-mul@1 square + cond-mod-mul@1 conditional multiply per rung, exponent bit read at run time → live-editable) reads them back via top-level aux-load-bytes@1 loaders. Phase 4 decomposes the former single-frame mod-inverse@1 oracle into a traced extended-Euclid loop: two coefficient seeds + an unrolled chain of eeaMaxIterations(W) = ⌈1.4404·8W⌉+2 eea-step@1 rungs (each shifts the (r, newR, t, newT) tuple by q = ⌊r/newR⌋, the Bézout coefficient kept reduced mod φ so every port stays non-negative; a rung with newR = 0 is an identity carry-forward) + an eea-extract@1 tail (id "d", reads the settled (gcd, inverse) slot, throws when gcd ≠ 1). mod-inverse@1 stays registered as the cross-check oracle. Every key-gen leaf + ladder rung carries a cipher-specific narrationOverride. Working width W=2 (n < 65536, 16 rungs); message m arrives on $input, must be < n. Big-int math is bigint-internal in the seven primitives (src/steps/{mul,sub,mod-mul,cond-mod-mul,mod-inverse,eea-step,eea-extract}.ts); KAT in tests/rsa-vectors.test.ts (Python-oracle-verified), topology + export gate in tests/rsa-key-gen-group.test.ts, the EEA worst-case (Fibonacci) K-bound gate in tests/rsa-eea-decomposition.test.ts. Plan: docs/plans/shimmying-booping-moth.md.

  • src/ciphers/lcg.ts — the linear congruential generators, the whole of the prng family today. buildLcgSpec(variant, outputLength) builds a direction-less spec for any of three: minstd-rand0 (a = 16807), minstd-rand (a = 48271) — both multiplicative, modulus 2³¹−1 — and ansi-c-lcg (a = 1103515245, c = 12345, modulus 2³¹), the mixed form from ISO/IEC 9899 §7.22.2.2 / POSIX rand_r. LCG_PARAMS is the (a, c, m) table; c === 0 selects the form, not just a value. Shape: a zero-fill@1 request leaf whose width is the requested output length, feeding a port-mode iterate's seedInput at blockByteLength: 4 with allowPartialFinalBlock — so the iteration count is ceil(N/4) and the body's in port is ignored on every pass but the last. Body is four leaves for the multiplicative form (mult/modu constant-load@1, state mod-mul@1, emit truncate-to-reference@1) and six for the mixed one, which inserts an incr constant and splits the arithmetic into prod (mod-mul@1) → state (add-mod@1). LCG_STATE_ID always names whichever node produces the next state, which is what lets chainFeedback and emit wire identically across both forms. chainInput bootstraps from $input (the seed, verbatim — no scrambling, no warm-up discard); chainFeedback reads the untrimmed state while bodyOutput reads the trimmed emit. That last distinction is byte-indistinguishable at every length (only the final block is short and its feedback is discarded — OFB's situation exactly) and is pinned as such by perturbation in tests/lcg-kat.test.ts, whose anchors are the ISO/IEC 14882 §rand.predef conformance values (MINSTD) and the published opening states + derived rand() values (ANSI C). The app emits the raw 32-bit state, not C's (state/65536) % 32768 — the extraction is C's workaround for the low-bit weakness, so performing it would hide the defect; the emit narration says so with both sequences. Narration is split MINSTD/ANSI-C rather than woven with conditionals, which is also what keeps the MINSTD specs byte-identical (pinned by hash — spec-only saves feed the URL-share hash). readLcgOutputLength(spec) recovers the length on document load, mirroring readShakeOutputLength. Every leaf carries a narrationOverride. Plan: docs/plans/iterative-dancing-ocean.md.

  • src/ciphers/chacha20-csprng.ts — the ChaCha20 CSPRNG, the family's fourth generator and its only secure one. buildChaCha20CsprngSpec(outputLength) is the shipped ChaCha20 spec with the message and the final XOR removed: a zero-fill@1 request leaf drives a port-mode iterate at blockByteLength: 64 with allowPartialFinalBlock, the counter rides the cross-iteration carry (chainInput = a constant-load@1 big-endian zero — block 0, NOT the cipher's counter-1 convention — advanced by increment-counter@1), and bodyOutput is the trimmed keystream. The twenty rounds are not re-implemented: buildDoubleRoundGroups is imported from chacha20.ts, so the generator inherits analyzeArxGroup / arxRoundNeverModes / the canonical ARX layout cell / <ChaChaQuarterRoundDiagram /> for free — those surfaces key off wiring, never off the cipher. The seed reaches the body through aux["seed"], not a port, because port flow cannot cross a container scope; App.tsx publishes it for every generator and aux-load-bytes@1 fetches it — structurally the same leaf the cipher uses for its key, which is honest, since to this construction the seed is the key. Nonce = twelve zero bytes (safe for a generator, catastrophic for a cipher — and needing no endianness crossing, since a reversed zero word is a zero word); seed = 32 bytes through a permute@1 LE↔BE reversal. Every leaf outside the shared double rounds carries a narrationOverride (the ARX round internals carry none, exactly as the cipher's do — they come from the shared builder), and final-add's is the family's centrepiece: it names the feed-forward as the single step separating this from the LCGs. tests/chacha20-csprng-kat.test.ts pins it against node:crypto's chacha20 over zeros at fourteen lengths and against RFC 8439 §A.1's published vector (which the all-zero default seed reproduces on first paint), plus the pre-refactor ChaCha20 digests. Plan: docs/plans/iterative-dancing-ocean.md P3.

  • src/ciphers/mt19937.ts — the Mersenne Twister, the family's fifth generator and the only one that separates "passes statistical tests" from "unpredictable". buildMt19937Spec(outputLength) builds seed-state (mt19937.seed@1) → twist (mt19937.twist@1) → a byte-slice@1 rounding the request UP to whole words → a port-mode iterate of twelve tempering leaves → a sibling truncate-to-reference@1. Three firsts, all documented in the file header. (1) The iterate has no chainInput and no chainFeedback — the app's only carry-free port-mode iterate, because tempering is a pure map over the state array. (2) The 2496-byte state is the iterate's seedInput, i.e. on a port, not in aux — the CSPRNG needed aux only because its block function lives inside the loop, and nothing crosses a scope here. (3) The trim is outside the loop, which is forced: the body's only per-block reference is the iterate's in port, always a full 4-byte word, so no short reference exists — the shortness belongs to the request, not any block. Both novel properties are spiked with pre-existing primitives in tests/mt19937-kat.test.ts's first suite before any MT code runs. MT_MAX_OUTPUT_BYTES = 2496 (one twist) and the builder throws above it rather than emitting a stream missing a refill; the UI ceiling of 1024 keeps that unreachable, so maxPrngOutputFor needs no arm (measured: a 42 → 1024 change settles in ~325 ms). Default seed 5489, std::mt19937's default_seed — the family's rule was always "the seed that has a published vector", which merely happened to be 1 for the other three. Plan: docs/plans/validated-growing-dongarra.md.

  • src/steps/mt19937-seed.ts / src/steps/mt19937-twist.ts — the family's two deliberate monoliths, and the only ones in the app whose opacity is structural rather than a volume judgement. init_genrand adds the loop index (mt[i] = 1812433253·(mt[i−1] ^ (mt[i−1]>>30)) + i) and no leaf here produces one; the twist reads three words (mt[i], mt[i+1], mt[i+397]) where an iterate body sees only its own block, and it does not vectorize either — the update is in place, so from i = 227 the third read lands on a word the same loop already rewrote. Both export their arithmetic in plain numbers (initGenrand, twist, plus the BE codecs) so the KAT can drive long runs without building a trace. Both descriptions state the blocker, so the single frame does not read as laziness.

  • src/steps/shift-bits-left.ts — the fourth foundational ARX primitive ({bits, wordBits}, layout: "raw", bits >= wordBits short-circuits to zero), templated on shift-bits-right.ts. Exists rather than reusing rotate-bits-left@1 + and@1 on purpose: for MT19937's two published masks a rotation gives an identical answer, since each mask's low bits are exactly the positions a rotation wraps in — a coincidence of those constants, not a property of the operation, and one that dies the moment a learner edits a mask. rotate-bits-left@1's own argument, inverted. Its shl32 carries the one asymmetry with the right-handed sibling: JS << returns a signed 32-bit value, so 1 << 31 is negative and the trailing >>> 0 is load-bearing.

  • src/ciphers/mlkem-constants.ts — the lattice layer's published constants: ML_KEM_Q (3329), ML_KEM_N (256), NTT_ROOT (17), N_INV_128 (3303 = 128⁻¹, not 256⁻¹), the 128-entry ZETAS table verbatim from FIPS 203 Appendix A, and the byte encodings (Q_BYTES, N_INV_BYTES, ZETA_TABLE_BYTES) plus packPoly/unpackPoly. Module constants, never spec params — the Blowfish π-table precedent. The ζ table is stored in consumption order (ZETAS[i] = 17^BitRev7(i) mod q), not ascending exponent, which is what lets the spec carry it as a cursor that advances by one entry per butterfly group instead of computing an index; a table in exponent order yields a self-consistent transform that agrees with nothing. ZETAS[0] = 1 is never consumed by either direction and is present only so the table can be checked against the standard entry by entry, which tests/ntt-3329-256-kat.test.ts does. P2 added GAMMAS / GAMMA_TABLE_BYTES — the 128 per-pair moduli of the degree-1 quotient rings a base-case multiply happens in, γ[i] = 17^(2·BitRev7(i) + 1). Note the + 1: the plan called these "the 128 ζ² base-case values", which yields 1, 3328, 1729, … instead of the correct 17, 3312, 2761, …. Derived as ZETAS[i]²·17 so the relation to the published table stays visible, and cross-checked a second independent way — γ[2i] = ZETAS[64+i], γ[2i+1] = q − ZETAS[64+i], so the upper half of FIPS 203 Appendix A re-verifies this table.

  • src/ciphers/ntt-3329-256.ts — the number-theoretic transform, the whole of the lattice family today, and the app's first post-quantum object. buildNttSpec() / buildInverseNttSpec() produce the forward and inverse direction slots, both now thin wrappers over buildNttNodes — extracted in P3 so K-PKE can embed the same transform six times rather than grow a second copy, and pinned by spec digests captured BEFORE the extraction. buildNttGroup wraps it as a collapsible group with seedInput/bodyOutput, and takes a prefix: the flat trace keys frames by stepId, so six copies of layer1.split would break the scrubber and the graph derivation with no error. Shape: a top-level aux-load-bytes@1 fetching the ζ table, then seven sibling port-mode iterates, one per layer, with blockByteLength running 512 → 8 forward and 8 → 512 inverse. Each body is eight leaves — split-bytes@1 into halves, split-bytes@1 taking this group's ζ off the table, aux-load-bytes@1 for q, then the butterfly (zq-vec-mul-scalar@1 + zq-vec-add@1 + zq-vec-sub@1), a concat@1 recombine, and a concat@1 that rotates the ζ table by one entry. The twiddle table rides the cross-iteration chain and passes layer to layer via chainOutput → chainInput: CBC's machinery, unchanged, and the first shipped iterate whose chain width (a fixed 256 bytes) differs from its block width. The forward spec pre-rotates the table so the cursor starts at ζ¹; the inverse consumes from the BACK (ζ¹²⁷ down to ζ¹) so it takes the published table unrotated, and finishes with a zq-vec-mul-scalar@1 by 128⁻¹. q, the ζ table and 128⁻¹ ride spec.cipherConstants so they are editable in one place; q reaches the butterfly through aux because port flow cannot cross a loop scope — the CSPRNG-seed lesson, met again. LayerGeometry carries displayIndex and len separately: the inverse runs the layers in the opposite order, and deriving one from the other put layer 7's children inside layer 1's iterate on the first attempt. DEFAULT_NTT_INPUT is 0, 1, 2, …, 255; DEFAULT_NTT_OUTPUT is its transform, derived from FIPS 203's definition rather than from this spec, and pinned against a real run. Every leaf carries a narrationOverride. Plan: docs/plans/unified-stargazing-quasar.md P1. Since ML-DSA P1 (2026-10-18) the builder is parameterized by an NttProfile (n, layers, coeffBytes, zetaTableBytes + the per-occurrence prose as an NttNarration). ML_KEM_NTT_PROFILE is the default everywhere, so every ML-KEM / K-PKE spec is byte-identical (pinned by digests taken before the change, tests/ml-kem-768-spec-digests.test.ts + the K-PKE and NTT ones). Prose is part of the profile, not templated: a template would have to reproduce ML-KEM's sentences byte for byte to keep shared links stable.

  • src/ciphers/ntt-8380417-256.ts — ML-DSA's transform, the lattice family's second member (ntt-8380417-256). No transform logic: hands buildNttNodes the ML_DSA_NTT_PROFILE (eight layers, four-byte coefficients, a 1024-byte rotating ζ table, 255 butterfly groups) and its own prose for every leaf that quotes a number; the sum/difference/rejoin prose is shared. buildMlDsaNttSpec() / buildMlDsaInverseNttSpec(); DEFAULT_ML_DSA_NTT_OUTPUT derived from the DEFINITION (f(1753^(2·BitRev8(j)+1))), cross-checked against a transcription of FIPS 204 Algorithm 41 when generated. Plan: docs/plans/refactored-petting-tide.md P1.

  • src/ciphers/mldsa-constants.ts — ML_DSA_Q (8380417), ML_DSA_NTT_ROOT (1753, order 512), ML_DSA_N_INV_256 (8347681), the 256-entry ML_DSA_ZETAS verbatim from FIPS 204 Appendix B in consumption order — entry 0 is listed as 0, not 1753⁰ = 1, and is never read (a test changes it and asserts nothing moves) — plus the ML-DSA-44 parameter set (k, l, η, τ, d, γ1, γ2, β, ω, λ) for later phases and the 4-byte BE encodings.

  • src/steps/zq-*.ts — the Z_q vector step family, the lattice layer's whole arithmetic surface. P1 shipped the three transform primitives (zq-vec-add@1, zq-vec-sub@1, zq-vec-mul-scalar@1); P2 added five more. zq-compress@1 / zq-decompress@1 (FIPS 203 §4.2.1) are the only LOSSY operation in ML-KEM and are NOT inverses — decompression returns a bucket centre. Rounding is to-nearest ties up, and because q is odd only decompression can ever see a tie, which is why a truncating compressor passes every tie-focused spot check. Compress(Decompress(y)) = y holds only while 2^d ≤ q; at d = 12 it is pigeonhole-impossible. zq-byte-encode@1 / zq-byte-decode@1 (Alg 5 / 6) are the dense d-bit packing that makes an ML-KEM-768 public key 1184 bytes rather than 1568. Their bit order is FIXED by the specification (LSB-first) and is NOT the littleEndian param, which describes only the bytes within one coefficient — tangling the two yields a self-consistent encoding that matches nobody. Both write FIPS 203's m as min(2^d, q) rather than branching on d === 12, so the rule survives a learner editing q. zq-cbd@1 (Alg 8) is the one step that turns randomness into a ring element: a popcount difference over 2η bits, giving small values centred on zero, with negatives written as q − |v|. zq-base-case-mul@1 (Alg 12; Alg 11 over all 128 pairs) multiplies transformed polynomials per PAIR, never element-wise — the transform stops at 128 degree-1 polynomials, so a product lives in Z_q[X]/(X²−γ) and costs five multiplies per pair. It deliberately breaks the zq-vec- prefix to say so in the palette, and is polymorphic over k so the same executor serves P3's per-pair loop and the whole-polynomial form. All eight are on PROVENANCE_NO_OP_ALLOWLIST; the packing pair and zq-cbd@1 are there for COARSENESS (bit-level ops, byte-level answer — the rotate-bits-right@1 rationale) rather than the family's usual value-dependence. Plan: docs/plans/unified-stargazing-quasar.md P2.

  • src/steps/ml-kem-*.ts — the five Keccak monoliths (ML-KEM P3), one frame each: ml-kem.hash-g@1 (SHA3-512, whose 64 bytes the CALLER splits, because what the halves mean differs by call site), ml-kem.hash-h@1 (SHA3-256 — binding, not secrecy), ml-kem.kdf-j@1 (SHAKE256→32; registered apart from H because it is the only thing in ML-KEM deriving a secret from a value the attacker chose — implicit rejection), ml-kem.prf@1 (SHAKE256→64η; the counter byte is joined on the CANVAS, not taken as a param, because the s/e ordering is a fact about the algorithm), and ml-kem.sample-ntt@1 (Alg 7's rejection sampler). These are a THIRD kind of monolith, new to this app: a cross-reference. Not "no legible decomposition" (Blowfish) and not "structurally inexpressible" (MT19937) — the sponge is one dropdown away, fully decomposed, and each detail names its function and rate and points there. They collapse only for volume: ~17 sponge calls per key generation × 240 spec nodes would bury the polynomial arithmetic under ~4,080 nodes. sample-ntt's loop condition is the accepted-coefficient count and never a block count, and its second output port squeezes makes that variable cost observable — the app's first permanently unconsumed output port.

  • src/steps/ml-dsa-*.ts + zq-vec-mul.ts + zq-inf-norm-below.ts — ML-DSA's arithmetic layer (P2 of docs/plans/refactored-petting-tide.md), palette-only until P3/P4. Twenty steps in four groups. Generic: zq-vec-mul@1 (element-wise, CORRECT at q = 8380417 and wrong for ML-KEM: the sibling of zq-base-case-mul@1, and a test runs both) and zq-inf-norm-below@1 (strict <, centred on zero, also publishes the norm). Rounding: power2round, decompose (two outputs), make-hint, use-hint, hint-count (≤ ω, the opposite edge from the norm checks). One-frame cross-references: ntt/intt (FIPS 204 Alg 41/42 written out, pinned equal to the traced ntt-8380417-256 spec; no modulus port because the ζ table only means something at 8380417), hash-h (SHAKE256, outBytes), expand-a (the WHOLE k × l matrix, Â[r][s] from ρ ‖ s ‖ r: a deliberate departure from ML-KEM's visible index join, because ML-DSA never builds the transpose), expand-s (2-byte LITTLE-endian indices, η ∈ {2, 4} only), expand-mask (κ and γ1 on ports), sample-in-ball (publishes rejections), format-message (0 ‖ |ctx| ‖ ctx ‖ M). Packers: bit-pack/bit-unpack (the signed b − w flip; the unsigned case is zq-byte-encode@1 unchanged), w1-encode (width derived from γ2), hint-pack/hint-unpack (positions plus running totals; unpack refuses all three malformed cases as a valid = 0 flag, never a throw). The design rule: every constant a learner might edit (q, γ1, γ2, ω, bounds, packing ranges) is a PORT; params hold only structural sizes (d, k, l, η, τ, output lengths).

  • src/ciphers/keccak-compute.ts — the sponge in one call, for those monoliths, plus shake128Reader / shake256Reader (an incremental squeeze at either rate, because a rejection sampler cannot know how many bytes it needs) and byteStream (the same squeeze one byte at a time, FIPS 204's Squeeze(ctx, 1) shape). Not a second Keccak: keccakPermuteBytes drives the same nine step executors buildKeccakRound emits as spec nodes, with the same constants out of keccak-f.ts, so the one-frame hash cannot drift from the sponge the Hash selector shows. Duplicated here is only the wiring. Pinned against both node:crypto and the app's own traced buildSha3256Spec(). Measured ~48 ms for a keygen-sized workload.

  • src/ciphers/k-pke.ts — K-PKE (FIPS 203 §5), ML-KEM's IND-CPA core, as three separate spec builders and no selector entry (it lacks the wrapper that makes it safe against chosen ciphertexts; that is P4). Three builders rather than one because the oracle gives checkpoints: ρ pins G, dk_PKE pins PRF+CBD+NTT(s), ek pins the matrix and the matrix-vector product. ek/dk_PKE/r reach Encrypt and Decrypt through cipherConstants + aux-load-bytes@1, the channel q and ζ already use. Six embedded forward transforms in KeyGen and seven in Encrypt, each the shipped buildNttGroup so the transform cannot drift between where it is taught and where it is used — which is what makes prefixed node ids load-bearing. Carries narrationOverride on the three leaves where a self-consistent wrong answer is possible: the ‖ k byte, the ρ/σ split, and the matrix index bytes. Measured: keygen 485 nodes / 6,197 frames / 146 ms; encrypt 572 / 7,236 / 129 ms; decrypt 293 / 4,101 / 22 ms.

  • src/ciphers/ml-kem-768.ts — ML-KEM-768 (FIPS 203 §7), the KEM the whole lattice arc was building toward and the first phase of it with a selector entry (Asymmetric, beside RSA). buildMlKemEncapsSpec / buildMlKemDecapsSpec; measured 1,066 nodes / 13,441 frames and 1,359 / 17,542, the latter re-running in ~0.9 s in the browser. The key pair is born inside both specs from an editable 64-byte d ‖ z constant-load@1 leaf (RSA's p, q, e precedent), in a default-collapsed key-generation group whose single output is ek ‖ dk — exactly what FIPS 203's KeyGen returns, so the split-bytes@1 outside it is the standard's own pair rather than an artefact of the container rule. Key generation is grouped and the K-PKE bodies are not, and that asymmetry is forced: a group has exactly one input port, so a body taking two or three inputs could only be boxed by a concat-outside/split-inside pair that serves the box rather than the algorithm. q and gamma appear twice — once at the top level for the inline bodies, once inside the group — because port flow cannot cross a scope. Decapsulation parses ek out of dk rather than reading the group's copy, which is what a real decapsulation has. Its j-in concat feeds ml-kem.kdf-j@1 from the received ciphertext; wiring it to the re-encryption is not merely wrong but unrepresentable where the node stands, since same-scope wiring is forward-only. Defaults are OpenSSL's own vectors (ML_KEM_DEFAULT_SEED is the fixture's counting seed; ML_KEM_DEFAULT_CIPHERTEXT is its encapsulation, stored as hex like DEFAULT_NTT_OUTPUT). Plan: docs/plans/unified-stargazing-quasar.md P4.

  • src/ciphers/ml-dsa-44.ts — ML-DSA-44 key generation, signing and verification (FIPS 204 Algorithms 6, 7 and 8); the third Asymmetric member since P4 (sign = encrypt slot, verify = decrypt). Signing (P4): buildMlDsa44SignSpec({ seed, context, attemptCap }) — message is the INPUT; the once-per-signature values (Â, ŝ1, ŝ2, t̂0, μ, ρ″) are filed under aux["ml-dsa.sign.*"] by ml-dsa.publish@1 (src/steps/ml-dsa-publish.ts), then a port-mode iterate with stopWhen runs mlDsaAttemptNodes until ml-dsa.attempt-verdict@1 (src/steps/ml-dsa-attempt-verdict.ts — flag + value + limit per check; outputs accepted and a failed bitmask that becomes the loop's per-attempt log) accepts; rejected z/h are multiplied by the accept bit before packing; ml-dsa.release-signature@1 (src/steps/ml-dsa-release-signature.ts) emits the signature or NOTHING at the cap (ML_DSA_44_DEFAULT_ATTEMPT_CAP = 32, measured). ~31 ms / 71 frames per attempt. Verification (P3): buildMlDsa44VerifySpec({ seed, message, context }): the signature is the spec INPUT (fixed 2420 bytes), the message an editable constant-load@1 leaf, and the key pair is born from an editable 32-byte seed inside a default-collapsed keygen group (one input ξ, one output pk ‖ sk, split outside — the ML-KEM-768 shape). Exports the prefixable builders P4 reuses: mlDsaKeyGenNodes, mlDsaKeyGenGroup, mlDsaKeyMaterialNodes, mlDsaVerifyNodes, mlDsaConstantNodes and matVecNodes (all k·l products in ONE element-wise zq-vec-mul@1 frame). q, γ1, γ2, β and ω ride cipherConstants; γ1 − β and γ1 − 1 are sub@1 leaves, never second constants. Ends in ml-dsa.verdict@1 (src/steps/ml-dsa-verdict.ts), which judges the challenge, the norm flag and the hint flag together. Defaults are the fixture's p3.defaultPair (counting seed, "Hello, world!", noble's deterministic signature). Measured: 91 frames, ~0.2–0.5 s in runSpec, almost all of it ExpandA's SHAKE128. Plan: docs/plans/refactored-petting-tide.md P3.

  • src/steps/ml-kem-select-shared-secret.ts — implicit rejection (FIPS 203 Algorithm 18), the one step separating the IND-CPA scheme from the IND-CCA KEM. Four input ports (received c, re-encrypted c′, K′ from G, K̄ from J) → one 32-byte output. A select, not a branch: both secrets are computed every run and one is chosen with a constant-time mask, because a real implementation must not branch here either — whether a ciphertext was rejected is precisely the bit the construction hides. A length mismatch between the two ciphertexts THROWS rather than reporting "different", since it means the ports were bound to values from different schemes. Its narrator (src/ui/narration/lattice.tsx) is the app's only one whose headline is a VERDICT.

  • src/ciphers/prng-request.ts — the two things every generator must agree on: PRNG_REQUEST_ID (the zero-fill@1 request leaf's id) and readPrngOutputLength(spec), which recovers the requested length on document load so a save/load round-trip does not silently reset the control. A leaf module rather than a home in lcg.ts, so a second generator family need not import from a sibling generator to learn the convention. readLcgOutputLength is now an alias of it.

  • src/steps/add-mod.ts — add-mod@1, (a + b) mod n over three input ports at the modulus' byte width; the exact sibling of mod-mul@1 (operands need not match in length, bigint internally, Uint8Array at every port). The + c of a mixed congruential generator. Not add-mod-32@1, whose modulus is the fixed machine word: here the modulus arrives on a wire, so it stays visible in the trace and editable — the alternative composition (add-mod-32@1 + and@1 mask) is correct only for power-of-two moduli, which in an app built around editing constants is a footgun. In PROVENANCE_NO_OP_ALLOWLIST under "approximate" (carries cross byte boundaries, and the reduction can rewrite every byte); in NO_PARAMS_PORT_NATIVE_TYPES with a blurb pointing at the wired constants. Tested directly by tests/add-mod.test.ts.

  • src/steps/zero-fill.ts — zero-fill@1, emitter of params.byteLength zero bytes. Zero inputs; function-form output contract (width known at spec time), like constant-load@1. Its bytes are never read — its width is the point: a generator has no message, so this is how a requested output length reaches the spec and drives the iterate's count. Deliberately not constant-load@1 with a zero array (that leaf documents itself as the emitter of published cryptographic constants, so the trace would lie, and the array would bloat every saved/shared document). In PROVENANCE_NO_OP_ALLOWLIST under "no inputs".

  • src/ciphers/serpent-constants.ts — SERPENT_SBOXES (8 forward), SERPENT_INV_SBOXES (computed from forwards at module load), SERPENT_IP, SERPENT_FP (128-entry bit permutations, mutual inverses), SERPENT_LT_TABLE, SERPENT_INV_LT_TABLE (128 entries each, where LT_TABLE[i] lists the input bit positions whose XOR yields output bit i), SERPENT_PHI = 0x9e3779b9 (key-schedule constant), SERPENT_ROUNDS = 32, SERPENT_BLOCK_BYTES = 16. Bit-numbering convention is LSB-first within bytes everywhere.

UI stores (singletons; module-scope signals on purpose)

  • src/ui/stores/trace.ts — current trace + frame index, setTrace preserves focus by stepId across re-runs.
  • src/ui/stores/format.ts — active byte format, persisted in localStorage.
  • src/ui/stores/spec.ts — current spec + (cipher, cipherMode, mode) selector. defaults is a 3D Record<Cipher, Partial<Record<CipherMode, Record<Mode, CipherSpec>>>> table — each cipher's inner record carries the modes-of-operation it supports — since Twofish's core landed (2026-07-20) that is single-block + ecb + cbc + ctr for every cipher, though the fallback below still matters for any cipher added without a core. resolveDefault falls back to single-block when the requested cipherMode isn't registered, so picking ECB on AES-128 and then flipping to DES just lands you back on single-block DES without crashing. setMode, setCipher, setCipherMode, resetSpec, and setPadding all funnel through applyPaddingScheme so the active padding scheme survives every selector flip. setSpecFromDocument(doc) (Slice 5) is the Load boundary — it bypasses the public setters' spec-rebuilds (each would overwrite the doc's literal spec with the canonical default for the new selectors) and lands stores in hydration order: byteFormat first (so a later format of restored bytes uses the new format), then cipher/cipherMode/padding/mode via the raw signal setters, then setSpec(doc.spec) directly. Cross-store consistency is NOT corrected — the document's spec is authoritative; an inconsistent (cipher, cipherMode) combo just greys out the dropdown but still runs. Slice 8 added insertStepIntoSpec(stepType, anchor) — the boundary GraphView's drop handler calls. Two anchor flavors: {kind:"after", stepId} routes through insertStepAfter (works for leaf or container anchors thanks to Slice 4's findStepAndParent); {kind:"root-append"} rebuilds spec.steps directly so the empty-canvas / empty-spec edge case stays uniform. Id generation is a <lastStepTypeSegment-without-@N>-<n> walker that scans every node (leaf + group + iterate) for collisions before incrementing — generic.byte-substitution@1 → byte-substitution-1, byte-substitution-2, … Returns the generated id so callers can route trace focus once the auto-rerun lands (even if that frame is the expected error frame for a leaf with params: {}). Also exports removeStepFromSpec(stepId) — the boundary for the three delete affordances (hover × button on graph nodes, ParamEditor's "Delete this step" button, Delete keypress on a focused node). Wraps removeStep from spec-mutations.ts (tree-aware: removing a container removes every descendant) and is lenient on stale ids (console.warns rather than throws) so a delete racing with another spec mutation doesn't crash the UI. isCustomSpec() accessor — returns true when the live spec has diverged from the canonical default for the current (cipher, cipherMode, mode, padding) selectors (any param edit, palette insert, or delete). Drives the App's Custom (was AES-128) indicator in the header + cipher dropdown and the visibility of the inline reset button next to the dropdown (calls existing resetSpec()). Implementation uses a small recursive deepEqualJson rather than JSON.stringify so a future param editor that round-trips keys through a different object shape can't produce a false-positive "modified" via key reorder. Padding selector flips do NOT trigger Custom because setPadding rebuilds via applyPaddingScheme from the live tree, which produces the same canonical structure when the spec was canonical to begin with — pinned by tests/app-custom-spec-indicator.test.tsx.
  • src/ui/stores/cipher.ts — active AES variant (aes-128 / aes-192 / aes-256), persisted in localStorage. Also exports DEFAULT_KEY_BYTES_BY_CIPHER (FIPS-197 §A.1/§A.2/§A.3 canonical keys) used by the App's cipher-change handler to swap the key field only when it still holds the previous cipher's default. endpointLabelsFor(algorithm, mode) lives here too: the ONE source for what the input and output are called, read by both the sidebar (App.tsx) and the graph's endpoint pills (GraphView.tsx).
  • src/core/ml-dsa-diagram.ts + src/ui/components/MlDsaRoundingDiagram.tsx — ML-DSA's rounding picture (P5, 2026-10-19) on ml-dsa.decompose@1 / make-hint@1 / use-hint@1 frames: a strip of all m buckets plus a close-up of the value's bucket and its two neighbours (wrapping mod m). Three rules in the pure model: a value is placed by its LOW PART from its own bucket's centre, never by raw residue (a wrapped value would land outside its box; placeValue / offsetInBucket, and bucket 0 is closed at both ends because it holds one extra value); bucket numbers, low parts, the hint bit and UseHint's corrected bucket come from the frame's OUTPUT ports, and a frame whose outputs disagree with the geometry draws nothing; q and γ2 are read with the executors' own readers. firstHinted is the coefficient choice the MakeHint/UseHint narrators share. Labels sit inside their own bucket, running away from the near edge, and a hinted shift is an arc — both found by browser smoke, where a centred label had the edge running through its digits and a straight arrow vanished between two dots 9 px apart.
  • src/ui/narration/ml-dsa-attempts.ts + src/ui/components/MlDsaAttemptsPanel.tsx — ML-DSA signing's rejection loop, one row per attempt (P5, 2026-10-19). The pure module is the ONE reader of ml-dsa.attempt-verdict@1 frames, shared by that step's narrator and the panel (two hand-kept readers would drift — the endpoint-label bug): readAttemptChecks (flag + value + limit per check; pass/fail is the check step's FLAG, never value vs limit, because the < / ≤ rule lives in the check steps), attemptLogFromFrames (rows grouped by iterateScopeKey, each row carrying its verdict frame's index for click-to-scrub), attemptOfFrame / isAttemptLogRelevant. The values come from verdict frames, not the loop's out log, which holds only the failed bitmask. The panel collapses outside the loop the way RoundKeyPanel does, and renders nothing for any trace without verdict frames.
  • src/ui/stores/forward-input.ts + src/ui/components/MlDsaVerifyMessage.tsx — a session-only, per-algorithm record of the forward direction's input for the schemes whose way back cannot reuse the output (ML-DSA Sign's message, ML-KEM's m); the mode flip restores it going back. ML-DSA-44 Verify's message row reads it too: it shows the verify spec's message leaf and an opt-in "Use the message from Sign" button.
  • src/core/iteration-noun.ts — what one loop pass is called ("block" or "attempt"), derived from stopWhen; read by the graph's collapsed-loop chips and the step view's badge.
  • src/ui/stores/cipher-mode.ts — block-cipher mode of operation (single-block / ecb / cbc / ctr), persisted in localStorage. Two related constants: SUPPORTED_CIPHER_MODES is the globally shipped set (Phase 1: single-block, ecb); SUPPORTED_CIPHER_MODES_BY_CIPHER is the per-cipher matrix that says which (cipher, cipherMode) combinations have an actual spec wired up in stores/spec.ts's defaults table (Phase 1: only aes-128 × ecb; everything else is single-block only). The matrix is consulted by isCipherModeSupported from both the App's dropdown (to disable unsupported combos in the option list) and setCipher in spec.ts (to reset cipherMode to single-block when the user switches to a cipher that doesn't support the active mode). loadInitial runs the same check at boot so a persisted cross-store mismatch (e.g. cipher=aes-192, cipherMode=ecb in localStorage from before AES-192-ECB ships) gets self-corrected on next reload.
  • src/ui/stores/history.ts — 5-deep run snapshot ring buffer + pushSnapshot (auto-dedups identical re-runs), findPreviousRunFrameByStepId, and the showPreviousRun overlay toggle.
  • src/ui/stores/padding.ts — active padding scheme (none / pkcs7 / zero-pad / iso7816-4), persisted in localStorage. paddingLimits(mode, scheme, cipher, cipherMode?) returns the allowed raw-input byte-length range so the Run handler can produce friendly errors. Cipher-mode is a fourth arg with a "single-block" default — multi-block ECB/CBC widen the range to MAX_BLOCKS_UI × 16 (16 blocks = 256 bytes today), CTR gets its own padding-free branch — {min: 1, max: MAX_BYTES} in BOTH directions, no alignment and no scheme dependence, since a stream mode's message may end anywhere and its ciphertext is exactly as long as its plaintext; single-block keeps today's 0..15 / 1..16 / 16..16 ranges per scheme. The cap is a UI guard (trace browsability), not a runtime limit — bump the MAX_BLOCKS_UI constant to encrypt a paragraph at the cost of a noisier scrubber.
  • src/ui/stores/auto-rerun.ts — two signals: autoRerun (user preference, persisted in localStorage, default true) and dirty (session-only flag set when manual mode receives a spec edit). The App's createEffect(on(spec, ...)) branches on autoRerun() — true keeps the 200ms debounced re-run; false just calls setDirty(true) so the inline "edits pending" banner appears. Flipping autoRerun back ON clears dirty so the banner doesn't go stale.
  • src/ui/stores/view-mode.ts — three-mode signal (linear / graph / json), persisted in localStorage. Drives the tab bar above .trace-view: linear shows today's per-frame view (frame header, StepStrip, BlockBadge, FrameStateView, StepDescription, ParamEditor); graph swaps in GraphView; json swaps in a <pre> of JSON.stringify(spec(), null, 2). Slice 2 of the 2D editor plan.
  • src/ui/stores/view-replication.ts — boolean signal (replicationEnabled, persisted as cryptographer.replicationEnabled, default false). When true, GraphView pipes the collapsed graph through replicateHighFanoutSources(graph, REPLICATION_THRESHOLD, modes). The threshold is exported as a constant (= 6); commit 5 wired the modes argument up from the layout store's replicationModes. Off by default because the "one source, many edges" view of a key schedule is also legitimately pedagogical; user opts in via the graph toolbar checkbox. Master-switch semantic: when the toggle is OFF, GraphView skips the transform entirely — per-source "always" overrides in LayoutSpec.replicationModes only fire when the global toggle is ON. Commit 4 of the graph-readability sequence.
  • src/ui/stores/view-density.ts — three-preset signal (compact / normal / spacious), persisted in localStorage. Default normal = 1.0× exactly, so the pre-density layout is byte-stable. Exports DENSITY_SCALE (compact 0.75× / normal 1.0× / spacious 1.25×) as the single source of truth for the scale factor. GraphView's layoutConstantsFor(density) builds a LayoutConstants record (LEAF_W, LEAF_H, STACK_GAP, FLOW_GAP, CONTAINER_PAD) by multiplying the base constants by DENSITY_SCALE[density] and rounding to integer pixels; layoutNode / layoutRoot / labelTextLength take this record so they remain pure (testable without rendering) and re-run automatically on density flips via the createMemo chain inside GraphView. Density lives in its own store (not in LayoutSpec) because it's a viewer preference, not a document fact — a shared .cipher.json renders at whatever density the reader prefers, and the Slice 5 byte-stability property is preserved unconditionally. Commit 3 of the graph-readability sequence.
  • src/ui/stores/url-share.ts — 2D editor Slice 7. Exports encodeDocumentToHash(doc): Promise<string> and decodeHashToDocument(hash): Promise<{ok:true,doc}|{ok:false,error}> plus the HASH_PREFIX = "doc=" constant and the buildShareHash / extractHashPayload helpers. Pipeline is serializeDocument → UTF-8 → CompressionStream("deflate-raw") → base64url; measured payload sizes: AES-128 spec-only 1.6 KB, AES-256 + session worst case 1.9 KB (raw JSON would have been ~21 KB; plain base64 ~28 KB). Decision pinned by measurement, not preference — pako would have added ~12 KB bundle for the same ratio; CompressionStream is platform-native (Chrome 80+, Firefox 113+, Safari 16.4+, Node 21+ as a global) so the dependency is zero. Three non-obvious correctness pieces locked in by tests: (1) writer-side rejection swallow — CompressionStream's writer's write/close promises reject independently of the readable side; without .catch(() => {}) on each, Node treats them as unhandled even when the readable rejection is caught downstream. (2) portable streaming form — feeds bytes through the writer interface instead of new Blob([bytes]).stream().pipeThrough(...) because jsdom 29's Blob lacks .stream(); production + test paths stay identical. (3) deflate-raw over gzip because gzip's header + CRC32 footer (~18 bytes) matters at this size class. App-side wiring: the [share…] button calls shareDocument (async; honors the includeSession toggle the same way Save does), writes the encoded URL to navigator.clipboard.writeText, falls back to placing the URL in the address bar via replaceState if clipboard is blocked. Boot decode runs in App's onMount — reads window.location.hash, routes through the shared applyDocument boundary (factored out of handleLoadFromText so file-load and URL-load both go through one path), strips the hash on success via replaceState so refresh doesn't re-apply. Failure preserves the hash for forensic value.
  • src/ui/stores/layout.ts — per-spec.id LayoutMap ({ [specId]: LayoutSpec }), persisted as one JSON blob under cryptographer.layouts. Mirrors the existing format.ts template: defensive loadInitial (missing localStorage → empty map; corrupted JSON → empty; non-LayoutSpec values silently dropped via lightweight isLayoutSpec shape check); useLayoutMap() for reactive reads; getLayoutForSpec(id) for non-reactive snapshots. Writes funnel through setNodePosition(specId, nodeId, x, y), toggleCollapse(specId, containerId), setReplicationMode(specId, sourceId, mode | null) (commit 5 of the graph-readability sequence — null clears the override back to implicit auto), and the Load boundary setLayoutForSpec(specId, layout) — which removes the entry when given null OR an empty layout (!hasUserLayout). The empty-layout removal is the byte-stability gate Slice 5's spec-only-save test depends on (an always-present empty sidecar would defeat URL-share determinism). hasUserLayout(l) returns true iff at least one pinned position, one collapsed container, OR one replicationModes entry is present; App's buildSaveText gates doc.layout inclusion on it. Empty replicationModes maps are NOT preserved on the LayoutSpec (the internal withReplicationModes helper drops the field when empty so a serialized layout never carries "replicationModes":{} — that would differ byte-for-byte from absence and break the gate). NO pruning of stale stepIds — if a future spec mutator deletes a step that has a pinned position or replication override, the entry sits unused in localStorage indefinitely (renderer skips dangling refs; storage is cheap; pruning is risk surface for a Slice 8 follow-up). Slice 6 of the 2D editor plan; commits 4–5 of the graph-readability sequence extended it.

UI components

  • src/ui/components/ByteCellInput.tsx — format-aware editable byte cell. Width adapts (hex=2/dec=3/ASCII=4 chars). Used by SboxEditor (16x16) and MatrixEditor (4x4).

  • src/ui/components/BytesView.tsx — variable-length sibling to MatrixView. Renders BytesState frames (pkcs7-pad / pkcs7-unpad) as a 1×N wrapping row with length-delta "missing" cell placeholders when the row's compareTo side is longer. When the longer side of a row pair exceeds 16 bytes (i.e. the frame is multi-block), cells are visually segmented into per-block .bytes-block-group panels with "Block N" headers; each panel is a 4×4 grid with grid-auto-flow: column so the byte ordering matches the AES State matrix the reader has been watching inside the round (byte[0..3] fill column 0 top-to-bottom, etc — FIPS-197 §3.4). This makes ECB-mode repetition obvious: identical plaintext blocks become identical-looking 4×4 ciphertext blocks side-by-side. Single-block frames (max length ≤16) keep the flat layout. The same grouping is mirrored in App's SingleStateView for mixed-shape frames (notably concat-blocks, where the bytes side is N×16 long).

  • src/ui/components/BlockBadge.tsx — small "Block i of N" chip rendered above the per-frame state view when the current frame belongs to an iterate loop (i.e. frame.blockIndex !== undefined and the trace has more than one block). Pure visual — no scrubber surgery. Driven by App-level blockCount memo that scans trace.frames for the max blockIndex.

  • src/ui/components/RunExplorerModal.tsx — side-by-side run comparison modal (uses native <dialog> for backdrop + escape handling). Pure delta-string formatter lives in run-delta-format.ts so node-env tests can pin its output without spinning up jsdom.

  • src/ui/components/StepPalette.tsx — 2D editor Slice 8: left sidebar inside .graph-view-layout listing every non-padding registered step type, grouped by namespace (aes / generic / speck / serpent, in GROUP_ORDER; unknown namespaces tail-append alphabetically). Each entry is draggable={true} and on dragstart writes the step type onto dataTransfer under both the project-private STEP_TYPE_DRAG_MIME (application/x-cryptographer-step-type) and text/plain (cross-browser fallback). Excludes PADDING_STEP_TYPES from core/spec-mutations.ts — palette-dropping a padding overlay step type would create a top-level leaf that the next padding-selector flip silently strips via stripPaddingLeaves, so surfacing those types as palette entries would be a stealth footgun. Entries within a group sort alphabetically by display name (from StepDocumentation.name, falling back to the type id for doc-less registrations). Each <li> carries data-step-type=<typeId> + data-testid="step-palette-entry-<typeId>" so tests can target by registry key.

  • src/ui/components/GraphView.tsx — SVG renderer over deriveAuxGraph(getTrace(), spec()) followed by collapseGraph(graph, collapsedSet) as a view-time transform. Hand-rolled FIPS-197-flavored layout: top-level and iterate bodies flow LEFT-TO-RIGHT (time rightward), groups stack their children VERTICALLY. Iterates carry a ×N chip in the header when blockSpan > 1. Edges are bezier curves with geometry-driven exit/entry sides: vertical sides (bottom→top) when boxes overlap on x but not y (intra-group siblings like AES's sub-bytes → shift-rows), horizontal sides (right→left) for everything else (root flow, iterate bodies, cross-container fan-outs). Inset + pull are clamped to half the natural gap so STACK_GAP=6 siblings get a clean monotonic line. The auxKey lives in a hover <title>. Clicking a leaf calls setFrame(idx) for the first trace frame whose canonical (:b{i}-stripped) stepId matches. Slice 6 added container drag + collapse. Container headers carry a transparent drag-handle band (onPointerDown → window-level pointermove/pointerup listeners; setPointerCapture is best-effort and jsdom-tolerant). Click-vs-drag disambiguation uses a 4px movement threshold — sub-threshold pointerups commit no position. The chevron <g> on the right side of the header toggles collapsedGroups for that container; clicking again restores. Pinned root-level entries do NOT advance the auto-layout cursor (so dragging round.5 doesn't push 6–10 sideways); canvas dimensions track max-right/max-bottom across pinned + un-pinned boxes so the SVG grows to fit anywhere you drag a container. Slice 8 layered palette drops on top. The outer wrapper is now .graph-view-layout (flex row: palette on the left, scroll wrapper on the right). The scroll wrapper carries onDragOver/onDragLeave/onDrop; dragover accepts only payloads that carry the project's application/x-cryptographer-step-type MIME (or text/plain as a cross-browser fallback) and toggles .graph-drop-zone-active for the dashed-outline highlight. LeafRect and ContainerRect carry data-drop-anchor=<stepId> on their outer <g>; the drop handler walks up via closest("[data-drop-anchor]") from the event target to find the anchor. Three anchor flavors: drop on a leaf → insertStepAfter(leafStepId); drop on a container's outer <g> → insertStepAfter(containerId) (which places the new leaf AFTER the container in its parent, NOT into the container body — explicit Slice 8 semantic, since insertStepAfter is the only mutator Slice 4 shipped and "into-container" would have needed a separate helper); drop on the SVG background (no anchor) → root-append via insertStepIntoSpec(..., {kind:"root-append"}). Replica leaf nodes (graph-leaf-replica) carry data-drop-anchor={clickTargetId} — the SOURCE's real stepId, not the synthetic ${src}@->${consumer} replica id — so a drop on a replica lands the new step after the source. Leaf-level drag is still NOT enabled (the Slice 6 deferral); palette-dropped leaves take their starting coordinates from auto-layout, not from the drop pixel.

  • src/ui/narration/lattice.tsx — the lattice family's eleven per-frame value-prose narrators (2026-08-10), covering everything P2 and P3 registered EXCEPT the three element-wise zq-vec-*@1 primitives, which sit on NARRATION_NO_OP_ALLOWLIST instead (128–256 identical coefficients per frame ⇒ the only honest conceptual unit would mean hundreds of <details>, against narration/registry.ts's own authoring convention). Split on the truncate-to-reference@1 criterion — does the teaching point change frame to frame? mlKemSampleNttNarration is the strongest case in the app: it reads the squeezes output port, the app's only value-dependent cost, and reports the block count that draw actually spent rather than the typical one. zqCompressNarration is the only narrator here that derives anything (the bucket centre, to state the rounding error) and says so in the file header, MT19937-twist-style; its bound is ⌈q/2^(d+1)⌋, round to NEAREST — a floored version renders "worst error 2 of a possible 1" at d = 10. Coefficients are read with core/zq-vector.ts's own readCoeff, so the prose cannot disagree with the executor about element boundaries. Every narrator returns null rather than throwing on malformed params/ports — a palette-dropped leaf gets params: {}, so that path is reachable by an ordinary user action.

Tests

  • tests/lattice-narration.test.tsx — jsdom. Drives all eleven lattice narrators with real frames from the shipped K-PKE specs (KeyGen's real ek/dk are fed to Encrypt/Decrypt, because an all-zero key makes several assertions vacuous — a zero polynomial compresses without error and samples no negatives). ml-kem.hash-h@1 / ml-kem.kdf-j@1 have no shipped-spec leaves until P4, so they get a purpose-built two-leaf CipherSpec run through the real runtime rather than a hand-built TraceFrame. The assertions check that prose reports the FRAME's values, not that prose rendered: the reported squeeze count equals the port on every frame, the compression bound is re-derived independently (which is what caught the floored-bound bug), the CBD histogram sums to the frame's coefficient count, and the base-case-mul contrast is asserted to DIFFER from the actual output. Also pins that narrators decline rather than throw.
  • tests/aes-vectors.test.ts — FIPS-197 Appendix C.1 and B known-answer tests for forward AES-128.
  • tests/aes-decrypt.test.ts — round-trip + decryption tests (AES-128).
  • tests/aes-192-vectors.test.ts — NIST AES Core 192 KAT + FIPS-197 §A.2 roundKey.12 assertion + round-trip with aes-192-decrypt.
  • tests/aes-256-vectors.test.ts — NIST AES Core 256 KAT + FIPS-197 §A.3 roundKey.3 (pins the Nk>6 SubWord-only branch in key-expansion) + roundKey.14 + round-trip + Nk+6==rounds assertion error.
  • tests/app-cipher-selector.test.tsx — jsdom integration. Drives the App through the actual <select> change events to prove the cipher dropdown wires up the key-field auto-swap, dynamic key length, and that AES-192 / AES-256 produce the NIST canonical ciphertexts end-to-end.
  • tests/spec-mutations.test.ts — spec mutation helpers + the headline "swap S-box → ciphertext changes" modularity test.
  • tests/spec-mutations-structure.test.ts — 2D editor Slice 4: 29 node-env tests pinning the structural mutators (findStepAndParent, insertStepAfter/insertStepBefore, removeStep, reorderStep). Covers top-level + nested-in-group + nested-in-iterate locations against the real AES-128 and AES-128-ECB specs. Pins immutability (original spec untouched), reference-equality preservation on untouched branches, identity short-circuit for reorder-to-same-index, clamping for out-of-range indices, throw-on-missing-anchor semantics, and a remove-then-reinsert round-trip producing structurally equivalent output.
  • tests/chacha20-kat.test.ts — RFC 8439 §2.1.1 (quarter round), §2.3.2 (block function), §2.4.2 (encryption), node:crypto across the block boundary, and a counter-advance contrast. Ordered by what discriminates: round-trip is ranked LAST and labelled a tautology, because encrypt and decrypt are the same spec.
  • tests/app-chacha20-stream.test.tsx — the App seam for a coreless cipher: mode lands in "stream", padding disabled, the annotated IV caption, and the REGRESSION that ChaCha20 gets its own canonical IV despite sharing AES's 16-byte width (found in a browser, invisible to every other test).
  • tests/rotate-bits-left.test.ts — hand-derived KATs across all four word widths plus the complement identity against rotate-bits-right@1 and its converse (ROL n must NOT equal ROR n where they differ).
  • tests/markdown.test.ts — parser tests for the step-doc renderer.
  • tests/format.test.ts — byte format core (round-trip, validation, length errors).
  • tests/trace-frame-preservation.test.ts — setTrace keeps the scrubber on the same stepId across re-runs.
  • tests/byte-cell-input.test.tsx, tests/matrix-view.test.tsx, tests/app-format-toggle.test.tsx — jsdom component tests for the format toggle (the .tsx files run in jsdom; see Conventions). matrix-view also covers the Phase 2b previous-run overlay.
  • tests/run-history.test.ts, tests/run-explorer-delta.test.ts — Phase 2 store + delta formatter tests (node-env; the modal's pure helper was split out so it can be tested without DOM). run-explorer-delta also pins the per-cell rendering — scalar diffs, 1D/2D cell diffs, the 8-line overflow cap, and the bare-diff fallback.
  • tests/app-auto-rerun-toggle.test.tsx — jsdom integration for the auto/manual rerun toggle. Drives the App through an initial Run, flips the toggle off, edits the spec via editStepParams, and asserts no new snapshot lands until the user clicks Run again (the whole point of manual mode — preserving the prior snapshot for the Run Explorer).
  • tests/pkcs7-pad.test.ts, tests/load-store-block.test.ts, tests/spec-mutations-padding.test.ts — Phase 4 step + overlay tests (node-env).
  • tests/bytes-view.test.tsx, tests/app-padding-roundtrip.test.tsx — Phase 4 jsdom integration tests. The headline round-trip drives the App from "apple" through encrypt → ciphertext → decrypt → "apple".
  • tests/runtime-iterate.test.ts — Multi-block Phase 1: loop-primitive unit tests. Per-iteration stepId:b{i} suffixing, blockIndex frame stamps, accumulation into aux[outBlocksAux], aux["blockIndex"] exposed to executors during iteration, count=0 no-op, and the two malformed-aux throw paths.
  • tests/aes-128-ecb-kat.test.ts — Multi-block Phase 1: NIST SP 800-38A §F.1.1 encrypt + §F.1.2 decrypt against the 4-block published vector. Cross-checked against node:crypto during implementation (caught a single-character typo in my own copy of the standard — block 4 ends …6c3710 not …6c2710). Also asserts the 164-frame total count and pins specific :b0 / :b3 step ids.
  • tests/multi-block-padding-boundary.test.ts — Multi-block Phase 1: round-trip across input lengths 0/1/15/16/17/31/32/64 under PKCS#7 + ECB; pins the canonical "16-byte input pads to a full extra block" behaviour; and pins the paddingLimits ranges for every (encrypt/decrypt, scheme, cipherMode=ecb) combination.
  • tests/app-multi-block-roundtrip.test.tsx — Multi-block Phase 1 jsdom integration. Drives the App through ECB + PKCS#7 + ASCII format, types "the quick brown fox jumps over" (30 bytes / 2 blocks), encrypts, switches to decrypt mode, pastes ciphertext, recovers the original. Also pins the cipher-mode dropdown's disabled-state on CBC/CTR.
  • tests/cipher-mode-fallback.test.ts — Regression test pinning the SUPPORTED_CIPHER_MODES_BY_CIPHER matrix at Phase-1 values. The intentional canary: when Phase 4 lands AES-192/256 ECB, this test's expect(isCipherModeSupported("aes-192", "ecb")).toBe(false) will start failing — that's the gate that forces you to update the matrix alongside the new spec factories.
  • tests/serpent-vectors.test.ts — Serpent KAT against the Python CryptoPlus.Cipher.pyserpent reference for all three key sizes (encrypt + decrypt of the "single-bit key, all-zero plaintext" vector, plus three additional cross-checks). The Python ref was used at implementation time to debug a hybrid bug (IP'd state + bitslice S-box/LT) that round-tripped internally but produced wrong absolute ciphertexts — see docs/gotchas.md, "Serpent has two equivalent forms".
  • tests/serpent-key-schedule.test.ts — Pins roundKey.0 and roundKey.32 against the reference, plus structural assertions (Serpent-128 vs 192 vs 256 produce different round keys for the same first 16 bytes thanks to padding; the zero-key schedule produces non-zero keys thanks to the phi XOR i term).
  • tests/serpent-ip-fp-roundtrip.test.ts — Pins that IP and FP are mutual inverses (IP[FP[i]] === i and FP[IP[i]] === i), that both tables are permutations of {0..127}, and that round-trip on random states recovers the input.
  • tests/serpent-sbox-roundtrip.test.ts — Pins that each of the 8 forward S-boxes is a 16-entry permutation of {0..15} and that the computed inverse table inverts it exactly. Also pins specific entries per-S-box so a wholesale table swap can't pass the structural checks.
  • tests/serpent-roundtrip.test.ts — Encrypt/decrypt round-trip across all three Serpent variants × four plaintexts each, plus structural assertions (99 frames per encrypt, 33 unique round keys per variant) and bad-key-length error paths.
  • tests/aux-graph-derivation.test.ts — 2D editor Slice 1 + 6 + graph-readability sequence commits 1 + 2: 32 node-env tests. Slice 1 (17): deriveAuxGraph across AES-128 (no iterate), AES-128-ECB (:b{i} collapse + iterate-mediated edge synthesis + blockSpan annotation), Speck32/64 (flat, no containers), Serpent-128 (32 round groups, 33 round-key fan-out edges), edge dedup, and the empty-trace structural-skeleton path. Existing aux-fan-out tests (from === "key-expansion" etc.) filter to kind === "aux" because the producer also carries an outgoing spine edge after commit 2 — comments on each filter call out why. Slice 6 (7): collapseGraph — empty collapsedIds returns the same graph reference (identity short-circuit); collapsing a round group drops its leaves but keeps the container with childIds: []; round-key fan-out edges that pointed at a collapsed container's child get re-routed to the container itself (count preserved when the remap is unique); self-loops after remap are dropped; multiple containers collapse simultaneously; rootIds filters to entries that still resolve; collapsing an iterate container (ecb-blocks in AES-128-ECB) hides every leaf and round group inside while leaving the iterate visible as a chip. Graph-readability commit 2 (8): state-spine counts pinned per cipher (AES-128 → 40, AES-128-ECB → 41 with the 2 + 39 + 0 scope split, Speck32/64 → 22, Serpent-128 → 98); spine crosses round-group boundaries (round.1.add-round-key → round.2.sub-bytes); spine breaks at iterate boundary in BOTH directions (compute-block-count → initial.add-round-key and round.10.add-round-key → concat-blocks MUST NOT exist) AND doesn't silently skip the iterate (compute-block-count → concat-blocks MUST NOT exist either); empty trace yields the full state spine but zero aux edges (the pre-run pedagogical property); aux + state populations disjoint by "state" sentinel auxKey. Collapse interactions with the spine: round.5 collapse drops 3 internal spine edges as self-loops while the boundary entries remap their inside-endpoint to the chip; iterate collapse drops all 39 body spine edges while the 2 pre-iterate edges remain intact.
  • tests/graph-view.test.tsx — 2D editor Slice 2 + graph-readability sequence commits 1 + 2: 9 jsdom tests. Component-level: GraphView renders 41 leaf rects + 10 container rects for AES-128, edges carry the auxKey in <title>, leaf-click drives setFrame to the matching frame; the trace-less render now shows the 40-edge spec-derived state spine (0 aux edges) — pin updated from "no edges at all" once commit 2 made spine spec-derived; the marker-end + class-tagging test asserts BOTH .graph-edge-aux + url(#graph-arrow-aux) AND .graph-edge-state + url(#graph-arrow-state) populations are non-empty with the right marker reference and exactly one kind-class per edge. App-level: tab bar renders three tabs with linear active by default, clicking graph / JSON swaps content, tab switching preserves the stepId-anchored frame focus.
  • tests/file-save-load.test.tsx — 2D editor Slice 5 + Slice 6: 11 jsdom tests for the file Save/Load UI. Renders the buttons + include-session checkbox. Save with include-session OFF → spec-only document (no session, no metadata). Spec-only byte-stability — two consecutive Saves of the same spec produce byte-identical files (the property Slice 7 URL share will lean on; createdAt is gated on the session toggle on purpose so this holds). Save with include-session ON → full session with selectors + inputBytes + keyBytes + metadata.createdAt. Save → Load round-trip across a remount: pick AES-192 + ASCII, save with session, reset all stores, remount, load, assert every selector + trace final state matches. Load errors: malformed JSON → friendly inline error + spec untouched; schemaVersion=2 → "not supported" pre-check error. Byte-format-before-bytes ordering: a doc that pins byteFormat=ascii is verified to render the restored input field in ASCII (not the pre-load hex), pinning the hydration order documented on setSpecFromDocument. Slice 6 added three layout-sidecar tests: spec-with-no-customization omits doc.layout entirely (the byte-stability gate for spec-only saves; mirrors the existing repeat-save test as an explicit absence check); save-after-dragging-a-container emits doc.layout with the pinned position + collapsedGroups; Save→reset→Load round-trip restores positions into the per-spec.id layout store keyed by doc.spec.id. Drives Load via a real File object + Object.defineProperty(input, 'files', ...) (jsdom's FileList is read-only; this is the standard workaround). Save spy is a URL.createObjectURL override that pushes Blobs into an array — using an array instead of a mutable field sidesteps TS control-flow narrowing of null reassignments.
  • tests/layout-store.test.ts — 2D editor Slice 6: 18 node-env tests for src/ui/stores/layout.ts. Per-spec.id partitioning (AES-128 + AES-256 layouts coexist; setters don't cross-pollute). toggleCollapse semantics (add on first call, remove on second; multiple distinct ids accumulate). localStorage persistence (each setter writes synchronously and the blob round-trips through JSON.parse). setLayoutForSpec as the Load boundary — replace wholesale; null removes the entry; an empty LayoutSpec (no positions + no collapses) ALSO removes the entry (this is the byte-stability gate Slice 5's spec-only save depends on). hasUserLayout truth table (null → false; empty → false; any position → true; any collapse → true). clearLayoutForSpec removes one spec's entry without touching siblings. Uses a per-test in-memory localStorage polyfill so persistence assertions can observe the persisted JSON form directly.
  • tests/graph-view-drag.test.tsx — 2D editor Slice 6: 7 jsdom tests for the drag + collapse layer of GraphView.tsx. Drag: pointerdown on a container header (data-testid="graph-container-header-round.5") + window-level pointermove → up moves the container; getLayoutForSpec(specId).positions["round.5"] reflects the delta. Sub-threshold movement (2px) does NOT pin (click vs drag disambiguation). Window listeners detach after pointerup so a stray pointermove can't keep updating. Collapse: clicking the chevron data-testid="graph-container-chevron-round.5" reduces the leaf-rect count by exactly 4 (round.5's 4 children disappear) and adds a .graph-container-rect-collapsed chip; clicking again restores. Re-queries the chevron between clicks because Solid swaps the DOM node when its glyph text changes (▾ → ▸). Isolation: setNodePosition + toggleCollapse calls leave getTrace() ref unchanged — the layout signal and the trace signal are decoupled, so dragging doesn't re-run the cipher (advisor constraint). The test name calls out exactly what it verifies (store writes don't touch trace), not the broader "real pointer drag doesn't re-run" claim that would require driving the full pointer sequence end-to-end for honest coverage.
  • tests/url-share.test.ts — 2D editor Slice 7: 11 node-env tests for the URL hash encode/decode pipeline. Round-trip: spec-only AES-128 and AES-256 + layout + session both encode-then-decode to deep-equal originals. Determinism: encoding the same document twice produces byte-identical hashes (deflate is a pure function for a given input, and serializeDocument already sorts keys — the property Slice 7 inherits from Slice 5's spec-only byte-stability test). Size budgets: spec-only AES-128 < 4 KB encoded (measured ~1.6 KB; budget is generous-side so future spec growth doesn't flap immediately but a 2× regression catches); AES-256 + layout + session < 4 KB (worst realistic case shipped). Failure modes with distinct prefixes so the boot hook can route: garbled base64url → decode failed (invalid base64url); valid base64 of non-deflate bytes → decode failed (could not inflate); valid-deflate-of-future-schema-doc → decoded document is invalid: schemaVersion 999 is not supported (proves the parseDocument error path survives the compression layer). Hash key helpers: buildShareHash wraps with #doc= prefix; extractHashPayload returns null for empty / unrelated / wrong-key hashes and the payload for matching ones.
  • tests/app-url-share.test.tsx — 2D editor Slice 7: 9 jsdom tests for the App's Share button + boot-decode pathway. Share: button renders next to save/load; click writes a ${origin}${pathname}#doc=… URL to the clipboard (stubbed via Object.defineProperty(navigator, "clipboard", {...}) with configurable: true so each test's afterEach can reset cleanly); include-session ON produces a strictly longer payload than spec-only (proves the toggle is honored); success flips the inline .share-status-success pill with "link copied". Boot decode: encode a doc, stash it in window.location.hash via history.replaceState BEFORE render(App); onMount decodes async and snaps the spec store from the localStorage default (aes-128) to the URL's literal doc (aes-256). Hash is cleared from the address bar on success (so refresh doesn't re-apply); kept on failure for forensics. Session sidecar verified end-to-end (byteFormat=ascii drives the plaintext input field to render in ASCII — mirrors Slice 5's setSpecFromDocument hydration-order test); layout sidecar restores LayoutSpec.positions + collapsedGroups into the per-spec.id layout store. Malformed payload shows .error banner with "Could not load shared link"; empty / unrelated #section hash is a clean noop. Helpers at() and payloadOf() extract array entries / URL fragments without ! non-null assertions (biome lint compliance under noUncheckedIndexedAccess).
  • tests/step-palette.test.tsx — 2D editor Slice 8: 14 jsdom tests across three describes. Palette render (4): every non-padding registered step type appears as a [data-step-type=…] entry; every type in PADDING_STEP_TYPES is absent; the four shipped namespaces (aes, generic, speck, serpent) render as [data-testid="step-palette-group-<ns>"] sections; every entry has draggable === true. insertStepIntoSpec mutator (6): inserts a new leaf with empty params after a named anchor in the top-level; root-append produces a leaf at the end of spec.steps; the unique-id walker increments past <base>-1 / -2 / -3 for repeated inserts; returns the generated id so callers can route focus; root-append preserves reference-equality of unchanged top-level children; an anchor inside a group (round.1.shift-rows) lands the new leaf inside round.1 (NOT at root), pinning the findStepAndParent parent-resolution wiring. GraphView drop integration (4): firing a synthetic drop Event with a dataTransfer-shaped object on a leaf <g> (graph-leaf[data-drop-anchor="key-expansion"]) inserts the new leaf; firing on a container <g> anchors after the container in its parent (NOT into the container body — Slice 8's locked-in semantic, pinned with parent === null for a root-level container drop); unregistered step types are silently ignored; drops without a step-type MIME payload are silently ignored. Why synthesized events not real DnD: jsdom's DragEvent and DataTransfer support is partial across versions; we test the drop-handler boundary by injecting an already-populated dataTransfer via Object.defineProperty on a plain Event. Real DnD UX verification (mouse-tracking palette → canvas with the browser's drag ghost) is reserved for a Playwright smoke pass on the equivalent Slice 6 drag gate; not done in this session — flagged.
  • tests/document-roundtrip.test.ts — 2D editor Slice 3: 54 node-env tests for the CipherDocument file format. Round-trips all 18 shipped specs (3 AES sizes × 2 directions + AES-128-ECB × 2 + Speck × 2 conventions × 2 directions + 3 Serpent sizes × 2 directions). Sidecar coverage (LayoutSpec / SessionSnapshot with + without bytes / DocumentMetadata / all sidecars together / empty metadata). schemaVersion handling (accepts 1, rejects 0 and 2 with the friendly pre-check error). Error paths (malformed JSON, missing spec, unknown wrapper-layer key, unknown step kind, invalid cipher, byte > 255). Stable key order (re-orders wrapper-level keys + asserts identical bytes; preserves array order for positional params like S-box tables). Enum coverage loop — every Cipher / CipherMode / PaddingScheme / ByteFormat value round-trips through a SessionSnapshot, complementing the compile-time Exclude<> checks in document-schema.ts.
  • tests/zq-compress-decompress.test.ts — the FIPS 203 §4.2.1 formulas exhaustively: every x ∈ [0, q) and every y ∈ [0, 2^d) for every d ∈ 1..12 (~40k evaluations, free at this modulus). The oracle rounds by floor-then-compare-distances where the executors add half the denominator and floor — same function, different derivation, so an algebra slip cannot hide in both. Also pins the tie asymmetry with a truncating implementation run alongside, the d = 12 pigeonhole, and the ceil(q / 2^(d+1)) error bound that is the doc's own claim.
  • tests/zq-byte-encode-decode.test.ts — three ranked oracles. Rank 1 is a real ML-KEM-768 encapsulation key from Node 24's OpenSSL, stored as a byte fixture: it decodes to 768 coefficients all inside [0, q) (~19% of arbitrary 12-bit values are not) and re-encodes byte-identically, and the three wrong bit orders are RUN. Captured rather than generated because CI is on Node 22 and has no ML-KEM, and because generateKeyPairSync silently ignores its seed option on Node v24.14.1 (P3 later recovered determinism by importing a seed-arm PKCS#8 instead — see tests/ml-kem-oracle-fixture.test.ts). Rank 2 is Algorithms 3–6 written out as bit arrays; round-trip ranks last and says so.
  • tests/zq-cbd.test.ts — the definition, the distribution, and the representation. The distribution check is exhaustive rather than statistical: feeding every distinct 2η-bit pattern exactly once makes the histogram BE C(2η, η+k)/2^(2η) instead of an estimate of it, which is what catches a sampler reading its bit windows one position out (a shifted window still yields small centred values in range). Negatives-as-q − |v| gets its own describe, including the legal-value SET rather than merely the range.
  • tests/zq-base-case-mul.test.ts — the γ table two independent ways (re-derived from the exponent, then the ± pairing against the published ζ table's upper half), then the convolution theorem end to end through the SHIPPED NTT specs against a schoolbook product computed here. Asserts both plausible wrong answers — element-wise multiplication and a single shared γ — are different from the right one. tests/ntt-3329-256-kat.test.ts keeps its own inline base-case multiply on purpose: calling this step from there would delete P1's oracle and turn its convolution test into a tautology.
  • tests/k-pke-kat.test.ts — K-PKE against Node 24's ML-KEM, checkpoint by checkpoint: ρ (pins G and the ‖ k byte) → dk_PKE (pins the PRF, the counter ORDER, CBD, and NTT(s)) → the whole ek (pins SampleNTT, the matrix index order, and Â∘ŝ + ê), over 18 seeds. Encrypt and Decrypt — which the plan expected to be pinned only transitively in P4 — get a direct oracle by running the FO re-encryption loop test-side: decrypt OpenSSL's ciphertext, recompute (K, r) = G(m′ ‖ H(ek)) here, re-encrypt, and require the ciphertext back byte for byte. Round-trip ranks LAST. Five perturbations assert the FAILURE SIGNATURE rather than merely "something changed" — a swapped s/e counter pair must leave ρ green, a transposed matrix index must leave ρ and dk_PKE green. Ends with an id-uniqueness walk over all three shipped specs.
  • tests/ml-kem-monoliths.test.ts — the five Keccak monoliths against node:crypto directly, because hash-h and kdf-j belong to the FO wrapper and no P3 spec calls them ("covered by the KAT" would be false for them). SampleNTT is checked against a rejection loop driven by OpenSSL's SHAKE128 and structured differently (bulk squeeze then slice, against the executor's squeeze-on-demand), so a divergence in the incremental reader surfaces. The squeeze-count section measures the distribution over 162 draws and asserts it VARIES.
  • tests/ml-kem-oracle-fixture.test.ts + tests/fixtures/ml-kem-768-seed-vectors.json — the deterministic seed→key oracle P2 concluded did not exist, recovered by IMPORTING a hand-assembled PKCS#8 with the implicit-tagged [0] seed arm. 18 seeds → ek/dk_PKE/H(ek), 4 encapsulations, one cross-decapsulation pair; two seeds share d and differ in z, which is the only way to assert §7.1's split. Committed as bytes because CI is Node 22.
  • tests/ml-dsa-44-oracle.test.ts — ML-DSA-44 assembled from nothing but the registered P2 executors, held to real implementations via tests/fixtures/ml-dsa-44-vectors.json (Node 24's seed-import PKCS#8 for keys, @noble/post-quantum 0.7.1 for deterministic signatures, each cross-checked against the other before capture; CI is Node 22). Key generation reproduces Node's pk AND 2560-byte expanded sk; verification agrees with Node on valid signatures and on one malformed hint per refusal rule; deterministic signing reproduces noble byte for byte, which pins the four bound checks implicitly because a wrong check changes κ. Two runnable perturbations (the round-3 H(ξ) without ‖ k ‖ l, and a transposed Â) record their failure signatures. The fixture signatures take 1 to 12 attempts, rejected by the z and r0 checks only.
  • tests/ml-dsa-rounding.test.ts — Decompose and Power2Round over all 8,380,417 residues against a floor-division oracle (the wrap fires for exactly γ2 values); UseHint(MakeHint(z, r), r) = HighBits(r + z) at every r for the boundary z, plus a check it FAILS past γ2; ±1 tests on the < and ≤ edges.
  • tests/ml-dsa-samplers.test.ts — each sampler against an independent rejection loop over node:crypto's SHAKE in one bulk squeeze; the NTT pair against the traced spec; shake256Reader across 5000 bytes.
  • tests/ml-dsa-packing.test.ts, tests/ml-dsa-narration.test.tsx, tests/ml-dsa-param-editor-coverage.test.tsx — the packers' edges, every ML-DSA narrator on real runtime frames, and a palette-drop editor walk derived from registry.types() by prefix.
  • tests/keccak-compute.test.ts — pins the one-call sponge against node:crypto AND against the app's own traced buildSha3256Spec(), so the monolith and the visible SHA-3 trace cannot drift. Includes the incremental SHAKE128 reader against a bulk squeeze, block after block.
  • tests/zq-param-editor-coverage.test.tsx — closes a gap param-editor-coverage.test.tsx leaves open by design: that file walks only types appearing in a SHIPPED spec, which does not cover a palette-only family. Drops all nine Z_q types through the real insertStepIntoSpec path. It found a real bug: a dropped leaf gets params: {}, so rows gated on a value being present vanish exactly when the user needs them — the d and η rows are keyed on the step TYPE, with a mirror assertion so the gate cannot degrade into "always show everything".

For step-type-specific guidance (adding new ones), see src/steps/CLAUDE.md.