Phase 1 Implementation Plan

On this page

Date: 2026-08-08 · Status: Draft 1 · Purpose: the engineering plan for Phase 1 (“prove the format,” PRD §8): what gets built, in what order, gated by what, defined-done how. This is the umbrella document for the tech-spec suite:

DocCovers
docs/08-test-vector-spec.mdVector suite: formats, schemas, harness contract, injection, cross protocol
docs/09-core-architecture.mdLanguage-agnostic core design all implementations follow
docs/10-core-python.md / docs/11-core-typescript.mdPhase 1 core bindings
docs/12-adapter-django.md / docs/13-adapter-prisma.mdPhase 1 adapters
docs/14-conformance-ci.mdConformance claims, CI, the cross job
docs/15-tooling.mdBackfill and leakage estimator (P1; constraints known now)
docs/adr/Decision records for the Phase-1-blocking choices (spec §13.1, §13.2)

Standing constraint (revised 2026-08-22): the Phase 0 exit gate is now two gates (PRD §8). Gate 0a — every spec gap resolved or provisionally resolved and marked, ADRs decided provisionally, registry on provisional identifiers — authorizes writing code, and the project can close it itself. Gate 0bat least two people with real cryptographic credentials have read the spec and their objections are addressed or documented — authorizes freezing the format, publishing stable vectors, and inviting adoption. Independent review is still a release gate, not a nice-to-have (PRD §9, top risk); it is no longer an implementation gate, because none of Phase 1’s deliverables depend on the answers it will produce. This plan starts at Gate 0a.


1. Gate 0a — what must close before code

The Phase 0 exit gate is split (PRD §8, revised 2026-08-22). Gate 0a is permission to implement and can be closed by the project itself; Gate 0b is permission to freeze and still requires two credentialed reviewers. This section is Gate 0a. Gate 0b is §1b.

In dependency order:

  1. ADR-0001 (envelope: profile AWS or define fresh) — everything serializes the envelope; spec §13.1 calls it the highest-leverage open decision. Provisionally decided 2026-08-22: option C (fresh envelope, AWS-aligned constructions), on the strength of Appendix A’s expressibility mapping, which the ADR itself recorded as favoring C. The envelope-novelty question stays open for Gate 0b.
  2. ADR-0002 (the mandatory suite’s AEAD) — determines envelope arithmetic and whether gap G1 applies to the mandatory suite. Provisionally deferred 2026-08-22: the status quo (AES-256-GCM + explicit commitment) is retained so that arithmetic exists to build against. The ADR’s own overhead evidence says the numbers do not decide it; the two criteria that do — a testing lab’s opinion on option B, and a reviewer’s endorsement of the per-write-key mitigation — are Gate 0b inputs.
  3. The spec-gap issues G1–G13 (§5 below) — each is a spec issue per CONTRIBUTING.md (justification + citation, breakage statement, vectors); ready-to-post drafts live in docs/issues/. Scope within the gate: G1–G8 (the vector-blocking set) must merge before M0; G9–G13 must be filed before M0 but may close during Phase 1 alongside the workstreams they touch (G8/G12 before adapter DDL ships, G13 before the Prisma conformance claim). Status 2026-08-09: G9–G13 all closed early, ahead of that allowance, together with G3, G6 and G8 from the vector-blocking set — so the entire no-crypto-review portion of the gate is done. Status 2026-08-22: the five that remain (G1, G2, G4, G5, G7) are provisionally adopted under Gate 0a — the proposed direction in each issue draft becomes normative spec text carrying a [PROVISIONAL] marker, and the tracker issue stays open. A provisional adoption is not a closure and MUST NOT be logged as one in docs/issues/README.md.

Items 1–3 produce artifacts reviewers can react to; run them concurrently with reviewer recruitment, not before it. That ordering is now load-bearing rather than merely efficient: the artifacts are the recruitment pitch, because a reviewer is being asked to react to a concrete proposal rather than to brainstorm.

1b. Gate 0b — what must close before freeze

Cryptographic review covering, at minimum: the per-write-key nonce argument (§4.4/§5.3), the commitment construction provisionally adopted for G1, the blind-index construction (§7), and the envelope-format novelty question posed in ADR-0001. The full question set and the reading path are in docs/16-reviewer-brief.md.

Nothing in Phase 1 depends on this gate, and nothing in Phase 1 may be described as if it had closed. Concretely, until Gate 0b closes: suite identifiers stay in the provisional range (spec §4.8), the vector suite stays -provisional, conformance claims name the provisional suite (spec §4.8, “On conformance”), nothing reaches 1.0, no production adoption is invited, and the Phase 3 dissemination track does not start. Experimental pre-1.0 releases for evaluation are permitted under PRD §8’s conditions (2026-09-18, §7). Gate 0b is also the gate that converts the five provisional adoptions above into closures — or into spec changes.

2. Workstreams and dependency graph

Gate 0a ──► WS-A vectors & harness ──► WS-B Python core ──► WS-E Django adapter ──► WS-G example app
                    │                      │                                            ▲
                    │                      ▼                                            │
                    └────────────► WS-C TypeScript core ──► WS-F Prisma adapter ────────┘
                                           │
              WS-D cross CI ◄──────────────┘   (needs both cores)
              WS-H docs & certification page   (parallel, continuous)
  • WS-A — vector generator (tools/vector-gen/, Python), JSON Schemas, MANIFEST.json, errors/format+policy families (authorable now), remaining families as G-issues close. Output: vector suite v0.1.0-provisional.
  • WS-B — Python core per docs/10. Also hosts the generator’s primitive checks (docs/08 §7).
  • WS-C — TypeScript core per docs/11, under the independence rule (docs/11 §6): built from spec + docs only, no reading Python source, until first freeze. Divergences it finds are recorded in §7 below and are the cheap substitute for a second review pass.
  • WS-D — CI per docs/14: per-core workflows first, then the N×N cross job. The cross job green is the Phase 1 exit criterion (PRD §8).
  • WS-E / WS-F — adapters per docs/12/docs/13, each shipping its coverage matrix and refusal tests.
  • WS-G — one end-to-end demonstration app (PRD Phase 1 deliverable). Built 2026-09-09 as examples/patient-directory (design: docs/20-demo-patient-directory.md), as proposed: a small patient-directory service with both a Django and a Prisma frontend over one shared Postgres schema — making the demo itself a live cross-language proof (a row written by either stack reads from the other), which is the project’s pitch in runnable form.
  • WS-H — “Certifying an implementation” page (docs/14 §6), operational docs skeletons (threat-model-as-deployed, KMS degradation modes — PRD DO-1/DO-6), and logging the resolution of library-fact [VERIFY] flags in docs/10–13 as dated entries in §7 of this document (decided 2026-09-22, §7: docs/06-verification-log.md is a dated snapshot of one verification pass, not the running log).

3. Milestones

#MilestoneExit test
M0Gate 0a closedADRs 0001/0002 decided provisionally with reasoning recorded and status set accordingly; G1–G8 spec PRs merged with vector obligations attached, the five review-gated ones (G1, G2, G4, G5, G7) as provisional adoptions carrying a spec [PROVISIONAL] marker and staying open on the tracker; the registry on provisional identifiers per spec §4.8. Gate 0b’s reviewer criterion is not part of this milestone and does not gate any Phase 1 milestone below — it gates the freeze, which Phase 1 does not perform.
M1Python core passes provisional vectorsconformance-report.json with fail: 0 at vectors v0.1.0-provisional; property/fuzz suites green
M2Independent reproductionTypeScript core reproduces every expected value from spec+docs alone, built under the handoff protocol in docs/17-m2-implementer-brief.md; divergence log triaged (each = impl bug or spec ambiguity → new G-issue); vector suite pinned at v0.1.0-provisional — pinned means regeneration requires a version bump, not that the constructions are settled. A non-provisional freeze at v0.1.0 requires Gate 0b (PRD §8) and is therefore not a Phase 1 milestone.
M3Cross CI greenN×N produce/consume matrix green including self-pairs, on merge and nightly — the PRD Phase 1 exit criterion, permanent gate thereafter
M4Adapters at levelDjango L1+L2(a)(b), Prisma L1+L2(b)-with-throws; coverage matrices generated from green test suites; refusal tests complete
M5Phase 1 doneDemo app runs the two-frontend cross-language scenario; docs current; Phase 1 retro decides Phase 2 entry. Closed 2026-09-22: the retrospective is docs/25-phase-1-retro.md; Phase 2 opened as PRD §8 scopes it, Java core first (§7 entry of that date)

Sequencing within the ~12-week PRD window is deliberately not calendarized per-week here: there is no calibration data for this team on this kind of work, and invented week numbers would be false precision (the PRD’s own DO-5 makes measured effort a deliverable). Relative sizing instead: WS-A M · WS-B L · WS-C L · WS-D M · WS-E L · WS-F L · WS-G S · WS-H S — with WS-B/WS-C not parallelizable with each other under a single implementer by design (M2’s independence value assumes different authors or at minimum a hard sequencing with the vectors frozen in between; if one person writes both cores, say so in M2’s report — it weakens the independence claim and the honest move is stating it).

4. Definition of done (applies to every workstream)

  • Tests green in CI including the vector harness at a named suite version; for adapters, the path matrix and refusal tests.
  • Every [VERIFY] flag in the workstream’s tech spec resolved: confirmed (flag removed, fact dated) or corrected (spec doc updated + dated entry in §7 of this document, written in docs/06-verification-log.md’s style).
  • Docs shipped with the artifact: README, coverage matrix (adapters), honest-limitations section (per spec’s MUST-document items — §3.3 overhead, §5.5 cache exposure, §7.3 Argon2id cost, §8.1 KMS dependency).
  • No overclaim audit: someone other than the author reads the README against CONTRIBUTING.md’s two standing rules.

5. Spec gaps found while writing the tech-spec suite

Each becomes a spec issue (label: the section it touches, per AGENTS.md); full ready-to-post drafts live in docs/issues/. “Proposed direction” is a starting point for the issue, not a decision — several need a cryptographer’s sign-off (noted). Consolidates docs/08 §9 (G1–G8), G9–G11 found in the core/adapter specs, G12–G13 found in the suite’s consistency review, G14 found in review of the TypeScript core (tracker #43 — the Gn = #n alignment ends at G13), G15 found in the M2 divergence list once a second core had pinned the same answers (tracker #48), G16 found in the consequences of closing G15 (tracker #60), G17 found while tracing an interface question the PR #55 review left unfiled (tracker #67), G18 found when the first adapter met the core’s public surface and one specified check could not be written against it (tracker #75), G19 found writing that adapter’s §7.5 re-verification filter — the first gap in the specification itself rather than in a tech-spec document (tracker #78), G20 found in that adapter’s review round, when the LIMIT audit question was asked of ORDER BY, GROUP BY and DISTINCT (tracker #80), G21 and G22 found when the second adapter met the documents (trackers #87, #88), and G23 found after that adapter merged — the first gap surfaced as a live behavioural divergence between the two shipped adapters rather than in advance of one (tracker #89), G25 found while planning the demonstration app, by asking what two adapters would write for one value rather than what one implementation does (tracker #123), and G26 found reviewing the designs for the first Phase 2 cores — the first gap in the conformance report format rather than the specification (tracker #176). The table is the as-found record; closure status lives in the §7 decision log and docs/issues/README.md.

#SectionGapProposed directionNeeds crypto review
G1§4.6, §3.1Commitment construction undefined (32 B reserved, no formula)Derive a commit value from the record key via the suite KDF with a dedicated label (the AWS ESDK v2 key-commitment shape its algorithm-suite docs describe; also cf. Albertini et al., How to Abuse and Fix Authenticated Encryption Without Key Commitment, USENIX ‘22); verify constant-time pre-decryptYes
G2§7.3Argon2id invocation incomplete: parallelism, output length, version, and the password/salt/secret layout unspecified (deterministic salt strategy needed — Argon2 requires a salt, but the index must be deterministic per key)Full parameter tuple in the spec, RFC 9106 as the normative reference; salt derived from the index key via HKDF with a dedicated labelYes
G3§7.2, §7.4truncate(raw, b bits) bit semantics undefinedKeep the leading ceil(b/8) bytes, zero the trailing 8·⌈b/8⌉−b bits of the final byte (MSB-first); state byte orderNo (but vectors pin it)
G4§6.2tenant_id = null encoding unspecified (omission defined only for row_id); null vs zero-length ambiguityDefine explicitly; consider a field-count or presence-bitmap discriminator so omission can never alias a crafted valueYes (canonical-encoding forgery surface)
G5§9, §3.4Error classification order undefined: format → policy → key → commitment → AEAD precedence, and how context mismatch (which surfaces as a wrong derived key under dual binding) maps to AAD_MISMATCH vs COMMITMENT_INVALIDThe decrypt state machine in docs/09 §3.2, pinned normatively; acknowledge the §6.3 ambiguity honestly in §9Partly
G6§9, §10.3No error code for mode violations (encrypt() in readonly); whether readonly may compute blind indexes for queries unstated; readonly’s non-envelope read behavior undefined (§10.3 defines the other two modes by exactly that behavior)Add a code; clarify readonly = no writes, index computation for WHERE permitted, non-envelope input passes through as permissiveNo
G7§4.2Suite 0x0002’s XChaCha20-Poly1305 has no IETF RFC; no normative definition namedName libsodium’s construction as normative (draft-irtf-cfrg-xchacha expired), or drop 0x0002 to keep the registry leanPartly
G8§7Blind-index stored representation undefined — two languages sharing one database must byte-agreeRaw bytes, length ceil(b/8), binary column; hex alternative for text-only stores, declared per columnNo
G9§11.1Sync-only blind_index blocks the Node event loop 10–100 ms per Argon2id term (docs/11 §2); L4-capable adapters (Prisma) could use an async variant the spec forbade when this row was written (resolved 2026-08-09; §11.1 permits one under four constraints, and the TypeScript core ships two companions as of 2026-09-04)Optional async companions for L4 adapters, sync remains mandatory and primaryNo
G10§3No plaintext length bound; implementations should reject at the same limit2³¹−1 bytes at the API boundary (docs/09 §4)No
G11§6.1, §7.2purpose/index-id grammar unconstrained (charset, length) — interacts with G4’s encoding-aliasing concernConstrain to [a-z0-9-]{1,32} after the index: prefixNo
G12§7.10, §7.4§7.10 permits unique constraints “on the index column only” while §7.4 mandates collisions (P × 2^(−b) ≥ 2) — a UNIQUE truncated index rejects legitimate distinct values§7.10 row becomes “No”; application-level uniqueness fallback documented with its race honestyNo
G13§10.2, §7.10§10.2’s Prisma bullet unconditionally MUST-rejects in: while §7.10 supports membership (“N indexes OR’d”)Scope the MUST to non-rewriting adapters; a correct index rewrite + §7.5 re-verify is conformantNo
G14§6.1, §6.2 (§5.3, §7.2)tenant_id/row_id unbounded, so canonical_context — the HKDF info — is unbounded; Node caps info at 1024 B, OpenSSL 3.0–3.5 (and .NET on Linux through it) at 32 KiB. Found in TypeScript-core review 2026-08-22 (docs/18 §4), not in the suiteBound each optional field (255 B proposed, so the whole encoding fits under the smallest known cap with extension headroom); refuse at the API boundary; max-context vectorsNo
G15 ✅ CLOSED 2026-08-24§3.1, §3.4, §9, §10.3, §11.1, §4.8; docs/09 §7Four behaviors both cores pinned with no spec text: which fmt_ver bytes raise UNKNOWN_FORMAT_VERSION (and the is_ciphertext/pass-through tension); rotate() on non-envelope input in permissive; the §4.8 arming variable’s name; nfc-casefold-v1’s Unicode version, folding variant, post-fold normalization and invalid-UTF-8 handling. From docs/18 D-03/D-10/D-13/D-14 after PR #47Closed as filed on A, B, C, with three additions; D item 4 closed the other way. §3.1 reserves 0x02 and floors a future version at 111 B — the length gate otherwise assumed a layout for a format nobody has written; §3.4 states the three-way recognition, the asymmetry, and why it is decided opposite to the retired-suite case; §11.1 makes rotate ciphertext-to-ciphertext in every mode (reverses both cores); §4.8 names FIELDSEAL_ARM_PROVISIONAL_SUITES, exactly 1, and adds byte-exactness and the either-arms rule; docs/09 §7.1 pins nfc-casefold-v1 to Unicode 17.0.0 with vendored folding and normalization, strict UTF-8, refusal of unassigned code points, and a post-fold NFC — the proposal’s “no second normalization” was measured to leave 8 Greek letters failing to collide with their own uppercaseNo
G16 ✅ CLOSED 2026-08-25docs/09 §7, §7.1; §7.5, §7.6, §10.2; docs/12, docs/13Three consequences of the G15 closure. (A) A bytes-only index API cannot enforce §7.1’s own refusal — TextEncoder substitutes U+FFFD, so two distinct lone surrogates reach one index value; the TypeScript core refuses string and points callers at exactly that conversion, while the Python core accepts str and refuses both. (B) encrypt does not normalize and blind_index does, so a post-pin character stores but cannot be indexed, and no document says whether the adapter refuses the write or stores an unfindable row — the pointer in §7.1 to “§12 of docs/12 and docs/13” is dangling, those sections do not exist. (C) The pin has no currency policy, and bumping VERSION today silently pins the draft: Public/18.0.0/ucd/ 302-redirects to Public/draft/ucd/ over plaintext HTTP, and generate.py follows redirects while printing the requested URLA: blindIndex accepts string | Uint8Array; the caller-side check stays as advice, not as the mechanism. B: IndexDeclaration.on_unindexable = refuse | bucket, default refuse, bucket gated on §7.6’s logged-reviewed-override shape and resolved through §7.5 re-verification so neither value is the §10.2 forbidden pattern. C: §7.1 states that the pin tracks the latest release until freeze and is redefined in place, and becomes a new identifier plus a re-index after; guard the generator against redirects and protocol downgrades now; bump when 18.0.0 stops redirecting. D: none of it goes to the Gate 0b reviewers — Part A reads like a reviewer question (a false match in a blind index) but decides a parameter type, and the refusal rule’s breadth turns on whether Unicode’s allocation practice is a guarantee, for which a cryptographer is the wrong expertNo — no reviewer dependency in either direction
G17 ✅ CLOSED 2026-08-25docs/09 §8.1, §3.1/§3.2, §8.3; docs/10, docs/11docs/09 §8.1 gives the KeyProvider signatures without an ownership contract, and §3’s four erasure points never say what the core is entitled to erase. TypeScript zeroizes what encryptionKey returned (api.ts:162) and deliberately does not zeroize what decryptionKeys returned (api.ts:229) — the second citing the reasoning that contradicts the first; safe today only because the shipped providers return copies, so a custom provider returning a reference to its own cached DEK loses it on the first encrypt. Python does neither (bytes is immutable), which also makes two of the four erasure points unperformable there without docs/10 saying so, against §8.3’s honesty obligationKey material is provider-owned: a core MUST NOT mutate or retain it and MUST copy if it needs to erase — the only rule implementable in every target language, and what the decrypt path already argues for in a comment. §3’s erasure steps gain a mutability precondition; docs/10 §5 gains the paragraph docs/11 §5 already has; a key-material-ownership pinned_decisions key carries it into the report, since no vector can observe buffer lifetime. Folds in docs/10 §6 item 2’s “candidate reads must not deplete max_uses”, which §8.3 states only from the encrypt sideNo
G18 ✅ CLOSED 2026-08-26docs/09 §2, §7, §8; docs/10, docs/11; docs/12 §5, docs/13docs/09 §2 specifies a client that validates everything at construction and is immutable afterwards, and never says any of it can be read back. docs/12 §5’s E006 — a user-supplied FIELDSEAL["CLIENT"] whose index registry must exactly match the model declarations — is therefore unimplementable and shipped as a W004 warning (PR #73). Python keeps the registry in _indexes (private by convention); TypeScript keeps it behind #cfg on a frozen instance, so a Prisma or TypeORM adapter has no workaround at all. The silence has already produced divergence in two layers: the cores disagree on reflection generally (TypeScript exposes four config accessors, Python none), and docs/13 §2 removes the client option outright, citing the Django check as its reasonA principle, not a field list: a client MUST report every validated element affecting stored bytes, query results or read behaviour — including the index declarations, in resolved form — through accessors that cannot mutate it, and MUST NOT expose its KeyProvider, cache or key material. ValidatedIndex becomes public API in both cores; docs/12 restores E006 and withdraws W004. No pinned_decisions key, unlike G17: buffer lifetime was unobservable in principle, an accessor’s presence is testable, and a report key would legitimise the divergence this ends. Closed as filed, plus one addition: validate_index_declaration and index_registry_key are public too, because a caller that can read the registry and not resolve its own declarations still cannot compare against itNo
G19 ✅ CLOSED 2026-08-26§7.5, §7.2, §7.1; docs/09 §7, §7.2; docs/12 §3.2/§3.3, docs/13§7.5 requires an implementation to “decrypt and compare the actual values” and never says under what equality. On a column declaring nfc-casefold-v1, a row stored Ada@Example.com and queried ada@example.com returns 0 rows byte-compared and 1 row normalize-compared. docs/12 §3.3 already implies the second (it refuses iexact because “case folding belongs to the normalizer, not the query”); docs/09 §7.2’s bucket paragraph is phrased for the first. Canonical equivalence (U+00E9 vs e + U+0301) has no sane byte answer and no ORM lookup to refuse people towardsCompare normalize(stored) against normalize(queried) under the index’s own normalizer; adapters document that such a column has exactly one equality and MUST NOT offer a second. Decisive argument: byte-compared, a folding normalizer enlarges every candidate set, coarsens the bucket and buys no capability — worse than identity on the columns §7.3 recommends it for. Vector-expressible, unlike G17/G18, and belongs in blind-index/ rather than per-adapter testsNo
G20 ✅ CLOSED 2026-08-26§10.2, §7.10; docs/12 §3/§5/§6, docs/04 §1, docs/13 §5§7.10 rules ORDER BY out and §10.2 contains both the throw rule and the exact sentence — for exactly one ORM (Prisma). Nothing names the shape elsewhere, and “wrong results” needs an interpretive step for ordering that it does not need for values. The shipped designs diverged: docs/13 §5 throws, the Django adapter serves it silently — measured: .order_by("email") returns envelope-byte order; .values("email").annotate(n=Count("pk")) returns four groups of n=1 where the truth is three with one n=2, two groups printing the identical key; .earliest("email") returns the wrong row. §7.10’s GROUP BY/DISTINCT row covers only the index sibling, not the ciphertext columnAn All ORMs bullet in §10.2’s known-cases list (Prisma’s sentence stays as the named instance); §7.10’s row split — sibling: yes with the collision caveat, ciphertext column: No with the materialize-then-sort fallback; the Django refusal design in docs/12 (every FieldsealQuerySet, obligations or none; .candidates() does not lift it — ciphertext order has no semantics to accept; checks for Meta.ordering and admin sortable columns); interception honesty in docs/04 — ordering resolution has no field hook, so plain-manager relation ordering is a documented residue in the raw-SQL classNo
G21 ✅ CLOSED 2026-08-31§10.2, §7.10, §7.5; docs/13 §4/§2.1, docs/12 §3.3docs/13 §4 grants notIn: an index rewrite that G13’s scoping of §10.2 never granted (in: only) and §7.10’s table has no negated-membership row. The asymmetry docs/12 already states for exclude(): a positive in: returns a superset §7.5 trims; a negated one excludes whole §7.4-mandated collision buckets — false negatives the database never returns and §7.5 cannot put back. docs/13 §2.1’s .not entry shares the defectRefuse notIn:/.not (the shipped conservative reading, adapters/prisma); correct docs/13 §4 and §2.1 to match §10.2’s in:-only permissionNo
G22 ✅ CLOSED 2026-08-31docs/09 §7.1; docs/10, docs/11; docs/12 §10.2, docs/13 §9docs/09 §7.1 says cores MUST export the assigned-code-point check for adapters that hold the text earlier — and neither core’s public surface does (TypeScript re-exports it internally but the exports map makes it unreachable; Python has it outside fieldseal/__init__.py). The Prisma adapter reaches the code point and offset by regex over the core’s error message — a user-facing requirement resting on prose. Second under-implementation of the clause (G16 part A found its message actively countermanding §7.1)Export first_unassigned / firstUnassigned from both package roots; the adapter drops the regex routeNo
G23 ✅ CLOSED 2026-08-27§10.2, §7.10; docs/12 §3/§6, docs/13 §4§10.2’s All-ORMs aggregate clause (G20) refuses on the ground that “an aggregate computes on bytes” — true of MIN/MAX/SUM/AVG, false of plain COUNT(col), which reads null-ness alone and is exact under the NULL-preservation invariant. The two shipped adapters read the clause opposite ways: Prisma serves _count (measured exact, PR #86), Django refused Count(field) with the false justification while docs/12’s own rationale advised filter(f__isnull=False).count() — the same COUNT as a WHERE clauseDiscriminator reworded to reads envelope bytes; §10.2 carries a conditional permission (plain, non-distinct, single-column COUNT(col) MAY be served) and a prohibition (a refusal MUST NOT be justified as bytes-computation); the NULL-preservation invariant stated normatively — it was enforced and tested in both adapters and stated nowhere, with the served IS [NOT] NULL shapes silently resting on it; §7.10 gains the non-null-count row carrying the condition; Django serves the bare shape (distinct=True, Count(Length(...)) and filter= forms stay refused; the grouping and §7.5 SQL-answered rules untouched)No
G24 ✅ CLOSED 2026-09-06§10.2, §7.10, §7.5; docs/12 §3.2, docs/13 §2.2/§4G21 refused negation over a blind index and left open whether the documented bucket-semantics hatches lift it. Django’s .candidates() did (and the refusal message recommended it); Prisma’s candidateScope() refused not/notIn and lifted NOT, none and isNot without either document noticing. A caller who read one adapter’s error text and moved to the other got the opposite behaviour, with no clause to appeal toNeither hatch lifts it. §10.2 gains a MUST NOT scoped by position — wherever a wider index match set yields a narrower result — plus the carve-out for IS [NOT] NULL and a rule that a refusal message MUST NOT point at the hatch. Django loses exclude(), ~Q and XOR under .candidates(); Prisma loses NOT, none and isNot under candidateScope()No
G25 ✅ CLOSED 2026-09-18§3, §3.6, §10.2, §12; docs/09 §7, docs/12 §2, docs/13 §1Spec §3 pins the byte layer and nothing pins the logical-type-to-plaintext rendering; the adapter vocabulary is itself unspecified and has no decimal. Three measured divergences in three classes. date: Prisma reads Django’s b"2026-09-08" as an instant, and re-writing it stores b"2026-09-08T00:00:00.000Z", which Django cannot read — one ordinary write through the second stack makes a row permanently unreadable to the first, silently; and an instant rendered in local time west of UTC is the previous day. Decimal: no vocabulary entry, so as: "float", and b"12345678901234567.89" returns 12345678901234568 — a silent wrong answer through the codec, the one door §10.2 does not cover. boolean: loud in both directions, and unindexable anyway (P=2 fails §7.4’s floor of 16, which §7.6’s override does not reach). Invisible to the N×N harness, which has each producer compare against its own recorded plaintextA normative vocabulary and rendering table, with vectors — the vocabulary needs decimal and probably date, both defects being cases where an adapter had no faithful declaration and reached for the nearest. Or an explicit refusal to standardize, which would make adapters MUST-refuse the unpinned types. §10.2 gains the codec as a named wrong-answer site. Not resolvable by editing one adapter to match the other: either edit is a backfill under §7.8’s reasoningNo Closed by spec §3.6 (§7, 2026-09-18).
G26 ✅ CLOSED 2026-09-22docs/14 §4, docs/08 §5; spec §3.5, docs/09 §7.1An out_of_band entry whose operand the language cannot represent has only not-run, which blocks a level claim: the Java and .NET cores cannot hold a 2³¹-byte operand (int-length arrays), and the Go core cannot hold a lone surrogate, so three of five target languages could never claim L0. Filed 2026-09-22 when Phase 2 opened; blocks docs/26 P2-M0A pass rule for the class, not a new status: refusal proven on a synthetic operand through the path every public call takes, with the representability argument in the entry’s method and the binding doc; the lone-surrogate reading left to discussion; not-verified retired. Revised 2026-09-22 in the tracker after review: a basis field; the seam open to any core and named in docs/09 §4; the surrogate entry split into blind-index/ vectors (G15’s unshipped invalid-UTF-8 obligation) and a representability record. Decided 2026-09-22: that record is pass with basis: representability, conditional on the vectors (§7)No Closed by #179 and #180 (§7, 2026-09-22).

6. Risk register (delta to PRD §9 — implementation-phase risks)

RiskMitigation
A G-issue closes differently than a tech spec assumedThe specs mark every dependent section with the G-number; closure includes a sweep of those markers (grep for G<n> across docs/07–15, docs/adr, docs/issues — not repo-wide: docs/00 uses G1–G6 as market-gap numbers, a different namespace)
Sync Argon2id in Node is a product-killer for Prisma usersSurface early: benchmark in WS-C week one at spec-minimum parameters. G9 has since closed, so the async companion no longer needs a spec change — the benchmark now decides whether the TypeScript core ships one (docs/11 §2). The escalation that remains is worse and narrower: if the sync path is untenable at every parameter set, that is a §7.3 cost problem, not an API problem, and goes back to the spec as a new issue
Unicode normalization drift across languages silently breaks shared indexesnfc-casefold-v1 pinned to a vendored folding table, not platform Unicode (docs/09 §7 flag); vectors carry pre- and post-normalization forms
Library-fact flags wrong (pyca XChaCha, sync argon2 APIs, DMMF access)Every [VERIFY] is a tracked task at workstream start, not discovered mid-build
Single-author cores weaken M2’s independence claimState it in the M2 report if so; recruit a second implementer for one core as the preferred fix (also feeds the OpenSSF multi-maintainer requirement, PRD §9)
Vector generator becomes a de-facto oracledocs/08 §7 rule: agreement of two independent implementations freezes a vector, never the generator alone
Windows/line-ending corruption of vector bytesGenerator writes bytes; .gitattributes already pins LF; harness verifies MANIFEST hashes before use

7. Decision & divergence log

Append-only, verification-log style (docs/06): every M2 divergence, every G-issue outcome, every ADR closure with date and consequence sweep.

  • 2026-09-22WS-L: the conformance tooling compares N cores, not two (#182; docs/26 §1 item 4). Two cores were wired into three places: compare_result_ids.py’s CORES pair and its pairwise comparison; the cross-core-result-ids job; and each cross-consume consumer’s hand-written list of the four producer files. Now: the comparison takes --cores and compares each core’s synchronous and out-of-band id sets with the union of all of them. A finding names the core that did not run an id and the cores that did, grouped so that no finding names a core that did not run it. A missing or unreadable report is a finding by name, and the rest are still compared. The producer list lives once, in the workflow’s CROSS_PRODUCERS; both consumers build their arguments from it, and a step before them fails on a listed producer with no upload or an upload nobody listed. That is the check that keeps it from drifting from the cross-produce matrix, which cannot read it. What “one line” turned out to mean. docs/26 hoped a third core would be a one-line matrix change. It is a handful of list entries plus that core’s own jobs, because producing and consuming are per-language code. What a new core no longer needs is any edit to the comparison or to the existing consumers; the comment above cross-produce lists the rest. Checked: 30 comparison tests, 7 of them new three-core cases, which fail when the comparison is made to iterate a hard-coded pair; the real reports (182 identical ids); a copied third report, passing and then failing by name once one family is removed; the producer guard with a file missing and with an unlisted one. An outside review reproduced these and raised two points taken before merge: docs/14 §2’s delta date, and that the guard cannot fire when a producer leg is red, since the bare needs skips the consumers. What it catches is a green leg that uploaded nothing. Found along the way: adapters/prisma/tests/value-path.test.ts asserts that a base64 envelope does not contain the substring Ada, which random base64 occasionally does. It failed once on #182’s CI and passed on re-run, and it is the likely cause of the unexplained one-off Prisma failure of 2026-08-31. The fix, asserting on the decoded bytes, is not made yet. P2-M0: the plan, G26 and WS-L are done; the Java binding doc remains.

  • 2026-09-22G26 closed (#176): a conformance report can now say how an out-of-band entry was established, which was one of the four conditions for Phase 2’s entry (P2-M0). Implemented in #179 and #180, on the direction the two #176 comments of this date settled (the §7 entry below records them). What changed. docs/14 §4: an out-of-band status is pass, fail or not-run and nothing else, and every entry carries a basisdirect, seam or representability; a level claim quoted outside the report names every entry that is not direct. The length bound may be proven through a seam, which docs/09 §4 now names as architecture and which any core may use; #decrypt is defined by an implied plaintext length of at least 2³¹, not by an overhead constant. A Go or Rust core records the lone-surrogate entry with basis: representability and passes only under docs/08 §5 item 9’s conditions. The TypeScript harness’s report type, which admitted a not-verified that docs/14 never defined, now matches. Vectors: suite 0.8.0-provisional, 150 vectors, 182 results, both cores green with identical synchronous ids (the TypeScript core 364 with its #async pass, 69 through a companion). The four new vectors are a new blind-index/ assertion shape, refuse: the generalized-UTF-8 forms of U+D800 and U+DC00 (ED A0 80, ED B0 80), passed as bytes under on_unindexable = refuse, each asserting INVALID_ARGUMENTdocs/09 §7.1 clause 5, and the obligation G15 recorded and never shipped. Two choices made while planning the implementation, both by the maintainer. The vectors went into blind-index/ rather than errors/, because docs/08 §4.6 limits errors/ to §9 codes and INVALID_ARGUMENT is local, and because #176 had said blind-index/. And distinguishable diagnoses on invalid UTF-8 are required only of a core whose entry takes the representability route: neither shipped core distinguishes them (both use a fixed message), and neither needs to, since both verify the entry directly. One rule is new normative text, introduced by the implementation rather than by the issue thread: docs/08 §5 item 9’s bound on that diagnosis — it names the byte offset and the malformed sequence, at most four bytes, and nothing around it, because error text is routinely logged. #176 asked for distinguishable diagnoses; the bound is what keeps that from becoming a plaintext echo. It is normative from this date and binds the first Go core. Review. #179 was merged before the maintainer had reviewed it; the review ran on the merged commit instead. A /code-review at medium found no defects. An outside review found two harness comments still saying a Go or Rust core records not-run (stale after this change), the diagnosis bound above, and that the refuse shape’s truncate_bits and -b15 suffix decide nothing about a refusal. #180 fixed the comments, gave the Python report the two harness notes the TypeScript one had gained, and says in docs/08 §4.4 why truncate_bits is there (the index declaration is invalid without it) and that the shape has no positive control of its own — the bytes-in #pipeline results are what rule out a core that refuses every bytes input. Checked: both core suites; compare_result_ids (identical ids); suite-integrity and vectors-reproducible; the Django adapter; and bite checks, each reverted — a non-fatal TextDecoder in the TypeScript core and errors="replace" in the Python core each fail all four new vectors, and an entry emitting not-verified fails the new status test. Isolation: the harness edits were made by a session that had read both shipped harnesses, which is maintenance and not the docs/17 build; that session does not implement a Phase 2 core (docs/26 §2.2). Phase 2: this was P2-M0’s second item (docs/26 §1). What remains of P2-M0 is the N-report conformance tooling (WS-L) and the Java binding doc.

  • 2026-09-22G26 (#176): the direction is revised after review, and its one open question is decided — in Go and Rust the lone-surrogate text entry records pass with basis: "representability", not not-run. An outside reviewer reproduced the draft against the tree. Three findings held on re-check: the TypeScript harness’s out-of-band status type is "pass" | "fail" | "not-verified", with no not-run, so not-verified is a bug in a shipped harness and not a synonym; the draft’s Go candidate (ED A0 80 / ED B0 80) reached docs/09 §7.1 clause 5, strict UTF-8, rather than the surrogate refusal it was meant to test; and G15’s vector obligation, “an invalid UTF-8 byte sequence and a lone surrogate, asserting INVALID_ARGUMENT”, never shipped — neither blind-index/ file carries one, and neither G15 entry below records dropping it. One finding did not hold: that the two cores disagree on 0xFF01’s overhead (97 against 111). Both compute 111; the reviewer accepted the correction. The revised direction (comment): status is pass | fail | not-run; every out-of-band entry carries a basis (direct, seam, representability); the length bound may be proven through a seam that docs/09 §4 names as architecture, open to any core, with #decrypt defined by implied length ≥ 2³¹ rather than a constant; the lone-surrogate entry splits, its bytes layer becoming blind-index/ vectors (settling G15’s leftover inside G26, and bumping the suite to 0.8.0-provisional) and its text layer staying out-of-band. The decision (comment). For a language whose string type cannot hold the operand, that text-layer entry is pass with basis: "representability". not-run keeps its narrow meaning, could not be established this run, because it is the status docs/25 §10 item 3 uses to keep a core out of metric M1, and widening it to mean established by argument would bend exactly that rule; the always-printed basis is what makes a widened pass safe to read. Three conditions attach. The pass is available only once the clause-5 vectors are pinned, since a Go string is a byte sequence and the core’s only text path is its strict-UTF-8 path, which is what the vectors run on. The method states the premise and cites the vector ids. And distinguishability is asserted by the harness on the two byte inputs, not argued from the caller still holding distinct bytes; the reviewer’s argument was the latter, and docs/14 §4 frames the requirement around diagnoses. Also taken from the review: docs/26 §4 now says the Java and .NET cores inherit the seam, since it is their only route to a length-bound pass. What stays open: G26 itself, until the docs/14/docs/08/docs/09 amendments, the TypeScript harness fix and the vectors, with two-core agreement on the bump, reach main. Isolation: verifying the review meant reading small ranges of run.ts, registry.ts and run_vectors.py; the fix to the TypeScript harness is made by someone who then does not implement a Phase 2 core (docs/26 §2.2).

  • 2026-09-22The Phase 2 plan is docs/26-phase-2-plan.md; the maintainer made four of its five decisions, and G26 is filed (#176) as the first thing Phase 2 must close. This document stays the Phase 1 plan, as the retrospective’s §10 said it would; docs/26 is its counterpart in the same shape (entry, workstreams, milestones, definition of done, risk delta, non-goals), and it keeps this section as the one decision log and §5 as the one gap table, because a second log would drift from the first. Structure. Workstreams continue the lettering: WS-I Java, WS-J .NET and WS-K Go cores; WS-L conformance and cross CI (Phase 1’s WS-D, grown to N reports); WS-M SQLAlchemy, WS-N Hibernate, WS-O EF Core and WS-P GORM adapters; WS-Q benchmarks (DO-4); WS-R migration tooling (AD-6, CL-8). Milestones are prefixed P2- so they cannot be confused with this plan’s M0–M5, PRD §7’s metrics or the designs’ internal stages. P2-M3, the Go core passing and the cross job green at 5×5, is PRD §8’s exit criterion, read as one pinned suite version reached by all five reports with no not-run standing in for a pass. The reviewed JVM and .NET designs become their cores’ binding docs rather than separate pages. On the way in they lose the #167 premise that #168 removed, their “M5 is not closed” sentences and every reference to a file outside the repository. Decisions, 2026-09-22. (1) The Java core’s JDK floor is 21: HKDF written as RFC 5869 over HmacSHA512, checked by kdf/. The ground is that Hibernate is in scope; the design’s claim about the Hibernate audience’s JDK versions is unsourced and is not part of the ground. (2) Each adapter follows its core directly: Java, Hibernate, SQLAlchemy, .NET, EF Core, Go, GORM. The alternative, all cores first, reaches P2-M3 sooner; the ground for rejecting it is that the first adapter on each Phase 1 core found a gap in that core’s public surface (G18, G22), and found early such a gap reaches the later cores’ binding docs before they copy it. (3) A backfill frontend for all six adapters, taking AD-6’s “per adapter” as written. docs/15 §1.2 already designs thin per-language frontends over one PROCEDURE.md; WS-R writes the procedure and the Django and Prisma frontends, and each Phase 2 adapter builds its own, so the procedure must merge before Hibernate can close. DO-5, the migration cost model, stays out of Phase 2, since it needs real migrations. (4) The Go design is drafted while the .NET core is built, by an author who does not read core/java/** or core/dotnet/**. Open: the .NET target framework (net10.0 only, or also net8.0), needed before WS-J, with a [VERIFY] on which EF Core versions run where. G26. docs/14 §4 names representability as a category of exclusion and gives it only not-run, which blocks a level claim, so the Java and .NET cores (whose int-length arrays cannot hold a 2³¹-byte operand) and the Go core (which cannot hold a lone surrogate) could never claim L0. The closure comment on #167 recorded this and left it unfiled until Phase 2 opened. It is filed today and closes before any Phase 2 core emits a report (P2-M0). Proposed: a pass rule for the class, not a new status, with the lone-surrogate reading left to the discussion. The isolation note that matters for later: docs/26 is a read input for every Phase 2 implementer, and it was written from the specification, docs/, the two designs and the tracker, with no core source read.

  • 2026-09-22M5 closed: the Phase 1 retrospective is docs/25-phase-1-retro.md, and Phase 2 opens as PRD §8 scopes it, Java core first. The retrospective’s §2 shows all three exit clauses met, the last two on this date. Its §3 is the calibration docs/07 §3 said did not exist: Phase 0 in two weeks against about eight estimated, Phase 1 in four and a half against about twelve, for one maintainer directing an AI assistant — a fact about that arrangement, not about the problem, and not the person-hours DO-5 asks for. §4 and §5 check the PRD goals and metrics: portability proven at the provisional level and nowhere else; two implementations against a target of three; every other metric at zero or without data, by design, behind Gate 0b. §6 records which risks materialized: the Argon2id event-loop cost (measured, mitigated by the async path), single-author cores (stated, not fixed), and an unflagged library fact (Node’s HKDF info cap) where the flagged ones held. The decision. §10 weighed three options — the full Phase 2 scope, holding at Phase 1 until Gate 0b, and one core with no adapters — and the maintainer chose the full scope, with the arguments against left standing in the document: Gate 0b is the bottleneck and more cores do not move it, regeneration exposure multiplies by N, and the bus factor worsens. Sequencing: the docs/14 §4 amendment for out-of-band operands lands before any int-length core emits a report (the closure comment on #167, 2026-09-20, is where that need was recorded); then the Java core under the docs/17 protocol; then .NET and Go; each adapter after its core. What this does not change: Gate 0b, every MUST NOT in PRD §8, the provisional suite. This document remains the Phase 1 plan; Phase 2’s own plan is the next planning deliverable and does not exist yet.

  • 2026-09-22M5’s “docs current” is read as the shipped artifacts describe themselves accurately, and on that reading it is met; WS-H has not landed and is carried forward by name; [VERIFY] resolutions are logged here, in §7. The 2026-09-09 audit recorded this as the scope question it could not answer. The definition taken is the only one the plan gives: §4’s Definition of done, whose four bullets now hold for every Phase 1 workstream — every [VERIFY] flag in the binding docs resolved (the one left open deliberately is spec §7.3’s libsodium parallelism, G02), the honest-limitations section shipped with all four packages, and the demo README past its non-author read (#170). The other reading, WS-H has landed, was rejected because it would gate Phase 1’s exit on documents Phase 1 was never scoped to produce: PRD §8’s Phase 1 deliverables are the two cores, the shared vectors in CI, the two adapters and one demo, and its exit criterion is the cross-language round trip. What WS-H still owes, and where each item now sits. (1) PRD DO-1, threat model as a standalone document: the normative threat model exists as spec §2; the standalone form, and the “as deployed” variant §2 above names, describe a deployment and there is none — Phase 3 (hardening). (2) PRD DO-6, KMS-outage runbooks: “tested” needs a KMS deployment to fail — Phase 3; the spec §8.1 MUST beneath it, the read-path hard dependency and its degradation mode, is already stated in all four package READMEs. (3) The “Certifying an implementation” page: docs/14 §6 is still a future-tense paragraph and bench/third-party/ does not exist; it is written when the first third-party report arrives, not before. (4) The docs/06 re-run — next paragraph. None of the four is small, and none moves Gate 0b. Where [VERIFY] resolutions are logged. Five entries below (the 2026-09-09 audit, #167, A2, #170, #171) used this log on the grounds that docs/06’s scope is regulatory and vendor claims. That ground is overstated: docs/06’s own method line lists “project source code”, and its verified table carries library facts (the Rails cipher rows). The ground that holds is structural — docs/06 is a dated snapshot of one verification pass, under one date and one method, and this section is the running append-only log; a snapshot cannot absorb dated resolutions without becoming a second log. So §7 is the home. The two present-tense sentences that said otherwise are changed today: §2’s WS-H line no longer says “keeping docs/06 current”, and §4’s second bullet now says a dated entry here, in docs/06’s style. docs/06 gains a scope note pointing here. The five earlier entries stay as written. The docs/06 standing rule was passed without a re-run. It read “re-run this verification before any public release of any document in this repository.” The repository is public, the site is live, and the first experimental package release (v0.1.2) was published on 2026-09-18; no re-run happened. The rule is amended today rather than the verification re-run: the claims it covers are the regulatory and vendor citations of docs/00 and docs/03, which no package README carries (the repository README’s two, Grubbs et al. and AWS-2025-032, were themselves verified in the 2026-08-08 pass), and the track that circulates them is Phase 3 dissemination (PRD §8; docs/05 §5 onward). The amended rule names the date it was passed and re-scopes its trigger to that track. The re-run is owed before that track starts, not before. M5 is not closed by this. Its third clause, the Phase 1 retro deciding Phase 2 entry, remains and is the maintainer’s.

  • 2026-09-22The demo’s Django model states idf="argon2id" explicitly, matching the Prisma schema (#171). The Argon2id entry of the same date below says the Django model drops the explicit idf; it stays as written, and this entry supersedes that one sentence. #170’s review asked for the kwarg back: BlindIndex.idf defaults to argon2id, so the declaration relied on an adapter default plus a seven-line comment for what schema.prisma states in one token. Change: one kwarg and a shorter comment in models.py; the §7.3 rationale — Argon2id is the Required IDF for an enumerable column such as email, and the §7.3 table puts HMAC out of scope for exactly that column — stays in the model file. Nothing else moves: BlindIndex.deconstruct() strips defaults, so 0001_initial.py still serializes only projected_population=100000 and makemigrations --check reports no changes; the golden narration, the migration and both adapters are untouched. What the kwarg buys, stated plainly: readability at the declaration and symmetry across the two stacks, and no more. It is not a new safety net — check_declarations.py compares the resolved idf on both sides, so a changed adapter default failed CI before this change too, naming the parameter and spec §7.8’s new-index consequence. The issue’s first draft claimed the drift would be silent; its review corrected that before anything landed. Checked against the digest-pinned Postgres 17 image: makemigrations --check --dry-run (no changes), manage.py check --fail-level WARNING, migrate, prisma generate, check_declarations.py (argon2id / nfc-casefold-v1 / 15 bits / P=100000 on both sides), run_scenario.py --check (191-line golden matches; b950/ecee/7bac unchanged), check_transcript.py (11 steps, acts 1–7, 29 assertions), check_schema_shape.py (1 table, 5 columns, 2 indexes), ruff and tsc --noEmit.

  • 2026-09-22The demo’s email index is Argon2id, closing the one finding the 2026-09-10 M5 entry left open, and the demo README has passed the non-author no-overclaim read (§4 bullet 4). The leftover was: examples/patient-directory still declared idf="hmac-sha512" on an email column, which §7.3’s table puts out of scope for enumerable domains — after both package READMEs had already been corrected. Review of this change found one more copy of the same declaration outside the demo: the Prisma adapter’s annotation-grammar docstring (adapters/prisma/src/annotations.ts) still showed index: "email" with idf: "hmac-sha512" while claiming to mirror the docs/13 §1 example, which already says argon2id; that docstring now says argon2id too. After this entry, every hmac-sha512 declaration over an email column that a reader is shown as an example — the three package READMEs, the demo and this docstring — says argon2id; the remaining occurrences in shipped source are the two cores’ IDF registries, the Prisma parser’s test data and the vector suite’s own blind-index/hmac-sha512 family, which pins the primitive rather than recommending it. Change: the Django model drops the explicit idf (the adapter’s BlindIndex defaults to argon2id, so BlindIndex.deconstruct() now serializes only projected_population=100000), the Prisma schema declares idf: "argon2id" explicitly (required on that side), the migration’s serialized BlindIndex loses idf='hmac-sha512', and the golden narration’s eight blind-index bytes move with the IDF: ada@example.com aba0b950, Grace@Example.COM 4beaecee, mallory@example.com 34987bac (act 5’s writer comparison and act 7’s table; envelope lengths, headers and every other line are untouched). No executable code in either core or adapter and no README prose changed — the demo README never named the IDF; the one adapter edit is the docstring named above. How the goldens were produced and checked, stated plainly: the new values were first computed offline through the Python core against the demo’s key and declaration (suite 0xFF01 tenant-a-dek-v1, table/column 018f5a10…0001/…0002, nfc-casefold-v1, b=15, P=100000, Argon2id at the §7.3 minima both stacks default to), after reproducing the old aba0/4bea/3498 byte-for-byte through the same code — the argon2id outputs are then the same path with the IDF swapped. One bug-free hand edit is still not a run, so the whole local demo chain was executed against a Postgres 17 container (the digest-pinned CI image): makemigrations --check --dry-run reports no changes against the edited model, manage.py check --fail-level WARNING is clean, migrate applies, prisma generate emits the field map, check_declarations.py resolves argon2id / nfc-casefold-v1 / 15 bits / P=100000 on both sides, run_scenario.py --check matches the 191-line golden exactly — so the values a real run prints are b950/ecee/7bac — and check_transcript.py (11 steps, acts 1–7, 29 assertions), check_schema_shape.py (1 table, 5 columns, 2 indexes), ruff and tsc --noEmit all pass. The no-overclaim audit: performed 2026-09-22 by a non-author — the README was read cold against CONTRIBUTING.md’s two standing rules. Findings, all verified against sources rather than the README’s own claims: the status sentences (§4.8 provisional, Gate 0b open, quoted test-keys.json banner) match the artifacts; the floors (“Python 3.12+”, “Node 24.7+”, “Postgres 17”) are adapters/django’s requires-python = ">=3.12", the TypeScript core’s engines.node = ">=24.7.0" and the CI job’s digest-pinned image; “seven acts, each a separate process invocation” matches docs/20 and the runner (11 transcript entries across acts 1–7; reset is not an act); the database-only-channel claim matches run_scenario.py’s per-process architecture; “the two stacks warn differently about the key provider” is true of the cores (the TypeScript one emits static-key-provider per client; the Python one has no equivalent, warning only on non-strict read_mode); and the honest-limitations section is present and specific. No violation of either rule was found; nothing in the README needed changing. The 2026-09-09 M5-sweep entry’s sub-bullet below ("§4’s fourth bullet remains structurally unmet for WS-G") is now discharged, the §7 log stays append-only, and the leftover from the 2026-09-10 entry is that entry’s history.

  • 2026-09-22The last of the 2026-09-09 currency sweep’s three [VERIFY] flags is discharged as confirmed: pyca cryptography still ships no XChaCha20-Poly1305, so docs/10 §2 keeps the PyNaCl row. The flag read “[VERIFY — if pyca has added it, drop PyNaCl]”, which is a question about someone else’s API surface and not a request to install anything: suite 0xFF02 is deliberately unbuilt (G7), nothing here builds it, and the required dependency set stays cryptography alone. Checked at the pin and ahead of it, because the row has to be true of the version CI resolves without going stale the moment upstream moves: hazmat/primitives/ciphers/aead.py exports exactly AESCCM, AESGCM, AESGCMSIV, AESOCB3, AESSIV and ChaCha20Poly1305 — the same six at pyca’s 50.0.0 tag, which core/python/constraints-ci.txt pins, and on pyca’s main — the Rust binding stub _rust/openssl/aead.pyi declares those six classes and no seventh, so nothing is reachable below the Python layer either, and docs/hazmat/primitives/aead.rst documents no XChaCha20. The string XChaCha also appears nowhere in the changelog, which rules out the one case a snapshot of main cannot: that it was added in some release and later withdrawn. Consequence sweep: docs/10’s library-fact caveat said “one remains” and now says none, dating this one; the row keeps PyNaCl, names the evidence, and says it is revisited if G7 closes, since only then does anything build the suite. No [VERIFY] remains in the binding docs docs/1013docs/11’s closed in the same 2026-09-09 sweep, docs/12’s and docs/13’s earlier. Flags outside that set are untouched and still open, and this entry does not touch them: spec §7.3’s libsodium internal parallelism (G02) and the exception-type read in G14. The two dated entries below that say two flags remain are left as written — they were true when written, and this log is append-only. Logged here rather than in docs/06 on the same grounds as the 2026-09-09 audit, that docs/06’s scope is regulatory and vendor claims; where [VERIFY] resolutions belong is still an open question, and this entry settles it no more than the last one did.

  • 2026-09-20The docs/09 §4 buffer-maxima [VERIFY] is resolved as corrected: it named the wrong set of languages and could not be discharged as written. The flag asked for “the actual buffer maxima for each Phase 1 language” and then listed Node, the JVM and .NET. Python is Phase 1 and was missing; the JVM and .NET are Phase 2, which docs/09’s own second paragraph and PRD §8 both say. Both sentences landed in the same commit (4bea6dd, 2026-08-08), so this was an internal inconsistency in one document, not drift against a later decision. Read literally it held a Phase 1 exit clause (§4’s [VERIFY] rule, through M5’s docs current test in §3) open until two Phase 2 cores existed — and would still not have covered Python. It had already cost something real: two Phase 2 core design plans were written on the premise that this flag needed those cores. Change (#167): the bracket becomes a per-binding obligation with no language list — each core’s binding doc states its platform’s largest byte buffer and whether that ceiling or spec §3.5’s bound binds first — so the sentence cannot go stale again as cores arrive. The Phase 1 halves are discharged in the same change: docs/10 §5 records CPython’s sys.maxsize = 2⁶³−1 (measured 2026-09-19, CPython 3.14.6 x86-64), and docs/11 §5 cites docs/18 §4’s Node measurement (buffer.constants.MAX_LENGTH = 2⁵³−1 on Node 24 x64) rather than copying the number. On both platforms the spec bound binds first, so the ceiling-not-a-guarantee clause never engages and both cores’ spec/3.5/length-bound out-of-band entries verify the refusal directly. The Java, .NET and Go halves are owed by those cores’ binding docs when Phase 2 opens; spec §3.5 already states the JVM case qualitatively, and no number for either was written down here, because a measurement needs a runtime someone ran it on. The normative content did not change — not the bound, not the ceiling clause, not a vector, not an envelope byte, not a report field, and nothing Gate 0b touches. Logged here rather than in docs/06 on the 2026-09-09 audit’s reasoning: docs/06’s scope is regulatory and vendor claims, and this is a phasing-consistency correction rather than a library fact. Left open deliberately: docs/14 §4 gives a runtime that cannot represent the 2³¹-byte operand only not-run, which blocks a level claim — the same category it already describes for lone surrogates in Go and Rust. That needs deciding before a Java, .NET or Go core emits a report, and not before; it is out of scope here and is recorded in #167.

  • 2026-09-19Both adapters now index a tenant_bound column in their fixtures, which closes a declared cross-producer limitation. No fixture model declared an index on a tenant-bound column. Spec §5.2’s sibling-key scope on the index path was therefore cross-checked only by the core producers, and both adapter producers listed “tenant-bound index” in producer.limitations (#103 review). An index derived without its tenant fails silently: the lookup misses, or it links one value’s rows across tenants. The envelope half of the cross job cannot see either. Change (#164, on main via #165): TenantDoc.handle in both fixtures is encrypted, tenant-bound and nullable, with an exact / hmac-sha512 / nfc-casefold-v1 / b=15 index. Each adapter tests three things: one value in two tenants stores two index values; a lookup finds its row only under its own tenant; and a lookup with no tenant refuses rather than returning nothing. Each cross producer emits a tenant-bound index case carrying tenant-0001 and drops the limitation. No adapter source changed: the tenant already reached the index context, and the fixture is what shows it. Checked: dropping the tenant from either adapter’s index context turns that adapter’s two scoping tests red. The adapter tests pass on SQLite and Postgres in CI, and both core consumers verify both adapters’ tenant-bound cases there. With tenant_id nulled on those cases, both consumers fail exactly them, and each derives the same wrong value as the other.

  • 2026-09-19The cross-core result-id guards have tests; until now each had been verified by hand only. cross-core-result-ids is the one CI job that reads both cores’ reports, and it is what makes docs/08 §9’s claim of identical result ids on the synchronous pass enforced rather than asserted. Its comparison was Python inline in the workflow YAML, so nothing exercised its guards and a refactor could have deleted one silently (#114 review, item 10). The duplicate-id check had the least cover: pytest suffixes a duplicated test id and the TypeScript harness collapses results in a Map, so no per-core run can see one. Change (#161, on main via #162): the comparison moved unchanged to tools/conformance/compare_result_ids.py (stdlib only, so the job still installs neither core), and the job first runs test_compare_result_ids.py. It has 21 cases, each breaking one input in a synthetic pair of agreeing reports: an unreadable, 0-byte or missing report; a duplicate id standing in for a dropped one; skipped results, including both cores skipping one family; a partial second pass; both async_companions mismatches; a failing or divergent out_of_band entry; a family only one core ran; both cores dropping the same family; both running nothing; the manifest prefix needing its slash; and an unreadable manifest. Checked: disabling each of the 13 guards turns at least one test red, and on real reports from both cores the script’s output is byte-identical to the inline version’s, both passing and on a 0-byte report and a dropped family. One behaviour changed, from the PR’s review: an unreadable manifest is now a finding listed with the others, where it had been a traceback that buried them. The job failed either way, so the gate itself never depended on it. docs/14 §2’s delta says so.

  • 2026-09-18pip install fieldseal-django could not save to an indexed column, and nothing in CI could see it. BlindIndex defaults to idf="argon2id", which the Python core computes with argon2-cffi, an optional extra of fieldseal. The adapter depended on plain fieldseal, so a registry install of 0.1.0–0.1.2 raised ModuleNotFoundError: No module named 'argon2' on the first save to an indexed column (reproduced in a clean virtualenv from PyPI). Every CI job and the release smoke test install fieldseal[argon2] explicitly, which is why none of them failed. Found while rewriting the adapter README’s quickstart and running it against the published packages. Fix: the adapter now depends on fieldseal[argon2]; ships with the next release. The same rewrite moved the README’s coverage matrix, refusal rationale, known gaps and development notes to adapters/django/REFERENCE.md, so the README can lead with installation and a quickstart; docs/14 §4 now names either file as the place the matrix lives.

  • 2026-09-18v0.1.2 is the first complete release, and the v0.1.1 entry below had the cause half right. v0.1.2’s first attempt failed exactly like v0.1.1: 403 OIDC permission denied for this action. This time no npm token was configured anywhere, the new guard confirmed it, and the always-auth warning was gone. Removing registry-url fixed a real defect, but it was not what the registry was refusing. The actual cause was on the npm side. A trusted publisher created after 2026-09-03 allows only npm stage publish unless direct npm publish is enabled under Allowed actions, and both were created on 2026-09-18 (docs.npmjs.com/trusted-publishers). The option was listed in npm’s documentation when the workflow was written, and was not read. The maintainer enabled direct publishing on both packages. Re-running the failed job on the same v0.1.2 run then skipped PyPI (already there) and published @fieldseal/core and @fieldseal/prisma 0.1.2, each with publish and provenance attestations. No new version was needed. Final state: npm 0.1.2 for both packages, and PyPI 0.1.0, 0.1.1 and 0.1.2 for both, all the same package code. The GitHub release for v0.1.2 is a draft until its notes have been read against PRD §8 condition 5. What this cost, stated plainly: two extra PyPI versions and three release runs. Neither npm failure was reachable before a real upload: one was argument parsing, now covered by a dry-run; the other was a registry setting nobody here can see. tools/release/README.md now names both.

  • 2026-09-18v0.1.1 reached PyPI but not npm either; the first complete release is v0.1.2. The v0.1.0 path fix worked: npm packed the tarball, signed the provenance statement and logged it to Sigstore. The registry then refused the upload with 403 OIDC permission denied for this action. Cause: the publish job’s actions/setup-node step set registry-url, which writes _authToken=${NODE_AUTH_TOKEN} into .npmrc and fills in a placeholder token. npm then believed it was already authenticated and never did the trusted-publishing exchange. Two lines in the log said so: an always-auth config warning, and npm’s notice about tokens. Fix: no registry-url in the publish job, NODE_AUTH_TOKEN cleared, and a guard that refuses to publish if any npm config file or environment variable carries a token. The guard’s first form called npm config get …:_authToken, which npm refuses to print, so it would always have passed. It now reads the config files directly, and was tested against a clean config, the .npmrc setup-node writes, and a token in the environment. What a dry-run cannot show: authentication. Neither failure was reachable before a real upload, and whether npm’s trusted-publisher settings are right is only testable by publishing. Recovery, chosen by the maintainer: v0.1.2 for all four packages, as for v0.1.1. PyPI keeps 0.1.0 and 0.1.1, which have the same code, and neither tag moves.

  • 2026-09-18v0.1.0 reached PyPI but not npm; the first complete release is v0.1.1. The v0.1.0 run built, checked and smoke-tested all six artifacts, the maintainer approved the publish job, and fieldseal and fieldseal-django 0.1.0 were uploaded to PyPI by trusted publishing. The pending publisher created fieldseal-django on that first upload. The npm step then failed before uploading anything: npm publish dist-release/fieldseal-core-0.1.0.tgz passes a bare dir/file.tgz path, which npm reads as the GitHub shorthand user/repo, so it ran git ls-remote against a repository that does not exist. Why CI missed it: nothing had ever run that exact command before the real upload. The smoke test installs the tarballs but does not publish them. Fix: the path is ./-prefixed, and both the CI release-readiness job and the release build now run the publish command as --dry-run, which needs no credentials and parses the argument the same way. It reproduces the failure on the old form. Recovery, chosen by the maintainer: a new version, not a moved tag. Re-running the job would have re-run the buggy workflow from the tagged commit. Moving v0.1.0 would have left PyPI’s 0.1.0 attestation naming a commit the tag no longer points to. All four packages go to 0.1.1, so npm’s first real version is 0.1.1. PyPI keeps its 0.1.0, which has the same code, and the v0.1.0 tag stays where it is.

  • 2026-09-18The four packages are release-ready under PRD §8’s conditions; nothing is published. What changed, per condition:

    1. Version and maturity. Every package is 0.1.0. The Python packages were 0.1.0.dev0, which pip skips without --pre, and the npm packages were 0.1.0-provisional, which a caret range skips. The maintainer decided a plain install should work, because spec §4.8’s arming gate already stops unintended writes. Both Python packages carry Development Status :: 3 - Alpha.
    2. Provisional suites and arming. Nothing in the packages changed. tools/release/smoke.py checks the installed artifacts: every registered suite is provisional, and an unarmed client refuses to write. A text scan for arming was tried first and dropped. It flagged an error message explaining how to arm and the Prisma adapter passing the caller’s choice through, and could not tell either from a default that arms.
    3. The warning comes first. Every registry description now starts EXPERIMENTAL, NOT INDEPENDENTLY REVIEWED, NOT FOR PRODUCTION DATA. Every package README opens with a warning block saying the format may change, data may need re-encrypting, and writing refuses until armed. The READMEs’ 15 relative links are now absolute, so PyPI and npm can resolve them.
    4. The honest limitations. The checker found a real gap: the Prisma README never stated the spec §5.5 exposure of the in-memory key cache to memory dumps, core files and swap, which every Prisma user runs through the core. It does now.
    5. No overclaiming. Descriptions and READMEs are checked against a short list of claims. Release notes and announcements still need a human read.

    Other changes. Each artifact carries the LICENSE. fieldseal-django pins fieldseal>=0.1.0,<0.2. The sdists hold source and metadata only: the core’s had been picking up a local .hypothesis/ cache. The Prisma adapter still depends on the core as file: in the repository, so CI tests the checkout, and build_dists.py rewrites that to ^0.1.0 in a staging copy. The new CI job release-readiness builds the six artifacts, checks them and smoke-tests them. The smoke test is the first to prove the central claim between installed packages rather than checkouts: each core decrypted the other’s envelope. It passed locally, and the checker was shown to fail on a planted 1.0.0 package with no warning and a “Production-ready” description.

  • 2026-09-18G25 closed (#123): spec §3.6 pins the logical-type → plaintext rendering, and a new vector family binds adapters. The project took direction 1 of the issue (a normative table) over direction 2 (refuse everything unpinned), and decided the two open parameters itself. Decimals are canonical by value. 1.50 renders as 1.5, so that equal values are one plaintext and so one blind-index value. Preserving scale would have kept 1.5 and 1.50 apart in the index and needed strings on the JavaScript side anyway, because decimal.js drops trailing zeros. Types §3.6 does not pin are refused at declaration. UUID was considered and left out, because a string column carries it without a new rule. Two findings from building the fix, both now in §3.6’s justification. First, the Django codec’s str() rendering split Decimal("1.5") and Decimal("1.50") into different bytes. The blind index is derived from those bytes, so an equality lookup missed rows within one adapter — a false negative §7.5 cannot repair. This was derived from the measured bytes and the §7.2 construction; no end-to-end query was run against the old code. Second, a binary64 renders differently in CPython (1e+16, 1e-07) and ECMAScript (10000000000000000, 1e-7). Node was also measured silently truncating .123456 to .123 in Date and replacing an unpaired surrogate with U+FFFD in Buffer.from. §3.6 makes each of these a refusal. What changed:

    • Spec: §3.6, a §10.2 codec bullet, and a §12 obligation.
    • Suite: codec/logical-types.json (124 vectors: 58 write, 66 read, 59 refusals). The generator renders it from its own §3.6 implementation, and its float renderer agreed with Node on 23,160 doubles. It is listed under the new MANIFEST.adapter_files; CI checks its hash, status and version; the suite is now 0.7.0-provisional.
    • Django: codec.logical_type(), refusal in Encrypted(), canonical rendering and strict reads.
    • Prisma: as: "decimal" and as: "date", the same rules, and the index operand’s string shortcut restricted to as: "string". Without that last change a decimal would have been encrypted as 1.5 and indexed as 1.50.
    • docs/08 §4.8 documents the family and its capability-gated skips. The adapter-producer “≥1 case per codec rendering” rule moved to the family, which neither producer had met beyond a few types.

    Checked by mutation, not only by green runs. Restoring the old renderings fails 17 Django tests and 7 Prisma tests. The review round found one defect the mutations did not. Django’s Encrypted.index_operand still passed every str to the index unchanged — the same shortcut this change had just restricted in Prisma — so a DecimalField written as "1.50" was encrypted as 1.5 and indexed as 1.50. The codec tests called the codec directly, never the index path. Fixed with the same guard, and pinned by tests through index_operand that fail on the previous code for all five non-string types. Stated plainly: the generator’s rules and the Django codec were written in the same session by the same author, so they are not independent in the docs/17 sense (docs/08 §4.8 says so). The demo keeps its three-type model as scope, because widening it changes the transcript. No deployment needs a backfill, because nothing had been released. The tracker closes when this reaches main.

  • 2026-09-18Experimental releases are permitted before Gate 0b; the freeze is unchanged. Four weeks after the gate split (2026-08-22, below), reviewer recruitment has still not succeeded (the docs/16 §4 log is the evidence), and the working code (two cores, two adapters, a gated cross-language CI) had no sanctioned way to reach anyone who could evaluate it. PRD §8 already forbade only production adoption, but the READMEs, AGENTS.md and §1b above said “no adoption is invited”, which is stricter than the gate. The project decided to publish rather than wait, and wrote down the terms before any release. PRD §8 gains a third bullet: pre-1.0 packages MAY be published for evaluation and non-production use if (1) they are below 1.0 with the registry’s maturity marker set, (2) they register provisional suites only and ship spec §4.8’s arming gate as specified, (3) the unreviewed status and the re-encryption risk come first in the README and the registry description, (4) the MUST-document limitations travel with the package, and (5) nothing describes the package as secure, reviewed, production-ready or frozen. Gate 0b also gains an explicit bar: no package at 1.0 or above. What does not move: no suite identifier is assigned, the vector suite stays -provisional, the six review-gated issues (#1, #2, #4, #5, #7, #43) stay open, ADR-0001/0002 stay provisional, and production adoption and the Phase 3 track still wait on Gate 0b. The specification is kept. Dropping it was considered and rejected: it is what the two cores interoperate through, it is already written, and a library ships the same constructions, so dropping the spec would remove the reach without removing the need for review. Residual risk, stated plainly: an operator can arm provisional use and put real data behind a construction Gate 0b later changes. §4.8 makes that a deliberate act and makes every affected row identifiable from its stored bytes, and the conditions make it an informed act. They cannot make it a safe one. No package has been published by this entry. Both package.json files are still private: true, and preparing a release against the five conditions is separate work.

  • 2026-09-10The M5 sweep’s two honest-limitations gaps are closed, and one of the omissions was covering a false claim. The sweep below left two §4 bullet-three gaps as maintainer calls: adapters/django/README.md stated neither §3.3 storage overhead nor §7.3 Argon2id cost, and core/python/README.md omitted §3.3 and §5.5. Both READMEs now state all four MUST-document items — §3.3, §5.5, §7.3, §8.1 — plus spec §2.2’s compromised-process limitation, in the terms the TypeScript and Prisma READMEs already use, and each against what the code does rather than what the other READMEs say: Django’s binary-by-default storage and check W003, its idf="argon2id" default and the one derivation per __in value, and the prefork hazard WARM_ON_READY creates under gunicorn’s --preload. The Python README’s “no key caching, no KMS providers yet” was false, not incomplete. DekCache and EnvelopeKeyProvider landed in 7d821c6 and the bullet was never revised, so the sweep’s reading that §5.5 was “arguably discharged” by it rested on a core that no longer existed. The README now says what the cache erases and what it does not (docs/10 §5), and that DerivedKeyProvider, one of spec §8’s three required providers, is still unported. One finding is left open: the project’s own examples choose HMAC for email, which §7.3 forbids. §7.3’s table lists email as enumerable, and its Required IDF for that class is Argon2id. The Django README’s example declared idf="hmac-sha512" on an email column directly above the new text saying so, and now takes the default; the TypeScript and Prisma README examples made the same declaration and now declare argon2id, at the §7.3 minimum cost both stacks default to. The declaration still stands, with no stated reason, in the demo (examples/patient-directory: the Django model, its migration and the Prisma schema), and there it is not a one-line edit: its index bytes and golden narration change with the IDF, and that belongs to the demo’s owner and to §4’s fourth bullet, the non-author overclaim read the demo still lacks. No code changed.

  • 2026-09-10The AES-256-GCM [VERIFY] in docs/08 §7 is discharged: the generator’s primitive layer is now checked against NIST CAVP. The M5 sweep below left it open as real work. tools/vector-gen/fieldseal_vectorgen/kat_aesgcm.py vendors 50 NIST CAVP GCM cases in suite 0xFF01’s shape (25 encrypt, 25 FAIL; source, file hashes and selection rule in the module), and the generator checks them on every run, beside the Argon2id check. It covers both of the generator’s AES-GCM paths: cryptography’s AESGCM, which seals every envelope vector, and gcm.py’s own GHASH, tag and keystream, which build the salamander vector and until now had only been checked by cryptography accepting their output. Each part of the check was shown to be load-bearing by mutation before it was trusted: a one-bit change to a tag or a ciphertext, a wrong GHASH reduction constant, a keystream starting at the tag-mask counter, and a FAIL case swapped for a valid tag are each caught. It found no defect, and was not expected to: cryptography’s AES-GCM is OpenSSL’s, and the salamander vector already required gcm.py’s tag to verify under it. What changed is that §7’s MUST now holds for AES-GCM as it does for Argon2id. Nothing in the suite moved: a regenerated vectors/ is byte-identical, so no expected value, manifest hash or conformance result changed. Two of the sweep’s three [VERIFY] flags remain, for the reasons it gave (per-language buffer maxima; PyNaCl’s XChaCha support).

  • 2026-09-09The M5 “docs current” sweep: nine findings, six taken, and one shipped claim that was the opposite of the truth. M5’s exit test (§3) is three clauses, and the demo satisfied one of them; this is a pass over the second against §4’s Definition of done. It is recorded here rather than in docs/06-verification-log.md because that document is a dated re-verification of regulatory and vendor claims and its scope is not this.

    • A claim that was wrong, not merely unverified. docs/10 §5 and core/python/README.md both stated that Argon2id holds the GIL for most of its 10–100 ms per term, flagged [VERIFY] since the document was written, and repeated as a shipped limitation in the core’s own README. Measured: the GIL is released. On argon2-cffi 25.1.0 / argon2-cffi-bindings 26.1.0 under CPython 3.14.6, one hash_secret_raw at the §7.3 parameters takes 36.9 ms and two on separate threads take 40.0 ms, where serialization would cost about 74 ms. The cost is wall-clock latency on the requesting thread, not a process-wide stall, so a threaded deployment serves other requests through it. Both places corrected and dated, and the README says it is a correction rather than quietly changing. It stays a product constraint per query term; it was never a concurrency ceiling.
    • Two stale claims in docs/15, both about the Prisma backfill write path: it was described as proposed, not verified, and as carrying a [VERIFY] in docs/13 §6. docs/13 §6 has recorded it Confirmed 2026-08-27 since the adapter merged, and carries no flag — so the pointer was dangling as well as wrong. This is exactly the drift CONTRIBUTING.md’s two standing rules exist to catch, and it survived because the confirmation landed in one document and the claim lived in another.
    • docs/11’s XChaCha flag had been resolved for eighteen days and never removeddocs/18 confirmed on 2026-08-22 that node:crypto on Node 24.16 / OpenSSL 3.5.6 has no XChaCha variant. docs/11 now carries no flags at all, and its library-fact caveat says so instead of describing a convention it no longer uses.
    • Three docs/10 dependency facts confirmed against the pinned versions. hash_secret_raw’s version defaults to 19 (as does ARGON2_VERSION), an explicit version=19 is byte-identical to the default, and hash_len=64 returns 64 bytes. The Python floor question resolves the other way from how it was posed: cryptography 50.0.1 declares >=3.9below ours — and the binding floor is argon2-cffi-bindings 26.1.0 at >=3.10, which is the [argon2] extra rather than the core dependency, landing exactly on the declared floor. The cryptography>=42 floor is now stated as declared-not-tested rather than flagged: CI resolves against a pinned 50.0.0 and nothing has ever run at 42, and a flag there implied a measurement that lowering a floor honestly would require.
    • adapters/prisma/README.md told readers to expect 250 tests; the suite is 268 — stale across two closures. Re-measured before editing rather than copied from the last log entry, which is how the number went stale in the first place.
    • docs/07 §2’s WS-G bullet still read “Proposal:” for a workstream that shipped and is gated in CI.
    • Left open, deliberately, and not by oversight. Three [VERIFY] flags survive and each has a reason: a CAVP vector in the generator’s primitive layer (docs/08 §7, real work); per-language buffer maxima (docs/09 §4 — Node discharged in docs/18, the JVM and .NET halves belong to cores that do not exist); and PyNaCl’s XChaCha support (docs/10, for suite 0xFF02, which G7 leaves deliberately unbuilt). Two honest-limitations gaps are also left as maintainer calls: adapters/django/README.md states neither §3.3 storage overhead nor §7.3 Argon2id cost where the Prisma README states both, and core/python/README.md omits §3.3 and §5.5 where the TypeScript one covers all four. Both are §4 bullet-three obligations and both are about what the project chooses to claim, not about a fact anyone can measure.
    • §4’s fourth bullet remains structurally unmet for WS-G: the no-overclaim audit requires someone other than the author to read the README against CONTRIBUTING.md’s two standing rules, and the four PR reviews on #124 were code reviews.
    • The scope question this sweep could not answer. “Docs current” is not defined beyond §4, and §4 is per-workstream. WS-H — the docs workstream — is largely unstarted: the operational-docs skeletons it names (PRD DO-1, threat model as a standalone document, P0; DO-6, KMS-outage runbooks) do not exist, and docs/06-verification-log.md is still dated 2026-08-08 with its own instruction to re-run before any public release. Whether M5’s “docs current” means the shipped artifacts describe themselves accurately — which is now much closer to true — or WS-H has landed, is a maintainer decision and is recorded here as one.
  • 2026-09-09WS-G: the demonstration app, and what building it found. examples/patient-directory ships: one Postgres table, a Django frontend and a Prisma frontend over it, and a scripted seven-act scenario in which the database is the only channel between the two stacks — each act is a separate process invocation, so nothing can pass a value in memory from one to the other. Twelfth conformance job (demo), examples/** added to both paths: filters, 29 assertions across 11 transcript entries, and a golden narration diffed by run_scenario.py --check in the shape of vectors-reproducible. Design and full reasoning: docs/20-demo-patient-directory.md. The workstream was framed as presentational and it is not. The N×N cross jobs move a document between implementations and every producer compares against its own recorded plaintext, so they grade their own homework at the logical-value layer; nothing in this repository had pointed two adapters at one live database or compared the two declarations of one column. Doing so produced a live defect, three tool facts, and one design conclusion worth recording separately from the code.

    • The defect: nothing pins what bytes a logical type becomes, and three of the six diverge. Spec §3 pins the byte layer and stops there, so both adapters choose a rendering, both are conformant, and they disagree. Filed as G25 (#123), which is vector-expressible unlike G19–G24, because it is bytes rather than a query shape. Three measured classes, in the order they matter. date is silent in and fatal back: Prisma reads Django’s b"2026-09-08" successfully — as an instant at UTC midnight, where the value was a calendar date — and re-writing it stores b"2026-09-08T00:00:00.000Z", which Django then refuses, so one ordinary read-modify-write through the second stack makes a row permanently unreadable to the first with nothing raised when the damage is done; and before any rewrite, that instant rendered in local time west of UTC is the previous day (observed on a GMT-0500 machine), which for an encrypted date of birth is a wrong answer with no error. Decimal has no vocabulary entry at all, so it must be declared as: "float": b"1.50" loses its scale and b"12345678901234567.89" comes back as 12345678901234568 and re-writes as that — a silent wrong answer arriving through the codec, which is the one door §10.2’s wrong-answer rule does not cover, being written entirely about query shapes. boolean is loud in both directions and the least important: each side refuses the other’s rendering rather than coercing it, which is correct behaviour, and an encrypted boolean cannot be blind-indexed at all — P=2 fails §7.4’s hard floor of 16 and §7.6’s override does not reach that floor — so it is a column nobody can filter on. The issue as first filed led with boolean, which is the case that breaks today rather than the case that matters; the framing was corrected on 2026-09-09 after the point was put directly. Not fixed here, deliberately: which rendering is right is the question the issue exists to answer, and either edit changes the plaintext of every row already written, which under §7.8’s reasoning is a backfill rather than an edit. The demo’s shared model is restricted to string, int and bytes, and check_declarations.py refuses any other inner type with that reason in the message — a tripwire rather than a comment.
    • provider = "fieldseal-prisma-generator" works, and this is the first time it has ever been run. adapters/prisma/tests/generator.test.ts writes a shim because a package cannot link its own bin, so the spelling the adapter README tells people to write had never been exercised. It resolves correctly through an npm file: link and emits the field map. The related R1 hazards did not materialise either: both local packages declared at demo-relative paths resolve to single copies, and tsc --noEmit over the demo passes against them.
    • makemigrations over an Encrypted model was expected to be the risky part and was not. Encrypted.deconstruct() puts a live models.EmailField() instance into positional args and BlindIndex.deconstruct() exists only because that path was a shipped blocking defect once; this demo adds db_column overrides, a db_table, a UUIDField primary key and a Meta.indexes entry over an EncryptedIndex on top of it. The migration generated correctly on the first attempt, emitting only non-default BlindIndex kwargs, and makemigrations --check --dry-run and manage.py check --fail-level WARNING are both clean. The adapter’s own suite still never runs the migration machinery; this job now does.
    • Prisma 7.10 removed migrate diff --to-schema-datamodel in favour of --to-schema; the CLI says so. check_schema_shape.py uses the current spelling. The scratch-schema comparison it performs (diff --from-empty into prisma_expected, then compare information_schema.columns and pg_indexes against public) exists because the obvious form is a trap: migrate diff against the live database reports a permanent diff on django_migrations, and declaring that table on the Prisma side introduces a second one, because Django 4.1+ emits bigint GENERATED BY DEFAULT AS IDENTITY where @default(autoincrement()) emits a sequence.
    • An observation, not a change: a typed consumer of the documented Django import path gets object. fieldseal_django/__init__.py defers Encrypted and index_column through a module-level __getattr__ annotated -> object, so from fieldseal_django import Encrypted type-checks as object and every use of it is an error under mypy. PEP 562’s own signature is -> Any. It affects any typed consumer, not only this demo, and it is recorded here rather than fixed in a demo PR.
    • The golden narration caught two things on its first two runs, which is the argument for having it. First, its own defect: the setup step printed how many rows it deleted, so a fresh database and every run after it disagreed — every other value in the narration is a function of the inputs and that one was a function of history. Second, and more useful: the first CI run failed because the local adapters/prisma/dist/ was a stale build carrying an older count refusal message, while CI builds from source. The adapter’s own suite cannot see that — vitest runs from src/ — so this demo is the first consumer of the adapter’s built output, and the narration pins the refusal text both adapters actually emit. The coupling is intended (act 6 exists to show what a caller is told) and its cost is stated in the demo README: changing a refusal message in either adapter makes the golden file stale, and the failure surfaces in the demo rather than where the message was edited.
    • M5 is not closed by this. Its row is “Phase 1 done”, which also requires docs current and a Phase 1 retro deciding Phase 2 entry — both maintainer calls, neither performed here.
  • 2026-09-08A refusal no longer invents the bucket it justifies itself with (G24 review round, item 5; both adapters). Three refusal families in each adapter argue from §7.4 bucket mechanics — an exclusion drops the whole bucket, a database-answered operation computes over one, an OR branch leaves a candidate §7.5 cannot decide — and each is checked before the column is checked for having an index at all. Measured on both: Patient.objects.exclude(note=…) and Prisma’s NOT: { note: … }, on a column with no declared index, said “the SQL excludes the whole index bucket” and told the caller to run the positive form instead — which raises “declares no BlindIndex”. Every subtractive spelling carried it on such a column — exclude(), ~Q, exclude(Q(…)) on Django; NOT, the scalar not and notIn, and the negating relation wrappers on Prisma — as did the OR refusal on both and Prisma’s database-answered one (count). The lookups spec §7.1 keeps off the index (exclude(email__contains=…)) were the same defect through a different door, and .candidates() / candidateScope() did not change any of it. G23 settled the principle one issue earlier for the aggregate clause: a refusal MUST NOT be justified by a mechanism that is not operating.

    The fix is in the message, not in the check order, and that is the part worth recording. Reordering is the obvious repair and it is wrong: record() in the Prisma visitor already carried a comment saying the site refusal comes before the missing-index refusal on purpose, because declaring a blind index is a schema migration and a caller sent to run one for a shape that is refused with the index has migrated for nothing. Both orderings produce a message that wastes the reader’s time; only one of them also asserts something false. So each refusal now asks whether the column has a bucket at all, names the real reason first, and keeps the position rule as an explicitly counterfactual second half — refused over a blind index too, because … — ending at the fallback that works either way. On an indexed column the justification is unchanged and pinned as such — not the wording, which is the distinction the first cut of this entry got wrong: three Answered.why clauses are interpolated into the indexed-column message too, so count, aggregate, groupBy and _count on an indexed column do read differently, with the bucket claim moved from the clause into the paragraph after it. One string moved rather than branched: three Prisma Answered.why clauses asserted the bucket themselves ( `count` is answered by the database as a COUNT over the index bucket), duplicating the paragraph that follows them and contradicting the no-index branch inside a single sentence; the bucket claim now lives only in that paragraph.

    Django 286 → 303 tests, Prisma 250 → 268; 12 of the 17 new Django tests and 10 of the 18 new Prisma ones fail against bb8afb4, and the rest pin what stays true on an indexed column. A medium-effort review of the branch found five defects in the first cut, all fixed here and all pinned: the absence clause named the querying model, so Visit.objects.exclude(patient__note=…) reported “Visit.note declares no BlindIndex” for a column Visit has not got — correcting a false justification with a different false statement; the answered branch replaced the operation’s own fallback with the read-side one, so the remedy for updateMany/deleteMany no longer performed the write, and asserted “returns an answer rather than the rows” two sentences after a why clause saying the opposite; notIn lost the §7.10 sentence that is its own reason and holds either way; this entry overclaimed as above; and the G24 doc block ended up attached to the wrong function. No behaviour changed: every shape that was refused is still refused, and nothing that was served became refused — this is a message correction, and the tests are assertions about message content because that is what the defect was. No spec change, and one observation left for the user rather than acted on: §10.2 states this principle twice as instances (G23’s bytes-computation sentence, G24’s escape-hatch sentence) and not once as a clause. A third instance implemented without one is the point at which generalising it becomes worth considering — but that is a normative change and needs an issue first, per CONTRIBUTING.md.

  • 2026-09-08The G24 review round: two shapes the closure claimed to cover and did not, both measured. The refusal docs/12 and both READMEs described as holding “on every queryset” was implemented as a check ordered after the relation-traversal branch, and that branch hands off to a compile-time backstop (_refuse_cross_model) which passes by design when the encrypted column belongs to the querying model. Every negated path whose owner is the querying model therefore fell out of both layers — a self-FK, an MTI parent, the round trip Patient → visit → patient → email. Measured: Patient.objects.all().candidates().exclude(visit__patient__email=v) was served, the colliding row dropped, nothing raised. The ordering is now the rule rather than an accident: the negation check runs ahead of the traversal check in both walkers, because G24 scoped the refusal by position and who owns the column does not enter into it. A negated traversal consequently gets the subtractive message rather than the traversal one, which mattered on its own account — the traversal message recommends the hatch, for a shape §10.2 now forbids pointing at it.

    The second shape is the same rule reached through a subquery, where the outer predicate names no encrypted column at all. exclude(pk__in=Owner.objects.filter(enc=v).candidates()), its relation form, difference(…candidates()) and annotate(has=Exists(…candidates())).filter(has=False) were all served with the collision row dropped. resolve_expression, which owns subquery embedding, cannot close it: it is handed the operand without the position, so it cannot tell this from filter(…__in=qs.candidates()) — the shape three refusal messages recommend, and the shape the hatch exists for. So the marks now travel on the Query and the position is read where it is visible: filter(), exclude(), difference() and annotate(). Putting them on the Query rather than the queryset is what surfaced a third defect, wider than the review’s item 3 and running in both directions: Exists(qs) and Subquery(qs) keep qs.query and never call the queryset’s resolve_expression, so every expression route was reaching SQL unrefused — as an annotation, as a positional filter()/exclude() argument, as an expression child of a Q, and as a keyword operand. Measured on each: over a verifying queryset the bucket was served as the answer with no obligation recorded anywhere, and over a .candidates() one under NOT EXISTS the collision row was dropped. The bare pk__in=qs spelling is deliberately left to resolve_expression, because that is the one route where the positive form is the shape the refusal messages recommend; a wrapper has no layer beneath it and is refused wherever it stands. Nor can it be refused only in the subtractive direction: an annotation carries no position (filter(has=True) and filter(has=False) are the same annotation), so the message names the remedy that is right either way — materialize the verified rows, annotate from their primary keys — rather than the hatch. union() and intersection() still lift, and that is the rule holding rather than an exception to it: widen the bucket and both return more rows for §7.5 to trim, while difference returns fewer. Django adapter 260 → 286 tests, 18 of the 26 new ones failing against the closure commit; the other 8 pin what stays served — the positive filter(…__in=qs.candidates()), the positive traversal message, IS [NOT] NULL under a negated traversal, union()/intersection(), and a .candidates() queryset that never touched an index, which is exact and embeds anywhere — or measure, through a cursor, the answers the refusals now prevent. One residue, documented rather than closed: a model with no encrypted column of its own has no FieldsealQuerySet for the position to be read from, so Plain.objects.exclude(fk__in=Owner_qs.candidates()) is not reachable — the same interception-honesty class as the ordering residue in docs/12. No spec change and no new clause: §10.2 already scopes the rule by position, and scoping it that way is what made both of these findable as defects against a written rule rather than as gaps in a list of operators. The review also raised the Prisma-side ordering questions and the message-honesty items around the no-index and §7.1 checks; those are not in this commit.

  • 2026-09-06G24 closed (#100): neither bucket-semantics escape hatch lifts negation, and the rule is by position rather than by operator. G21’s closure (#87, below) refused negation over a blind index and deliberately left one question open, because answering it would have made a shipped adapter non-conformant in the same commit that closed an issue: whether a documented opt-out — Django’s .candidates(), Prisma’s candidateScope() — lifts the refusal. The two adapters answered differently and each one’s error text asserted its own answer as the rule. Decided in Prisma’s direction. The deciding argument is what the caller can do with what they were handed: the hatch transfers §7.5, and §7.5 is a filter obligation — dischargeable from a superset (decrypt the candidates, drop the surplus) and not dischargeable from an exclusion, because the rows the database removed are not among the ones transferred. Under filter().candidates() the caller holds more rows than the answer and can reach it; under exclude().candidates() they hold fewer and cannot. The consequence sweep found the divergence was wider than the issue knew, in both directions. Django lifted exclude() and ~Q, which the issue recorded — and also XOR, which it did not: a XOR b is (a AND NOT b) OR (NOT a AND b), so a widened bucket flips rows out of the answer exactly as an exclusion does. Prisma, which the issue recorded as refusing negation outright, was refusing only the two scalar operators; NOT was gated on verify alongside OR and lifted with it, and the negating relation wrappers none and isNot rode in on the relation-filter family. Measured before fixing, in both adapters: with one row forged into another’s bucket, the exclusion returns a set the colliding row is missing from, and nothing is raised. That is why §10.2’s clause is scoped by position — wherever a wider index match set yields a narrower result — rather than by naming operators, which is what let two adapters enumerate different lists and each believe it had covered the family. The tracking detail worth keeping: both adapters had folded negation into the flag that records why verification cannot decide a row, and the two are not the same property. Under filter(Q(x) | ~Q(enc=v)) the enclosing OR claims that flag first, so the inner negation was never seen — one is “verification cannot decide this row”, the other is “the row is not here to decide”, and they now travel in separate slots in both visitors. Ergonomics were half the issue and are fixed: Django’s refusal message recommended .candidates() for the shape the hatch no longer serves, so §10.2 now forbids an adapter pointing a caller at its hatch for a covered shape. Consequences swept: spec §7.10 (the row points at the clause that governs the hatches) and §10.2 (the open question replaced by the MUST NOT, its scope, its carve-outs and the message rule); docs/12 §3.2 and the Django README; docs/13 §2.1/§2.2/§4/§6 and the Prisma README. Both adapters changed, unlike G21 — Django adapter 252 → 260 tests, Prisma 243 → 250; 7 of the 9 new Django tests and 4 of the 7 new Prisma ones fail against the pre-decision code, and the rest are pins on what stays lifted (OR, some/every/is) or served (IS [NOT] NULL under negation, which reads the envelope column’s own null-ness and touches no bucket). No vector obligation, no core change, no envelope byte, no Gate 0b bearing: the rule is an adapter obligation over query shapes, the same shape as G19, G20, G21 and G23, and per-adapter tests are its executable form.

  • 2026-09-04blindIndexAsync and unindexableMarkerAsync shipped in the TypeScript core. The 2026-08-31 entry below decided the companion on measured evidence and did not build it; this builds it, and the price §11.1 attaches is paid rather than deferred. Scope is those two methods and nothing else — they are the Argon2id derivations; encrypt, decrypt, rotate and isCiphertext deliberately have no companion, because the reason the core API is synchronous is that the frameworks in the value path cannot await, and an async encrypt would offload microseconds of AES-GCM to an API none of them can call.

    The property worth recording is where the key material is when the derivation starts. idfAsync derives the 16-byte Argon2id salt before its first await, so the tenant index key’s copy and the per-column index key are both erased as soon as idfAsync returns and nothing the in-flight derivation holds reads them. That is a statement about reads, not about submission: idfAsync has no await of its own and node:crypto queues the threadpool job inside the call, so the job is already queued when the erasure runs — it captured the salt and the copy, never the key. The salt is erased too, by idfAsync once the derivation completes, because spec §7.3 puts the whole strength of the column’s index key into it (“keying now rests entirely on the salt”); zeroing the key and leaving the salt to GC would protect nothing. What is in flight is that salt and a private copy of the normalized value. The copy buys less than an earlier draft of this entry claimed, and the difference is worth stating because the repo’s own rule is not to overclaim: on the shipped backend node:crypto copies its message argument synchronously, so the caller’s array is not read across the await with or without it. What the copy actually makes safe is the erasure that follows it — under the identity normalizer the normalized value is the caller’s own array, and on the marker path it is the process-wide reserved preimage, so zeroing the original would corrupt a caller’s buffer or every subsequent marker in the process. It is also insulation against a future backend that reads its arguments lazily, which the Argon2Backend contract permits until the derivation completes. Both entry points share one refusal prelude and one input step with their synchronous forms, so KEY_UNAVAILABLE still precedes INVALID_ARGUMENT on both paths, and both methods are declared async so that a refusal reached before any await is still a rejection.

    The suite runs twice (docs/08 §5 item 10 — cited as “clause 9”/“item 9” in three places until today, and item 10 since G16 part A inserted the lone-surrogate item; all three corrected). runSuite is asynchronous, one loop serves both passes so a family cannot be added to one and missed by the other, and the #async suffix is applied to the synchronous result id at the pass boundary, so it lands last (<id>#pipeline#async). Results 178 → 356, out_of_band 3 → 4, async_companions true — a flag docs/14 §4 reserved on 2026-08-09 and never defined, now defined there as both halves of one claim (the companions exist and this report carries the second pass). Where an operation has no companion the twin re-runs the synchronous form, which is what keeps “the entire suite” honest; the harness note says which.

    What the second pass cannot see, and what covers it. A companion implemented as async f() { return this.blindIndex(...) } produces byte-identical output for all 178 twins and fixes nothing — the 352 ms p99 that decided the feature would still be there. So: a setImmediate counter must advance during an in-flight blindIndexAsync, and a spy on the Argon2Backend seam must show each form calling its own primitive and not the other’s (spec §11.1’s “MUST NOT be implemented by blocking on the companion” is otherwise untestable). The counter also reads 0 across the synchronous call, but that half is a weaker control than it looks and is recorded as such in the test: turns is 0 for any callable that does not yield, a no-op included, so it rules out a blindIndex that yields to the loop and does not observe blocking. Both real assertions were verified by breaking them deliberately before they were trusted: routing idfAsync through backend.argon2id fails exactly those two tests and nothing else, and dropping the normalized-value copy while keeping the erasure that follows it fails exactly the three ownership tests. Error-code parity has almost no vector to rest on either — both errors/ blind_index vectors are positive controls — so it is held by the unindexable-bucket refusal half, a new #async out-of-band entry (the lone-surrogate refusal through the companion, arriving as a rejection), and fifteen refusal conditions in tests/async-companions.test.ts including the KEY_UNAVAILABLE-before-INVALID_ARGUMENT order pin.

    The threadpool obligation is now owed by anything that depends on this (docs/11 §2, measured on two machines: four concurrent derivations took an unrelated fs.readFile from p50 ~0.28 ms to 67 ms on one and 402 ms on the other). It is in the package README and in the method’s own doc comment: a deployment calling the companion concurrently sizes UV_THREADPOOL_SIZE at or above the concurrent-derivation count, because the pool is shared with fs, dns and zlib. Tests never set it — eight concurrent derivations against the default of 4 exercise the queue, and no test asserts a duration (docs/14 §7).

    Python is untouched and stays that way by design: docs/10 declines companions because its frameworks cannot await in the value path, spec §11.1 makes the choice per-implementation, and its report keeps async_companions: false. The two cores’ result-id sets remain identical on the synchronous pass, which is the claim docs/08 §9 and this log had been making unqualified. TypeScript core 527 tests; report 356/356, fail 0, out_of_band 4/4. Not done here: the Prisma adapter still calls the synchronous forms — adapters/prisma/README.md says so rather than leaving a stale “not built yet” on main — and that switch-over is the follow-up, with the L4 claim and the coverage-matrix row moving with it.

  • 2026-09-01The PR #108 review round: the promotion’s own claims tested, four found false or unsupported, and what fixing them added to the suite. Four reviewers and a separate verification pass read the branch; everything below was reproduced before it was fixed. (1) “Both cores would have failed all eight identically” was false. Tested by stripping the eight idf_params blocks from a copy of the suite and running both harnesses: the TypeScript harness emitted its report with eight recorded failures; the Python harness aborted inside _argon2_params with a bare KeyError('idf_params') and emitted no report at all — nothing for the CI gate to read, the failure mode the #103 round had already named. run_blind_index had no per-vector boundary where run_envelope and run_errors did; it has one now, and a vector the harness cannot derive is a recorded failure with a reason. The same boundary is what had made the argon2 extra a hard dependency of the report: without argon2-cffi, run() raised ModuleNotFoundError from inside the core and test_vectors.py errored at collection, so pytest reported no result rather than a failing one. CI installs [argon2,dev] and never saw it. (2) The TypeScript harness had needed a change after all — twice. Its HARNESS_NOTES still described the family as held out and not iterated, and that sentence shipped verbatim in the docs/14 §4 report beside 30 passing Argon2id results; three reviewers caught it independently, and nothing in the invariant tests looked at the notes (both cores now assert that no note contradicts the results). More seriously, its reserved-marker check called idf() without the vector’s cost, so the primitive half of unindexable-marker-b15 derived at the core’s default while the API half derived at the declared cost. Every shipped vector sat at the minima, so the two agreed and the omission was invisible — exactly the silent case docs/08 §4.4 and #62 describe, in the harness rather than the core. Demonstrated by setting that vector’s time_cost to 4 in a suite copy: TypeScript failed only the API half, Python failed both. (3) The suite now carries what would have caught it. docs/issues/G02 has asked for a raised-cost vector since the narrowing; blind-index/argon2id.json gains raised-cost-t4-b15 (a primitive vector at t = 4, with its #pipeline companion) and unindexable-marker-t4-b15 (the marker for a column declared at t = 4 — the shape the omission was in). The suite stays 0.6.0-provisional, which had not been published, at 146 vectors / 178 results, both cores green with identical ids. test_gates.py’s “only the minima are shipped” is no longer true and no longer says so. (4) Three claims in the prose had no support. docs/08 §4.7 said the cross corpus “records the same limitation in producer.limitations” — nothing does, and the clause is gone. §4.4 never said that assertion shapes carry idf_params under inputs, or that a missing block is malformed, though both harnesses enforced it; it says so now, and it scopes the declared-cost rule honestly: a harness reads time_cost and memory_kib and checks version, parallelism and output_len against §7.3’s constants, refusing a vector it cannot derive at rather than deriving at the constant regardless — a reviewer’s point, since a vector declaring parallelism: 2 would otherwise have been silently honoured as 1 by both harnesses. And the Python harness never asserted idf_params.salt, so §4.4’s “asserted separately” was true of one core; it is true of both. Smaller, all fixed: the Python harness dispatches on the literal manifest path (RUNNERS) where the TypeScript one dispatches on doc.group, so a promotion touches one more place in one core — loud (KeyError), not silent, and recorded so the next promotion expects it; _argon2_params names the missing key instead of raising bare; the generator derives the Argon2 salt once per file rather than once per shape; a primitive vector with a malformed cost had one uncaught path left in the TypeScript harness too; and the stale hold-out language in the top-level README (still 145/145 at 0.4.0), docs/17 §4, the G02 draft, the cross-corpus generator and the KAT module is corrected or dated. The lesson this round adds to the last one: the promotion entry below claimed a symmetry it had not tested, and the untested claim was the wrong one. What a hold-out hides, a promotion does not automatically reveal — the malformed case has to be run, not only the well-formed one.

  • 2026-08-31blind-index/argon2id.json is promoted out of held_out and pinned; suite 0.5.00.6.0-provisional. This is the decision the hold-out’s own unblocks_when asked this section to record, and it is a project decision rather than an engineering one (docs/18 D-15: “the decision to count the family, which the project makes, not the generator”). The ground for holding it was already gone. The family went out of the suite on 2026-08-22 because the Argon2id primitive had never been checked against an external known-answer source — RFC 9106 §5.3’s vector cannot serve, since it sets a nonzero K and X that spec §7.3 forbids and Python cannot supply. Since 2026-08-23 the generator checks argon2-cffi against libsodium’s seven published crypto_pwhash answers on every run and refuses to emit if one fails (kat_argon2id.py); libsodium cannot supply K or X either, which is exactly what makes it the right source for the case §7.3 uses. The TypeScript core independently reproduces the family through node:crypto. Both cores now report 175/175 with identical result ids and held_out: 0 — 145 before, plus this family’s 19 vectors and the 11 #pipeline companions its primitive vectors earn. No expected value moved, which is what the hold-out promised and is the check that it was a decision and not a repair. The WS-C benchmark was the stated ordering constraint (the vectors are computed at t=3, m=32 MiB and a benchmark that moved §7.3’s minima would have moved them); it ran on 2026-08-31, outcome 3 did not trigger, and the minima are the cheapest point of the sweep — so the ordering was satisfied rather than overridden. Promoting it found two defects that a held-out family structurally cannot show, and this is the part worth keeping. (1) Eight of the nineteen vectors — every equal, unindexable-marker and unindexable-bucket assertion — declared idf: argon2id and carried no idf_params. docs/08 §4.4 makes a missing cost malformed on purpose, and both cores reject rather than defaulting to the minimum (issue #62: defaulting is what makes a vector at a raised cost fail against a correct core and blame the primitive). Both cores reject rather than default — but not, as the review round found, in the same way; the 2026-09-01 entry above has it. The generator now emits the declared cost on every shape via one declared_cost() helper. (2) The Python core’s harness refused every non-HMAC IDF outright — raise ValueError("harness runs hmac-sha512 only") — so flipping the manifest alone would not have run the family; it now dispatches through the core’s own blindindex.idf at the vector’s declared cost. The TypeScript harness needed no change to run the family (the review round qualified that twice; 2026-09-01 above), which is the asymmetry worth noting: one core was ready and the other was not, and nothing could tell them apart while the family was unrun. Nor was the ValueError the whole of it: the Python harness dispatches on the literal manifest path (RUNNERS) where the TypeScript one dispatches on the file’s group, so the promotion also needed a RUNNERS entry — a loud KeyError without it, not a silent skip, but one more place a promotion touches in one core and none in the other. The lesson, stated plainly: a held-out family is reviewable by eye and unreviewed by machine, and eight malformed vectors sat in a file that two cores had “reproduced” for nine days. The held_out mechanism is kept — a listed family with a reason still beats a quietly omitted one — but the cost of using it is now on the record beside the benefit. Not done here: an argon2id case in the cross corpus. Its stated blocker was this hold-out and is gone, but every cross leg would then pay an Argon2id derivation per case per producer across four producers and two consumers on every merge, so it stays a declared omission with a cost rather than a blocked one (docs/08 §4.7).

  • 2026-08-31The WS-C Argon2id benchmark ran, and blindIndexAsync is decided: ship it. §6’s risk row has asked for this since the plan was written (“benchmark in WS-C week one at spec-minimum parameters”), and docs/11 §2 pre-committed three outcomes to it. Two legs, neither in CI (docs/14 §7 forbids benchmark-as-gate) and neither the start of bench/ (§8 puts that in Phase 2): core/typescript/tests/bench/argon2-eventloop.ts for the mechanism and adapters/prisma/tests/bench/argon2-request-path.ts for the criterion as worded. The numbers. At the §7.3 minima (t=3, m=32 MiB, p=1, ~44 ms/call on Node 24.16), twenty synchronous derivations let the event loop take 1 turn in 871 ms against ~832 000 turns idle; through the Prisma extension, a findMany on a table with no encrypted column went from p99 0.8 ms to 352 ms under eight concurrent indexed lookups. The async form leaves the loop at 98% of idle turns at the same wall time and is 3.2× faster at eight-way concurrency. The finding that changes the recommendation’s shape: async relocates the cost to the libuv threadpool rather than removing it — at the default UV_THREADPOOL_SIZE=4, four concurrent derivations pushed an unrelated fs.readFile from p50 0.30 ms to 45.6 ms, and sixteen to 690 ms; a pool of 16 returned the four-concurrent case to 0.40 ms. So the companion ships with a documented sizing obligation. Outcome 2 (“HMAC domains and worker threads cover it”) was rejected on two grounds, one measured and one architectural: §7.3 requires Argon2id precisely for the low-entropy domains where an index is most needed, so HMAC is unavailable exactly where the problem is; and docs/11 §5 makes the DEK cache per-instance with no SharedArrayBuffer key storage, so a worker pool of four means four clients, four §5.5 caches, four times the KMS traffic and a fragmented max_uses counter — a difference invisible to a stopwatch. Outcome 3 did not trigger: the minima are the cheapest point of the sweep (42 ms, rising to 728 ms at t=6 / 256 MiB) and being off the loop rescues them, so the cost is a cost and §7.3 is not implicated. Confirmed and corrected on a second machine (arm64 macOS, Node 24.7), same day. The structural finding replicated exactly: 1 event-loop turn, on both. Three things did not, and a second data point earning its keep is the point of taking one. (a) Per-call cost is hardware-dependent — ~44 ms on the Zen 4 desktop, ~70 ms on Apple Silicon, both inside §7.3’s quoted 10–100 ms. The “~22 encrypted searches per second per core” figure derived from the first number is really ~14–23 depending on hardware. (b) The async path is not uniformly clean. Windows left the loop at 98% of its idle turns; macOS left it at 52%, and under eight-way concurrency macOS still stalled for 561 ms against Windows’ 11.8 ms. Async beats sync on both — always better than one turn — but “the async form frees the loop” was a one-platform claim and the second platform does not support it. The decision is unchanged; the description of the fix is. (c) The threadpool numbers were not defensible and are now worse. The probe awaited each read before issuing the next — coordinated omission, in the same file whose Class B design correctly avoids it — so it under-sampled exactly the loaded cases it was measuring, and the 16-concurrent figure came from two samples. Made open-loop; on Windows the corrected numbers are p50 67 ms at four concurrent and 818 ms at sixteen, from ~120 samples each. The mitigation figure above is uncorrected and should not be quoted: “a pool of 16 returned the four-concurrent case to 0.40 ms” came from the discarded probe, and both fixed runs were taken at the default uv_threadpool_size = 4 only. Raising the pool remains the right lever — it is what the sizing obligation asks for — but this benchmark has not measured it since the probe was fixed, and no document should claim a number for it until it does. The macOS re-run then closed both open items. Threadpool contention is confirmed on both machines and is worse on macOS — four concurrent derivations take an unrelated fs.readFile from p50 0.28 ms to 402 ms there against 67 ms on Windows — so the apparent macOS “inversion” was entirely an artifact of the broken sampling, and the corrected result is the opposite of what the broken one suggested. And monitorEventLoopDelay, now recorded on every run rather than asserted from one, reported count = 0, max = 0.00 ms for the full stall on both platforms (893 ms and 1219 ms). The first write-up suspected the earlier count = 3, max = 15.84 ms reading was a Windows timer artifact; it was not — the blindness is a property of the instrument, since a histogram of delays between samples cannot report a stall during which it took none. It under-reports partial stalls too: 178.91 ms against a real 527 ms in the macOS async-concurrent case. What remains genuinely platform-dependent is how well async recovers: the loop stayed 97% alive on Windows and 50% on macOS, and under eight-way concurrency 26% against 2.8%, with a 527 ms residual stall on macOS. Async is better than sync on both — the decision does not move — but on some hardware it leaves a stall large enough to matter, and that is now what the documents say. Two runs on the same Mac also gave 69.8 and 62.5 ms per call, so single figures here carry roughly 10% run-to-run variance and should be read as ranges.

    Three methodology findings worth keeping, because each would have produced a wrong answer. (1) perf_hooks.monitorEventLoopDelay cannot see this: a solid 428 ms block reported count = 3, max = 15.84 ms — a plausible number 27× too small, floored at the platform timer granularity, because a histogram of gaps between samples cannot report a stall during which no sample was taken. Believing it would have concluded the sync path is fine. (2) setInterval is useless on Windows for the same reason — an idle 5 ms interval measures 15.5 ms gaps, so “ticks per elapsed ms” measures the clock. The instrument is setImmediate chaining, which fires once per loop turn and has no granularity floor. (3) The Class B probe is open-loop on purpose: a closed-loop probe waits on its own previous request, so during a stall it issues fewer requests and its measured latency barely moves — coordinated omission would have hidden the whole effect. Not built here: the companion itself. Its price is unchanged (§11.1 and docs/08 §5 item 10: byte-identical output, identical error codes, the entire vector suite run a second time through the async path, and a sync path that is not a blocking wait on it), and docs/11 §2 now records the two assertions byte-identity alone does not buy. (Built on 2026-09-04; see the entry at the top of this log.)

  • 2026-08-31The cross-language index half ships (cross/v2), closing the increment this log recorded on 2026-08-26 as “deliberately not done”. The envelope half proves a value encrypted by one implementation decrypts in another; it cannot see the failure that matters more, which is why that entry called this “the more valuable assertion”: a mismatched blind index is a silent lookup miss, not an error. The row is stored, decryptable, and simply stops being findable, and every envelope pair stays green through it. cross/corpus.json gains index_cases and a producer carrying them emits cross/v2. Both cores derive the ten corpus cases byte-identically — including a case-fold pair and the G19 NFC pair, which each land on one index value, the property spec §7.5’s re-verification rule rests on. Both adapters emit their own: the sibling column as the database holds it, with the declaration read off the model or the field map rather than restated, and two cases only an adapter can produce — an index whose source column is base64 text while the sibling stays raw bytes (§7.11), and the §7.2 reserved marker the write path stored for a refused value with nobody asking for it. The N×N matrix goes from 98 envelope pairs to 160 pairs of which 62 are indexes. Three things worth recording. (1) A pre-requisite nobody had noticed: neither consumer read doc["schema"]. Each wrote one into its own verdict and ignored the producer’s, so a v1-era consumer handed a v2 document would have decrypted every envelope, reported fail: 0, and never touched the index half — a green run that skipped the more valuable assertion, which is the same silent-skip shape this whole increment exists to catch. Landed first and on its own. (2) The declaration block travels with each case and carries fields that affect no derived byte (projected_population, on_unindexable, the override ceremony), because a cross producer derives through a constructed client and §7.4’s band and §7.6’s gate run at construction — a consumer that cannot rebuild the declaration cannot build the client. That is the one structural difference from the pinned blind-index/ family, which drives primitives directly. (3) docs/08 §4.7’s case-set rule is split. Its single sentence asks for 16 cases spanning §4.1’s sizes plus every context shape — written when only cores produced, and asking for coverage an adapter structurally cannot give: the adapter corpora are 6 and 11 cases and neither can emit a row_id-present case, because L3-row is not built. Core producers keep the sweep; adapter producers are governed by the decisions they own and MUST declare a producer.limitations array naming the shapes they cannot reach, so the gap is visible in the artifact and closes itself the day L3-row ships. Suite 0.4.0 → 0.5.0-provisional (additive, docs/14 §5); the regeneration changed the version string in the pinned files and no expected value.

  • 2026-08-31G22 closed (#88): the assigned-code-point check reaches both package roots, and the signature it needed was not the one the issue described. docs/09 §7.1 has required cores to export first_unassigned / firstUnassigned since G16 part A, “for adapters that hold the text earlier and can give a better-sited error”. Neither core did. The issue proposed exporting it as it stood, describing the existing signature as “the first unassigned or surrogate code point and its offset out” — and that was wrong about both implementations: each returned the code point alone. Exporting it unchanged would have satisfied §7.1’s letter and left docs/12 §10.2’s “name the character and its position” no closer. Measured while checking that: the Prisma adapter’s offset regex only ever matched the identity/bytes path, so on nfc-casefold-v1 — the normalizer every indexed column declares — detail.offset was always null and the shipped refusal named a character without saying where it was. A live defect the issue as filed would not have fixed. Closed by returning (code_point, offset) from both checks, in code points (the unit docs/12 §10.2’s own example counts, and the only one every target language produces without extra work — the TypeScript core’s encodeUtf8Strict message still reports a UTF-16 index and deliberately still does, being the bytes path with an operator for a reader), exporting both checks and UNICODE_VERSION from the roots, and deleting both of Prisma’s regexes. The review round then caught the adapter using the accessor normalizer-blindly (#101): the accessor answers nfc-casefold-v1’s rule, and identity refuses only unpaired surrogates — so on an identity column the wrap named a character that column indexes fine, at the wrong position, with a remedy that did not apply. Reproduced, then fixed by probing the column’s own normalizer, which is what Django’s locate_unindexable already did and what this change’s own Django docstring gave as the reason. Writing a rule down in one place and violating it in another, inside one commit, is the failure worth remembering. The two cores were checked to agree byte-for-byte on nine inputs, astral cases included — a cross-core property no vector can express, since an unpaired surrogate has no UTF-8 encoding and blind-index/ keys its input as hex bytes. Django’s locate_unindexable keeps its normalizer-aware probe: the accessor answers nfc-casefold-v1’s refusal rule, and an identity column refuses strictly less. Two smaller findings recorded and not acted on: Python’s identity-path message is byte-identical for U+D800 and U+DC00 (the G16 distinguishability property is asserted under nfc-casefold-v1, where both cores do distinguish them, so the out_of_band entry is honest — but the identity path could name the code point); and the Prisma test that shipped this defect was named “carrying the code point and offset” while asserting only the code point, which is the more useful lesson: a test name is not an assertion.

  • 2026-08-31G21 closed (#87): negated membership over a blind index is refused, and the specification now says so. docs/13 §4 had listed in:/notIn: as one row, both upgrading to an index rewrite where an index is declared, citing G13. G13 scoped §10.2’s permission to in: only, and §7.10’s table has never carried a row for negated membership — so this document was extending a permission on a citation that did not cover it. The argument needs no new source: §7.4 mandates collisions and §7.5 makes the index a filter, so a positive predicate returns a superset that re-verification trims, while NOT IN over the same buckets removes rows that belong in the answer in the database, where §7.5 never sees them. A filter’s false positives are recoverable; an exclusion’s false negatives are not. Consequences swept: spec §7.10 (new row with the fallback), §10.2 (the in: bullet scoped explicitly, plus a new All ORMs negation bullet stating that the bucket-semantics escape hatches must not lift it either — a hatch hands the caller §7.5, and §7.5 cannot recover a row the database already excluded), docs/13 §4 and §2.2, docs/12 §3.3. No adapter code changed: both shipped the refusal already, Django carrying the asymmetry argument in the message text since L2 and Prisma refusing notIn/.not from PR1 pending this issue. The implementations were right and the specification was wrong — the reverse of the usual direction, and the vindication of shipping the conservative reading while the issue sat open. The closure surfaced a second divergence and filed it rather than deciding it: G24 #100. Writing the §10.2 bullet required saying whether the documented bucket-semantics hatches lift the refusal, and the two adapters already answer differently — Django’s .candidates() lifts exclude() (and the refusal message recommends it), Prisma’s candidateScope() refuses notIn unconditionally (and its message says so). A first draft of the bullet settled it with a MUST in Prisma’s direction; that would have made a shipped adapter non-conformant in the same commit that closed an issue, so it was cut and the clause now records the question as open. Same shape as G23: one shape, two adapters, opposite answers, each asserting its own in its error text.

  • 2026-08-27Prisma L2(b): the Django LIMIT audit asked of Prisma, and the answer narrows the claim docs/13 made. WS-F PR2 lands the index rewrite and the spec §7.5 re-verification that makes it correct (adapter 129 → 209 tests; local audit internal/prisma-l2-audit-2026-08-27.md, not in the repo — internal/ is gitignored, and the same measurements are pinned by the adapter’s test suite, per docs/13 §2.0). The design work was not the rewrite but the audit docs/07 §7’s 2026-08-26 entry established for Django — who answers this query before §7.5 runs? — which had never been asked of Prisma. Measured against 7.10.0, exactly two where sites in Prisma’s surface select rows that come back to the extension: the top-level where of findMany, and a relation where under include/select (whose matched rows arrive nested in their parents, at any depth). Everywhere else the database computes the answer and only the answer returns, so §7.5 has nothing to run on and spec §10.2’s existing clause — reject “a filter path its interception surface does not reach” — decides them all.

    Three claims in docs/13 were corrected by measurement, not by preference. (1) §2’s findFirst note said the rewrite “MUST be rewritten as an over-fetch (findMany + verify + first) or refused”; the over-fetch does not exist — Prisma refuses a take on findFirst that is not 1 or -1, and an extension cannot turn one operation into another, so findFirst is refused and the message names findMany. (2) §2.1 listed relation filters (some/every/none/is/isNot) as rewritten; they cannot be, because the rewrite lands in a join the database resolves and only the parent rows return — this closes the item the plan carried as “the relation-filter case still needs deciding”, settled by §10.2’s existing text rather than by a new spec decision, so no gap issue was filed. (3) §6’s coverage row claimed findMany/findFirst/count; it now claims findMany alone, and states outright that this adapter’s L2 is narrower than the Django adapter’s — Django serves count(), exists(), get() and first() by materializing the bucket inside its own QuerySet, and a Prisma extension has no equivalent. Not claiming parity is the point of the row.

    Refused with a rewritten filter present, each measured rather than argued: findFirst/findFirstOrThrow, count/aggregate/groupBy (bucket count 2 where the verified answer is 1), updateMany/deleteMany/update/delete/upsert (deleteMany removed 2 of 2, one holding a different value — not recoverable), take/skip/cursor/distinct at the level carrying the obligation (a take-1 page returned the collision and missed the match; distinct over four rows returned three before verification, so the row it discarded cannot be recovered), an encrypted term under OR/NOT, relation filters, and _count relation filters. All 29 refusal tests were run against neutered code to confirm they bite; all 29 fail there.

    Two findings worth keeping. The §7.5 projection check runs on the returned row, not on the arguments: three things remove the column verification needs — a select that omits it, a query-level omit, and a client-level omit on new PrismaClient({ omit: … }) — and the third never appears in args at all, so an argument-side check cannot see it. And Prisma client methods return lazy promises: the first draft of the candidateScope opt-out used a synchronous AsyncLocalStorage.run, which exited the scope before the query dispatched and re-verified anyway — an opt-out that silently does nothing. It awaits inside the scope now, and the “measures why” tests fail if that regresses.

    The escape hatch is candidateScope(fn) (docs/13 §2.2), a callback scope mirroring tenantScope, since Prisma has no chainable queryset for Django’s .candidates(). It lifts the verification family including mutations — parity with Django’s .candidates().delete(), because a silent divergence between two shipped adapters is the failure G23 was filed about — and does not lift the G20 family, not/notIn (spec §7.10 has no row for negated membership; serving it inside the scope would decide G21 [#87] by engineering judgment), equality with no declared index, or findUnique. No spec change, no vector obligation, no core change, no envelope byte, no Gate 0b bearing. Fixture schema gained two index siblings (Visit.reason, Patient.nickname) so the nested where site and a nullable base64-stored indexed column are exercised.

  • 2026-08-27G23 closed, the day after it was filed, resolved to serve with the review panel’s adjustments: plain COUNT is served, and the invariant it rests on is now spec text. The gap was the first found as a live behavioural divergence rather than in advance of one: PR #86’s review round ruled Prisma’s _count served (measured exact), while the Django adapter refused Count(field) under §10.2’s “an aggregate computes on bytes” — a justification true of MIN/MAX/SUM/AVG and false of plain COUNT(col), which reads null-ness alone. Three independent reviews on the tracker converged on serve unanimously; the recorded consensus adopted three adjustments beyond the filing. (1) A bare MAY would have blessed the divergence the issue exists to end, so §10.2 carries a permission and a prohibition: the plain, non-distinct, single-column non-null count MAY be served, conditional on the NULL-preservation invariant, and an adapter that refuses it anyway MUST NOT justify the refusal as bytes-computation — the false justification is how the divergence arose. The bullet’s discriminator is reworded from “computes on bytes” to “reads envelope bytes”, the honest line: COUNT(DISTINCT col) stays refused on it, since it compares envelopes and therefore counts rows. (2) The invariant was enforced everywhere and stated nowhere: both adapters stored NULL as NULL, tested it, and served IS [NOT] NULL shapes that silently rested on it — while every NULL mention in the spec concerned the optional context fields. §10.2 now states it normatively (NULL is never an envelope; a present value — the empty string included — is never NULL; the index sibling is NULL exactly when its source is), and §7.10 gains the “Non-null count → Yes” row carrying the condition, so neither can be read without the other. (3) The advertised workaround was the same query: docs/12’s G20 table advised filter(f__isnull=False).count() two lines from the refusal — now labeled an alternative spelling, not a workaround. Implementation: the Django adapter serves the bare shape only (_is_exempt_plain_count: non-distinct, no filter=, counted source a bare F); Count(distinct=True), Count(Length(...)) and filtered forms stay refused, the values(enc).annotate(…) grouping refusal and the §7.5 SQL-answered rule are untouched, and the compute-refusal message names the served shape instead of the isnull spelling. Django adapter 233 → 243 tests — 6 of the 10 new ones fail against the pre-fix code (the 4 that pass are the stays-refused pins); the Prisma suite stays at 129 with the empty-string trap added to its exactness test ("" is a value, becomes an envelope, and counts). No vector obligations, no core change, no envelope byte, no Gate 0b bearing.

    The closure’s review round then found a hole one door over, measured before fixing (adapter 243 → 248 tests): conditions inside expressions – an aggregate’s filter= and a When.condition – resolve when the expression compiles, not when .filter() runs, so their lookups arrived without §7.5 obligations: Count("created", filter=Q(email=…)) was served and returned index-bucket counts, and a When(email=…) branch fired on a bucket match, both silently – iter_reference_names cannot see into a Q, which is a tree of (path, value) tuples rather than an expression. A separate condition walk now refuses an encrypted column inside any such condition (in aggregate/annotate/alias/values/order_by/earliest/latest), with a message stating the ungoverned-resolution reason; the filter= form of the plain count gets its own message naming .filter(<cond>).aggregate(n=Count(field)) as the same count – per the review’s finding that the generic bytes message would have been false for it, the exact white-lie class this closure outlaws. 4 of the 5 new tests fail against the pre-fix code; the hole predates G23 and was reachable since the G20 refusals shipped.

  • 2026-08-26G19 closed, adopted as proposed: §7.5 now names the equality it always meant. The rule: normalize(stored_plaintext) against normalize(queried_value), under the normalizer declared for the index that produced the candidates, compared on the normalizer’s output bytes — with the one-equality adapter obligation drafted in the issue (a non-identity column’s equality lookup is equality under that normalizer, documented, and no second differently-folded equality may sit beside it). The three deciding arguments are in §7.5’s justification paragraph rather than only in the issue: byte-exact verification makes a folding normalizer strictly worse than declaring no transformation at all, on the email-like columns §7.2’s example points folding at; the index’s equivalence classes are normalization classes, so a different comparison is a filter disagreeing with its own check; and canonical equivalence has no sane byte answer.

    The vector obligation that kept the issue open is met, and it is one vector. Suite 0.3.0-provisional → 0.4.0-provisional adds blind-index/hmac-sha512/normalizer-collapses-e-acute: precomposed U+00E9 against decomposed e + U+0301 — the exact pair the issue and §7.5 cite — as an "equal" assertion beside the five collision pairs G15/G16 already pinned (the case-variant half of the obligation was covered by normalizer-collapses-case since 0.2.0). The pair is guarded twice: the two NFC-collapsible pairs (-e-acute and -precomposed) are written in the generator as backslash-u escapes, and _vectors asserts every collision pair’s preimages differ before deriving — identical preimages would satisfy the must_be_equal check vacuously, and neither harness re-checks distinctness. The escape half of that claim was false in this PR’s first commit — the pair landed as literal UTF-8 while four documents said otherwise (a shell transport stripped the escapes and the sanity check verified the wrong property) — caught by three of the four reviews; the assert, which no spelling can defeat, is the review round’s durable contribution. Regeneration is deterministic; both cores pass 145/145 with unchanged pinned_decisions — two independent implementations agreeing with the generator, which is docs/08 §7’s freeze protocol for the new value. The comparison rule itself is an adapter obligation held by per-adapter tests, so docs/14 §4 is untouched, exactly as the issue predicted.

    Consequence sweep: docs/09 §7 states that the declared normalizer fixes re-verification’s equality and that this is why normalize/NORMALIZER_IDS are public; §7.2’s bucket paragraph — the one the issue quoted as reading byte-exact — now reads correctly under the rule (a bucket column’s two unindexable values share one marker and are separated by exactly this comparison, under raw bytes since the normalizer refuses them). docs/12 §3.2/§3.3 move from proposed to resolved phrasing; docs/13 §3 gains the rule for the Prisma extension and names mode: "insensitive"’s §5 rejection as the one-equality consequence. No adapter or core code changed: the Django adapter shipped the rule as its prototype in PR #79 (TestNormalizedEquality), and both cores made normalize public then. Python core 304 → 305 tests (the harness iterates the suite), TypeScript 295 → 296.

  • 2026-08-26G20 closed, the day it was filed: SQL that computes on envelope bytes is refused everywhere, and the worst case was quieter than the issue knew. Spec §10.2 gains the All ORMs bullet (the Prisma orderBy sentence becomes its named instance); §7.10’s GROUP BY/DISTINCT row splits into the sibling row (yes, with the collision caveat) and the ciphertext row (No, materialize-then-compute fallback). Closed as filed plus one addition: aggregate and function expressions joined the refusal set after aggregate(Min("age")) over {30, 40} was measured returning 40 — the byte-wise minimum envelope decrypts cleanly to an arbitrary row’s value and is presented as the minimum, with nothing raised. Every other failure in the family is either loud or visibly absurd; this one is silent and plausible, and it was found only because the closure re-ran the issue’s measurements with more operators.

    The Django implementation is a second refusal class, deliberately orthogonal to §7.5 verification: active on every FieldsealQuerySet (a meaningless order needs no filter present to be wrong) and not lifted by .candidates() — bucket semantics are a meaningful thing to accept for a filter, ciphertext order has no semantics to accept. Covered: order_by (strings, - prefixes, relation paths, F/OrderBy/function expressions), earliest/latest including the Meta.get_latest_by fallback (which never passes through order_by() — it compiles add_ordering directly), distinct in both call orders relative to values(), expression references in annotate/alias/aggregate/values, and values(enc).annotate(<aggregate>) grouping. A bare F("email") annotation stays allowed: it only selects, and the converter decrypts what comes back. The declaration doors the SQL compiler applies without consulting the queryset get startup checks — E009 for Meta.ordering/get_latest_by (Error: every unordered query would silently sort by envelope bytes), W005 for an encrypted column the admin changelist would order by (Warning: the refusal makes the failure loud, but as a 500 on a header click). The pins file grew the assumptions this leans on: meta.ordering read inside SQLCompiler._order_by_pairs, _earliest reading get_latest_by, values_select/query.distinct meaning what the grouping checks assume.

    The residue is documented rather than owned: Django resolves ordering names to a plain Col with no field hook in the path, so a plain-manager model ordering through a relation onto another model’s encrypted column is not reachable — unlike the equality traversal, which the lookup layer closes for every queryset. Recorded in docs/04 §1 in the raw-SQL class. No vector obligations, no envelope byte, no derived value, no error code, no pinned_decisions key; both cores untouched. Django adapter 196 → 228 tests; the refusal tests were run against the pre-fix code to confirm they bite (20 of 32 fail there).

  • 2026-08-26The PR #79 review round: three findings from review, and the sweep they forced. Reviewer 2 found (1) a relation traversal (Visit.objects.filter(patient__email=...)) served unverified — the SQL compiled through the join onto the sibling while the queryset recorded no obligation, against _resolve’s own comment; (2) filter(field=None) raising to_bytes(None)’s write-path message at filter time; (3) plain-AND Q(field=...) refused against the documented contract. All three confirmed against the code.

    The traversal hole was worse than reported. The suggested fix — walk the relation in _resolve and refuse — only protects models whose manager is the verifying one, and the traversing model is typically not one of those: a Visit with no encrypted columns has a plain manager and never passes through FieldsealQuerySet at all. The fix that holds is at the lookup layer: _IndexedLookup refuses to compile for any model but the column’s own (MTI children excepted — their queryset materializes rows that carry the column), so the backstop covers every queryset including hand-built ones. The FieldsealQuerySet walk is kept as the friendlier filter-time refusal.

    NULL turned out to need no verification at all, which is the finding under the finding. NULL plaintext stores NULL in both columns, and Django rewrites exact=None to isnull — so filter(field=None), __isnull and exclude(field=None) compile to IS [NOT] NULL on the envelope column: exact, index-untouched, safe under negation and OR alike. The fix is to record no obligation rather than to teach the matcher about NULLs (__in drops None targets instead, mirroring SQL IN semantics). The rewrite is pinned in test_query_private_api.py.

    Auditing every remaining fetch path with the same question — “who applies a LIMIT before §7.5 runs?” — found four more silent-wrong-answer paths, none flagged in review: get() samples LIMIT MAX_GET_RESULTS (21) of the candidates, so a §7.4 bucket larger than the window could hide the true match past it and raise DoesNotExist about a row that exists (fixed: verified get() materializes the bucket); iterator() and aiterator() bypass _fetch_all by design and streamed unverified candidates (fixed: the stream is filtered as it passes — aiterator separately, as the one async method that does not delegate to its sync twin, which delegation is now pinned); qs[i] compiles to LIMIT i,1 (refused, like slices); earliest()/latest() are LIMIT 1 behind another name (refused). Two composition paths could smuggle candidates past verification entirely: embedding a verifying queryset as a subquery (__in=qs, Subquery, Exists) and the set combinators, where obligations cannot be applied to rows the other operand contributed — both refused, on either side for combinators. .candidates() now genuinely lifts the filter-time refusals (exclude, Q under OR) its own message already told callers to rely on. Plain-AND Q records obligations and verifies like keyword arguments — under AND every returned row must satisfy the encrypted term, so per-term verification is exact.

    docs/12 §3.2’s table and the README matrix now state the full contract. Django adapter 159 → 196 tests; the new behavioural tests were run against the pre-fix code to confirm they bite (23 of 30 fail there).

  • 2026-08-26The cross-language claim reaches the layer people deploy: a row written by Django, decrypted by the TypeScript core. adapters/django/tests/cross_produce.py writes through the real ORM path — real save(), runtime CSPRNG, fieldseal.testing never imported — reads the raw column back through a database cursor, and emits a standard fieldseal-vectors/cross/v1 document. The consumers needed no changes at all: django is a third value of the cross-produce matrix and both core consumers take cross-django.json alongside the other two, so the adapter joins the N×N matrix as one more producer instead of acquiring a bespoke checker. Verified locally before wiring: 6/6 cases, TypeScript consumer unmodified.

    This is not a restatement of the core cross job. Three decisions between an application value and the stored column belong to the adapter and to nothing the cores test. The codec’s renderingIntegerField(45) becoming b"45" is a choice, and a consumer that expected an integer encoding would decrypt successfully and read the wrong value, which is why the expected plaintext is asserted rather than only round-tripped. The storage form, binary against base64. And context assembly, where table_uuid, column_uuid and the tenant come from model declarations and a contextvar rather than from a caller — a consumer that rebuilds it differently gets COMMITMENT_INVALID, a decrypt-side error for a write-side configuration mismatch.

    The gate was checked for vacuousness rather than assumed. A one-bit flip in a stored envelope makes both consumers report COMMITMENT_INVALID and exit non-zero — confirmed directly, because the first check read a pipeline’s exit status rather than the consumer’s and would have reported a working gate either way.

    The local suite decrypts the produced file with a client built independently from vectors/keys/, so a case passes only if the stored bytes are readable from the shared key material alone — what a consumer in another language actually has. It also asserts that two runs disagree on every envelope, since a producer that had drifted onto the injection seam would pass every other assertion (spec §4.4).

    The index half is deliberately not done. A blind index written by Django and derived identically by the TypeScript core is the more valuable assertion — a mismatched index is a silent lookup miss rather than an error — but it needs the cross/v1 schema to carry the index declaration, which is a schema change touching docs/08. Recorded as the next increment rather than half-implemented alongside this one.

    Django adapter 154 → 159 tests.

  • 2026-08-26M4’s deferred list closes: on_unindexable, fieldseal_gen_uuids, and warm(). Three items the L1 PR listed as not-done, plus two findings that only appear once the code is written.

    docs/12 §10 is implemented on both sides, and the interesting part is where refuse raises. The index can only be derived in pre_save — the one field hook that receives the instance — and pre_save runs inside the INSERT, so a ValidationError raised there propagates out of Model.save(), whose transaction.atomic(savepoint=False) marks the connection for rollback. §10.1 asks for a failure a ModelForm renders beside the input; what that produces is a field error and a transaction the caller cannot use, which is not that. The check therefore also lives in Encrypted.validate(), which full_clean() calls, so the form path fails before the database is touched. A direct create() still marks the transaction — Django’s behaviour for every exception in save(), not a choice this adapter made — and is documented rather than pretended away. Second finding, cheap to hit and silent: ValidationError discards code and params when its message is a dict, so the machine-readable half has to be carried by the inner error; a caller who builds the dict itself loses it with no warning.

    §10.2’s message is normative in shape and the core’s own message meets none of it — correctly, since the core is addressing whoever wired the column. The adapter builds the user-facing half: it names the character and its position, puts the fault on the system (“a gap on our side, not a problem with your name”), and offers a route that ends with the value stored. Finding the offending character uses the core as the oracle rather than a second copy of the Unicode assignment table — that table decides index values, so a copy that drifts is a silent lookup miss. The engineer-facing detail (U+0378 at index 3) goes to params, deliberately not into the sentence the person reads: §10.2’s requirement 2 does not survive a code point in the same breath.

    bucket needed no query-path special-casing, as docs/09 §7.2 predicted. A query for an unindexable value derives the same marker unaided, and §7.5 re-verification separates two different unindexable values that share it — the case where re-verification is doing all the work rather than trimming a collision.

    warm() exposed a drift between docs/10 §4 and the Python core. §4 has specified a sync warm_blocking() since the binding was written and the core did not have one — the same shape of drift PR #61 found in the declaration-time gates. It is added now, and it refuses inside a running event loop rather than deadlocking, because blocking on a coroutine from within the loop that would run it is a hang, and a hang at startup is diagnosed by guesswork.

    The ready() hook is opt-in, which departs from docs/12 §7’s wording deliberately. ready() runs for makemigrations, shell, collectstatic and every test process, so warming unconditionally would make a command that touches no encrypted row pay a KMS round trip and fail when the key service is unreachable. A migration blocked by a cold KMS is a worse failure than a cold cache. §7 now records the departure and the consequence it does not hide: under an EnvelopeKeyProvider §8.2 confines unwrapping to warm, so something must prime the cache before the first read — FIELDSEAL["WARM_ON_READY"] or manage.py fieldseal_warm, with no third option. Warming covers index keys as well as data keys (spec §5.2 makes them siblings, so a cache warmed only for the DEK still stalls every indexed lookup, and the symptom reads as a slow query rather than a cold cache), and tenant-bound columns must have their tenants named because the adapter cannot enumerate them — a run that omits them names what it skipped rather than reporting a warm cache it did not warm.

    fieldseal_gen_uuids prints and never edits, because a column_uuid that changes is a data-loss event and one that repeats binds two columns to a single key derivation. Its --missing mode reports only table_uuid: column_uuid is a required keyword argument, so a column without one does not construct, and the first version of the command looked for those too and would have reported nothing forever.

    Django adapter 113 → 154 tests, Python core 301 → 304. ruff and mypy --strict clean; the CI job name is corrected from “Django adapter (L1)” to “(L1+L2)”, which had been wrong since L2 landed.

  • 2026-08-26M4 continues: the Django adapter’s L2 query path ships, and docs/12 §3.2’s deferred decision is settled as option C. filter(email=...) and __in compile onto the blind-index sibling as Lookup subclasses (so they compose with Q, joins and subqueries for free), and FieldsealQuerySet._fetch_all performs the spec §7.5 re-verification that makes them correct. The rejected alternative — an explicit .verified() — fails silently: one forgotten call returns collision rows, which is the §10.2 wrong answer the adapter exists to prevent. The safe path has to be the default path, which is also why the manager installs itself: class_prepared attaches FieldsealManager to any model with an indexed encrypted column that declares no manager of its own (Django flags its own auto-created objects with auto_created, an exact test for “the author did not choose”), and system check E008 covers the case the adapter deliberately will not overwrite.

    The rewrite was the easy half; the design work was what shrinks. Verification drops rows after the database has applied COUNT, LIMIT and OFFSET, so every queryset method answered from SQL is wrong by default — and each bypass was verified against Django 6.1’s source rather than assumed. count() and exists() never touch _fetch_all (query.get_count(), query.has_results()); first()/last() are implemented as [:1], so a single colliding candidate returns None with a match sitting in the next row. Those four are implemented correctly against verified rows, with exists() and first() short-circuiting — affordable because §7.4 sizes truncation at 2 ≤ P·2^−b < √P, so an equality lookup fetches a small multiple of its true match count by design. Slicing, aggregate(), values()/values_list()/only()/defer() are refused; §7.5 states outright that pagination built directly on an indexed encrypted column is incorrect, and the documented pattern is over-fetch → decrypt → filter → paginate.

    Two refusals are about unrecoverable damage rather than wrong numbers. update() and delete() run in the database against the bucket, so they would write to or destroy rows whose value differs — the only data-loss cases on the list. And exclude() is refused for a reason worth stating in its own right: the SQL excludes the whole bucket, so it drops rows it should have kept, and those rows never reach the adapter for §7.5 to put back. A filter’s false positives are recoverable; an exclusion’s false negatives are not. The same asymmetry refuses a Q that reaches an encrypted column through OR or negation, where a candidate may be present because another branch matched and verification cannot decide it without evaluating the whole predicate in Python.

    The private-API cost of option C is paid explicitly. _fetch_all is not a documented extension point, and this module mirrors its body rather than calling it so that candidates are dropped before _prefetch_related_objects runs. tests/test_query_private_api.py pins every assumption — that _fetch_all exists, that its body is still the two steps mirrored and has not grown a third, that count()/exists() still bypass the result cache, that first()/last() still slice, that filter/exclude still funnel through _filter_or_exclude and chaining through _clone — each with a message saying what to re-verify. A Django upgrade now breaks the build instead of silently returning unverified rows.

    G19 was filed on the way (#78) and the implementation follows its proposed direction: §7.5 says “decrypt and compare the actual values” without saying under what equality, and on a nfc-casefold-v1 column the two readings return different rows. fieldseal.normalize and NORMALIZER_IDS became public in both cores so the adapter can compare under the column’s own normalizer rather than reimplementing nfc-casefold-v1 — portability surface where a disagreement is a silent lookup miss, not an error.

    Django adapter 74 → 113 tests (L2 round trips, forged-collision verification, normalized equality, every refusal, the manager, the private-API pins), Python core 298 → 301, TypeScript 292 → 295. ruff and mypy --strict clean, tsc clean, both reports still 144/144 with identical six-key pinned_decisions. No envelope byte, derived value, vector expectation or error code changed, and no backfill is needed — the index column has been written correctly since #73.

  • 2026-08-26G18 filed and closed the same day (issue #75). docs/09 §2 gains a normative Configuration reflection clause: a constructed client MUST report back every validated element that affects stored bytes, query results or read behaviour — read_mode, write_suite, allowed_suites, the §4.8 arming state and the validated index declarations — in resolved form, through accessors that cannot mutate it, and MUST NOT expose its KeyProvider, its cache or any key material.

    What was actually broken was a claim, not a mechanism. §2 already said the client’s immutability “makes ‘which config produced this ciphertext’ answerable”. Immutability is necessary for that and not sufficient; the sufficient half — being able to ask — was never specified, and neither core implemented it for the one element that matters. docs/12 §5’s E006 (a hand-supplied FIELDSEAL["CLIENT"] whose registry must exactly match the model declarations) was consequently unimplementable and shipped in PR #73 as a W004 warning that reported the gap instead.

    The rule is a principle, not a field list, for one reason worth keeping: “expose the configuration” would put a handle to the object holding key material on the public surface of every client. The carve-out is what makes the clause safe to state broadly, and stating it broadly is what stops it being re-litigated per field.

    Resolved, not as-declared, because the two differ in the direction that matters. Validation fills in the §7.3 Argon2 minima, the "exact" index_id default and the §7.2 refuse default, so reporting the as-supplied form would let two declarations that agree textually and differ operationally register as a match — #62 exactly. ValidatedIndex, validate_index_declaration/validateIndexDeclaration and index_registry_key/indexRegistryKey became public in both cores as part of this: an addition beyond the issue draft, recorded rather than absorbed. Without them a caller can read the registry and still cannot compare against it, because it would have to reconstruct the key format and the defaults by hand — the coupling the accessor exists to remove.

    Three runtime guarantees are load-bearing and none comes from a type. The TypeScript indexes and allowedSuites accessors both return copies, because ReadonlyMap/ReadonlySet are erased at runtime and each getter would otherwise hand out the very collection the value path consults — one as Map/as Set cast clears the registry or changes what the client will decrypt. And validateIndexDeclaration now freezes what it returns, because readonly on an interface member is a compile-time claim only. Python needed none of the three: MappingProxyType over frozen dataclasses carries no mutating methods at all, and allowed_suites was already a frozenset. The two cores are safe by different mechanisms again, as in G17, and this time both are written down.

    allowedSuites was missed on the first pass and caught in review, which is the finding worth carrying rather than the fix. It predates G18, so the clause this issue adds made an accessor that already existed non-conformant — the new members got the treatment and the old one was assumed to have had it. That is the general hazard of writing a rule for a surface that grew before it, and the reason the regression test asserts behaviour rather than shape: the pre-fix client, after a mutation through its own read-only accessor, raised SUITE_NOT_ALLOWED decrypting an envelope it had written moments earlier.

    The two cores were not equally affected, and the worse one had no workaround. Python’s _indexes is private by convention; TypeScript’s #cfg is a hard private field on an instance the constructor freezes, so a Prisma or TypeORM adapter could not have written the check well or badly. docs/13 §2’s no-client-option decision is unchanged but its justification is narrowed: it was recorded as though verifying a supplied client were impossible, and it now reads as the design choice it is.

    A second defect fell out of implementing the check, and it is worse than the one the issue was filed on. FIELDSEAL["CLIENT"] was silently switching off E003. build_client returns a supplied client immediately, without ever assembling the registry — so on that path the core never saw the model declarations, and neither the §7.4 truncation band nor the §7.6 cardinality gate ran against them. A project could declare a column at any truncation length, supply its own client, and get a clean manage.py check. Nothing reported it because nothing looked: the escape hatch that E006 exists to police was disabling a different check entirely. The E006 comparison has to resolve the model side anyway, which is what makes the gate reachable at all on this path; a refusal there is reported under E003, since it is E003’s condition rather than a registry mismatch. The regression test was confirmed to fail against the pre-fix code rather than merely to pass against the new — with CLIENT set and an out-of-band declaration, check_fieldseal returned an empty list.

    Consequence sweep. docs/09 §2 and §7; docs/10 §4 and docs/11 §4 name their accessors; docs/12 §5 restores E006 as an Error and withdraws W004, whose id is not reused — two ids for one condition is how a check suite starts overstating its coverage. The Django check now compares both directions, and only one of them is loud: a client missing a declared index fails every lookup at runtime, while a client carrying an index the models do not declare stores values under rules no model states and nothing raises. W004 had no test at all, which is the second reason not to keep it. docs/15 §1.1’s backfill config hash is the other consumer this unblocks and is deliberately out of scope — it is noted in the issue and left for whoever next touches that document.

    No pinned_decisions key, deliberately unlike G17. Buffer lifetime was unobservable in principle, so a declared key was the only instrument available; an accessor’s presence is directly testable in each core, and pinned_decisions declares a choice a conformant core may make differently. A key would legitimise the divergence this ends. No out_of_band entry either: that substitutes for a vector that cannot be expressed, and here there is no vector to substitute for. No vector obligations, no envelope byte, no derived value, no error code, no registry entry. Both reports stay 144/144 with unchanged pinned_decisions key sets. Python 294 → 298 tests, TypeScript 287 → 292, Django adapter 68 → 74; ruff and mypy --strict clean, tsc clean on both projects.

  • 2026-08-25G16 part B landed; issue #60 is now complete on all four parts. New normative docs/09 §7.2 gives IndexDeclaration an on_unindexable of refuse (default) or bucket, and — the part §7.1 had pointed at for a section that did not exist — docs/12 §10 and docs/13 §9 now carry the adapter obligation and the message rules.

    One design change fell out of implementing it, and it is an improvement. The issue described bucket as storing a reserved marker that every indexed lookup additionally probes. That is unnecessary and strictly worse. The marker is derived from the value’s normalization, and normalization does not care which direction the value is travelling — so a query for an unindexable value derives the marker by itself, matches the bucketed rows, and spec §7.5 re-verification (already mandatory and unconditional) narrows them. A query for an indexable value never touches the bucket, because a bucketed row by definition contains a character no accepted value can contain. The query path needs no special case at all: the bucket is not a new mechanism, it is one more collision class in an index §7.4 already mandates collisions in. The issue’s version would have doubled every query’s candidate set and widened the leak for nothing. §7.2 records the correction rather than quietly substituting it.

    The marker is derived, not constant. truncate(IDF(index_key, 0xFF || "fieldseal-unindexable-v1"), b). The leading 0xFF can never appear in UTF-8, so no input nfc-casefold-v1 accepts can normalize to that preimage — the marker cannot collide with a real value by construction rather than by luck. Deriving it under the column’s own index key also means it looks like every other index value; an all-zero constant would have announced to anyone able to read the column, with no key at all, exactly which rows hold a character outside the pin.

    The cost is stated rather than mitigated away. The bucket is an equivalence class that can grow far past §7.4’s expected P × 2^(−b), so it is distinguishable by frequency, and it is growable by any writer, which makes lookups against it progressively more expensive — bounded by re-verification cost, not by anything cryptographic. Declaring bucket therefore requires unindexable_override {reason, approved_by, date}, deliberately the same ceremony §7.6 already requires to relax the cardinality gate. Declaring it on a normalizer that can never refuse (identity, digits-only-v1) is a ConfigurationError: the setting could not fire, and accepting it would misrepresent the column as protected.

    Vector suite 0.2.0 → 0.3.0-provisional, three vectors, all in blind-index/. unindexable-marker-b15 pins the marker’s bytes, which matter as much as its behaviour — two cores deriving different markers would bucket their unindexable rows separately and a lookup across them would silently return nothing, which is the failure the setting exists to prevent, reintroduced by the fix. unindexable-bucketed-b15 pins that a refused value lands on it and that refuse still refuses. replacement-char-is-text-b15 closes part A’s remaining obligation: U+FFFD indexes normally, so a core that rejected it as a data-quality signal would refuse what every other core indexes. docs/08 §3 documents the two new one-sided assertion kinds and requires a harness to fail on an unrecognised assertion rather than skip it; the TypeScript harness already did, which is how the suite bump was caught rather than silently ignored.

    Both cores 144/144 on the new suite with the same result ids (ordering differs — Python sorts, TypeScript keeps file order; pre-existing, first divergence is at index 2 among vectors that predate this work). TypeScript 281 tests, Python 264, ruff and mypy –strict clean, generator output stable across reruns.

  • 2026-08-25G16 part A landed (issue #60 stays open on B). docs/09 §7.1’s note becomes normative in the opposite direction from how it started: an index-derivation API MUST accept the language’s text type, bytes remaining permitted through clause 5’s strict decode. The obligation it replaces — “a core that accepts bytes only MUST expose the assigned-code-point check, and an adapter encoding on the core’s behalf MUST apply it before encoding” — was unenforceable by construction: advice to a frame the core cannot inspect, where a missed call raises nothing and fails no test. The TypeScript core’s blindIndex now takes string | Uint8Array; the message that used to refuse strings named TextEncoder as the supported route, which is the substituting conversion. docs/10 and docs/11 record the encrypt-is-bytes / blind_index-is-text asymmetry as intended rather than as drift — normalization is a text operation and encryption is not, and Python has had exactly that shape since it was written.

    Rejecting U+FFFD outright stays declined and is written into §7.1 as considered-and-declined, with the reason: it would convert a false match into an unindexable row (part B’s failure mode) and would still miss truncation that lands on valid text. It remains available as an opt-in per-column check and is not part of the normalizer.

    A vector obligation could not be met, and the reason is worth more than the vector. The lone-surrogate case is not portably expressible: 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’s String is UTF-8 by invariant — two of the five target languages cannot carry the operand at all. It is now the out_of_band entry docs/09/7.1/lone-surrogate-refusal in both reports (docs/14 §4, on the G10 precedent), asserting not just that both surrogates are refused but that they are refused distinguishably: an identical diagnosis would leave two values indistinguishable to the caller, which is the property the refusal exists to deny them. docs/14 §4 now separates the two exclusion reasons — §3.5 is excluded by size, an accident of the harness; this one by representability, a property of the requirement that will not go away.

    Both cores 140/140 with three out_of_band entries passing and identical ids; TypeScript 270 tests (new tests/index-boundary.test.ts), Python 253, ruff and mypy –strict clean. The remaining expressible vector for A — a legitimate U+FFFD indexing normally — is batched with B’s vectors into one suite bump rather than bumping twice in a day.

  • 2026-08-25G16 part C landed (issue #60 stays open on A and B). docs/09 §7.1 gains Pin currency (normative), splitting the bump into two regimes at the format freeze: before it, nfc-casefold-v1 tracks the most recent released Unicode version and moves in place (no stored values, no frozen identifier, provisional vectors — the cost is a regenerated blind-index/ family); after it, the same move MUST mint nfc-casefold-v2 and MUST come with a planned re-index. The point the section now makes explicitly is that the cost jumps at the freeze rather than rising with stored rows, so deferring is a decision to keep the old pin permanently or pay for a migration, not a way to save anything.

    tools/ucd-gen/generate.py gains the fetch guards: redirects refused, non-HTTPS hops refused, and the URL that answered checked against the URL requested. This was a live trap, not a hypothetical — Public/18.0.0/ucd/ answers 302 to http://…/Public/draft/ucd/ for all three consumed files, urllib followed both the redirect and the protocol downgrade by default, and download() printed the URL it asked for, so a premature VERSION bump would have produced draft-derived tables labelled 18.0.0 under a build log asserting a fetch that never happened — surfacing later as an unexplained --check failure. Verified end to end against the live redirect: 18.0.0 refuses with UnsafeFetch, 17.0.0 fetches and --check still reports all three targets clean. New tools/ucd-gen/test_generate.py holds the guards offline (5 checks, no pytest, no network, so a flake cannot mask a regression in the guards themselves) and runs in CI ahead of the regeneration step. tools/ucd-gen/README.md rewrites its bump section onto the two regimes and states the released-not-numbered rule.

    Both cores unchanged and green (Python 253, TypeScript 258); the vendored tables are byte-identical, since nothing about 17.0.0 moved. The bump itself is deliberately not done — 18.0 is not released, and under the new rule it cannot be adopted until it is.

  • 2026-08-25G16 filed (issue #60) (docs/issues/G16-index-boundary-and-unicode-pin.md), carrying four decided directions. Filed rather than decided in place because all three change normative text and CONTRIBUTING.md requires the issue first. Found while closing G15: none of the three is a defect in either core, and all three are things docs/09 §7.1 either leaves to someone else or points at a section that does not exist.

    Part A — the index boundary. docs/09 §7.1’s note puts the lone-surrogate refusal on the caller (“a core that accepts bytes only MUST expose the check”), which is unenforceable and, in the TypeScript core, countermanded: blindIndex refuses string with “encoding is the adapter’s job” and the encoding it points at is TextEncoder, which substitutes U+FFFD. Measured 2026-08-25: encode("a\uD800b") and encode("a\uDC00b") both give 61 ef bf bd 62 — one index value for two inputs, a false-match primitive inside the feature built to prevent false matches, invisible to every vector and conformance report. The Python core has been immune since it existed, by accepting str (api.py:235); its bytes route fails loudly too, since str.encode("utf-8") raises on a lone surrogate. Decided: blindIndex accepts string | Uint8Array — a widening, no stored byte changes, and the write-up’s preferred U+FFFD rejection is declined as the mechanism (it catches one symptom of one cause, misses truncation that lands on valid text, and converts a false match into Part B’s missed match) while remaining available as an opt-in per-column validator. The encrypt-is-bytes / blind_index-is-text asymmetry is adopted deliberately, not tidied away: normalization is a text operation and encryption is not.

    Part B — values that encrypt but cannot be indexed. §7.1 says the refuse-vs-store-unfindable choice is “a product decision this document does not make” and that it is “recorded in §12 of docs/12 and docs/13”. It is notdocs/12 ends at §9, docs/13 at §8, and neither file contains the word “unassigned”. The flag existed in one place and its forward pointer went nowhere. Decided: IndexDeclaration.on_unindexable = refuse | bucket, default refuse, bucket gated on the {reason, approved_by, date} shape §7.6 already requires for a cardinality override. bucket stores a reserved index constant rather than no index, so the lookup matches it and resolves the candidates through the §7.5 re-verification the spec already mandates unconditionally — which is why neither value is the §10.2 forbidden pattern, and why “save unindexed and log a warning” was rejected rather than granted an exception. The leak bucket creates (a visible set of rows containing post-pin characters, growable by anyone who can write to the column) is stated in §7 rather than mitigated away.

    Part C — the pin. Two problems, one deadline. There is no policy saying when the pin moves, so the default is drift; and the deadline is not “before production data” but Gate 0b or freeze, because the identifier is the definition — before freeze the pin is redefined in place, after it costs a nfc-casefold-v2 and a dead v1 in a closed set. Separately, bumping VERSION today would silently pin the draft: Public/18.0.0/ucd/ 302-redirects to Public/draft/ucd/ and downgrades to plaintext HTTP, generate.py follows redirects by default, and it prints the requested URL rather than the served one (generate.py:280), so the build log would assert a fetch that did not happen. Decided: state the policy in §7.1, guard the generator against redirects and non-HTTPS hops now, bump when 18.0.0 stops redirecting.

    A measurement that reverses the write-up’s impact analysis. internal/product-decision-indexing-edge-cases.md reported 11,329 of the 13,007 draft-18 additions as CJK and built its case on a CJK-market customer whose legal name is refused. Recomputed 2026-08-25 from UnicodeData.txt with range pairs expanded: the 11,328-point block is Seal Script (U+3D000–U+3FC3F), a historic Chinese calligraphic script Unicode names <Seal Character> distinctly from <CJK Ideograph Extension …>; 914 are Jurchen, extinct. CJK Unified Ideographs gained one code point. The living-script exposure is 29 BMP additions (Hebrew points, Oriya signs, Armenian modifier letters, three currency signs including the UAE Dirham) and 27 emoji — and the frequency ordering inverts the severity ordering, since emoji are both the likeliest to appear and the easiest to refuse. Part B survives with a different justification: not “do not refuse a legal name”, but “an emoji in a display name and a vowel point in a legal name should not be governed by one rule.” Also measured: 0 additions carry a canonical decomposition and 34 have non-zero combining class, and no 17.0.0 character changed either — so the drift exposure the refusal rule guards against is confined to those 34 marks. Every figure is against the draft, and is provisional in exactly the way the pin would be.

    Part D decided: nothing here goes to the Gate 0b reviewers. The question was worth asking because Part A sounds cryptographic — a false match in a blind index is exactly what §7 exists to prevent — but what it found is a lossy conversion in a caller and what it decides is a parameter’s type: no construction, no derivation, no stored byte changes. The one candidate item, the breadth of the refusal rule, turns on whether Unicode’s block-allocation practice is a guarantee or a pattern, which is a UCD stability question and not one a cryptographer is the right expert for; it stays a product decision, with the UTC rather than Gate 0b as its address if it needs an outside answer. Consequence: G16 has no reviewer dependency in either direction — it neither waits on Gate 0b nor gates it, making it the first issue since G13 that is entirely the project’s own to close, and removing the scheduling pressure the first draft of this entry claimed for it.

  • 2026-08-24G15 closed (issue #48), by engineering judgment per its no-crypto-review flag. Parts A, B and C adopted as proposed, with three additions; part D adopted in shape with item 4 decided the other way. Retiring pinned_decisions keys unknown-format-version-set, rotate-in-permissive, provisional-arming, normalizer-text-over-bytes (docs/14 §4) and closing docs/18 D-03, D-10, D-13, D-14.

    Part A — §3.1 assigns the version byte (0x01 this format, 0x02 reserved for the next, the rest non-envelope) and adds what the proposal left implicit: a future version using 0x02 MUST NOT define an envelope shorter than 111 bytes. Without it §3.4’s length gate rests on an assumption about a format nobody has written — a v2 on a natively committing AEAD drops the 32-byte commitment and lands at 79, and every such envelope would be handed to the application as plaintext by §10.3. The issue declined a bytes-1–2 layout rule as “constraining a format that does not exist yet”; a minimum-length floor is the same class of commitment and much weaker, so it is made explicitly rather than assumed silently. §3.4 gains the three-way recognition table, states the is_ciphertext/decrypt asymmetry, and — new — explains why it is decided opposite to the retired-suite case in the same section (three bytes of evidence there, one here), and states the cost in both directions: the proposal recorded only the plaintext false positive, not that is_ciphertext = false makes a backfill tool double-encrypt a genuine v2 envelope, which §3.4’s own justification calls the worse outcome.

    Part B — §11.1 makes rotate ciphertext-to-ciphertext in every mode; MODE_VIOLATION still precedes operand inspection in readonly, and reserved-version input raises UNKNOWN_FORMAT_VERSION, not NOT_CIPHERTEXT. Reverses both cores. The justification leads with domain independence from read mode rather than with the metric argument the issue led with: an operation whose domain varies with a configuration flag unrelated to it is the portability hazard, and it is the one a cross-implementation vector can actually adjudicate. The metric argument is real but softer — a rotate that encrypts is a second, unverified path to the cutover gate’s zero, bypassing verify’s comparison against the legacy plaintext column (docs/15).

    Part C — §4.8 names FIELDSEAL_ARM_PROVISIONAL_SUITES, arming iff the value is exactly 1, read at construction, in-code form left to bindings. Two additions, both found in the cores rather than in the issue: the comparison is byte-exact with no trimming (so 1\n from a .env file or ConfigMap does not arm), and a client is armed if either the variable or the in-code form arms it (so an explicit false alongside a set variable is armed). Both cores already behaved this way and agreed only by coincidence of both doing the naive thing — which is exactly the convergence the issue warns is not evidence.

    Part D — items 1, 2, 3 and 5 as proposed; item 4 reversed. “No second normalization after folding” was argued from “the requirement is determinism, not Unicode-conformant caseless matching — any fixed function satisfies the first.” Determinism is a constraint on the answer, not the question: the normalizer exists to collide case variants, and measured against UCD 16.0.0 the proposed composition fails to collide 12 case/canonical variant pairs at the code-point level and 302 letter-plus-combining-mark strings in the BMP. Eight are real Greek letters — U+0390 ΐ, U+03B0 ΰ, U+1FD2, U+1FD3, U+1FD7, U+1FE2, U+1FE3, U+1FE7 — where the uppercase spelling precomposes and folds to a precomposed form while the lowercase folds to a decomposed one: one letter, two blind indexes, a silently missing row, and no cross-implementation vector able to see it because the divergence is within a core. Adding a post-fold NFC fixes 8 and 294 of those and breaks none; it is canonically invariant and idempotent over every assigned code point. The issue’s objection to a second pass — “that function’s output is NFD, longer and surprising as a stored value” — is an argument against NFD(toCasefold(NFD(X))), not against NFC(toCasefold(NFC(X))), whose output is NFC and is shorter than both NFD and the proposal’s own output. docs/09 §7.1 states that this is not canonical caseless matching so nobody cites it as if it were.

    Item 3 tightened: refusing code points newer than the platform’s Unicode, as a way to use an older platform normalizer, is not a conformant route. It makes the accepted input set a property of the deployment’s runtime — one core indexes what another refuses, both pass the whole suite, and neither report shows it. A core below the pin MUST vendor. This also dissolves the 16.0.0-versus-17.0.0 cost argument: CPython 3.14 is 16.0.0 and CI runs 3.12 at 15.0.0, so a 16.0 pin needs the same machinery and buys one platform, not the mechanism. New tools/ucd-gen/generate.py generates the tables for both cores and the vector generator from the published UCD; CI re-runs it with --check. Three independent implementations of UAX #15 now exist (both cores and the generator, which imports neither): the Python one and the generator’s pass the official NormalizationTest.txt 60,102/60,102, and the TypeScript one agrees exhaustively with ICU 78 over every code point, combining-mark sequences and Hangul.

    One finding recorded rather than resolved: a core whose index API takes only bytes cannot enforce the lone-surrogate refusal, because TextEncoder substitutes U+FFFD and U+FFFD is assigned — so two distinct lone surrogates reach one index, reintroducing one layer up the collision that refusing invalid UTF-8 removes. docs/09 §7.1 puts the obligation at whatever boundary accepts text. And item 1’s refusal has an adapter-visible consequence the issue did not raise: encrypt does not normalize, so a value with a post-17.0 character can be stored but not indexed, and an adapter must choose between failing the write and storing a row its own queries cannot find. That is a product decision, flagged in docs/09 §7.1 as an adapter obligation rather than settled here.

    Vector consequences: blind-index/hmac-sha512.json and argon2id.json regenerate (expected values change for the 26 code points whose fold output is not NFC); fold-not-nfc-stable becomes fold-nfc-stable; four new collision-assertion vectors pin what item 4 is for, since the old obligation list pinned bytes and not collision; three new version-pin vectors (U+A7D2 folding added in 17.0, a Beria Erfe capital assigned in 17.0, a U+1AD9 reordering case). errors/policy.json un-withholds part B’s case and gains rotate-non-envelope-strict, rotate-non-envelope-permissive and rotate-reserved-fmt-ver. Both cores: 140/140, 0 fail.

  • 2026-08-08 — Suite-wide consistency review (two independent read-through passes) applied 26 corrections across docs/08–15; the substantive ones: decrypt-side suite_id sourcing pinned to the parsed header (docs/09 §3.2 step 4 — a write-suite-sourced context breaks mixed-suite reads and rotation); example blind-index declarations corrected to the §7.4 band (16 → 15 bits at P = 100,000); adapter-owned core-client construction (the index registry cannot reach construction-time validation any other way); the determinism-injection arming gate (FIELDSEAL_TEST_MODE); and two newly found spec-internal contradictions filed as G12 (§7.10 unique-constraint row vs §7.4 collision mandate) and G13 (§10.2 Prisma in: reject vs §7.10 membership support).

  • 2026-08-08docs/02 §7.1 cross-reference corrected: the prefix-index mechanism is §7.9, not §7.7 (pure pointer typo; prose-clarification bar per CONTRIBUTING.md).

  • 2026-08-08G3 resolved (issue #3), by engineering judgment per its no-crypto-review flag: truncate(raw, b) pinned normatively in spec §7.2 — keep the leading ⌈b/8⌉ bytes, zero the trailing 8·⌈b/8⌉−b bits of the final byte, bits numbered MSB-first (byte-prefix property; matches §3.1/§6.2 network byte order). §7.4 cross-references the definition; §12 adds the b mod 8 ≠ 0 vector obligation (≥3 non-aligned lengths + one aligned control per blind-index file). Marker sweep completed: docs/08 §4.4/§9, docs/09 §3.3 pipeline, docs/10/11 module maps, issue draft + README. Consequence: blind-index/hmac.json is now fully authorable; Argon2id expected values still wait on G2.

  • 2026-08-09G6 and G8 resolved (issues #6, #8), both by engineering judgment per their no-crypto-review flags, both adopted as proposed. G6: spec §9 gains MODE_VIOLATION, and §10.3 is rewritten onto two explicit axes (non-envelope read behavior; whether ciphertext-producing operations are permitted) so that all three modes are defined on the same terms — readonly inherits permissive’s pass-through, refuses encrypt/rotate, and expressly permits blind_index, since an index computed for a WHERE clause is not a write. The code is raised at the API boundary before any cryptographic processing, which deliberately keeps it clear of G5’s still-open decrypt-path ordering. Writing the two axes out exposed something the three-mode table had hidden: the modes cover three of four possible combinations, and the missing one (raise on non-envelope input and refuse writes) is a configuration a fully migrated read replica would legitimately want. §10.3 records that as a stated limitation rather than adding a fourth mode speculatively. G8: new spec §7.11 pins the stored representation (raw ⌈b/8⌉ bytes in a binary column as the MUST, declared-per-column lowercase hex as the MAY, exact equality under a binary collation), numbered as §7.11 rather than inserted mid-section because §7.9/§7.10 carry live cross-references from G12, G13, docs/12 and docs/13. Marker sweeps completed across docs/08 (§4.4 vector shape gains expected.stored, §4.6 mode rows, §9 gap table), docs/09 (§3.1, §3.2, §3.3, §3.5, §9), docs/10, docs/12, docs/13, and the G03/G10 issue drafts. Consequence: errors/policy.json is now fully authorable and the blind-index/ storage assertions are unblocked, leaving blind-index/argon2id.json waiting only on G2. Gate 0’s vector-blocking set (G1–G8) is down to G1, G2, G4, G5, G7 — three flagged Yes for cryptographic review and two flagged Partly, so every remaining member of that set now waits on the reviewers rather than on us. G9–G13 stay open as planned; they were only ever required to be filed before M0.

  • 2026-08-09G9–G13 resolved (issues #9–#13), the whole remaining no-crypto-review set, closed as one batch by engineering judgment. Three adopted as proposed (G11, G12, and G13’s core scoping); two adopted with a modification, both in the direction of specifying less. G9 takes the four constraints that make an async companion safe (sync mandatory and primary, byte-identical output and error codes, no sync-implemented-by-blocking-on-async, L4-only dependence) but declines the proposed function names: a companion is consumed by exactly one adapter written against exactly one core in one language, so no cross-implementation test can observe its shape, and pinning it would fix a surface before any implementation has shown what it should be. That also converts the docs/07 §6 risk from “G9 may need to escalate to a spec change” into a straight build decision for WS-C. G10 adds a ceiling-not-guarantee clause the draft lacked — the JVM cannot reliably allocate Integer.MAX_VALUE bytes, so a runtime failing below 2³¹−1 with an allocation error is still conformant and only the 2³¹-byte refusal is testable — which turns the draft’s [VERIFY] on per-language buffer maxima from a correctness risk into a per-core documentation obligation. G11 is a partial close and labelled as such: the index-id grammar is settled, but canonical_context injectivity stays with G4, and spec §6.1 says so explicitly rather than letting a closed sub-question imply a closed question. G12 and G13 were the two internal contradictions found in the suite’s own consistency review, so closing them removes the last places where the specification instructs two incompatible things; G13 additionally forbids the startsWith:-onto-a-prefix-index rewrite that scoping its MUST would otherwise invite. Two obligations landed outside the spec: docs/14 §4 gains an out_of_band report block (a bound verified by a test the report never mentions is indistinguishable from a bound nobody checked) and an async_companions flag with #async result ids. Marker sweeps completed across docs/08 (§4.3, §4.6, §5), docs/09 (§3.1, §3.2, §4, §7, §9, §11), docs/10, docs/11 (§2, §6), docs/12 (E002/E005), docs/13 (§1, §2, §4, §6), docs/14 (§4). Consequence: every spec gap that can close without a cryptographer is now closed. G1, G2, G4, G5, G7 remain — precisely the five flagged Yes or Partly — so the G-list has stopped being work and become a reviewer queue; it moves only when reviewers do.

  • 2026-08-08 — ADR-0001’s expressibility-mapping task delivered as first-pass evidence (docs/adr/0001-appendix-a-expressibility-mapping.md); headline findings: §6.3 dual-layer binding not expressible in the AWS format; per-cell embedding costs 1.4×–2.4× the fresh envelope. ADR remains OPEN pending reviewer input and the appendix’s own §8 verification items.

  • 2026-08-23First outside input on a gated question: CFRG replies on G7. Two substantive replies to the 2026-08-22 list post (Madden; Preuß Mattsson), recorded in docs/16 Q6 and docs/issues/G07. The expired draft-irtf-cfrg-xchacha-03 is archival and citable as it stands, so the citation problem was smaller than framed; and both replies recommend K' = KDF(K, E) then a standard AEAD for random extended nonces — which is §5.3 verbatim, with msg_seed as E. G07 therefore gains a third option, now proposed: re-base the second suite on RFC 8439 ChaCha20-Poly1305 under the derived key, with its purpose restated as a different hardness assumption from AES rather than “non-NIST”. Nothing is decided — the question stays with the reviewers, and a list reply is input, not review. A reply to the thread is owed and not yet drafted. Spec §4.4 gained the SP 800-38D r1 second-public-draft citation on non-96-bit IVs from the same thread.

  • 2026-08-22The Phase 0 exit gate split into Gate 0a / Gate 0b (PRD §8), because reviewer recruitment had not succeeded and a single gate was holding work that does not depend on review. Gate 0b — two credentialed reviewers — is unchanged in substance and now gates only the freeze: suite-identifier assignment, a non-provisional vector release, conformance claims against a frozen format, adoption, and the Phase 3 dissemination track. Gate 0a is closable by the project and gates implementation. Consequences recorded across the suite: new spec §4.8 Provisional suites (normative) reserving 0xFF000xFFFF, requiring affirmative out-of-band arming before a provisional suite may write (new error SUITE_PROVISIONAL, §9) while leaving decrypt ungated; the registry (§4.2) moved onto 0xFF01/0xFF02 with 0x0001/0x0002 reserved-but-unassigned; [PROVISIONAL] markers on §4.2, §4.6, §6.2, §7.3 and §9, each naming its tracker issue and its reviewer question; ADR-0001 provisionally decided (option C) and ADR-0002 provisionally deferred to the status quo; G1, G2, G4, G5, G7 provisionally adopted, not closed, and deliberately absent from the docs/issues/README.md closure log. §4.8 was appended rather than inserted mid-section, on the G8 precedent — §4.3–§4.7 carry live cross-references. Suite-identifier sweep applied to docs/08–11 and docs/14 (wire semantics) and deliberately not to the ADRs and issue drafts, which refer to the mandatory-suite slot rather than to bytes; docs/08 §4.2 additionally pins suite_id hex case, which did not matter while every digit was numeric. Residual risk is recorded in PRD §9’s top row rather than only here: implementation proceeds against provisional constructions, and a Gate 0b finding costs regenerated vectors plus a retired identifier.

  • 2026-08-22Outreach preparation surfaced a portability finding that outranks the question it was prepared for. While drafting a CFRG list post on G7 and a direct approach to Paragon Initiative on G2/G7, primary-source verification established that Argon2’s optional secret parameter K is not portable: libsodium’s crypto_pwhash exposes no secret parameter, Python’s argon2-cffi exposes it only through the ultra-low-level core() call its own documentation warns against, and Node’s node-argon2 exposes it as secret. G2’s proposed invocation layout routes index_key through K, so as proposed it is expressible in the TypeScript core and not in the Python core — a direct threat to the central cross-language claim, found before code rather than during M2. Recorded as a “Portability finding” section in the G2 issue draft with the source table, mirrored into docs/10 §2 (which also carries a naming trap: argon2-cffi’s secret= keyword is the password, not K) and docs/11 §2 (where it disqualifies sodium-native, previously a leading candidate for the sync raw-output requirement). G2’s review flag gains a third question that may dominate the original two. Separately, two citation errors were corrected in the G7 draft — a garbled “RFC 8539” for RFC 8439, and a claim that PASETO v2 and v4 depend on libsodium’s XChaCha20-Poly1305, when only v2.local does — and the observation recorded that PASETO’s author and the expired draft’s author are the same person, so that precedent is one party’s judgment rather than two. Both outreach items are drafted and logged in docs/16 §4 as not yet sent.

  • 2026-08-22G2 narrowed under Gate 0a; blind-index/argon2id.json unblocked. The portability finding above rules out routing index_key through Argon2’s secret parameter K — an engineering finding, not a cryptographic one, since no reviewer’s answer can make Python able to express it, so settling it needed no review. Spec §7.3 now pins the full invocation normatively (HKDF-SHA-512 → 16-byte salt; Argon2id v0x13, t = 3, m = 32 MiB, p = 1, out = 64), forbids K and X outright rather than merely omitting them (an implementation that “helpfully” adds a pepper would diverge silently), and states two costs plainly: keying now rests entirely on the salt, losing K’s defense-in-depth argument, and the salt is 128 bits rather than 256 because libsodium fixes the length at 16. p = 1 is likewise forced by libsodium exposing no parallelism parameter, with its internal value flagged [VERIFY] for Phase 1. The change is a deletion rather than a redesign — the salt derivation is unchanged from the original proposal — and what remains is CipherSweet’s shape plus an HKDF step so the raw index key never reaches Argon2 directly. Consequences swept: docs/10 §2 (argon2-cffi now viable; the secret=-means-password trap retained in the row), docs/11 §2 (secret-parameter support no longer constrains the backend choice, so sodium-native returns as front-runner and the decision turns purely on sync raw output), docs/16 Q3 (rewritten to one question), docs/issues/README.md (G2 row). G2’s review flag drops from three questions to one and the withdrawn question is marked withdrawn rather than answered. Effect on WS-A: every vector family is now authorable — the last blocked one was blind-index/argon2id.json.

  • 2026-08-22blind-index/argon2id.json held out of the pinned suite. docs/08 §7 named RFC 9106 §5.3 as the Argon2id known-answer check; that vector supplies a nonzero K and X, both forbidden by spec §7.3 and unsuppliable from Python, and no substitute has been found yet. The risk of shipping it anyway is specific rather than theoretical: with no external corroboration, both reference implementations would inherit the same unverified assumption from a single generator and agree with each other, and that agreement would look like the two-implementation rule being satisfied while establishing nothing. The mechanism is deliberately visible in four places, because a hold-out recorded only in the manifest is one a harness can miss — MANIFEST.json gains a held_out list carrying the reason and the unblocking condition (listed rather than omitted: a missing file reads as an oversight, a listed one reads as a decision); the vector file itself carries "status": "held-out"; docs/14 §4’s conformance report gains a held_out block whose status is not-run rather than skipped, since “skipped” already means “this implementation does not claim that suite” and a reader could mistake that for a capability gap rather than a suite-integrity decision; and docs/08 §§2/9 plus vectors/README.md carry it in prose. docs/14 §4 also gains provisional_suites, set whenever any supported identifier falls in spec §4.8’s reserved range, so that no report can be quoted as evidence of conformance to a format nobody has frozen.

  • 2026-08-22M1: the Python core passes the pinned suite (37/37), plus 26 gate and totality tests. One decision is worth recording beyond the code: a deliberate divergence from docs/10 §7, which anticipated the vector generator importing fieldseal.testing. It does not, and core/python imports nothing from tools/vector-gen; the core takes HKDF from pyca/cryptography where the generator hand-rolls it from hmac. Had they shared code, M1 would have been close to tautological — the same implementation checking itself — and a green run would have certified nothing. The cost is honest and stated at both ends: HKDF, canonical_context and the envelope layout now exist twice in this repository, and a spec change touches both. That is the price of M1 meaning anything before M2 lands, and M2 — an independently written TypeScript core reproducing these values from the specification alone (docs/11 §6) — remains the real check. Revisit only if the duplication starts drifting rather than being caught. The gate tests cover what positive vectors structurally cannot, since a gate is only proven by the operation it refuses: the §4.8 provisional refusal on encrypt/rotate and its deliberate absence on decrypt, the §10.3 readonly refusal, the FIELDSEAL_TEST_MODE arming gate and the negative-import rule, is_ciphertext totality on arbitrary input, and a typed error for every prefix of a valid envelope.

  • 2026-08-22The M2 independence rule became a protocol (docs/17-m2-implementer-brief.md). It had been one sentence in docs/11 §6 — “works from spec + docs only, no reading the Python source” — which is correct and insufficient, because the sentence does not survive paraphrase and the part that gets dropped in paraphrase is the part that does the work: the order in which the implementer works. Seeing an expected value is unavoidable, since every vector file carries inputs and outputs in one object; what protects M2 is implementing from the spec first, and then treating a mismatch as a finding to record rather than a number to tune toward. Without that, a second core silently reconciles itself to the first and the two-implementation rule certifies nothing while appearing satisfied — the same failure that put blind-index/argon2id.json on the held-out list, arriving by a different route. The brief also names the prohibition as concrete paths (core/python/**, tools/vector-gen/**) rather than as “the Python source”, requires the divergence report to be delivered even when empty, and states that a disclosed compromise is recoverable while an undisclosed one makes every downstream conformance claim false. Written to be handed to a person or a fresh session; the language-specific rows are marked so the third and later cores can reuse it. docs/11 §6, docs/07 M2, README.md and AGENTS.md all point at it.

  • 2026-08-22M2: the TypeScript core reproduces the pinned suite from the specification alone (42/42, both directions, intermediates agreeing), built under the docs/17 protocol; the divergence report is docs/18-m2-report.md. No expected value was tuned and no mismatch occurred — the project’s central claim survives its first real test for provisional suite 0xFF01, with the independence caveat stated in docs/18 §1: the isolation was session-level, not author-level, which this plan’s §3 anticipated and said to declare. The empty mismatch list is the less interesting half of the report. The other half is twenty recorded ambiguities, of which three matter most: the commitment construction is not in the specification (§4.6 carries the [PROVISIONAL] marker but no formula; the G1 issue draft’s proposed direction was implemented and the vectors agree — D-01); the vector files drift from docs/08 in four places an implementer had to bridge by hand (no suite_id in context/; kdf/index-key carrying purpose = "encrypt" beside a separate index_id; blind-index/ field names and the unversioned normalizer id nfc-casefold; assertion vectors with no inputs — D-05..D-08); and three behaviors no vector can see could split two cores on real data (post-fold NFC and invalid UTF-8 under nfc-casefold-v1, rotate() of unmigrated plaintext in permissive mode, and which fmt_ver bytes earn UNKNOWN_FORMAT_VERSION — D-10, D-13, D-03). Two findings outrank the dependency question docs/11 §2 called the hardest in either core: Node ≥ 24.7 ships crypto.argon2Sync, so the TypeScript core has zero external dependencies at the cost of a Node floor of 24.7; and that backend reproduces RFC 9106 §5.3 exactly because, unlike libsodium and argon2-cffi, it accepts the vector’s K and X — the external known-answer check MANIFEST.held_out calls unreproducible “on this stack” is reproducible on the Node stack (D-15). Whether that satisfies the hold-out’s unblocks_when is a decision for this log, not for the report; the family stays not-run. CI gains a typescript-core job emitting conformance-typescript.json beside the Python report. errors/ and cross/ still do not exist, so M3 remains blocked on WS-A, not on either core.

  • 2026-08-22Post-M2 review found the first cross-core read failure, and it was not in the suite: HKDF info is unbounded and Node caps it. The TypeScript core passed 42/42 and could not derive the key for an envelope the Python core wrote under a 2000-byte tenant_idnode:crypto.hkdfSync (and Web Crypto deriveBits) refuse an info over 1024 bytes, and info is canonical_context, whose optional fields §6.1 leaves unbounded. The suite missed it because the largest tenant_id in vectors/ is 11 bytes and docs/08 §4.3’s boundary lengths stop at 64. The core is fixed (PR #42: RFC 5869 over createHmac, pinned to the RFC’s A.1–A.3 answers and to hkdfSync wherever it accepts the input; round-trips to 70 000 bytes; property generators now draw lengths uniformly instead of short-biased). The spec question is filed as G14 (tracker #43, docs/issues/G14): verified from source the same day, OpenSSL 3.0–3.5’s EVP_KDF HKDF caps info at 32 KiB and .NET on Linux routes System.Security.Cryptography.HKDF through it, so a Phase 2 .NET core would inherit a bound that differs between its Linux and Windows hosts; Java, Go and pyca hand-roll HKDF and do not cap. Proposed direction: bound each optional field (255 B) so the entire encoding fits under the smallest known cap with headroom for §6.2’s extension bits, refused at the API boundary like LENGTH_EXCEEDED; the alternative — leave the fields unbounded and require every core to hand-roll HKDF — is recorded as the fallback. No cryptographic review needed, but it should reach the Phase 0 reviewers before closing because it sizes the extension headroom Q4 asks about. Two generator obligations fall out regardless of the outcome: emit the 64-byte boundary docs/08 already asks for, and add a maximum-context vector to context/, envelope/ and both kdf/ files.

  • 2026-08-23The Python core brought to the TypeScript core’s pins (PR #47), the G1 construction written into the spec, and the remaining pins filed as G15. Three corrections to the record above. (1) Counts. The M1 entry’s “37/37” counted the six MANIFEST.json hash checks alongside the 30 vectors and was off by one even so (6 + 30 = 36); both conformance reports now count 42 — 30 vectors, 8 envelope/ decrypt-direction results (#decrypt) and 4 blind-index/ pipeline results (#pipeline) — with manifest mismatches aborting the harness rather than appearing in results (docs/14 §4). The two reports carry identical result ids and differ only in pinned_decisions’ ninth key. (2) G1. The commitment formula — HKDF-SHA-512 over record_key, empty salt, label fieldseal-commit-v1, 32 bytes, verified constant-time before AEAD open — existed from 2026-08-22 only in docs/issues/G01 and the two cores while docs/issues/README.md said it was in §4.6; it is in §4.6 now, under the existing [PROVISIONAL — G1] marker, with what it commits to and what stays open stated. The README’s G5 row is corrected the same way: §9 pins a rule (each core declares its order), not an order. (3) Cross-check. docs/10 §7’s “real cross-check” between the generator and the Python core is narrowed to HKDF — the two share canonical_context, envelope assembly and the commitment label by construction. G15 (#48, docs/issues/G15) files the four behaviors both cores had to pin with no spec text behind them (D-03, D-10, D-13, D-14), two of them with a proposed direction that reverses the cores’ current pin.

  • 2026-08-23Vector suite 0.2.0-provisional: the errors/ family exists, the G14 lengths are pinned, and both cores pass 127/127 with identical result ids. What the regeneration changed, in order of weight. (1) errors/ — 66 vectors in format.json, policy.json, crypto.json, every expectation taken from spec §3.4/§9/§10.3/§4.8 and the docs/09 §3.2 order both cores declare; G5- and G15-dependent outcomes carry provisional_on; the cases two readings of the text both defend (rotate on plaintext in permissive, AAD_MISMATCH) are listed as withheld rather than guessed. It includes the invisible salamander — a GCM ciphertext and tag valid under two record keys, built by solving one linear equation in GF(2^128) (gcm.py) — which is the first vector to show §4.6 refusing what the AEAD would have accepted. Both cores agree on every case, so the declared decrypt order is now a tested agreement. (2) G14 lengths — 255- and 2000-byte tenant_id/row_id in context/, envelope/ and both kdf/ files, plus the 1/16/64-byte boundaries and the anti-forgery case docs/08 §4.3 had asked for since 08-08; the 2000-byte envelope is the one the TypeScript core could not open on 08-22. (3) Per-vector seeds and nonces — suite 0.1.0 shared one seed and nonce across every envelope vector, so six vectors with the same context were AES-GCM nonce reuse under one record key, shipped as test data; now derived per id and asserted unique. (4) Format drifts D-05 to D-08 resolved as docs/18 asked; blind-index/ takes docs/08 §4.4’s shape with nfc-casefold-v1, b ∈ {12, 15, 16, 21, 30}, and a U+01F0 vector pinning no post-fold NFC; commitment/’s “one bit” description is now true. (5) keys/test-keys.json defines the key_ref format cross/ needs (docs/08 §4.7), under a new MANIFEST.support list. (6) Spec §7.3’s [VERIFY] on libsodium’s parallelism is closed from source (pwhash_argon2id.c passes the literal 1). Both harnesses were updated to the new shapes — plumbing only; no expected value was tuned in either direction, and the independence rule held (the TypeScript core was exercised through its harness and public types). Still open from item 4: cross/ static producers (core work), D-15 (a project decision on the Argon2id hold-out), and the G15 part B/D vectors once the issue settles.

  • 2026-08-23The N×N cross job exists: CI now tests the central claim in both directions on every run, nightly included. conformance.yml gains cross-produce / cross-consume (docs/14 §3): each core encrypts the 16-case shared corpus — vectors/cross/corpus.json, generator-emitted under MANIFEST.support so the two producers cannot drift apart on inputs — through its production path (runtime CSPRNG, fieldseal.testing never imported), uploads the file as an artifact, and every core decrypts every producer’s artifact, self-pairs included: 64 pair-cases per run, among them the 2000-byte-context envelope whose one-directional failure started G14. Producers/consumers: core/python/tests/cross_{produce,consume}.py, core/typescript/tests/cross/{produce,consume}.ts (npm cross:produce/cross:consume); verdict files are uploaded per consumer and a failed pair fails the workflow. Static cross/static/ files wait for a first release to pin, as docs/14 §3 defines them per release. Alongside it, the smaller CI debts: pip dependencies are pinned exactly (core/python/constraints-ci.txt, used by every Python install in the workflow) so a primitive bump is a chosen diff rather than background drift; the suite-integrity job now hashes held-out files too; a nightly schedule catches toolchain drift; and python-lint runs ruff (against a new project [tool.ruff] — the earlier “21 findings” were a user-level config’s opinion; the project config’s answer was 9, all import-sorting, all auto-fixed) and mypy --strict (clean). First full N×N run: 64/64.

  • 2026-08-24G15 closed (#48): the four behaviors both cores had pinned with no spec text behind them are now specification. Parts A, B and C landed as filed, plus three additions the closure surfaced: §3.1 reserves 0x02 and floors a future format version at 111 bytes (the length gate had otherwise been assuming a layout for a format nobody has written); §3.4 states the three-way recognition, its asymmetry, and why it is decided opposite to the retired-suite case; §11.1 makes rotate ciphertext-to-ciphertext in every mode, which reverses both cores; §4.8 names FIELDSEAL_ARM_PROVISIONAL_SUITES, requires the value to be byte-exactly 1, and adds the either-arms rule (in-code false beside a set variable still arms — the opposite reading is equally defensible and equally invisible, which is why it had to be written down). Part D item 4 closed the other way from its proposal, and the method matters more than the outcome. The issue proposed nfc-casefold-v1 = toCasefold(NFC(X)) with no post-fold normalization; it is NFC(toCasefold(NFC(X))), because the proposed composition was measured to leave eight assigned Greek code points (U+0390, U+03B0, U+1FD2/3/7, U+1FE2/3/7) failing to collide with their own uppercase spellings — a silent lookup miss within a single core that no cross-implementation vector could ever see. Three earlier memos had all endorsed the proposal, one of them claiming verification via U+01F0, which happens to be a case that works. The lesson recorded for reuse: when an issue proposes a normalization or encoding behaviour, measure the property it is for (do case variants collide?) rather than checking the property it states; agreement among reviewers is not evidence when they share the issue’s framing. Both cores now vendor UCD 17.0.0 folding and normalization through a new tools/ucd-gen/, with platform-narrowing explicitly non-conformant; three independent UAX #15 implementations exist in the repository (the generator imports neither core) and Python plus the generator pass the official NormalizationTest.txt 60,102/60,102. Four pinned_decisions keys retired into the text — unknown-format-version-set, provisional-arming, rotate-in-permissive, normalizer-text-over-bytes — so both conformance reports carried identical key sets for the first time, at 140/140 each.

  • 2026-08-25G16 closed (#60), the first issue since G13 with no reviewer dependency in either direction. It came from the consequences of closing G15 rather than from any core’s behaviour: neither core was wrong under the text as it then stood. Part A inverted docs/09 §7.1’s note — an index-derivation API MUST accept the language’s text type. The obligation it replaced (“a bytes-only core MUST expose the assigned-code-point check, and an adapter encoding on its behalf MUST apply it”) was unenforceable by construction: advice to a frame the core cannot inspect, where a missed call raises nothing and fails no test — and the TypeScript core actively countermanded it, refusing string with an error naming TextEncoder, which substitutes U+FFFD, so encode("a\uD800b") and encode("a\uDC00b") are the same five bytes. A false-match primitive in the one feature built to prevent false matches, one frame above where any vector could see it. blindIndex now takes string | Uint8Array; Python’s blind_index had accepted str all along, and the asymmetry with bytes-only encrypt is now stated as principled rather than tidied away — normalization is a text operation and encryption is not. One vector obligation could not be met, and the reason outlives the vector: the lone-surrogate case is not portably expressible (Go string literals may not hold a surrogate value; Rust’s String is UTF-8 by invariant), so it is the out_of_band entry docs/09/7.1/lone-surrogate-refusal, asserting that two distinct surrogates are refused distinguishably — an identical diagnosis would leave them indistinguishable to the caller, which is the property the refusal exists to deny them. docs/14 §4 now separates the two exclusion reasons: §3.5 is excluded by size, an accident of the harness; this one by representability, which will not go away. Part B added normative docs/09 §7.2 — on_unindexable = refuse | bucket — because encrypt does not normalize and blind_index does, so a post-pin character stores perfectly well and cannot be fingerprinted, leaving the adapter a choice (refuse the write, or store a row invisible to every indexed lookup) that no document made, the second being the pattern spec §10.2 forbids by name. The marker is truncate(IDF(index_key, 0xFF ‖ "fieldseal-unindexable-v1"), b): 0xFF is never valid UTF-8, so it cannot collide with an accepted value by construction rather than by luck, and deriving it under the column’s own index key stops it advertising which rows hold an out-of-pin character — an all-zero constant would have announced exactly that to anyone able to read the column, with no key at all. A design correction is recorded in §7.2 rather than substituted quietly: the issue had every lookup additionally probe the marker, which is unnecessary and strictly worse — normalization does not care which direction a value travels, so a query for an unindexable value derives the marker unaided and §7.5 re-verification narrows the result; the bucket is one more §7.4 collision class, not a new mechanism, and the original would have doubled every query’s candidate set for nothing. Its cost is stated rather than mitigated away: the bucket is distinguishable by frequency and growable by any writer, bounded by re-verification cost rather than by anything cryptographic — hence the {reason, approved_by, date} ceremony §7.6 already requires, and a ConfigurationError when declared on a normalizer that can never refuse. The adapter obligation landed in docs/12 §10 and docs/13 §9, closing the dangling “§12” pointer. Part C gave the pin a currency policy: before the freeze nfc-casefold-v1 tracks the latest released Unicode version and moves in place; after it, the same move MUST mint a v2 and come with a planned re-index — so the cost jumps at the freeze rather than rising with stored rows, and deferring the bump saves nothing. The generator guard was a live trap: Public/18.0.0/ucd/ 302-redirects to Public/draft/ucd/ over plaintext HTTP, urllib followed both by default, and download() printed the URL it asked for, so setting VERSION = "18.0.0" would have built tables from a moving draft, labelled them 18.0.0, and logged a fetch that never happened — surfacing weeks later as an unexplained --check failure. There is no published digest for these files, so the transport and the URL are the only things authenticating bytes that decide every blind-index value. generate.py now refuses redirects and non-HTTPS hops and verifies served-equals-requested; tools/ucd-gen/test_generate.py holds the guards in five offline checks (no pytest, so a flake cannot mask a regression in the guards themselves) and runs ahead of regeneration in the unicode-tables job. The 18.0 bump is deliberately not done and could not be — 18.0 is not released, and under the rule it cannot be adopted until it is; no tracker carries it because the rule is self-executing and the guard makes an accidental draft-pin impossible. Part D asked whether any of this belonged in the Gate 0b packet and answered no — Part A reads cryptographic (a false match in a blind index) but decides a parameter type, changing no construction, derivation or stored byte, and the refusal rule’s breadth turns on whether Unicode’s allocation practice is a guarantee, for which a cryptographer is the wrong expert. Kept as a part rather than deleted so the question is on the record as asked and answered. Vector suite 0.2.0 → 0.3.0-provisional, three blind-index/ vectors, both cores 144/144. Landed as PR #61, which also carried an unrelated finding worth its own line: the Python core enforced none of the declaration-time gates the TypeScript core enforced — no §7.4 band, no §7.6 cardinality gate, no skew check, no override ceremony — although docs/10 §4 had specified exactly that API from the start. The implementation had drifted from its own binding document, which is why two independent reviewers landed there separately.

  • 2026-08-25Argon2id cost parity (#62, PR #63): a “minimum” one core could raise and the other could not read. §7.3 states the Argon2id cost as a minimum, and the TypeScript core carried it per column (Argon2Params{timeCost, memoryKib}) while the Python core hardcoded ARGON2_TIME_COST = 3 / ARGON2_MEMORY_KIB = 32768 as module constants read directly by idf_argon2id, with IDFS typed as a dispatch table that had no slot to pass them. The values agreed, so nothing failed — but an operator raising the cost on TypeScript would have got values Python silently derived under t = 3: both cores conformant, every cross-implementation lookup returning nothing. Distinct from G2, which is the specification gap and explicitly says deployments may raise the parameters. Closed by taking the TypeScript shape: Python gained Argon2Params(time_cost, memory_kib) on IndexDeclaration.argon2, resolved onto ValidatedIndex and validated clause-for-clause against validateIndexDeclaration, and IDFS was re-typed from a dict[str, Callable] to a frozenset[str] with dispatch moved into idf(which, index_key, normalized, argon2) — the two IDFs do not take the same arguments, so a uniform callable table was the wrong shape in the first place. Two TypeScript harness paths were also dropping idf_params (the assertion runner and the unindexable-marker client) and now share one reader, so the fix was not one-sided. New normative text: docs/09 §7 and §12 (every core MUST take t/m per declaration) and docs/08 §4.4 (a harness MUST read them from the vector). Cross-core agreement at a raised cost still has no vector, because blind-index/argon2id.json stays held out until G2 closes; it is recorded as a G2 vector obligation rather than shipped, and both cores gained the unit test the module constant had made unwritable (raised cost ⇒ different index; absent ⇒ the minimum). Two process notes from the review rounds, both worth reusing: one reviewer reviewed the wrong diff, describing G16 files absent from the 11-file PR — verify a scope claim with gh pr diff N --name-only before acting on it — and GitHub’s Closes #62 did not auto-close on merge into dev even though dev is the default branch, so the issue was closed by hand. Check that after every merge.

  • 2026-08-25G17 closed: the KeyProvider ownership contract, and a filed defect that turned out not to exist. docs/09 §8.1 gave three signatures and never said who owns the byte strings they return, while §3 prescribed erasure at four points without saying what a core is entitled to erase. Closed by stating the rule in the direction both cores already implement: key material is provider-owned, a core MUST NOT mutate or retain it and MUST copy first if it wants something to erase; candidate reads do not count against §8.3’s max_uses (§8.3 stated use counting only from the encrypt side, so the read-path half was an inference). The converse rule was rejected as one a conformant core could not meet — a binding whose key material is an immutable type cannot erase anything. §3 gains a preamble qualifying its erasure steps by mutability and folding them into §8.3’s existing per-language honesty obligation; docs/10 §5 now names the two steps the Python core cannot perform (kdf.record_key() returns bytes); docs/11 §5’s “documented exception” becomes conformance with a stated rule. The issue was filed with a defect claim that is false, and it is withdrawn in place rather than quietly repaired. Part A asserted that api.ts:162’s ek.key.fill(0) erases the provider’s buffer, so a provider returning a reference to its own cached DEK would lose it on the first write; it erases a copy that #encryptionKey makes while validating the return (api.ts:182), and the blind-index path uses the same helper. The error came from reading the encrypt body without following the helper one frame down. What caught it was the regression test the issue’s own What it breaks section required to be written first: a provider handing out references to its own buffers, driven through encrypt, decrypt and blindIndex — it passed against unmodified code. That is the argument for the rule arriving by the wrong route: buffer lifetime is invisible to every vector and to the pseudocode a reviewer reads, so only an executable check could settle which reading of the file was true. The surviving finding is narrower and real — the safety of two .fill(0) calls rested entirely on a copy that exists for validation, stated in no document and asserted by no test, one ordinary refactor from becoming the bug that was reported. Both cores gained those tests and a key-material-ownership pinned_decisions key (docs/14 §4), the only executable form this requirement has; the Python harness also gained the docs/14 §4 required-key assertion the TypeScript harness has carried since G15, whose absence is how a report could have dropped a key with nothing noticing. No src/ file changed in either core. Python 278 → 294 tests, TypeScript 281 → 287, ruff and mypy --strict clean, both reports 144/144 with identical key sets and identical out_of_band entries.

  • 2026-08-25M4 begins: the Django adapter’s value path (L1) ships, and equality is deliberately refused. adapters/django (fieldseal-django, import path fieldseal_django) implements docs/12 §1–§2, §4 and §5 against Django 6.1 on Python 3.14: Encrypted(inner_field, column_uuid=...) composing over any Django field type, the explicit blind-index sibling, the tenant contextvar, seven system checks, and a codec that is fixed and non-executing (never pickledocs/04 §8’s Rails Marshal RCE lesson). 43 tests, ruff and mypy --strict clean, and a new django-adapter CI job running the suite on both SQLite and Postgres per docs/12 §8, with an AD-1 grep that fails the build on any cryptographic import under adapters/*/src — spec §11.3’s zero-cryptography rule is the kind that decays one convenient import at a time, so it is asserted rather than left to review. Equality lookups raise, on both columns, and that is the substantive decision. Shipping half of L2 would be worse than shipping none: a direct comparison against a randomized envelope returns an empty queryset rather than an error, and matching on the index column alone returns collisions, because spec §7.4 mandates them and §7.5 makes re-verification mandatory in response. Either would be a wrong answer, which spec §10.2 forbids. The index column is written correctly meanwhile, so enabling L2 needs no backfill. Three findings from implementation, each recorded rather than absorbed. (1) docs/12 §2’s expression row is corrected: “any expression RHS raises” would refuse bulk_update, which §6 lists as supported and which docs/15 requires backfill to use — bulk_update passes a Case/When to get_db_prep_save, and Value.as_sql re-enters the field with each literal and encrypts it. The refusal now tests the written expression rather than the whole class. (2) E006 is downgraded to a new W004: docs/12 §5 specifies an error when FIELDSEAL['CLIENT']’s index registry does not match the model declarations, and the core exposes no public accessor for a client’s validated registry, so the check could only be written against a private attribute. A check that depends on another package’s internals fails silently when they move, which is worse than the gap it closes; the warning names the gap instead, and the missing accessor is a docs/09 §8 follow-up. (3) The import path is fieldseal_django, not docs/12 §1’s fieldseal.django — the latter needs the core to become a namespace package or the adapter to ship inside the core distribution, and the second would put adapter code in the package that holds all the cryptography. Also deferred with reasons stated in the package README: L2 rewriting and §7.5 re-verification (the _fetch_all-versus-.verified() decision docs/12 §3.2 leaves to implementation), the fieldseal_gen_uuids command, L3-row binding, and warm() wiring.

8. What Phase 1 deliberately does not build

Java/.NET/Go cores and their adapters (Phase 2) · TypeORM/Sequelize adapters (deferred, PRD §8) · benchmarks beyond the demo app’s incidental numbers (bench/ methodology work is Phase 2, DO-4) · any hosted service · GUI/console tooling beyond the two CLIs in docs/15.