Test Vector Technical Specification
On this page
Date: 2026-08-08 · Status: Draft 1 · Purpose: the engineering specification for authoring, validating, and consuming the machine-readable test vectors described in spec §12 and vectors/README.md. Written for whoever builds the vector suite and the per-language conformance harnesses.
Depends on: docs/02-spec-v0.1.md (normative), docs/07-implementation-plan.md (decision gates). Blocked in part by: the spec gaps listed in §9 of this document — several vector families cannot be given expected values until those are resolved by spec issues.
1. Principles
- Vectors are the conformance oracle. An implementation claims a conformance level (spec §10) only by passing every vector for that level in CI. The vectors, not any implementation, are the source of truth. If a vector and an implementation disagree, the vector wins unless a spec issue proves the vector wrong.
- Vectors are append-only after release. Once a vector file ships in a tagged release, its vectors’
ids and expected values MUST never change. Corrections retire anid(moved to aretiredlist with a reason and a spec-issue link) and add a newid. This is what lets independent implementations pin a vector-suite version. - No implicit defaults. Every input an implementation needs to reproduce the expected output is present in the vector. A vector that depends on an implementation’s default configuration is a bug.
- Negative vectors are first-class. Spec §9 requires distinguishable error types;
vectors/README.mdmakes collapsingAAD_MISMATCHandTAG_INVALIDnon-conformant. Every error case therefore has vectors, and each names the exact expected error code. - Fixed randomness is a testing affordance only. Vectors fix
msg_seedandnoncefor determinism. Spec §3.1/§4.4 require fresh CSPRNG values on every real encryption. The injection mechanism (§6 below) MUST NOT be reachable from an implementation’s public production API — and because Python/npm packages cannot prevent a determinedimportof a shipped module, §6 defines what unreachable means in practice: a separated namespace that the main entry never re-exports, plus a runtime arming gate.
2. Directory layout
Extends the planned layout in vectors/README.md with schema and shared-key files:
vectors/
MANIFEST.json suite-wide metadata: vector-suite version, file list, sha256 per file;
`files` bind cores, `adapter_files` bind adapters (§4.8)
schema/ JSON Schema (draft 2020-12), one per family (extracted from §4 of this doc)
common.schema.json
envelope.schema.json
kdf.schema.json
context.schema.json
blind-index.schema.json
commitment.schema.json
errors.schema.json
cross.schema.json
keys/
test-keys.json shared, PUBLIC test key material referenced by key_ref (never real keys);
emitted by the generator, listed in MANIFEST.support, never run
cross/
corpus.json the shared producer-input corpus (MANIFEST.support): key refs,
contexts, plaintexts -- no envelopes, no expected values
envelope/
ff01.json suite 0xFF01 round trips
ff02.json suite 0xFF02 round trips (consumed only by implementations claiming 0xFF02)
kdf/
record-key.json
index-key.json
context/
canonical.json
blind-index/
hmac-sha512.json
argon2id.json pinned since 0.6.0-provisional (was held out; §9)
commitment/
ff01.json
ff02.json
errors/
format.json structural failures: truncation, unknown fmt_ver, unregistered suite
policy.json allow-list, read-mode cases
crypto.json tag flips, AAD alteration, commitment mismatch, salamander case
codec/
logical-types.json spec §3.6 renderings, both directions (MANIFEST.adapter_files; §4.8)
cross/
keys → ../keys/test-keys.json (by reference, not symlink — see §4.7's key_ref rule)
static/
python/ff01.json envelopes produced by each released implementation
typescript/ff01.json
MANIFEST.json carries a semver vector_suite_version. Implementations record the version they were validated against in their conformance claim (see docs/14-conformance-ci.md).
3. Encoding conventions (normative for vector files)
| Value | Encoding | Rationale |
|---|---|---|
| All binary values (keys, seeds, nonces, ciphertexts, contexts, UUID surrogates) | Lowercase hex, no 0x prefix, even length | Matches vectors/README.md; unambiguous, diff-friendly |
suite_id | String "0xFF01" (0x prefix, four uppercase hex digits) | Matches the existing README example; visually distinct from binary blobs. Case is pinned because provisional identifiers (spec §4.8) contain letters and harnesses compare this field as a string |
fmt_ver | String "0x01" | Same convention as suite_id |
Absent optional value (row_id, tenant_id) | JSON null | Distinct from empty: "" (hex) means present with zero length. Distinct on the wire since the §6.2 presence bitmap was adopted provisionally under Gate 0a (G4, 2026-08-22); context/canonical.json pins both (tenant-zero-length, absent-differs-from-zero-length) and regenerates if Gate 0b changes the bit assignment. |
Text values (purpose, normalization names, error codes) | JSON string, ASCII | purpose is a protocol string, not user text |
| Sizes/lengths | JSON integer, bytes unless the field name says _bits | Truncation length b is in bits per spec §7.4 |
Vector files are UTF-8, LF line endings (repo .gitattributes pins LF), 2-space indent, keys in the order given by the schema — so that regenerated files diff cleanly.
Two fields common to every family since suite 0.2.0 (2026-08-23):
provisional_on— a list of gap identifiers ("G5","G14","G15") or, for a pin not yet owned by an issue, thedocs/18divergence id ("D-11"). Present on every vector whose expected value would change if that gap closed the other way; absent otherwise. A harness does not act on it — it is the reader’s map from an expected value to the decision that produced it, and the regeneration list when a gap closes.assertionvectors ("distinct"/"equal") carry aninputsobject holding both sides’ inputs, so an implementation reproduces each side and then checks the relation. Earlier suites carried only the literal expected values (docs/18 D-08); a harness that merely compares the literals is no longer checking anything.- Suite
0.3.0-provisionaladds two one-sided assertions for docs/09 §7.2, both inblind-index/."unindexable-marker"carriesinputs.reserved_preimage(hex, because the preimage is deliberately not valid UTF-8 and has no text form) and pins the derived marker’s bytes."unindexable-bucket"carries aplaintext_preimagecontaining a code point the pin does not define, and pins both thaton_unindexable = bucketderives the marker for it and thatrefusestill raisesINVALID_ARGUMENT— abucketthat never fires is useless, and arefusethat stopped refusing would be a silent policy change. Neither carriesmust_be_equal; both carryexpected.index. A harness MUST fail on an assertion kind it does not recognise, never skip it — a core that silently ignored an unknown assertion could drop a whole class of requirement and still report green. - Suite
0.4.0-provisional(G19, resolved 2026-08-26) adds one"equal"collision pair toblind-index/:normalizer-collapses-e-acute, precomposedU+00E9against decomposede+U+0301— the pair spec §7.5’s comparison rule names. The index half of that rule is what a vector can express (two byte-different spellings of one text MUST land on one index value); the comparison itself is an adapter obligation held by per-adapter tests, sodocs/14§4 is untouched. The pair is guarded twice in the generator: the two NFC-collapsible pairs are written as backslash-u escapes, and generation asserts every collision pair’s preimages differ — identical preimages would satisfymust_be_equalvacuously, and a harness does not re-check distinctness. - Suite
0.8.0-provisional(G26, #176) adds a fourth one-sided assertion,"refuse", inblind-index/:inputs.preimageis hex bytes (not valid UTF-8, so no JSON string could hold it),inputs.on_unindexableis"refuse", andexpected.refuseis the code the index path must raise. It carries noindex. §4.4 has the vectors.
4. Vector file format per family
Every file shares a common wrapper:
{
"schema": "fieldseal-vectors/<family>/v1",
"vector_suite_version": "0.1.0",
"group": "<family>",
"spec_version": "0.1-draft",
"vectors": [ ... ],
"retired": [ { "id": "...", "reason": "...", "spec_issue": "..." } ]
}
Every vector object carries:
id— stable string, grammar:<family>/<file-stem>/<slug>whereslugmatches[a-z0-9-]{1,64}. Example:envelope/ff01/basic-roundtrip. Never reused, even after retirement.description— one sentence, for failure messages.spec_ref— the section(s) the vector exercises, e.g."§3.1, §4.2".
4.1 envelope/ — encrypt/decrypt round trips
Input state is complete: no key provider, no cache, no modes — this family tests the cryptographic core in isolation.
{
"id": "envelope/ff01/basic-roundtrip",
"description": "9-byte plaintext, tenant context, row_id absent",
"spec_ref": "§3.1, §4.2, §5.3, §6.2, §6.3",
"suite_id": "0xFF01",
"tenant_dek": "…64 hex chars (32 B)…",
"key_id": "…32 hex chars (16 B)…",
"msg_seed": "…64 hex chars (32 B)…",
"nonce": "…24 hex chars (12 B for 0xFF01)…",
"context": {
"table_uuid": "…32 hex…",
"column_uuid": "…32 hex…",
"tenant_id": "…hex…",
"row_id": null,
"purpose": "encrypt"
},
"plaintext": "…hex…",
"expected": {
"envelope": "…hex, the full concatenated envelope of spec §3.1…",
"canonical_context": "…hex…",
"aad": "…hex…"
},
"intermediates": {
"record_key": "…hex…",
"commitment": "…hex…"
}
}
expected.envelopeis the normative assertion, byte-exact.expected.canonical_contextandexpected.aadare also normative (they double ascontext/coverage in situ).intermediatesis non-normative debugging aid — harnesses SHOULD check it when their core exposes the values, and MUST NOT fail conformance on it alone.- Each envelope vector is exercised in both directions: encrypt(inputs) →
expected.envelope, and decrypt(expected.envelope) →plaintext. The harness contract (§5) requires both. suite_idsits at the vector’s top level, outside thecontextobject, deliberately: the context’ssuite_idmember is filled by the core, never by the caller — from the write suite on encrypt and from the parsed header on decrypt (docs/09 §3.2 step 4). A vector that carried it insidecontextwould imply the caller supplies it.
Minimum case coverage per suite: empty plaintext (0 B) · 1 B · a 9-byte SSN-shaped value · a 1 KiB value · a value crossing an AEAD block boundary · row_id present · row_id absent · tenant_id present · tenant_id absent · multi-byte UTF-8 plaintext (as raw bytes — the core is bytes-in/bytes-out).
Correction 2026-08-22 — “maximum-length purpose string” was removed from that list. It is not expressible in this family: spec §5.3 constrains record-key derivation to purpose = "encrypt", so an envelope vector cannot carry an index purpose at all. A generator that substituted an encrypt context to satisfy the requirement would emit a byte-identical duplicate of the basic round trip under a name claiming otherwise — which is worse than no coverage, because it reports as a passing case. The maximum-length index-id is covered where it is actually reachable, in context/canonical.json.
4.2 kdf/ — key derivation
{
"id": "kdf/record-key/row-id-absent",
"spec_ref": "§5.3",
"suite_id": "0xFF01",
"tenant_dek": "…hex…",
"key_id": "…hex…",
"msg_seed": "…hex…",
"context": { ... },
"expected": { "record_key": "…hex…" }
}
kdf/index-key.json mirrors it with tenant_index_key as input, context.purpose of the form "index:<index-id>", row_id null (spec §7.2), and expected.index_key; a top-level index_id repeats the identifier for readability only. (Until suite 0.2.0 the context carried purpose = "encrypt" and a harness had to construct the index purpose itself — docs/18 D-06.) The file carries two vectors differing only in the index identifier (email-eq vs ssn-eq) to pin the §7.2 distinctness rule, two differing only in column_uuid to pin per-column separation, one whose caller context carried a row_id (marked same_as the row-less vector, since §7.2 drops it), and one at the maximum index-path context (G14).
4.3 context/ — canonical encoding
Byte-exact canonical_context and AAD outputs for representative contexts. This family exists so an implementation can debug encoding independently of any cryptography.
Required cases: all fields present · row_id null (omitted entirely per §6.2) · tenant_id at boundary lengths (1 B, 16 B, 64 B) · purpose = "encrypt" and an index purpose · a context whose fields contain bytes that would be misparsed under naive concatenation (a tenant_id ending in bytes that look like a u64be length prefix followed by eight bytes) — this is the anti-forgery case that justifies §6.2. Every vector carries a top-level suite_id, since canonical_context embeds one (docs/18 D-05; emitted since suite 0.2.0, as are the boundary and forgery cases, which 0.1.0 lacked).
G14 lengths (suite 0.2.0, provisional_on: ["G14"]): tenant_id and row_id at 255 bytes each (the bound G14 proposes), at 2000 bytes each (above the 1024-byte HKDF info cap of node:crypto — the length that split the two cores on 2026-08-22 with no vector noticing), and the longest index-path context (2000-byte tenant_id, 32-character index-id). The same maximum context appears in envelope/ff01.json and both kdf/ files. If G14 adopts a bound below 2000, those vectors retire and a refusal vector replaces them.
Grammar refusals (G11, issue #11, resolved 2026-08-09): spec §6.1 now constrains index-id to [a-z0-9-]{1,32}, so this family also carries negative declarations — index:Exact (uppercase), index:é (non-ASCII), index: (empty), and a 33-byte identifier. These pin a refusal at index-declaration time, not an error code: configuration validation sits outside the §9 taxonomy, so the vector asserts that the declaration is rejected and deliberately does not name a code (each core maps it to its own ConfigurationError, docs/09 §9). They belong here rather than in errors/ for that reason.
Formerly blocked case, now authored: tenant_id null vs zero-length was blocked on G4 until 2026-08-22; the provisional §6.2 presence bitmap made it authorable and the family carries tenant-zero-length plus the absent-differs-from-zero-length assertion. G4 itself stays open for the reviewers (Q4); a changed bit assignment regenerates these vectors.
4.4 blind-index/
{
"id": "blind-index/hmac-sha512/email-15bit",
"spec_ref": "§7.2, §7.3, §7.4, §7.11",
"suite_id": "0xFF01",
"idf": "hmac-sha512",
"idf_params": {},
"index_key": "…hex (32 B)…",
"tenant_index_key": "…hex (32 B)…",
"index_id": "email-eq",
"context": { "…the §7.2 derivation context: purpose index:<id>, row_id null…" },
"normalize": "nfc-casefold-v1",
"plaintext": "…hex of the ALREADY-NORMALIZED value…",
"plaintext_preimage": "USER@Example.COM",
"truncate_bits": 15,
"expected": {
"raw": "…hex (full IDF output)…",
"index": "…hex (truncated)…",
"stored": { "binary": "…hex of the exact column bytes…", "hex": "…lowercase-hex column text…", "octets": 2 }
}
}
plaintextis the post-normalization byte string and is the normative input;plaintext_preimagedocuments where it came from and lets an implementation that ships the named normalizer test it too. Normalizer identifiers (nfc-casefold-v1, …) are declared indocs/09-core-architecture.md§7; the vector suite only ever references declared identifiers (suite 0.1.0 wrotenfc-casefold; corrected in 0.2.0, docs/18 D-07).index_keyis the normative key input.tenant_index_key,index_idandcontextare its provenance under spec §7.2, carried so a harness can run the publicblind_indexoperation end to end from the vector alone (reported as<id>#pipeline, docs/14 §4) without recovering the tenant key fromkdf/.- Seven vectors pin
nfc-casefold-v1’s definition (docs/09 §7.1), settled by G15 part D on 2026-08-24 and no longerprovisional_on. Three pin bytes:fold-nfc-stable(U+01F0, whose fold output recomposes under the second NFC),folding-added-in-17(U+A7D2, which gained its folding in Unicode 17.0.0), andassigned-in-17/nfc-reordering-17(a Beria Erfe capital and a U+1AD9 reordering case, which a core pinned to 16.0.0 would refuse). Theassertion: equalpairs pin collision, which is the property the normalizer exists for and which byte-pinning does not reach:normalizer-collapses-case,-fold-nfc(U+0390 against its uppercase spellingU+03AA U+0301),-fold-nfc-upsilon,-precomposed,-sharp-sand, since suite0.4.0-provisional,-e-acute(G19’s named canonical-equivalence pair). The Greek pairs are the ones that fail without the post-fold NFC. - Three more arrive with suite
0.3.0-provisional(G16).replacement-char-is-textpins that U+FFFD indexes normally: it is an ordinary assigned character, and a core that rejected it as a data-quality signal would refuse a value every other core indexes — a silent split in the direction of the unfindable row.unindexable-marker-b15andunindexable-bucketed-b15pin docs/09 §7.2 as described above. The lone-surrogate refusal that would naturally sit beside them is not here and cannot be:blind-index/keys its input as hex bytes and an unpaired surrogate has no UTF-8 encoding, and widening that field to text would not help, because Go string literals may not hold a surrogate value and Rust’sStringis UTF-8 by invariant. It is theout_of_bandentrydocs/09/7.1/lone-surrogate-refusalinstead (docs/14 §4). - Four more arrive with suite
0.8.0-provisional(G26, #176), two per IDF:invalid-utf8-high-surrogate-b15andinvalid-utf8-low-surrogate-b15, shape"refuse". Each preimage isa+ the generalized-UTF-8 encoding of a lone surrogate (ED A0 80for U+D800,ED B0 80for U+DC00) +b, passed to the publicblind_indexoperation as bytes underon_unindexable = refuse, and each MUST raiseINVALID_ARGUMENT— docs/09 §7.1 clause 5’s strict decode. They fill the obligation G15 recorded and never shipped (“an invalid UTF-8 byte sequence and a lone surrogate, assertingINVALID_ARGUMENT”), and they are the premise docs/14 §4’s representability route rests on: in a language whose string type is bytes (Go), this is the only form a lone surrogate can take. A vector pins the refusal’s code, which is the same for both; whether the two refusals are distinguishable is a harness assertion (§5 item 9), not a vector. Two fields are there for the declaration, not the outcome:truncate_bits(with the-b15id suffix that follows it, as elsewhere in the family) is carried because an index declaration is invalid without one, and nothing is truncated on a refusal. And arefusevector has no positive control of its own: a core that refused every bytes input would pass it. The bytes-in#pipelineresults of the primitive vectors are what rule that core out. expected.storedasserts the spec §7.11 storage contract (G8, issue #8).stored.binaryMUST equalexpected.indexbyte for byte andstored.octetsMUST equal⌈b/8⌉; the redundancy is deliberate, because it converts an assumption every implementation would otherwise make silently into a test that fails when one of them pads, length-prefixes, or base64s the column.stored.hexis the lowercase text-column form for implementations supporting that alternative — a harness whose implementation is binary-only skipsstored.hexand reports it skipped, but MUST assertstored.binaryandstored.octets.- Argon2id vectors carry full
idf_params:{"version": 19, "time_cost": 3, "memory_kib": 32768, "parallelism": 1, "output_len": 64, "salt": "…hex…"}— spec §7.3’s invocation at the cost that vector was generated at: the minima, except for the two raised-cost vectors below. (This document previously allowed “reduced parameters” for CI speed; §7.3 pinsversion,p,output_lenand the salt derivation outright, and the cost is ~40 ms per vector, so there is nothing to reduce.)saltis the HKDF-derived value of §7.3, asserted separately so an HKDF-step bug and an Argon2-step bug are distinguishable. - A harness MUST read
time_costandmemory_kibfromidf_paramsrather than from its core’s defaults. §7.3 states those two as minima a deployment MAY raise, so a vector at a raised cost is authorable and is a G2 obligation; a harness that assumed the minima would fail such a vector against a correct core and misattribute it to the primitive. The requirement runs through to the core, which is where the silent case lives: an implementation whose IDF reads the cost from a constant cannot be handed a vector’s parameters at all, passes every vector at the minima, and diverges from the cores that can only once a deployment raises the cost — #62. - Every shape carries
idf_params, and a missing block is malformed. The primitive shape above carries it at the top level; theassertion: equal,unindexable-marker,unindexable-bucketandrefuseshapes carry it underinputs, besideidf. A harness MUST treat anargon2idvector withouttime_costandmemory_kibas malformed — a recorded failure with a reason, never an assumption of the minima. (A harness that validates shape before running, as §5 item 2 asks, MAY refuse the file as a schema violation instead, the way it refuses a manifest mismatch; what it MUST NOT do is start the run and abort partway with no report emitted.) It MUST also check thatversion,parallelismandoutput_len, where declared, equal §7.3’s constants, refusing a vector it cannot derive at rather than deriving at the constant regardless. (Both harnesses enforced the first rule before this document stated it. The #108 review found that the eight assertion vectors had carried noidf_paramsfor as long as the family was held out, that the two harnesses reacted to the omission differently — one recorded eight failures, the other aborted with no report — and that neither checked the pinned fields.) Since suite0.6.0-provisionaltwo vectors are off the minima:raised-cost-t4-b15, a primitive vector att = 4with its#pipelinecompanion, andunindexable-marker-t4-b15, the reserved marker for a column declared att = 4. They are the only vectors that can tell a harness deriving at the declared cost from one deriving at its default and happening to agree — the review found the TypeScript marker check doing the latter — and they meet the raised-cost obligation in the G2 draft. - The bit-level truncation rule is pinned (spec §7.2, G3 resolved 2026-08-08: leading
⌈b/8⌉bytes, trailing bits of the final byte zeroed, MSB-first). Per spec §12, each file carries at least threeb mod 8 ≠ 0vectors plus one multiple-of-8 control; suite 0.2.0 ships b = 12, 15, 21, 30 and the control at 16. The Argon2id invocation is pinned provisionally (G2, narrowed 2026-08-22);blind-index/argon2id.jsonis in the suite as of0.6.0-provisional— see §9. Its expected values still carryprovisional_on: ["G2"]: pinned into the suite and provisional pending review are different statements, and both are true of it.
4.5 commitment/
record_key → expected 32-byte commitment, with expected.salt (empty), expected.info (the §4.6 label) and expected.length stated. Authored against the §4.6 provisional construction (G1; provisional_on: ["G1"]). The file’s assertion vector carries two record keys differing in exactly one bit (bit 0 of the last byte) and their distinct commitments — suite 0.1.0’s description claimed one bit while its inputs differed in 32.
4.6 errors/
{
"id": "errors/crypto/tag-bit-flip",
"spec_ref": "§9, §4.6",
"suite_id": "0xFF01",
"operation": "decrypt",
"config": {
"allowed_suites": ["0xFF01"],
"write_suite": "0xFF01",
"read_mode": "strict",
"registered_suites": ["0xFF01", "0xFF02"],
"arm_provisional_suites": true
},
"key_id": "…hex…",
"tenant_dek": "…hex…",
"context": { ... },
"input": "…hex, the (possibly malformed) envelope bytes…",
"derived_from": "envelope/ff01/basic-roundtrip",
"mutation": "flip bit 0 of tag byte 0",
"expected": { "error": "TAG_INVALID" }
}
inputis always literal bytes —derived_from/mutationare documentation, not instructions; harnesses never compute mutations.operationis one ofdecrypt(the default),encrypt,rotate,is_ciphertext,blind_index;inputis the operand (an envelope, a plaintext, a value to index). Ablind_indexvector also carriestenant_index_keyand anindex_declaration(index_id,idf,normalize,truncate_bits).configmakes policy explicit because several errors are policy-dependent (SUITE_NOT_ALLOWEDrequires a suite that is registered but not allow-listed;NOT_CIPHERTEXTinstrictvs the pass-through expectation inpermissive; the §4.8 gate depends onarm_provisional_suites).key_id/tenant_dekare the one key the provider resolves; a header naming any otherkey_idisKEY_UNAVAILABLE.expectedcarries exactly one of:error(a §9 code),value(the literal input back — §10.3 pass-through),plaintext,is_ciphertext(a boolean), orindex(a positive control throughblind_index).- Each file carries
pinned_order(the decrypt-path order its outcomes assume — docs/09 §3.2 as declared by both shipped cores) and awithheldlist naming cases deliberately not authored because two readings of the text are both defensible today, with the gap that decides them. Vectors whose outcome depends on an open gap carryprovisional_on.
Required error coverage (each case = one or more vectors):
| Case | Expected error | Notes |
|---|---|---|
Envelope shorter than minimum length; empty input; truncation at every field boundary (mid-key_id, mid-msg_seed, mid-nonce, mid-tag, mid-commitment) | NOT_CIPHERTEXT | Per spec §3.4 recognition rules |
ASCII plaintext presented in strict mode | NOT_CIPHERTEXT | The migration-accident case |
Same input in permissive mode | pass-through value | With the §10.3 warning requirement noted |
fmt_ver = 0x00, 0x03, 0xff on an otherwise valid envelope | NOT_CIPHERTEXT (strict) / pass-through (permissive) | Spec §3.4: not a recognized version |
fmt_ver = 0x02 at ≥ 111 bytes; at 110 bytes | UNKNOWN_FORMAT_VERSION in every mode; NOT_CIPHERTEXT | The reserved-known-future set {0x02} and the plausibility floor are G15 part A (provisional_on); is_ciphertext stays false for it |
| A long envelope shortened by one byte | COMMITMENT_INVALID | Still ≥ 111 bytes with a registered suite, so recognition passes; the damage surfaces at the commitment check. Recognition cannot detect truncation beyond the minimum, and the vector says so |
suite_id = 0xFF02 on a 115-byte blob | NOT_CIPHERTEXT | Per-suite minimum (123 for a 24-byte nonce), docs/18 D-11 (provisional_on) |
suite_id unregistered (e.g. 0x00ff) | NOT_CIPHERTEXT | Recognition, not authorization — §3.4 |
suite_id registered but not on allow-list | SUITE_NOT_ALLOWED | The §3.4 decoupling case: recognition must succeed |
key_id unknown to the provider | KEY_UNAVAILABLE | |
Each context field altered on decrypt (wrong tenant, column, table; row_id added; row_id dropped) | COMMITMENT_INVALID (provisional_on: ["G5"]) | Under §6.3 dual binding a context mismatch derives the wrong record key and fails at the commitment check, indistinguishably from key confusion; AAD_MISMATCH is not raisable on 0xFF01 and is listed as withheld. A wrong purpose is an API-boundary argument error (§5.3), also withheld |
nonce bit flipped | TAG_INVALID | The nonce is not in the AAD; the record key and commitment are untouched |
| Single bit flips in ciphertext; in tag | TAG_INVALID | |
| Commitment bytes altered | COMMITMENT_INVALID | |
| A ciphertext valid under two keys (invisible salamander) | COMMITMENT_INVALID | Constructed by tools/vector-gen/fieldseal_vectorgen/gcm.py following Len–Grubbs–Ristenpart (USENIX ‘21): GCM’s tag is linear in the ciphertext under GHASH, so one free block solves a linear equation in GF(2^128). The vector carries both record keys and the bytes the AEAD would have returned under the second key; a positive control decrypts the same envelope under the committed key. This is the vector that proves §4.6 does its job |
msg_seed altered | COMMITMENT_INVALID (provisional_on: ["G5"]) | Self-authenticating per §3.2: the derived record key changes, so its commitment no longer matches under the pinned order |
encrypt()/rotate() without §4.8 arming; decrypt()/blind_index() without arming | SUITE_PROVISIONAL; success | The gate and its deliberate absence on reads. encrypt() on a client both readonly and unarmed is MODE_VIOLATION (docs/18 D-04, provisional_on) |
rotate() on non-envelope input in permissive | withheld | G15 part B: both cores encrypt it; the issue proposes NOT_CIPHERTEXT. Authored when it settles |
encrypt() called in readonly mode | MODE_VIOLATION | Spec §9 and §10.3, pinned by G6 (issue #6). rotate() under readonly is the same case and MUST also be covered |
Reads in readonly mode (valid envelope; non-envelope input) | Plaintext; pass-through | Spec §10.3, pinned by G6: readonly takes permissive’s non-envelope behavior. Both are positive controls bounding the row above — they prove the mode refuses writes, not reads |
blind_index() called in readonly mode | Success | Spec §10.3, pinned by G6: computing an index for a WHERE clause is not a write. Positive control — a regression here silently makes read-only clients unable to query |
| Plaintext longer than 2³¹−1 bytes | LENGTH_EXCEEDED | No vector. Spec §3.5/§12 (G10, issue #10) exempt this case from the literal-bytes rule — a 2-GiB file is not a thing to put in git. Verified by an implementation-level test asserting the exact threshold, declared in the conformance report per docs/14 §4 |
4.7 cross/ — the central claim
Two mechanisms, one file format:
{
"schema": "fieldseal-vectors/cross/v1",
"producer": { "implementation": "python", "version": "0.1.0", "commit": "…", "produced_at": "…" },
"suite_id": "0xFF01",
"cases": [
{
"id": "cross/python/0001/case-001",
"key_ref": "tenant-a-dek-v1",
"context": { ... },
"plaintext": "…hex…",
"envelope": "…hex…"
}
]
}
key_refresolves intovectors/keys/test-keys.jsonso producers and consumers share key material without embedding it per file. Everything inkeys/is public test material by construction — the file carries a banner field stating so, and no value in it may ever be used outside tests. The file’s shape (emitted by the generator since suite 0.2.0):{"schema": "fieldseal-vectors/keys/v1", "banner": …, "keys": {"<key_ref>": {"label", "suite_id", "key_id", "tenant_dek", "tenant_index_key"}}, "context_defaults": {"table_uuid", "column_uuid", "tenant_id"}}. Two refs ship:tenant-a-dek-v1(the tenant every pinned vector uses) andtenant-b-dek-v1. It is listed inMANIFEST.support, hashed like every family and never iterated by a harness.- Static cross vectors (
cross/static/<impl>/): regenerated by each implementation at each release using its real production path — runtime CSPRNG formsg_seedand nonce, no injection. Checked in. Every other implementation decrypts every case and compares plaintext. This is the offline, versioned form of the claim. No release exists yet, so none are checked in; the dynamic exchange (below) runs in CI since 2026-08-23, from the sharedcross/corpus.jsoninputs. - Dynamic cross validation: in CI, each implementation produces a fresh cross file as a build artifact; every other implementation consumes all of them (full N×N including self). Defined in
docs/14-conformance-ci.md. CI MUST fail on any divergence (spec §12). - Case set per producer, split by producer kind as of 2026-08-31 — the single rule this line used to carry was written when only cores produced, and it asks for coverage an adapter structurally cannot give:
- Core producers MUST emit ≥16 envelope cases per supported suite spanning §4.1’s size and shape coverage, plus ≥1 case per context shape (
row_idpresent/absent, tenant present/absent). - Adapter producers are governed by a different axis, because their corpus is their own fixture and their coverage dimension is the decisions they own: ≥1 per storage form supported and ≥1 tenant-bound case. (Until 2026-09-18 this also asked for ≥1 case per codec rendering supported. The
codec/family (§4.8) now pins every rendering against spec §3.6 directly, which a producer’s cross case never could — it only compared a producer with its own recorded plaintext — so the obligation moved there. Neither shipped producer had ever met it for more thanstring,intand the Prismaas:types.) They MUST additionally carry aproducer.limitationsarray naming every context shape they cannot produce and why —[{"shape": "row_id-present", "reason": "L3-row binding is not built (docs/13 §8)"}]. That makes the gap visible in the artifact rather than absent from it, the same moveout_of_bandandharness_notesalready make elsewhere, and it closes itself the day L3-row ships instead of needing a documentation edit.
- Core producers MUST emit ≥16 envelope cases per supported suite spanning §4.1’s size and shape coverage, plus ≥1 case per context shape (
The index half (cross/v2, 2026-08-31)
The envelope half above proves a value encrypted by one implementation decrypts in another. It cannot prove the other half of what an implementation shares, and docs/07 §7 records why that half is the more valuable one: a mismatched blind index is a silent lookup miss, not an error. Nothing raises. The row is stored, decryptable, and simply stops being findable by the query that should return it — and no envelope test anywhere would notice.
A producer that emits index cases writes "schema": "fieldseal-vectors/cross/v2" and a top-level index_cases array beside cases. cross/v1 stays valid and means “this producer carries no index half”.
{
"id": "cross/python/index/exact-ascii",
"key_ref": "tenant-a-dek-v1",
"declaration": {
"index_id": "exact", "idf": "hmac-sha512", "idf_params": {},
"normalize": "nfc-casefold-v1", "truncate_bits": 15,
"projected_population": 100000, "on_unindexable": "refuse"
},
"context": { "table_uuid": "…", "column_uuid": "…", "tenant_id": "…", "row_id": null,
"purpose": "index:exact" },
"value_text": "alice@example.com",
"index": "dd3a"
}
- A consumer MUST read
schemaand MUST fail on a value it does not recognise. This is not decoration: a v1-era consumer handed a v2 document would decrypt every envelope, reportfail: 0, and never touch the index cases — a green run that skipped the more valuable assertion, which is worse than a red one and is the same silent-skip shape the index half exists to catch. Both shipped consumers read it as of 2026-08-31; neither did before. declarationtravels with the case, and carries fields that affect no derived byte —projected_population,on_unindexable, and any override ceremony. They are there because a cross producer derives through a constructed client, not through primitives: §7.4’s truncation band and §7.6’s cardinality gate run at construction, so a consumer that cannot rebuild the declaration cannot build the client that re-derives the value. This is the one structural difference from theblind-index/family, whose flat fields suffice because it drives primitives directly.purposeanddeclaration.index_idare deliberately redundant, and a consumer MUST assertpurpose == "index:" + index_id. The derivation string comes from the purpose; the registry lookup is keyed on(table_uuid, column_uuid, index_id). A producer that disagreed with itself is exactly the bug worth catching.- Exactly one of
value_text,value_bytes,value_markeris present.value_textis the primary form and is a real JSON string, not hex: §7.1 (G16 part A) exists because text and its encoding are not interchangeable for index derivation, and a case keyed as bytes cannot express the property that clause is about.value_bytesis permitted only where the declared normalizer isidentity, whose input is bytes.value_marker: truemeans the case derives the §7.2 reserved marker throughunindexable_markerinstead — a derivation with no plaintext at all. - No
raw. The pinnedblind-index/family carries the untruncated IDF output because a generator produced it. A production-path producer cannot: the cores zeroize it deliberately, on the grounds that it reveals more than the stored index value, and no public operation returns it. A diagnostic that needed it would need a new testing affordance for no correctness gain. - Collision pairs carry no assertion field. A case-fold pair and an NFC pair are emitted as ordinary cases with their literal values, and the consumer re-deriving both is the check that they agree —
blind-index/’sassertion: equalidiom exists because that family compares two vectors inside one file, and this family compares every case against another implementation instead. hmac-sha512only.idf_paramsis present and empty so the slot exists. The stated precondition — thatblind-index/argon2id.jsonleaveMANIFEST.held_out— was met on 2026-08-31, so the objection that blocked anargon2idcross case (asserting agreement on every merge while the manifest says the suite does not count that family) is gone. The case is not added here, for a different and smaller reason: every cross leg would pay an Argon2id derivation per case per producer, and the N×N job runs four producers against two consumers on every merge. It is a deliberate omission with a cost attached rather than a blocked one. Nothing in the corpus records it, and nothing should:producer.limitationsnames context shapes a particular producer cannot reach, and this is a cost decision the project made for every producer at once.
Case set for the index half: at least one non-ASCII value; at least one pair that MUST collide (a case-fold or NFC pair); the marker case wherever a column declares on_unindexable: "bucket"; and at least one case per normalizer the producer can reach — which differs by producer kind for the same reason the envelope rule above does. A core producer can declare any normalizer in the registry and MUST cover all of them. An adapter producer can only derive under the normalizers its own schema declares, so it covers those and names the rest in producer.limitations rather than being held to a rule its fixture cannot satisfy. (Rewritten in the #103 review round. As first written the rule said “the producer supports”, which the two adapters failed — and so did both cores, whose corpus covered two of the three registry normalizers. The cores now cover all three; the adapters declare the gap.)
4.8 codec/ — logical-type rendering (binds adapters, not cores)
Spec §3.6 pins the plaintext an adapter renders for each of eight logical types. A core never sees a logical type — it is handed bytes — so this family is the first to bind adapters. It is listed in MANIFEST.json under adapter_files, not files:
- A core’s conformance run iterates
filesonly and never loads it. - An adapter’s run loads it from
adapter_files, verifies its hash and"status": "pinned", and runs every vector through the adapter’s real codec — the function its write path and its index path both call, not a test double.
A file may be in files or adapter_files, never both; CI checks this, along with the hash and the version.
Vector shape. Every vector has id (codec/<type>/<write|read>/<slug>), logical_type, direction, requires and spec_ref.
- A write vector carries an
inputliteral. Itsexpectedis either{"plaintext": "<hex>"}or{"refused": true}. - A read vector carries
plaintext(hex). Itsexpectedis either{"value": <literal>}or{"refused": true}. - A refusal is the adapter’s not-supported error, and never a §9 error: the envelope is fine.
Value literals are one shape per type, used both for a write’s input and a read’s expected.value:
| Type | Literal | Notes |
|---|---|---|
string | {"text": "…"} or {"utf16": "<hex UTF-16BE code units>"} | utf16 exists so an unpaired surrogate, which JSON text cannot carry portably, can be an input |
bytes | {"hex": "…"} | |
int | {"decimal": "…"} | A string, never a JSON number: the values exceed 2⁵³ |
decimal | {"decimal": "…"} | Any notation on input (15E-1); canonical on output |
float | {"binary64": "<16 hex digits>"} | The IEEE 754 bits, big-endian, so no decimal-to-binary conversion stands between the vector and the value |
boolean | {"boolean": true} | |
date | {"date": "YYYY-MM-DD"} | {"utc_midnight_instant": "<RFC 3339>"} for §3.6’s no-calendar-date convention |
datetime | {"instant": "<RFC 3339 with offset>"} | {"naive": "…"} for a value with no zone. On read, instant is the canonical six-digit Z form |
Capabilities. requires lists platform capabilities, defined in the file’s capabilities object:
calendar-dateanddate-as-utc-midnight-instantmicrosecond-instantsandmillisecond-instantsnaive-datetimes
A vector requiring a capability the adapter’s platform lacks is reported skipped, with that reason — the rule in §5 item 7 — and MUST NOT be counted as passed. A skip for any other reason is a harness bug.
Each capability names something a platform can or cannot represent, not a choice, so a platform cannot opt out of one it has. Django’s platform holds calendar-date, microsecond-instants and naive-datetimes; Prisma’s holds date-as-utc-midnight-instant and millisecond-instants. Both adapters’ tests assert the exact number of vectors they skip, so a vector silently moved behind a capability is caught.
The platform-precision cases are the reason capabilities exist. The same bytes, 2026-09-08T12:00:00.123456Z, MUST be read exactly by a microsecond platform and MUST be refused by a millisecond one. They appear as two vectors, each gated on its capability, so each platform is held to its own half.
What the generator guarantees. Every expected value is computed by the generator’s own §3.6 implementation (tools/vector-gen/fieldseal_vectorgen/codec.py), written from the specification text, not from either adapter. The case list states for each case whether it is a refusal, and generation fails if the rules disagree with that statement.
- Every non-refused write is re-parsed and must return its own value.
- The float renderer was checked against Node’s
String(number)on 23,160 doubles, with no disagreement. That was a one-off check at authoring time, not a CI job; thefloatvectors carry the cases it found interesting.
Independence, stated honestly. The generator’s rules and the Django adapter’s codec were written in the same session by the same author. They are two implementations, but not independent ones in the sense of docs/17. The Prisma codec is a third, in another language. A shared misreading of §3.6 would pass all three. Only an adapter built under the docs/17 protocol would test that.
5. Harness contract (per implementation)
Each language core ships a conformance harness (in its own test framework) that MUST:
- Load
MANIFEST.json, verify file hashes, and recordvector_suite_version. - Validate every vector file against
vectors/schema/before use (a malformed vector suite must fail loudly, not skip silently). - Run every vector for every family the implementation claims (0xFF02 families only if the suite is implemented).
- For
envelope/: assert both directions (§4.1). - For
errors/: assert the exact error code — a mapping table from vector error strings to the language’s exception/error types is part of each core’s tech spec (docs/10-…,docs/11-…). - Report results in the machine-readable format defined in
docs/14-conformance-ci.md§4, so the conformance report is assembled identically across languages. - Skip nothing silently: a skipped vector (unsupported suite) appears in the report as
skippedwith a reason. - Assert the spec §3.5 plaintext length bound out-of-band, since it has no vector (G10): a test MUST show that 2³¹ bytes is refused with
LENGTH_EXCEEDED, and that an envelope whose implied plaintext length (received length minus the suite’s fixed overhead) is at least 2³¹ is refused with it too, and the harness MUST record the assertion in the report’sout_of_bandblock (docs/14 §4) with itsbasis. Directly (basis: "direct"), the harness allocates or maps the operand and calls the public operation. Through the seam (basis: "seam", G26), which any core MAY use and which a core whose byte type cannot hold the operand has no alternative to, it drives synthetic operands through the docs/09 §4 length seam, under the conditions docs/14 §4 sets. A harness that tried the direct route and could not allocate the input recordsnot-runwith the reason, rather than passing silently — the point of the block is that an unverified bound is visible in the report instead of absent from it. - Assert the docs/09 §7.1 lone-surrogate refusal out-of-band, since it cannot have a vector in every target language (G16 part A): a test MUST show that two distinct unpaired surrogates are both refused and refused distinguishably, and record it in
out_of_bandasdocs/09/7.1/lone-surrogate-refusal,basis: "direct". A harness in a language whose string type cannot represent an unpaired surrogate (Go, Rust) records it instead withbasis: "representability"(G26, #176), andpassonly when all of these hold: theblind-index/refusevectors of §4.4 are pinned and pass in this core; the harness shows that the two byte preimages those vectors carry (ED A0 80andED B0 80, each betweenaandb) are refused with distinguishable diagnoses on the same path; andmethodstates the premise — the language’s string type cannot hold a lone surrogate, this core refuses invalid UTF-8 on its string path (docs/09 §7.1 clause 5), and the vector ids that pin it. Such a core’s strict-UTF-8 diagnosis names the byte offset and the malformed sequence (at most four bytes) and nothing around it: error text is routinely logged, and a diagnosis that echoed its input would put plaintext there. Before those conditions hold, the entry isnot-run. Same terms as item 8: an unverified requirement is visible in the report rather than absent from it. - Where the implementation exposes the optional asynchronous companions of spec §11.1 (G9), run the entire suite a second time through them and assert identical bytes and identical error codes. Both passes appear in
results, the async pass suffixed#async, so a divergence names which path failed.
6. Determinism injection (testing affordance)
To reproduce envelope/ vectors, an implementation needs to encrypt with a caller-supplied msg_seed and nonce. Spec constraint (vectors/README.md): “an implementation that accepts a caller-supplied nonce or seed outside of vector-test mode is non-conformant.”
Contract, binding on all core tech specs:
- The injection entry point is
encrypt_with_materials(plaintext, ctx, msg_seed, nonce) -> envelope. - It lives in a clearly separated testing namespace, not exported from the main module: Python
fieldseal.testing, TypeScript subpath@fieldseal/core/testing(see the per-language specs). - Arming gate: the testing namespace MUST be inert unless the environment variable
FIELDSEAL_TEST_MODE=1is set — every function raises otherwise. A shipped module can always be imported; the gate makes accidental or lazy production use a loud failure instead of a silent non-conformance. Each per-language spec carries a negative test for the unarmed state. - It performs the full production pipeline except CSPRNG generation — same KDF, same AAD, same commitment path — so vectors exercise the real code, not a parallel test path.
- The production
encrypt()MUST NOT accept seed/nonce parameters in any form (no kwargs, no config hook, no environment variable). - Documentation for the testing namespace states the non-conformance consequence verbatim.
7. Authoring and generation
- Vectors are generated, reviewed, then frozen — never hand-computed. A generator tool (
tools/vector-gen/, Python, because Phase 1 builds the Python core first) produces every file from a single source of inputs. - Independent verification before freeze: expected values MUST be confirmed by a second, independently-written computation before a vector file is tagged. Phase 1 provides this naturally: the TypeScript core is written against the frozen inputs and must reproduce the expected values without consulting the generator. Divergence at this step means either an implementation bug or a spec ambiguity — both are exactly what the suite exists to catch. The generator is not an oracle; agreement of two independent implementations is.
- Where an external, already-published vector exists for a primitive, the generator’s primitive layer MUST be checked against it first. Status of each source, re-verified 2026-08-22 (this bullet previously carried a flag saying they had not been):
- HKDF-SHA-512 — RFC 5869’s test vectors are SHA-256 and SHA-1 only, so only the structure is reusable, not the values. Unchanged from the original assessment.
- AES-256-GCM — NIST CAVP GCM vectors apply directly. Wired 2026-09-10:
tools/vector-gen/fieldseal_vectorgen/kat_aesgcm.pyvendors 50 cases from NIST’sgcmtestvectors.zip(CAVS 14.0) in suite0xFF01’s shape — 256-bit key, 96-bit IV (spec §4.4), 128-bit tag (§4.5): Count 0 of each of the 25 plaintext-length × AAD-length sections ofgcmEncryptExtIV256.rsp, and the first FAIL case of each section ofgcmDecrypt256.rsp. On every run the generator checks both of its AES-GCM paths against them, and the run fails on any disagreement:cryptography’sAESGCM, which seals every envelope vector, and its own GHASH/tag/keystream (gcm.py, used only to build the salamander vector), which had previously been checked only for agreement withcryptography— a consistency check, not a CAVP one. The regenerated suite was byte-identical before and after. - XChaCha20-Poly1305 — no RFC vectors exist because the construction has no RFC (gap G7); libsodium’s test suite is the de-facto source.
- Argon2id — RFC 9106 §5.3’s vector supplies a nonzero secret (
Secret[8]: 03 03 …) and associated data (Associated data[12]: 04 04 …), both forbidden by spec §7.3 and unsuppliable from Python, so it cannot be the primitive check on this stack (the 2026-08-22 finding). Substitute found and wired 2026-08-23: libsodium’stest/default/pwhash_argon2id.ctv()table with its.expanswers — seven Argon2id 1.3 cases throughcrypto_pwhash, which can supply neitherKnorX, 16-byte salts,p = 1.tools/vector-gen/fieldseal_vectorgen/kat_argon2id.pyvendors them and the generator checks its primitive against all seven on every run, refusing to emit if one fails. Separately, the TypeScript core’snode:crypto.argon2Syncbackend reproduces RFC 9106 §5.3 withK/Xand all four held-out vectors (docs/18 D-15). The hold-out’s stated unblocking condition was therefore met, leaving only the project decision (§9) — taken 2026-08-31: the family is pinned as of suite0.6.0-provisional(docs/07§7). No expected value changed on promotion. Two things did, and both are the hold-out’s own doing: eight of the family’s nineteen vectors carried noidf_paramsdespite declaringidf: argon2id, which §4.4 makes malformed and which both cores reject rather than defaulting to the minimum — though not, as the #108 review round then found, in the same way (docs/07§7, 2026-09-01); and the Python core’s harness refused every non-HMAC IDF outright, so it could not have run the family even with the manifest flipped. Neither defect was detectable while the family was held out, because a held-out family is iterated by nothing. That is the cost of the mechanism, and it belongs beside its benefit.
- The Windows caveat: the repo pins LF via
.gitattributes— the generator writes bytes, not platform-dependent text.
8. What is deliberately out of scope for the vector suite
- Key-provider behavior (caching, TTL, zeroization, KMS interaction) — not byte-reproducible; covered by per-language unit tests against the contracts in
docs/09-core-architecture.md§8. - Adapter behavior (throw lists, coverage matrices) — covered by per-adapter integration test plans (
docs/12-…,docs/13-…); those tests are normative for adapter conformance claims but are not portable vectors. - Performance —
bench/, per PRD DO-4: measured, not estimated.
9. Spec gaps blocking vector authorship
Found while writing this document. Each needs a spec issue (per CONTRIBUTING.md: issue → citation → breakage statement → vectors). Consolidated with proposed resolutions in docs/07-implementation-plan.md §5; summarized here because they gate specific files above:
| # | Gap | Blocks |
|---|---|---|
| G1 | Key-commitment construction undefined (§4.6 mandates it; no formula) | commitment/, envelope/ expected values, salamander error vector |
| G2 | Argon2id IDF invocation incomplete: parallelism, output length, salt/secret strategy, version not specified (§7.3 gives only iterations/memory minima) | blind-index/argon2id.json |
| G3 | truncate(raw, b bits) bit-level semantics — resolved 2026-08-08 (issue #3): spec §7.2 pins leading ⌈b/8⌉ bytes, MSB-first bit numbering, trailing bits of the final byte zeroed | blind-index/hmac.json unblocked; argon2id.json was authored against a provisional G2 and pinned 2026-08-31 |
| G4 | tenant_id = null encoding in canonical_context unspecified (§6.2 defines omission only for row_id); null vs zero-length ambiguity | context/, any envelope vector with absent tenant |
| G5 | Error classification/precedence undefined: the order of format → policy → key → commitment → AEAD checks, and how a context mismatch (which manifests as a wrong derived key under dual binding) maps onto AAD_MISMATCH vs COMMITMENT_INVALID | most of errors/crypto.json |
| G6 | No error code for mode violations (encrypt() in readonly mode); readonly’s non-envelope read behavior undefined — resolved 2026-08-09 (issue #6): spec §9 adds MODE_VIOLATION, §10.3 specifies both axes per mode (readonly = pass-through on non-envelope input, refuses encrypt/rotate, permits blind_index) | errors/policy.json fully unblocked |
| G7 | Suite 0xFF02’s AEAD (XChaCha20-Poly1305) has no IETF RFC; the spec does not name a normative definition (libsodium’s construction is the de-facto standard; draft-irtf-cfrg-xchacha expired) | envelope/ff02.json confidence, though not its mechanics |
| G8 | Blind-index stored representation undefined (raw bytes vs hex; column width) — two implementations sharing one database must store identical index values — resolved 2026-08-09 (issue #8): spec §7.11 makes raw ⌈b/8⌉ bytes in a binary column the MUST, lowercase hex without prefix the declared-per-column MAY, exact byte/string equality under a binary collation | blind-index/ storage assertions unblocked; the adapter specs’ interim recommendation is now normative |
Status 2026-08-22 — nothing in this document is blocked any longer, and one family is deliberately held out. (The hold-out ended 2026-08-31; see the 0.6.0 status below.) G3, G6 and G8 closed on their merits; G1, G2, G4, G5 and G7 are provisionally adopted under Gate 0a (PRD §8) and remain open on the tracker, which is enough for a generator to be written against them. tools/vector-gen/ emits context/, kdf/, commitment/, blind-index/ and envelope/ff01.json.
Status 2026-08-23 — suite 0.2.0-provisional adds errors/ (66 vectors in format.json, policy.json, crypto.json) and keys/test-keys.json; both cores pass 127/127 with identical result ids. (Superseded: at suite 0.4.0-provisional, 2026-08-31, both cores pass 145/145.) The errors/ family became authorable once the two cores declared the same decrypt-path order (docs/14 §4 pinned_decisions); its G5- and G15-dependent outcomes carry provisional_on. Still not emitted: cross/static/ — the per-release checked-in envelopes, which wait for a first release to pin; cross/corpus.json, the producer inputs, has existed since 0.2.0-provisional and the dynamic N×N exchange has run in CI since 2026-08-23 — and envelope/ff02.json / commitment/ff02.json (G7). schema/ remains empty.
blind-index/argon2id.json is part of the pinned suite as of 0.6.0-provisional (2026-08-31). It was held out on 2026-08-22 because its primitive had never been checked against an external known-answer source: two reference implementations would otherwise inherit the same unverified assumption from one generator and agree with each other while being wrong, which is precisely the failure the two-implementation rule exists to prevent, so their agreement would have been evidence of nothing. Since 2026-08-23 the primitive is checked against libsodium’s seven published answers on every generator run (§7) and the TypeScript core reproduces the file’s values through an independent backend, which left only the project decision — taken in docs/07 §7. No expected value changed. Both cores now run it and report held_out: 0; MANIFEST.held_out is empty and the mechanism is retained for the next family that needs it.
Status 2026-08-31 — suite 0.6.0-provisional: both cores pass 175/175 with identical result ids, and nothing is held out. The 30 new results are the argon2id family’s 19 vectors plus the 11 #pipeline companions its primitive vectors earn. Promoting it surfaced two defects that a held-out family structurally cannot surface, both now fixed: eight of its vectors declared idf: argon2id with no idf_params (§4.4 makes that malformed, and both cores reject rather than assume the minimum), and the Python core’s harness refused every non-HMAC IDF, so it could not have run the family at all.
Status 2026-09-18 — suite 0.7.0-provisional. The core families are unchanged apart from the version string: 146 vectors and 178 results, identical ids across both cores. New in this version is codec/logical-types.json, 124 vectors under MANIFEST.adapter_files (§4.8), which both adapters run and no core iterates.
Status 2026-09-22 — suite 0.8.0-provisional (G26, #176): 150 vectors and 182 results, both cores green with identical synchronous ids. The four new vectors are §4.4’s refuse pair in each IDF family. The TypeScript core’s second pass carries 182 #async results, of which 69 route through a companion (the four new ones among them) and 113 re-run the synchronous operation. codec/logical-types.json is unchanged apart from the version string.
Status 2026-09-01 — suite 0.6.0-provisional, revised in the #108 review round before it was published: 146 vectors, 178 results, both cores green with identical ids. (From 2026-09-04 the id sets are identical on the synchronous pass: the TypeScript core additionally carries 178 #async results, item 10’s second pass through the spec §11.1 companions it now ships. Only 65 of those 178 route through a companion — the blind-index/ family and the two errors/ blind_index cases; the other 113 re-run the synchronous operation, because this core ships companions for blind_index and unindexable_marker only and item 10 asks for the entire suite a second time. Each result records which it was in details.async_route, and the report’s harness notes carry the same split as a computed line, so the second pass cannot be read as 178 results’ worth of companion coverage. The Python core has none and reports async_companions: false.) (“Identical ids” was prose with nothing behind it until 2026-09-04: each core job uploaded its own report and no CI job ever opened both, so the claim rested on a hand check. The Both cores report the same result ids job now enforces it on every run — not only on green ones, since it runs under if: !cancelled() and a red core is when a dropped family is easiest to miss. What it fails on, exactly: a synchronous id set either core ran and the other did not; a first-pass result left untwinned where a report declares async_companions; a skipped result, which is coverage lost rather than agreement; a differing or non-passing out_of_band set; and any file in MANIFEST.json that neither core reached — the last being the only one of these that can see a drop hitting both cores. It cannot see a family removed from the manifest as well; that is a suite change, and suite-integrity and vectors-reproducible own it. The counts below are what the job reports, not what it asserts: it compares sets and coverage, never a hard-coded total, so adding vectors does not fail it.) The family gains two vectors at a raised cost (raised-cost-t4-b15 and unindexable-marker-t4-b15, §4.4): what would have caught the third defect the round found — the TypeScript harness deriving the reserved marker at its own default cost — and what meets the G2 draft’s raised-cost obligation. The round also found that “both cores reject” above hid a difference: the Python harness aborted with no report where the TypeScript one recorded failures. Both record now. docs/07 §7 has the account.
What a provisional adoption costs is regeneration, not redesign: if Gate 0b changes a construction, the affected family is regenerated and the provisional suite identifier retires (spec §4.8).