M2 Report — the TypeScript core, built in isolation
Date: 2026-08-22 · Status: Draft 1 · Purpose: the deliverable docs/17-m2-implementer-brief.md §5 asks for: the isolation statement, the divergence list (delivered even where empty), every place the specification was ambiguous or underspecified even where the guess was right, the dependency deviations from docs/11, and the resolution of its [VERIFY] flags. Styled after docs/06-verification-log.md: the entries that say “this was wrong” are the ones that make the document worth trusting.
The code: core/typescript/. The harness: core/typescript/tests/harness/, emitting the docs/14 §4 report via npm run vectors.
1. Isolation statement
The brief’s prohibition (§1) was followed: core/python/** and tools/vector-gen/** were not opened, read, grepped, or listed at any point, and no search tool was pointed at either tree. Everything below was derived from docs/02, docs/08, docs/09, docs/11, docs/14, docs/17, the G-issue drafts, and the vector files themselves.
Three disclosures, so the record is complete rather than merely clean:
- Paths, not contents.
.github/workflows/conformance.yml(on the reading path as the CI definition) namescore/python/tests/run_vectors.pyand thepip install -e "./core/python[argon2,dev]"command;ls coreshowed the directory namepython. No file under either tree was opened. - High-level description of M1.
docs/07§7’s M1 entry (on the prescribed reading path) states that the Python core passed 37/37, uses pyca/cryptography’s HKDF, and shares no code with the generator. That is the extent of what this implementer knows about the Python core. - Same author, in the sense
docs/07§3 anticipates. This core was written by an AI assistant (Claude) in a fresh session with no access to the earlier sessions’ context. The Python core and the vector generator were, per the repository’s log, produced by the same maintainer with the same class of assistant in earlier sessions. The isolation is therefore session-level (no memory of the other implementation’s contents, and a hard prohibition on reading it), not author-level.docs/07M2 says to state this because it weakens the independence claim; it is stated. Where the two implementations could share a bias rather than code — the same reading of an ambiguous clause — §3 below lists every such clause so a human reviewer can check them with that risk in mind.
The order of work the brief requires (§3) was followed: every module was written from the specification first; the harness was run once the core compiled; no constant, label, order or encoding was adjusted afterwards. §2 is the result of that single run.
2. Result
Every vector in every file listed in MANIFEST.files passes, in both directions where the family requires it, on the first run. No value was tuned; no mismatch occurred.
| Family | Vectors | Results | Outcome |
|---|---|---|---|
envelope/ff01.json | 8 | 16 (encrypt + decrypt direction each) | pass; intermediates (record key, commitment) agree on all 8 |
kdf/record-key.json | 4 (3 computed + 1 assertion) | 4 | pass |
kdf/index-key.json | 3 (2 + 1 assertion) | 3 | pass |
context/canonical.json | 8 (7 + 1 assertion) | 8 | pass |
commitment/ff01.json | 2 (1 + 1 assertion) | 2 | pass |
blind-index/hmac-sha512.json | 5 (4 + 1 assertion) | 9 (4 extra #pipeline results through Fieldseal.blindIndex() end to end) | pass |
blind-index/argon2id.json | held out | not-run | not counted (see D-15 for the development-only observation) |
out-of-band (spec/3.5/length-bound, #decrypt) | — | 2 | pass: 2³¹ bytes refused with LENGTH_EXCEEDED on both sides, before key acquisition / allocation |
Report summary: { "pass": 42, "fail": 0, "skipped": 0, "held_out": 1 } at vector_suite_version: "0.1.0-provisional", provisional_suites: true, suites_supported: ["0xFF01"], async_companions: false. Environment: Node 24.16.0, OpenSSL 3.5.6, ICU 78.3 / Unicode 17.0 (NFC), vendored CaseFolding-17.0.0.txt (C+F). Beyond the harness, 79 further tests (127 in total) cover the gates and refusals vectors cannot express (brief §5 item 3): the §4.8 arming gate on encrypt/rotate and its deliberate absence on decrypt/blindIndex/isCiphertext; the three §10.3 modes on both axes; the FIELDSEAL_TEST_MODE arming gate and the no-seed-no-nonce rule for production encrypt; totality of decrypt and isCiphertext over arbitrary bytes (property-tested), every prefix of a valid envelope, and every single-field alteration mapped to its pinned §9 code; primitive known-answer checks (RFC 5869 A.1, GCM spec test cases 13/14, RFC 9106 §5.3); and the provider/cache contracts of docs/09 §8.
What this does and does not establish. The central claim — a value encrypted by one implementation is decryptable by another — survives its first real test for suite 0xFF01 at the pinned suite: two cores, written without reading each other, agree byte for byte on every published expected value, including the 32-byte commitment whose formula the specification itself does not state (D-01). It is L0 conformance to the provisional suite 0xFF01, not to any frozen format (PRD §8, Gate 0b), and the cross/ family that would make M3’s N×N matrix meaningful does not exist yet (D-09).
3. Divergences and ambiguities
The brief’s list (§3): for every mismatch, the vector id, computed and expected values, the clause, and which of the three causes. There were no mismatches. The table below is the other half of the deliverable — every place the specification was ambiguous, underspecified or self-contradictory, including where the guess turned out right. Each entry says what was done and what kind of finding it is: spec gap (the spec should say it), vector/doc drift (the vectors or a tech spec disagree with each other), or pin (an implementation choice the spec leaves open and the conformance report declares).
| # | Clause | Finding | What this core does | Kind |
|---|---|---|---|---|
| D-01 | §4.6, §3.1 | The commitment construction is not in the specification. §4.6 settles the requirement and marks the construction [PROVISIONAL — G1], and docs/07 §1 says each provisionally adopted gap’s “proposed direction in the issue draft becomes normative spec text” — but §4.6 carries no formula. An implementer reading only docs/02 cannot compute the envelope’s last 32 bytes. The formula exists only in docs/issues/G01 (“Proposed direction (starting point, not a decision)”). | Implemented the issue draft’s HKDF-SHA-512(ikm = record_key, salt = "", info = "fieldseal-commit-v1", 32), verified constant-time before AEAD open. The commitment/ and envelope/ vectors agree, which corroborates salt = "" (the one input the vector does not carry). | Spec gap — the provisional formula should be written into §4.6 with its marker, as §6.2 and §7.3 were. |
| D-02 | §9 [PROVISIONAL — G5] | Decrypt-path precedence is unpinned by design; a Gate 0a implementation MUST pin one and declare it. | Pinned docs/09 §3.2’s order verbatim: recognition → LENGTH_EXCEEDED → SUITE_NOT_ALLOWED → KEY_UNAVAILABLE → per-candidate commitment-then-open (TAG_INVALID after a verified commitment) → COMMITMENT_INVALID. AAD_MISMATCH is never raised on the 0xFF01 path; the optional diagnostic re-derivation is not implemented. Declared in pinned_decisions.decrypt-order. | Pin (known gap). The errors/ vectors that would test it do not exist (D-09); tests/totality.test.ts exercises every case the docs/08 §4.6 table lists. |
| D-03 | §9, §3.4 | UNKNOWN_FORMAT_VERSION is raisable only for a “reserved-known-future” version byte (docs/09 §3.2 footnote), but no document defines that set; docs/08 §4.6 gives only the example 0x02. There is also a standing tension: is_ciphertext MUST be false for an unrecognized version (§3.4) while decrypt raises a code whose meaning is “this is ciphertext from a newer implementation”. | Pinned the set to exactly {0x02} with a plausible-length requirement (≥ 111 bytes). 0x00, 0x03–0xFF are NOT_CIPHERTEXT (pass-through in permissive/readonly); 0x02 raises in every mode. | Spec gap — §9 or §3.4 should define the reserved range, or drop the code. Two cores could legitimately differ on 0x03 today. |
| D-04 | §9, §4.8, §3.5 | Three refusals are all “at the API boundary, before key acquisition” — MODE_VIOLATION, SUITE_PROVISIONAL, LENGTH_EXCEEDED — with no relative order. docs/09 §3.1 orders the first and third; SUITE_PROVISIONAL was added by §4.8 without a place in that pipeline. | MODE_VIOLATION → SUITE_PROVISIONAL → LENGTH_EXCEEDED → context validation (configuration-derived refusals before any look at the operand). Declared in pinned_decisions.api-boundary-order. | Pin; minor spec gap. No vector can pair two of them, so cores cannot be caught differing. |
| D-05 | docs/08 §1 principle 3, §4.3 | context/canonical.json carries no suite_id, yet every expected canonical_context embeds one (ff01). The family depends on an implicit default, which principle 3 (“no implicit defaults … a vector that depends on an implementation’s default configuration is a bug”) forbids. | The harness assumes 0xFF01 and records details.assumed_suite_id on every context result. | Vector drift — add a top-level suite_id to the family, as kdf/ and envelope/ have. |
| D-06 | docs/08 §4.2, §7.2 | kdf/index-key.json vectors carry context.purpose = "encrypt" plus a separate top-level index_id, while docs/08 §4.2 says the family mirrors record-key “with purpose of the form "index:<index-id>"”, and the expected info indeed encodes index:email-eq. A harness that feeds context as given derives the wrong key. | The harness constructs the spec’s context (purpose = "index:" + index_id, row_id = null) from the two fields. | Vector drift — either put index:<id> in context.purpose or document the split in docs/08 §4.2. The blind-index/ family has the same index_id field, harmlessly. |
| D-07 | docs/08 §4.4, docs/09 §7 | The blind-index/ files diverge from docs/08 §4.4’s shape in every field name (plaintext_utf8/normalizer/b_bits/expected.blind_index/expected.stored as a hex string/stored_bytes versus plaintext/idf/idf_params/normalize/truncate_bits/expected.index/expected.stored{binary,hex,octets}), carry no idf field (the IDF is inferable only from the file name), and name the normalizer nfc-casefold where docs/09 §7 declares nfc-casefold-v1. | The harness maps nfc-casefold → nfc-casefold-v1 explicitly and infers the IDF from the file stem; the core ships only the docs/09 identifiers. | Vector/doc drift — pick one shape; the versioned identifier matters because normalizers are portability surface (docs/09 §7). |
| D-08 | docs/08 §4 | Assertion vectors (assertion: "distinct" / "equal", e.g. kdf/record-key/seed-changes-key) carry only literal expected values and no inputs — the “different msg_seed” that produces key_b is not given. An implementation cannot reproduce them; it can only check that two literals differ. | Checked the literal relation; reported pass with details.reproducible = false and a note. | Vector drift — either carry the inputs or state in docs/08 that assertion vectors are file-level invariants, not implementation tests. |
| D-09 | docs/08 §2, §5, §4.6, §4.7 | vectors/schema/ and vectors/keys/ are empty, and the errors/ and cross/ families do not exist. Harness contract item 2 (schema validation) cannot be performed; item 4.6’s negative vectors and 4.7’s cross protocol have nothing to run. docs/08 §9 says “nothing in this document is blocked any longer” and lists the generator’s output as context/, kdf/, commitment/, blind-index/, envelope/ff01.json — so the omission is known but not flagged as a gap. | The harness performs its own structural validation of every vector object before running it (fails loudly on a malformed file), records the absence in harness_notes, and the §9 error coverage is supplied by implementation tests instead. No cross producer was written: key_ref resolution needs keys/test-keys.json, whose format docs/08 does not define. | Doc gap — docs/08 §9 should list schema/, keys/, errors/ and cross/ as not yet authored; M3 depends on the last two. |
| D-10 | docs/09 §7 nfc-casefold-v1 | Underspecified on four points, three of which docs/09 itself flags: (a) folding variant — pinned here to CaseFolding.txt statuses C + F, no Turkic T; (b) Unicode version — pinned to 17.0.0 via a vendored table, as docs/09 suggests; (c) whether to re-normalize after folding — Unicode §3.13 notes toCasefold(X) need not be normalized, and defines canonical caseless matching as NFD(toCasefold(NFD(X))); docs/09 says “NFC, then full case folding, then UTF-8 encode” with no second pass; (d) invalid UTF-8 input. | (a)–(b) as stated; (c) no post-fold NFC, following the doc literally; (d) refused with a typed INVALID_ARGUMENT rather than folded through replacement characters, which would collide distinct invalid inputs. The three pinned vectors agree — and grüße → grüsse is the one fact they actually pin: the folding is full (F), since simple folding and toLowerCase both keep ß. Nothing in the suite distinguishes (c) or (d). | Spec/doc gap — (c) and (d) can silently split two cores on real data; both belong in docs/09 §7 and then in vectors (a character whose folding is not NFC-stable; an invalid byte sequence). |
| D-11 | §3.4 | “at least the minimum envelope length for a registered suite” reads either as that suite’s minimum (after reading suite_id) or as the global minimum over all registered suites. They differ for a 0xFF02-tagged blob of 111–122 bytes. | Per-suite minimum (a 115-byte 0xFF02 blob cannot be an envelope). | Pin; minor ambiguity. |
| D-12 | §4.2, §9, G7 | What a core does when configured with a suite it cannot perform (0xFF02) is left to the implementer (brief §4). §9 has no code for “registered, allow-listed, not implemented”, and SUITE_NOT_ALLOWED would contradict the operator’s own allow-list. | 0xFF02 is registered (so isCiphertext() is true and a 0xFF02 envelope is never mistaken for plaintext — §3.4’s double-encryption story) but not implemented; allow-listing it or naming it as writeSuite is refused at construction with a CONFIGURATION_ERROR naming G7. Consequently the only §9 outcome a 0xFF02 envelope can reach is SUITE_NOT_ALLOWED, which is then true. | Pin, recorded as the brief asked. |
| D-13 | §11.1, §10.3 | rotate() is “a decrypt followed by an encrypt”. In permissive mode decrypt of non-envelope input passes through, so rotate of unmigrated plaintext encrypts it. The spec does not say whether that is intended (it is exactly what a backfill wants) or a mistake (rotate “rotates”; it did not decrypt anything). | Literal composition: it encrypts. Declared in pinned_decisions.rotate-in-permissive. | Spec gap — say which. Two cores could differ observably, and no vector covers it. |
| D-14 | §4.8 | The arming mechanism is constrained in shape (affirmative, out of band, not in the ordinary config) but not named. FIELDSEAL_TEST_MODE is named by docs/08 §6; its §4.8 sibling is not. | Environment variable FIELDSEAL_ARM_PROVISIONAL_SUITES=1, or { armProvisionalSuites: true } as the second constructor argument; a property inside the config object is ignored (tested). | Spec gap — name the variable, as §4.8 itself says the two gates should share one shape. Operators of two cores will otherwise meet two names. |
| D-15 | §7.3, docs/08 §7, MANIFEST.held_out | The hold-out reason says RFC 9106 §5.3’s vector is “unreproducible on this stack” because it needs K and X. It is reproducible on the Node stack: node:crypto.argon2Sync accepts secret and associatedData, and reproduces the RFC tag exactly (tests/primitives.test.ts). Separately, as a development-only exercise (permitted by docs/14 §4; not reported, not counted), this core’s §7.3 invocation over that backend reproduces all four held-out argon2id vectors, salt included. p is passed explicitly as 1; node:crypto has no version parameter — it is 0x13 only (an undocumented version key is silently ignored; verified), which the RFC vector confirms. | Argon2id shipped behind the Argon2Backend interface over node:crypto; the held-out family stays not-run. | Finding for the project, not a spec gap: the unblocks_when condition (“a known-answer source for Argon2id with empty K and X”) is stricter than necessary — a backend that matches the RFC vector with K and X, and then matches the generator without them, corroborates the generator’s invocation. Whether that is enough to un-hold the file is the project’s call, not this report’s. libsodium’s internal p (§7.3 [VERIFY]) remains unverified here; no libsodium is on this stack. |
| D-16 | §6.1 | tenant_id : bytes | null; TypeScript adds undefined for an omitted property. | undefined and null both mean absent. | Pin, trivial. |
| D-17 | docs/08 §4.1 | Vectors carry expected.envelope_bytes, absent from §4.1’s shape. | Asserted. | Harmless drift. |
| D-18 | §8 | decryption_keys “MUST return keys for all currently-valid versions”; a provider whose key_id names the version can do better than trying every one. | Named version first, then the active version, then the rest — literal compliance, candidate order optimized. | Observation. |
| D-19 | §9, docs/14 §4 | §9 obliges a Gate 0a implementation to “declare [its pinned order] in its conformance report”, but docs/14 §4’s JSON has no field for it. | Added pinned_decisions (and harness_notes) to the report as extensions of the documented shape. | Doc gap — docs/14 §4 should reserve the field so the Python report can declare the same thing in the same place. |
| D-20 | docs/11 §1 | Module format “ESM with exports map; CJS decided at implementation”. | ESM only. require("@fieldseal/core") works on Node ≥ 22.12 via require(esm) because the module graph has no top-level await; no separate CJS build. | Pin, documented. |
Nothing above changed a byte of the envelope. The three that could split two cores on real data without any vector noticing are D-10(c)/(d), D-13 and D-03; the one that would have blocked an implementer without the issue drafts is D-01.
4. Dependency deviations from docs/11 §2
docs/11 row | Finding | Deviation |
|---|---|---|
node:crypto for AES-256-GCM, HKDF-SHA-512, HMAC, CSPRNG, constant-time compare | Confirmed. hkdfSync(digest, ikm, salt, info, keylen) takes exactly that order and returns an ArrayBuffer — verified against RFC 5869 A.1 test case 1 and cross-checked against a hand-rolled extract-then-expand over createHmac (so a transposed argument would fail loudly, not derive quietly). Addendum, 2026-08-22 (post-review): the row above was wrong in the way that matters. hkdfSync — and Web Crypto deriveBits — reject an info longer than 1024 bytes (ERR_OUT_OF_RANGE), and this core’s info is canonical_context, whose tenant_id and row_id are unbounded (§6.1). Above ~930 bytes of optional context, encrypt, decrypt and blindIndex threw an untyped RangeError; an envelope written by a core without that cap was unreadable here, and the totality claim in §2 did not hold because the property generators stopped at 40-byte fields. Found in review, not by the suite — no vector carries a large context. src/kdf.ts now implements RFC 5869 over createHmac, pinned to the RFC’s A.1–A.3 answers and to hkdfSync wherever hkdfSync accepts the input; tests/gates.test.ts round-trips contexts up to 70 000 bytes and the property generators draw field lengths uniformly on 0..1500. | Deviation: HKDF is hand-rolled over createHmac. The [VERIFY] on argument order is moot; the open item is a spec one — whether §6.1 should bound the optional fields, since an unbounded info is a footgun for any core built on a platform HKDF. |
XChaCha20-Poly1305 via @noble/ciphers | node:crypto on Node 24.16 / OpenSSL 3.5.6 lists chacha20-poly1305 and no XChaCha variant — the [VERIFY] is confirmed. @noble/ciphers (2.3.0 on npm) was not added: 0xFF02 is deliberately unbuilt (G7), so the optional dependency would carry a supply-chain surface for nothing. | None in effect; the row stays as the plan for when G7 closes. |
Argon2id sync raw output — “the hardest dependency choice in either Phase 1 core” (sodium-native / @node-rs/argon2 / argon2) | Moot on Node ≥ 24.7.0: crypto.argon2Sync is built in (since v24.7.0, @types/node 24.13). Synchronous, raw output, explicit parallelism, memory in KiB, passes; accepts secret/associatedData (not used by the §7.3 invocation, but exactly what lets the RFC 9106 §5.3 KAT run — D-15); no version parameter, 0x13 only. @node-rs/argon2 2.1.0’s README documents only the async hash/verify; its sync raw API was not verified because it was not needed. sodium-native 5.1.0 and argon2 0.45.1 were not evaluated. | Deviation: zero external dependencies, behind the Argon2Backend interface the doc asked for. Cost: the Node floor rises from “≥ 20 LTS” to ≥ 24.7.0. A deployment pinned to Node 20/22 would need an optional native backend; none is shipped. Recorded as a documented trade-off, not hidden. |
| Runtime target “Node ≥ 20 LTS [VERIFY current LTS floor]” | Node 24 is an LTS line (24.16.0 on the development machine); which line is Active LTS on a given date was not re-verified against the Node release schedule here. | See above: engines.node >= 24.7.0, set by argon2Sync, not by the LTS calendar. |
TypeScript strict, tsc, no bundler | TypeScript 5.9.3, pinned with ~. npm’s latest is 7.0.2 — the native-compiler line — and was deliberately not adopted for a “boring toolchain”; erasableSyntaxOnly + rewriteRelativeImportExtensions let the same .ts sources run under Node’s native type stripping (the harness and report script run without a build step) and compile with tsc. | Pinned below latest, on purpose. |
| vitest | 4.1.11. fast-check 4.9.0 for the property passes. | None. |
| eslint + prettier / biome | Not configured. | Omission, stated. |
Node buffer maxima (docs/09 §4 [VERIFY]): buffer.constants.MAX_LENGTH is 2⁵³−1 on Node 24 x64, so the spec §3.5 bound (2³¹−1) binds before the platform does; a 2³¹-byte Uint8Array allocates lazily (1 ms, no RSS growth) and the bound was verified directly rather than recorded as unverifiable.
5. Conformance claim
L0 against provisional suite 0xFF01 at vector_suite_version 0.1.0-provisional, from a report with fail: 0, provisional_suites: true, the §9 precedence declared, the §3.5 bound verified out of band on both sides, and blind-index/argon2id.json reported not-run. Per spec §4.8 this claim names the provisional identifier and is not restated against 0x0001; per PRD §8 it is not a claim against a frozen format, because none exists. No adapter level is claimed. async_companions: false.
6. What was not done
- No
cross/producer (D-09):keys/test-keys.jsonand thekey_refformat do not exist yet. M3’s N×N job needs both and a second implemented suite to be more than a self-pair. - No
errors/vectors were authored here: that is WS-A’s generator’s job and the brief forbids touching it. The cases are covered by implementation tests whose expected codes follow the pinned order in D-02 — which means they test this core’s pin, not a shared one. - No lint/format configuration. No async companion (
docs/11§2’s benchmark condition was not run; the ~40 ms/term Argon2id figure above is a single-machine observation, not a benchmark). - The §7.3 [VERIFY] on libsodium’s internal parallelism is untouched; this stack has no libsodium.
7. Recommended follow-ups, in order of how much they protect the central claim
- Write the G1 formula into spec §4.6 under its
[PROVISIONAL]marker (D-01). - Author
errors/vectors from the pinned order once the Python report declares its own; if the two pins differ, that is the first real G5 data point (D-02, D-03, D-13). - Fix the four vector-format drifts (D-05, D-06, D-07, D-08) in the generator, which this implementer cannot touch, and regenerate with a suite version bump.
- Pin
nfc-casefold-v1’s post-fold normalization and invalid-UTF-8 behavior indocs/09§7 and add vectors for both (D-10). - Decide whether D-15 satisfies
unblocks_whenfor the Argon2id hold-out. - Reserve
pinned_decisionsindocs/14§4 (D-19) and name the §4.8 environment variable in the spec (D-14).