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.
src/core/types.ts—CipherSpec,StepNode,Statevariants,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 } | nullfor 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 areremoveStep + insertStepAfter),compareSpecs. Pure spec-in/spec-out.compareSpecsreturnsSpecParamDiff[]where each entry optionally carriesscalar(before/after for primitive params) orcells(per-cellParamCellDiff, 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 —describeDeltafalls 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 thezq-vec-*@1family (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 — pluszqElementCountandrequireZqPort. ML-DSA P2 addedreadScalarPort, the reader for the wired constants (γ1, γ2, ω, bounds) that family keeps on ports.bigintthroughout, because the modulus arrives on a port at whatever width it was wired at. P2 addedreadZqBitWidth, shared by the four steps carrying ador aneta— one error message and one "must fit an element" rule, the same guardreadZqModulusapplies toq.src/core/big-int-codec.ts—bytesToBigInt/bigIntToBytes(big-endian, fixed-width, throws on overflow). The arbitrary-width sibling ofword-codec.ts's fixed 8/16/32/64-bit helpers; the single conversion seam the RSA big-integer primitives share (bigintmath lives inside executors, exchangingUint8Arrayat ports).src/core/spec-shapes.ts— Static state-shape inference for aCipherSpec, added alongside theshapeContractfield onStepDocumentation. ExportsinferShapesAtAnchors(spec, registry): ReadonlyMap<string, StateShape>(returns the shape that exists immediately after each leaf/container completes; consumed byGraphViewto decorate everydata-drop-anchorelement with adata-state-shapeattribute) andvalidateShapes(spec, registry): GraphWarning[](emitsstate-shape-mismatchfor 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 —seedInputdefined, every shipped multi-block spec — enters and exits as"bytes"; the legacy aux-mode branch keeps the historicalmatrix4x4-bytesin/out shape only for old saved documents)."any"and missing-contract leaves skip validation and pass shape through. Used byGraphView'srawWarningsmemo (concatenated withvalidateGraph's trace-derived output) and by theshapesByAnchormemo that powers the drop-anchor greying CSS rules. Pure function; same inputs → same outputs.src/core/graph.ts—deriveAuxGraph(trace, spec): CipherGraphplusGraphNode/ContainerNode/GraphEdge/EdgeKindtypes. Slice 9 addedvalidateGraph(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 fromframe.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 ofgraph.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 declaredshapeContract.inputdisagrees with the spec-inferred upstream shape — produced byvalidateShapesincore/spec-shapes.ts, concatenated into the warning list inGraphView'srawWarningsmemo; 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'svalidateGraph(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/rootIdsfrom spec walk; edges fromauxRead/auxWrittenplus iterate-mediated synthesis (split-blocks → iterate → concat-blocks, since the runtime moves those aux values itself without recording them in any frame'sauxWrites);blockSpanannotations on iterate-body nodes fromframe.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 byinferPortEdgesfrom each leaf's declaredportInputs(port-flow-only); the description below is historical.inferStateEdges(spec)emitted onekind: "state"edge per DFS-consecutive pair of sibling leaves within each iterate-scope. Groups are TRANSPARENT (DFS through; the spine crossesround.k → round.k+1boundaries — the pedagogical headline); iterates are OPAQUE (their body is its own scope, no spine edge crosses the boundary in either direction, matching the runtime'sstate = 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 sentinelauxKey: "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 addedcollapseGraph(graph, collapsedIds): CipherGraph— a pure view-time transform that runs AFTERderiveAuxGraph. Per the advisor: kept SEPARATE (not folded into derivation) so Slice 1's "graph is pure (spec, trace)" contract survives. Semantics: collapsed containers stay incontainers(renderer draws them as chips) withchildIds: []; 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 thekind-defensive dedup key from commit 1 keeps state and aux edges with the same endpoints distinguishable.rootIdsis filtered to still-resolvable ids. EmptycollapsedIdsshort-circuits with reference equality. Commit 4 of the graph-readability sequence addedreplicateHighFanoutSources(graph, threshold, modes?): CipherGraph— a pure view-time transform that runs AFTERcollapseGraph. Sources with> thresholdoutgoingkind: "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 carryreplicaOfpointing at the source's canonical stepId so click handlers can scrub to the source's trace frame. Original source nodes stay innodes(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'scontainerPath, then are spliced into the parent container'schildIds(orrootIdsif root) immediately before the consumer. Commit 5 added per-source overrides via the optional thirdmodesargument ({ [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 whenthreshold <= 0so 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 <= 0with 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 | nullderives a Feistel round's structure (split / L-port / R-port / F-stack / fxor / recombine + theswapflag, read from the recombine's concat argument ORDER) PURELY from a port-mode group's wiring — nofeistel-roundkind, no spec tag. Cipher-agnostic (DES today; a future TEA/XTEA builtsplit → F → xor → concatrecognizes for free).findActiveFeistelRound+resolveFeistelRoundBytesback the linear-mode Feistel components (FeistelSwapDiagrametc.). Returns null for AES/SHA rounds + the outerroundsgroup.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, matchingconcat(R, L⊕F)), else straight (round 16). Consumed byGraphView'slayoutNodeFeistel branch + the swap overlay; the X is the rail-level picture (byte-levelrecombine → splitis 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 bysrc/ui/components/TwofishRoundDiagram.tsx.twofishDiagramModel(shape, group): TwofishDiagramModel | nulltakes aTwofishRoundShape(from its graph-side siblingstwofish-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 againstshape.fIds, never assumed from rail order), each rotation's label in the PAPER's direction (ROL 8, not the builder's equivalentROR 24), andswapSources/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 fromtwofish-shape.tsso 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<<< 7that ends each quarter round, cross-checking every leaf against a second path to it (e.g.a_cd1is reached both froma_cd2's operand and fromx_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 (opsis 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 byGraphView'slayoutNodevia thechachaRoundsparam, parallel tofeistelRounds/twofishRounds.src/core/chacha-diagram.ts— pure presentation model for the linear view's ChaCha20 quarter round (2026-07-20), consumed only bysrc/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.outputNis 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 fromchacha-shape.tsso 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 overanalyzeArxDoubleRoundwithanchorBits: 18plus 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<<< 18anchor FEEDS it, soxor18is 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@1are 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<<< 7rotations, and it is ChaCha's walk that declines), plus the two spec-wide walks over it —arxDoubleRoundsById(spec)(the canonical-layout map) andarxRoundNeverModes(spec)(the replication guard). Extracted fromGraphView.tsxbecause 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 joiningarx-round-shape.ts(which both*-shape.tsfiles import — a cycle) or being exported fromGraphView.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 sharedderiveArxWordIndices) and Bernstein's written form, including the y→z rail naming so line 2 readsz1 + y0exactly as the spec writes it. Its distinctive field islane: 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.kindis column / row (Bernstein's terms), andquadLabelprints 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 aniteraterather than aStepGroup: 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 owninport, the split readingchain, theaux-load-bytes@1all three arithmetic leaves takemodulusfrom — 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, becausezq-vec-add@1commutes and a swapped operand pair is a legal no-op edit (the Salsa precedent) whilehi − loreversed is not.SPLIT_LOW_PORT/SPLIT_HIGH_PORTreadsplit-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 holdsnttButterfliesById(spec)andnttButterflyNeverModes(spec)— nontt-group.tssibling, becausearx-group.tsexists 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 —twistanddiffshare the mid slot because the two directions' role sets are disjoint exactly there, so the direction arrives pre-resolved and nothing here readsshape.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 byGraphView'slayoutNodevia thenttButterfliesparam, parallel tofeistelRounds/twofishRounds/arxRounds.src/core/ml-dsa-arith.ts— ML-DSA's rounding rules as plainnumberfunctions (FIPS 204 Algorithms 35–40):modPm,power2Round,decompose(with the wrap-round special case, reported aswrapped),highBits/lowBits,makeHint/useHint,checkGamma2(refuses a γ2 that does not tileq − 1), plus the port helpers theml-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.mdP2.src/core/ntt-diagram.ts— pure presentation models for both lattice linear-view diagrams (2026-08-10), consumed byNttButterflyDiagram.tsxandZqBaseCaseMulDiagram.tsx.findActiveNttButterfly(frame, spec)walks the frame's containerpathfor 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 owniterateScopeKeyscope — seven sibling iterates run 1, 2, 4 … 64 groups.baseCaseMulDiagramModel(frame, spec)coverszq-base-case-mul@1, deriving the pair count from the frame's own input width and marking the one product term that folded back fromX²viaX² = γ.src/core/document.ts—CipherDocumentfile 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 arefield?: Trather than Zod's inferredfield?: T | undefined(matters underexactOptionalPropertyTypes). The wrapper holds a requiredspec: CipherSpecplus 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).schemaVersionis the migration anchor —CURRENT_SCHEMA_VERSION = 3,ACCEPTED_SCHEMA_VERSIONS = [1, 2, 3]; older versions migrate forward inparseDocument, 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 backingdocument.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 usesz.discriminatedUnion("kind", [StepLeafSchema, StepGroupSchema, IterateGroupSchema])for pinpoint error paths; recursion lives in thechildrenfields viaz.lazy(() => StepNodeSchema)so each per-kind ZodObject keeps its concrete type and satisfiesz.discriminatedUnion's per-option constraint without casts. Also exportsCIPHER_IDS/CIPHER_MODES/PADDING_SCHEMES/BYTE_FORMATSvalue tuples (mirrored fromui/stores/*via type-only imports +satisfies readonly Cipher[]bottom check +Exclude<...>top check) so adding a new variant to those unions surfaces here attsctime.src/version.ts—APP_VERSION: stringre-exported frompackage.json(viaimport pkg from "../package.json" with { type: "json" }; works underresolveJsonModule: true+verbatimModuleSyntax: true). Single source of truth so a release bump is a one-file edit. Consumed by the App-shell footer (informational) and bybuildSaveTextinApp.tsxto stampmetadata.appVersioninto exported documents on the session-on branch only (spec-only saves remain byte-stable so the planned URL-share invariant survives). Seedocs/versioning.mdfor the release process.
-
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; onlyROUNDSandinputs.key.byteLengthdiffer. -
src/ciphers/aes-256.ts,src/ciphers/aes-256-decrypt.ts— AES-256 (Nk=8, Nr=14). Same shape as AES-192; theaes.key-expansion@1step 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 frombyte-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 — viaaes-core.ts— by the generic mode builders. (Replaced the legacyaes-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 noBlockCipherCore. 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 owniteratewith the counter on the cross-iteration carry. The four LE↔BEpermute@1crossings 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 Rconconstant-load@1; RotWord =permute@1, SubWord =byte-substitute@1, word-XOR =xor@1;byte-slice@1repack) with a single meta-bearingaes.publish-round-keys@1tail that writesaux["roundKey.N"]byte-identically to the old monolith (B-minimal — consumers untouched).bumpKeyExpansionrebuilds it for duplicate-round. Speck / Serpent / DES have sibling*-key-schedule-builder-native.tsfiles (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 sharedport()helper +CipherDirection(which used to be mis-homed inaes-ecb-builder.ts). The seam that makes modes cost N ciphers + M modes, not N × M.idis astring, NOT theCipherunion — nothing insrc/ciphers/imports fromsrc/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 aBlockCipherCore(aesCore(variant)), plus theAesVarianttype. 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 aBlockCipherCore(Phase C, 2026-07-17). The promised sibling ofaes-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). WrapsbuildKeySetup+buildBlowfishBodyfrom 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 aBlockCipherCore(2026-07-20), the last core: with it every block cipher runs every mode. WrapsbuildTwofishBody+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 intwofish-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— theCipher-keyed core registry (Phase B, 2026-07-17):blockCipherCoreFor/blockByteLengthFor/hasBlockCipherCoreover aPartial<Record<Cipher, BlockCipherCore>>(every block cipher since Twofish landed 2026-07-20 — AES ×3 + Speck ×2 + Blowfish + Serpent ×3 + DES + Twofish; keptPartialbecause absence stays meaningful for the next cipher added). The consumption-layer half of the seam —block-cipher-core.tsdeliberately doesn't know theCipherunion, 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 offspec.ts) becausepadding.ts+App.tsxread it andspec.tsalready importspadding.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 aBlockCipherCoreand knows nothing else. (Replaced the AES-specificaes-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=$inputor the pad step's output,blockByteLength= the core's,bodyOutput= the core body's explicit exit); the oldsplit-blocks/compute-block-count/concat-blocksboundary steps were retired with the matrix iterate in Phase 5 / Slice 5.1 (2026-05-30).applyPaddingSchemeprependspkcs7-pad(or sibling) on encrypt and appendspkcs7-unpadon decrypt around the outside. Only AES-128 has dedicated spec FILES; AES-192/256's ECB+CBC are generated from their core instores/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-specificaes-cbc-builder.ts, deleted in Phase A.) Adds a pre-loopfetch-iv(aux-load-bytes@1readingaux["iv"], one block wide) and chains through the port-mode iterate'schainInput/chainFeedbackrather than an aux slot. The encrypt/decrypt asymmetry is the load-bearing detail and lives entirely in the spec: encrypt iscbc-xor → body, feeding the chain from the body's output (C_i); decrypt isbody → 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 interpolatecore.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'schainport 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 visibleincrement-counter@1frame. Encrypt and decrypt build structurally identical specs (both callcore.buildEncryptBody) because XOR is self-inverse;directionchanges only the id, name, and prose. Partial final blocks (2026-07-20): the iterate setsallowPartialFinalBlock, so a message may end mid-block and the final iteration gets a shortin;ctr-trim(truncate-to-reference@1) then trims the full-width keystream to that block's width soxor@1's equal-length rule is met. No padding overlay is ever spliced into a CTR spec —buildCanonicalPairpasses 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'schainport) 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 —chainFeedbackiscfb-xor.outputon encrypt (the ciphertext we emit) and the iterate's rawinon decrypt (the ciphertext that arrived). Get both wrong in a matched way and the round-trip still passes, which is whytests/cfb-all-cores-kat.test.tsleans onnode:crypto'saes-*-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:chainFeedbackis 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 untrimmedkeystream.output, notofb-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 — sotests/ofb-all-cores-kat.test.tsasserts it last and leans onnode:crypto'saes-*-ofb(no-ofb8variant 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: keepsinput[0 .. reference.length], readingreference's length only (never its bytes). No params — the target width depends on the message the user typed, so it changes per keystroke andbyte-slice@1(whoselengthis a spec-time param) cannot serve. Throws whenreferenceis wider thaninput. Has an exact provenance fn (identity prefix) and a per-frame value-aware narrator insrc/ui/narration/truncate.tsxthat names the discarded keystream bytes — the sentence explaining why CTR's ciphertext matches its plaintext length, which staticnarrationOverridemarkdown 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 tobuildSerpentSpec; 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 existingiterateprimitive 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/subderivingn,φ, and a traced extended-Euclid loop derivingd = e⁻¹ mod φ— Phase 4, below) compute the key from the editablep, q, ecipherConstantsand live in a default-expanded collapsible "Key Generation" group (Phase 2). Because port flow can't cross a group scope, the group'srsa.publish-key-params@1tail (src/steps/publish-key-params.ts, the one hybrid/meta step) mirrors this direction's key material intoaux— the B-minimal cross-scope export the four key schedules use; each direction publishes only the key its ladder consumes (keysparam: public{rsa.n, rsa.e}on encrypt, private{rsa.n, rsa.d}on decrypt, so no aux value is written-but-unread) — and the unrolledW·8-rung square-and-multiply ladder (mod-mul@1square +cond-mod-mul@1conditional multiply per rung, exponent bit read at run time → live-editable) reads them back via top-levelaux-load-bytes@1loaders. Phase 4 decomposes the former single-framemod-inverse@1oracle into a traced extended-Euclid loop: two coefficient seeds + an unrolled chain ofeeaMaxIterations(W) = ⌈1.4404·8W⌉+2eea-step@1rungs (each shifts the(r, newR, t, newT)tuple byq = ⌊r/newR⌋, the Bézout coefficient kept reduced mod φ so every port stays non-negative; a rung withnewR = 0is an identity carry-forward) + aneea-extract@1tail (id"d", reads the settled(gcd, inverse)slot, throws whengcd ≠ 1).mod-inverse@1stays registered as the cross-check oracle. Every key-gen leaf + ladder rung carries a cipher-specificnarrationOverride. Working widthW=2(n < 65536, 16 rungs); messagemarrives on$input, must be< n. Big-int math isbigint-internal in the seven primitives (src/steps/{mul,sub,mod-mul,cond-mod-mul,mod-inverse,eea-step,eea-extract}.ts); KAT intests/rsa-vectors.test.ts(Python-oracle-verified), topology + export gate intests/rsa-key-gen-group.test.ts, the EEA worst-case (Fibonacci)K-bound gate intests/rsa-eea-decomposition.test.ts. Plan:docs/plans/shimmying-booping-moth.md. -
src/ciphers/lcg.ts— the linear congruential generators, the whole of theprngfamily today.buildLcgSpec(variant, outputLength)builds a direction-less spec for any of three:minstd-rand0(a = 16807),minstd-rand(a = 48271) — both multiplicative, modulus2³¹−1— andansi-c-lcg(a = 1103515245, c = 12345, modulus2³¹), the mixed form from ISO/IEC 9899 §7.22.2.2 / POSIXrand_r.LCG_PARAMSis the (a, c, m) table;c === 0selects the form, not just a value. Shape: azero-fill@1request leaf whose width is the requested output length, feeding a port-modeiterate'sseedInputatblockByteLength: 4withallowPartialFinalBlock— so the iteration count isceil(N/4)and the body'sinport is ignored on every pass but the last. Body is four leaves for the multiplicative form (mult/moduconstant-load@1,statemod-mul@1,emittruncate-to-reference@1) and six for the mixed one, which inserts anincrconstant and splits the arithmetic intoprod(mod-mul@1) →state(add-mod@1).LCG_STATE_IDalways names whichever node produces the next state, which is what letschainFeedbackandemitwire identically across both forms.chainInputbootstraps from$input(the seed, verbatim — no scrambling, no warm-up discard);chainFeedbackreads the untrimmedstatewhilebodyOutputreads the trimmedemit. 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 intests/lcg-kat.test.ts, whose anchors are the ISO/IEC 14882 §rand.predef conformance values (MINSTD) and the published opening states + derivedrand()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; theemitnarration 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, mirroringreadShakeOutputLength. Every leaf carries anarrationOverride. 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: azero-fill@1request leaf drives a port-modeiterateatblockByteLength: 64withallowPartialFinalBlock, the counter rides the cross-iteration carry (chainInput= aconstant-load@1big-endian zero — block 0, NOT the cipher's counter-1 convention — advanced byincrement-counter@1), andbodyOutputis the trimmed keystream. The twenty rounds are not re-implemented:buildDoubleRoundGroupsis imported fromchacha20.ts, so the generator inheritsanalyzeArxGroup/arxRoundNeverModes/ the canonical ARX layout cell /<ChaChaQuarterRoundDiagram />for free — those surfaces key off wiring, never off the cipher. The seed reaches the body throughaux["seed"], not a port, because port flow cannot cross a container scope;App.tsxpublishes it for every generator andaux-load-bytes@1fetches 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 apermute@1LE↔BE reversal. Every leaf outside the shared double rounds carries anarrationOverride(the ARX round internals carry none, exactly as the cipher's do — they come from the shared builder), andfinal-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.tspins it againstnode:crypto'schacha20over 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.mdP3. -
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)buildsseed-state(mt19937.seed@1) →twist(mt19937.twist@1) → abyte-slice@1rounding the request UP to whole words → a port-modeiterateof twelve tempering leaves → a siblingtruncate-to-reference@1. Three firsts, all documented in the file header. (1) The iterate has nochainInputand nochainFeedback— 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'sseedInput, 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'sinport, 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 intests/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, somaxPrngOutputForneeds no arm (measured: a 42 → 1024 change settles in ~325 ms). Default seed 5489,std::mt19937'sdefault_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_genrandadds 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 fromi = 227the 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 >= wordBitsshort-circuits to zero), templated onshift-bits-right.ts. Exists rather than reusingrotate-bits-left@1+and@1on 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. Itsshl32carries the one asymmetry with the right-handed sibling: JS<<returns a signed 32-bit value, so1 << 31is negative and the trailing>>> 0is 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-entryZETAStable verbatim from FIPS 203 Appendix A, and the byte encodings (Q_BYTES,N_INV_BYTES,ZETA_TABLE_BYTES) pluspackPoly/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] = 1is never consumed by either direction and is present only so the table can be checked against the standard entry by entry, whichtests/ntt-3329-256-kat.test.tsdoes. P2 addedGAMMAS/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 yields1, 3328, 1729, …instead of the correct17, 3312, 2761, …. Derived asZETAS[i]²·17so 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 thelatticefamily today, and the app's first post-quantum object.buildNttSpec()/buildInverseNttSpec()produce the forward and inverse direction slots, both now thin wrappers overbuildNttNodes— 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.buildNttGroupwraps it as a collapsible group withseedInput/bodyOutput, and takes a prefix: the flat trace keys frames bystepId, so six copies oflayer1.splitwould break the scrubber and the graph derivation with no error. Shape: a top-levelaux-load-bytes@1fetching the ζ table, then seven sibling port-modeiterates, one per layer, withblockByteLengthrunning 512 → 8 forward and 8 → 512 inverse. Each body is eight leaves —split-bytes@1into halves,split-bytes@1taking this group's ζ off the table,aux-load-bytes@1forq, then the butterfly (zq-vec-mul-scalar@1+zq-vec-add@1+zq-vec-sub@1), aconcat@1recombine, and aconcat@1that rotates the ζ table by one entry. The twiddle table rides the cross-iteration chain and passes layer to layer viachainOutput→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 azq-vec-mul-scalar@1by128⁻¹.q, the ζ table and128⁻¹ridespec.cipherConstantsso they are editable in one place;qreaches the butterfly through aux because port flow cannot cross a loop scope — the CSPRNG-seed lesson, met again.LayerGeometrycarriesdisplayIndexandlenseparately: 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_INPUTis0, 1, 2, …, 255;DEFAULT_NTT_OUTPUTis its transform, derived from FIPS 203's definition rather than from this spec, and pinned against a real run. Every leaf carries anarrationOverride. Plan:docs/plans/unified-stargazing-quasar.mdP1. Since ML-DSA P1 (2026-10-18) the builder is parameterized by anNttProfile(n,layers,coeffBytes,zetaTableBytes+ the per-occurrence prose as anNttNarration).ML_KEM_NTT_PROFILEis 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: handsbuildNttNodestheML_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_OUTPUTderived 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.mdP1. -
src/ciphers/mldsa-constants.ts—ML_DSA_Q(8380417),ML_DSA_NTT_ROOT(1753, order 512),ML_DSA_N_INV_256(8347681), the 256-entryML_DSA_ZETASverbatim 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 becauseqis odd only decompression can ever see a tie, which is why a truncating compressor passes every tie-focused spot check.Compress(Decompress(y)) = yholds only while2^d ≤ q; atd = 12it is pigeonhole-impossible.zq-byte-encode@1/zq-byte-decode@1(Alg 5 / 6) are the densed-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 thelittleEndianparam, which describes only the bytes within one coefficient — tangling the two yields a self-consistent encoding that matches nobody. Both write FIPS 203'smasmin(2^d, q)rather than branching ond === 12, so the rule survives a learner editingq.zq-cbd@1(Alg 8) is the one step that turns randomness into a ring element: a popcount difference over2ηbits, giving small values centred on zero, with negatives written asq − |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 inZ_q[X]/(X²−γ)and costs five multiplies per pair. It deliberately breaks thezq-vec-prefix to say so in the palette, and is polymorphic overkso the same executor serves P3's per-pair loop and the whole-polynomial form. All eight are onPROVENANCE_NO_OP_ALLOWLIST; the packing pair andzq-cbd@1are there for COARSENESS (bit-level ops, byte-level answer — therotate-bits-right@1rationale) rather than the family's usual value-dependence. Plan:docs/plans/unified-stargazing-quasar.mdP2. -
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 fromHbecause 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), andml-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 eachdetailnames 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 portsqueezesmakes 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 ofdocs/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 ofzq-base-case-mul@1, and a test runs both) andzq-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 tracedntt-8380417-256spec; 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(publishesrejections),format-message(0 ‖ |ctx| ‖ ctx ‖ M). Packers:bit-pack/bit-unpack(the signedb − wflip; the unsigned case iszq-byte-encode@1unchanged),w1-encode(width derived from γ2),hint-pack/hint-unpack(positions plus running totals; unpack refuses all three malformed cases as avalid = 0flag, 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, plusshake128Reader/shake256Reader(an incremental squeeze at either rate, because a rejection sampler cannot know how many bytes it needs) andbyteStream(the same squeeze one byte at a time, FIPS 204'sSqueeze(ctx, 1)shape). Not a second Keccak:keccakPermuteBytesdrives the same nine step executorsbuildKeccakRoundemits as spec nodes, with the same constants out ofkeccak-f.ts, so the one-frame hash cannot drift from the sponge the Hash selector shows. Duplicated here is only the wiring. Pinned against bothnode:cryptoand the app's own tracedbuildSha3256Spec(). 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: ρ pinsG,dk_PKEpins PRF+CBD+NTT(s),ekpins the matrix and the matrix-vector product.ek/dk_PKE/rreach Encrypt and Decrypt throughcipherConstants+aux-load-bytes@1, the channelqand ζ already use. Six embedded forward transforms in KeyGen and seven in Encrypt, each the shippedbuildNttGroupso the transform cannot drift between where it is taught and where it is used — which is what makes prefixed node ids load-bearing. CarriesnarrationOverrideon the three leaves where a self-consistent wrong answer is possible: the‖ kbyte, 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-byted ‖ zconstant-load@1leaf (RSA'sp, q, eprecedent), in a default-collapsed key-generation group whose single output isek ‖ dk— exactly what FIPS 203's KeyGen returns, so thesplit-bytes@1outside 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.qandgammaappear twice — once at the top level for the inline bodies, once inside the group — because port flow cannot cross a scope. Decapsulation parsesekout ofdkrather than reading the group's copy, which is what a real decapsulation has. Itsj-inconcat feedsml-kem.kdf-j@1from 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_SEEDis the fixture'scountingseed;ML_KEM_DEFAULT_CIPHERTEXTis its encapsulation, stored as hex likeDEFAULT_NTT_OUTPUT). Plan:docs/plans/unified-stargazing-quasar.mdP4. -
src/ciphers/ml-dsa-44.ts— ML-DSA-44 key generation, signing and verification (FIPS 204 Algorithms 6, 7 and 8); the thirdAsymmetricmember 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 underaux["ml-dsa.sign.*"]byml-dsa.publish@1(src/steps/ml-dsa-publish.ts), then a port-modeiteratewithstopWhenrunsmlDsaAttemptNodesuntilml-dsa.attempt-verdict@1(src/steps/ml-dsa-attempt-verdict.ts— flag + value + limit per check; outputsacceptedand afailedbitmask 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 editableconstant-load@1leaf, and the key pair is born from an editable 32-byte seed inside a default-collapsedkeygengroup (one input ξ, one outputpk ‖ sk, split outside — the ML-KEM-768 shape). Exports the prefixable builders P4 reuses:mlDsaKeyGenNodes,mlDsaKeyGenGroup,mlDsaKeyMaterialNodes,mlDsaVerifyNodes,mlDsaConstantNodesandmatVecNodes(all k·l products in ONE element-wisezq-vec-mul@1frame). q, γ1, γ2, β and ω ridecipherConstants; γ1 − β and γ1 − 1 aresub@1leaves, never second constants. Ends inml-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'sp3.defaultPair(counting seed, "Hello, world!", noble's deterministic signature). Measured: 91 frames, ~0.2–0.5 s inrunSpec, almost all of it ExpandA's SHAKE128. Plan:docs/plans/refactored-petting-tide.mdP3. -
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 (receivedc, re-encryptedc′,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(thezero-fill@1request leaf's id) andreadPrngOutputLength(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 inlcg.ts, so a second generator family need not import from a sibling generator to learn the convention.readLcgOutputLengthis now an alias of it. -
src/steps/add-mod.ts—add-mod@1,(a + b) mod nover three input ports at the modulus' byte width; the exact sibling ofmod-mul@1(operands need not match in length,bigintinternally,Uint8Arrayat every port). The+ cof a mixed congruential generator. Notadd-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@1mask) is correct only for power-of-two moduli, which in an app built around editing constants is a footgun. InPROVENANCE_NO_OP_ALLOWLISTunder "approximate" (carries cross byte boundaries, and the reduction can rewrite every byte); inNO_PARAMS_PORT_NATIVE_TYPESwith a blurb pointing at the wired constants. Tested directly bytests/add-mod.test.ts. -
src/steps/zero-fill.ts—zero-fill@1, emitter ofparams.byteLengthzero bytes. Zero inputs; function-form output contract (width known at spec time), likeconstant-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 notconstant-load@1with 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). InPROVENANCE_NO_OP_ALLOWLISTunder "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, whereLT_TABLE[i]lists the input bit positions whose XOR yields output biti), SERPENT_PHI = 0x9e3779b9 (key-schedule constant), SERPENT_ROUNDS = 32, SERPENT_BLOCK_BYTES = 16. Bit-numbering convention is LSB-first within bytes everywhere.
src/ui/stores/trace.ts— current trace + frame index,setTracepreserves focus by stepId across re-runs.src/ui/stores/format.ts— active byte format, persisted inlocalStorage.src/ui/stores/spec.ts— current spec + (cipher, cipherMode, mode) selector.defaultsis a 3DRecord<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 issingle-block+ecb+cbc+ctrfor every cipher, though the fallback below still matters for any cipher added without a core.resolveDefaultfalls back tosingle-blockwhen 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, andsetPaddingall funnel throughapplyPaddingSchemeso 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, thensetSpec(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 addedinsertStepIntoSpec(stepType, anchor)— the boundary GraphView's drop handler calls. Two anchor flavors:{kind:"after", stepId}routes throughinsertStepAfter(works for leaf or container anchors thanks to Slice 4'sfindStepAndParent);{kind:"root-append"}rebuildsspec.stepsdirectly 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 withparams: {}). Also exportsremoveStepFromSpec(stepId)— the boundary for the three delete affordances (hover×button on graph nodes,ParamEditor's "Delete this step" button,Deletekeypress on a focused node). WrapsremoveStepfromspec-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'sCustom (was AES-128)indicator in the header + cipher dropdown and the visibility of the inlineresetbutton next to the dropdown (calls existingresetSpec()). Implementation uses a small recursivedeepEqualJsonrather thanJSON.stringifyso 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 becausesetPaddingrebuilds viaapplyPaddingSchemefrom the live tree, which produces the same canonical structure when the spec was canonical to begin with — pinned bytests/app-custom-spec-indicator.test.tsx.src/ui/stores/cipher.ts— active AES variant (aes-128/aes-192/aes-256), persisted inlocalStorage. Also exportsDEFAULT_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) onml-dsa.decompose@1/make-hint@1/use-hint@1frames: 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.firstHintedis 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 ofml-dsa.attempt-verdict@1frames, 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 byiterateScopeKey, each row carrying its verdict frame's index for click-to-scrub),attemptOfFrame/isAttemptLogRelevant. The values come from verdict frames, not the loop'soutlog, which holds only the failed bitmask. The panel collapses outside the loop the wayRoundKeyPaneldoes, 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'sm); the mode flip restores it going back. ML-DSA-44 Verify's message row reads it too: it shows the verify spec'smessageleaf 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 fromstopWhen; 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 inlocalStorage. Two related constants:SUPPORTED_CIPHER_MODESis the globally shipped set (Phase 1:single-block,ecb);SUPPORTED_CIPHER_MODES_BY_CIPHERis the per-cipher matrix that says which (cipher, cipherMode) combinations have an actual spec wired up instores/spec.ts's defaults table (Phase 1: onlyaes-128×ecb; everything else is single-block only). The matrix is consulted byisCipherModeSupportedfrom both the App's dropdown (to disable unsupported combos in the option list) andsetCipherinspec.ts(to resetcipherModeto single-block when the user switches to a cipher that doesn't support the active mode).loadInitialruns the same check at boot so a persisted cross-store mismatch (e.g.cipher=aes-192, cipherMode=ecbin 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 theshowPreviousRunoverlay toggle.src/ui/stores/padding.ts— active padding scheme (none/pkcs7/zero-pad/iso7816-4), persisted inlocalStorage.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 toMAX_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 theMAX_BLOCKS_UIconstant to encrypt a paragraph at the cost of a noisier scrubber.src/ui/stores/auto-rerun.ts— two signals:autoRerun(user preference, persisted inlocalStorage, default true) anddirty(session-only flag set when manual mode receives a spec edit). The App'screateEffect(on(spec, ...))branches onautoRerun()— true keeps the 200ms debounced re-run; false just callssetDirty(true)so the inline "edits pending" banner appears. FlippingautoRerunback ON clearsdirtyso the banner doesn't go stale.src/ui/stores/view-mode.ts— three-mode signal (linear/graph/json), persisted inlocalStorage. Drives the tab bar above.trace-view: linear shows today's per-frame view (frame header, StepStrip, BlockBadge, FrameStateView, StepDescription, ParamEditor); graph swaps inGraphView; json swaps in a<pre>ofJSON.stringify(spec(), null, 2). Slice 2 of the 2D editor plan.src/ui/stores/view-replication.ts— boolean signal (replicationEnabled, persisted ascryptographer.replicationEnabled, default false). When true, GraphView pipes the collapsed graph throughreplicateHighFanoutSources(graph, REPLICATION_THRESHOLD, modes). The threshold is exported as a constant (= 6); commit 5 wired the modes argument up from the layout store'sreplicationModes. 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 inLayoutSpec.replicationModesonly 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 inlocalStorage. Defaultnormal= 1.0× exactly, so the pre-density layout is byte-stable. ExportsDENSITY_SCALE(compact 0.75× / normal 1.0× / spacious 1.25×) as the single source of truth for the scale factor.GraphView'slayoutConstantsFor(density)builds aLayoutConstantsrecord (LEAF_W,LEAF_H,STACK_GAP,FLOW_GAP,CONTAINER_PAD) by multiplying the base constants byDENSITY_SCALE[density]and rounding to integer pixels;layoutNode/layoutRoot/labelTextLengthtake this record so they remain pure (testable without rendering) and re-run automatically on density flips via thecreateMemochain insideGraphView. Density lives in its own store (not inLayoutSpec) because it's a viewer preference, not a document fact — a shared.cipher.jsonrenders 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. ExportsencodeDocumentToHash(doc): Promise<string>anddecodeHashToDocument(hash): Promise<{ok:true,doc}|{ok:false,error}>plus theHASH_PREFIX = "doc="constant and thebuildShareHash/extractHashPayloadhelpers. Pipeline isserializeDocument → 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'swrite/closepromises 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 ofnew Blob([bytes]).stream().pipeThrough(...)because jsdom 29's Blob lacks.stream(); production + test paths stay identical. (3)deflate-rawovergzipbecause gzip's header + CRC32 footer (~18 bytes) matters at this size class. App-side wiring: the[share…]button callsshareDocument(async; honors theincludeSessiontoggle the same way Save does), writes the encoded URL tonavigator.clipboard.writeText, falls back to placing the URL in the address bar viareplaceStateif clipboard is blocked. Boot decode runs in App'sonMount— readswindow.location.hash, routes through the sharedapplyDocumentboundary (factored out ofhandleLoadFromTextso file-load and URL-load both go through one path), strips the hash on success viareplaceStateso refresh doesn't re-apply. Failure preserves the hash for forensic value.src/ui/stores/layout.ts— per-spec.idLayoutMap({ [specId]: LayoutSpec }), persisted as one JSON blob undercryptographer.layouts. Mirrors the existingformat.tstemplate: defensiveloadInitial(missing localStorage → empty map; corrupted JSON → empty; non-LayoutSpec values silently dropped via lightweightisLayoutSpecshape check);useLayoutMap()for reactive reads;getLayoutForSpec(id)for non-reactive snapshots. Writes funnel throughsetNodePosition(specId, nodeId, x, y),toggleCollapse(specId, containerId),setReplicationMode(specId, sourceId, mode | null)(commit 5 of the graph-readability sequence —nullclears the override back to implicit auto), and the Load boundarysetLayoutForSpec(specId, layout)— which removes the entry when givennullOR 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 onereplicationModesentry is present; App'sbuildSaveTextgatesdoc.layoutinclusion on it. EmptyreplicationModesmaps are NOT preserved on the LayoutSpec (the internalwithReplicationModeshelper 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.
-
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-grouppanels with "Block N" headers; each panel is a 4×4 grid withgrid-auto-flow: columnso 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'sSingleStateViewfor mixed-shape frames (notablyconcat-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 !== undefinedand the trace has more than one block). Pure visual — no scrubber surgery. Driven by App-levelblockCountmemo that scanstrace.framesfor the maxblockIndex. -
src/ui/components/RunExplorerModal.tsx— side-by-side run comparison modal (uses native<dialog>for backdrop + escape handling). Pure delta-string formatter lives inrun-delta-format.tsso 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-layoutlisting every non-padding registered step type, grouped by namespace (aes/generic/speck/serpent, inGROUP_ORDER; unknown namespaces tail-append alphabetically). Each entry isdraggable={true}and ondragstartwrites the step type ontodataTransferunder both the project-privateSTEP_TYPE_DRAG_MIME(application/x-cryptographer-step-type) andtext/plain(cross-browser fallback). ExcludesPADDING_STEP_TYPESfromcore/spec-mutations.ts— palette-dropping a padding overlay step type would create a top-level leaf that the next padding-selector flip silently strips viastripPaddingLeaves, so surfacing those types as palette entries would be a stealth footgun. Entries within a group sort alphabetically by display name (fromStepDocumentation.name, falling back to the type id for doc-less registrations). Each<li>carriesdata-step-type=<typeId>+data-testid="step-palette-entry-<typeId>"so tests can target by registry key. -
src/ui/components/GraphView.tsx— SVG renderer overderiveAuxGraph(getTrace(), spec())followed bycollapseGraph(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×Nchip in the header whenblockSpan > 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 callssetFrame(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-levelpointermove/pointeruplisteners;setPointerCaptureis 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 togglescollapsedGroupsfor 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 carriesonDragOver/onDragLeave/onDrop;dragoveraccepts only payloads that carry the project'sapplication/x-cryptographer-step-typeMIME (ortext/plainas a cross-browser fallback) and toggles.graph-drop-zone-activefor the dashed-outline highlight.LeafRectandContainerRectcarrydata-drop-anchor=<stepId>on their outer<g>; the drop handler walks up viaclosest("[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, sinceinsertStepAfteris the only mutator Slice 4 shipped and "into-container" would have needed a separate helper); drop on the SVG background (no anchor) → root-append viainsertStepIntoSpec(..., {kind:"root-append"}). Replica leaf nodes (graph-leaf-replica) carrydata-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-wisezq-vec-*@1primitives, which sit onNARRATION_NO_OP_ALLOWLISTinstead (128–256 identical coefficients per frame ⇒ the only honest conceptual unit would mean hundreds of<details>, againstnarration/registry.ts's own authoring convention). Split on thetruncate-to-reference@1criterion — does the teaching point change frame to frame?mlKemSampleNttNarrationis the strongest case in the app: it reads thesqueezesoutput port, the app's only value-dependent cost, and reports the block count that draw actually spent rather than the typical one.zqCompressNarrationis 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" atd = 10. Coefficients are read withcore/zq-vector.ts's ownreadCoeff, so the prose cannot disagree with the executor about element boundaries. Every narrator returnsnullrather than throwing on malformed params/ports — a palette-dropped leaf getsparams: {}, so that path is reachable by an ordinary user action.
tests/lattice-narration.test.tsx— jsdom. Drives all eleven lattice narrators with real frames from the shipped K-PKE specs (KeyGen's realek/dkare 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@1have no shipped-spec leaves until P4, so they get a purpose-built two-leafCipherSpecrun through the real runtime rather than a hand-builtTraceFrame. 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 withaes-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:cryptoacross 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 againstrotate-bits-right@1and 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—setTracekeeps 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.tsxfiles run in jsdom; see Conventions).matrix-viewalso 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-deltaalso 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 viaeditStepParams, 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-iterationstepId:b{i}suffixing,blockIndexframe stamps, accumulation intoaux[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 againstnode:cryptoduring implementation (caught a single-character typo in my own copy of the standard — block 4 ends…6c3710not…6c2710). Also asserts the 164-frame total count and pins specific:b0/:b3step 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 thepaddingLimitsranges 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 theSUPPORTED_CIPHER_MODES_BY_CIPHERmatrix at Phase-1 values. The intentional canary: when Phase 4 lands AES-192/256 ECB, this test'sexpect(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 PythonCryptoPlus.Cipher.pyserpentreference 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 — seedocs/gotchas.md, "Serpent has two equivalent forms".tests/serpent-key-schedule.test.ts— PinsroundKey.0androundKey.32against 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 thephi XOR iterm).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):deriveAuxGraphacross AES-128 (no iterate), AES-128-ECB (:b{i}collapse + iterate-mediated edge synthesis +blockSpanannotation), 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 tokind === "aux"because the producer also carries an outgoing spine edge after commit 2 — comments on each filter call out why. Slice 6 (7):collapseGraph— emptycollapsedIdsreturns the same graph reference (identity short-circuit); collapsing a round group drops its leaves but keeps the container withchildIds: []; 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;rootIdsfilters to entries that still resolve; collapsing an iterate container (ecb-blocksin 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-keyandround.10.add-round-key → concat-blocksMUST NOT exist) AND doesn't silently skip the iterate (compute-block-count → concat-blocksMUST 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:GraphViewrenders 41 leaf rects + 10 container rects for AES-128, edges carry the auxKey in<title>, leaf-click drivessetFrameto 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 withlinearactive by default, clickinggraph/JSONswaps 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;createdAtis 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 onsetSpecFromDocument. Slice 6 added three layout-sidecar tests: spec-with-no-customization omitsdoc.layoutentirely (the byte-stability gate for spec-only saves; mirrors the existing repeat-save test as an explicit absence check); save-after-dragging-a-container emitsdoc.layoutwith the pinned position + collapsedGroups; Save→reset→Load round-trip restores positions into the per-spec.id layout store keyed bydoc.spec.id. Drives Load via a realFileobject +Object.defineProperty(input, 'files', ...)(jsdom'sFileListis read-only; this is the standard workaround). Save spy is aURL.createObjectURLoverride that pushes Blobs into an array — using an array instead of a mutable field sidesteps TS control-flow narrowing ofnullreassignments.tests/layout-store.test.ts— 2D editor Slice 6: 18 node-env tests forsrc/ui/stores/layout.ts. Per-spec.id partitioning (AES-128 + AES-256 layouts coexist; setters don't cross-pollute).toggleCollapsesemantics (add on first call, remove on second; multiple distinct ids accumulate). localStorage persistence (each setter writes synchronously and the blob round-trips throughJSON.parse).setLayoutForSpecas the Load boundary — replace wholesale;nullremoves the entry; an emptyLayoutSpec(no positions + no collapses) ALSO removes the entry (this is the byte-stability gate Slice 5's spec-only save depends on).hasUserLayouttruth table (null → false; empty → false; any position → true; any collapse → true).clearLayoutForSpecremoves 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 ofGraphView.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 chevrondata-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-collapsedchip; clicking again restores. Re-queries the chevron between clicks because Solid swaps the DOM node when its glyph text changes (▾ → ▸). Isolation:setNodePosition+toggleCollapsecalls leavegetTrace()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, andserializeDocumentalready 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:buildShareHashwraps with#doc=prefix;extractHashPayloadreturns 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 viaObject.defineProperty(navigator, "clipboard", {...})withconfigurable: trueso 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-successpill with "link copied". Boot decode: encode a doc, stash it inwindow.location.hashviahistory.replaceStateBEFORErender(App);onMountdecodes 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'ssetSpecFromDocumenthydration-order test); layout sidecar restoresLayoutSpec.positions+collapsedGroupsinto the per-spec.id layout store. Malformed payload shows.errorbanner with "Could not load shared link"; empty / unrelated#sectionhash is a clean noop. Helpersat()andpayloadOf()extract array entries / URL fragments without!non-null assertions (biome lint compliance undernoUncheckedIndexedAccess).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 inPADDING_STEP_TYPESis absent; the four shipped namespaces (aes,generic,speck,serpent) render as[data-testid="step-palette-group-<ns>"]sections; every entry hasdraggable === true.insertStepIntoSpecmutator (6): inserts a new leaf with empty params after a named anchor in the top-level; root-append produces a leaf at the end ofspec.steps; the unique-id walker increments past<base>-1/-2/-3for 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 insideround.1(NOT at root), pinning thefindStepAndParentparent-resolution wiring. GraphView drop integration (4): firing a syntheticdropEvent with adataTransfer-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 withparent === nullfor 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'sDragEventandDataTransfersupport is partial across versions; we test the drop-handler boundary by injecting an already-populateddataTransferviaObject.definePropertyon a plainEvent. 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 theCipherDocumentfile 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).schemaVersionhandling (accepts 1, rejects 0 and 2 with the friendly pre-check error). Error paths (malformed JSON, missingspec, unknown wrapper-layer key, unknown stepkind, 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-timeExclude<>checks indocument-schema.ts.tests/zq-compress-decompress.test.ts— the FIPS 203 §4.2.1 formulas exhaustively: everyx ∈ [0, q)and everyy ∈ [0, 2^d)for everyd ∈ 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, thed = 12pigeonhole, and theceil(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 becausegenerateKeyPairSyncsilently ignores itsseedoption on Node v24.14.1 (P3 later recovered determinism by importing a seed-arm PKCS#8 instead — seetests/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 distinct2η-bit pattern exactly once makes the histogram BEC(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.tskeeps 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: ρ (pinsGand the‖ kbyte) →dk_PKE(pins the PRF, the counter ORDER, CBD, and NTT(s)) → the wholeek(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 ρ anddk_PKEgreen. Ends with an id-uniqueness walk over all three shipped specs.tests/ml-kem-monoliths.test.ts— the five Keccak monoliths againstnode:cryptodirectly, becausehash-handkdf-jbelong to the FO wrapper and no P3 spec calls them ("covered by the KAT" would be false for them).SampleNTTis 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 sharedand differ inz, 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 viatests/fixtures/ml-dsa-44-vectors.json(Node 24's seed-import PKCS#8 for keys,@noble/post-quantum0.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-3H(ξ)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 overnode:crypto's SHAKE in one bulk squeeze; the NTT pair against the traced spec;shake256Readeracross 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 fromregistry.types()by prefix.tests/keccak-compute.test.ts— pins the one-call sponge againstnode:cryptoAND against the app's own tracedbuildSha3256Spec(), 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 gapparam-editor-coverage.test.tsxleaves 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 realinsertStepIntoSpecpath. It found a real bug: a dropped leaf getsparams: {}, so rows gated on a value being present vanish exactly when the user needs them — thedandη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.