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 0b — at 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-11-04 — #250, review round on #267: three corrections to the Prisma adapter’s multi-row refusal and the write path beside it. (1) A nested createMany whose data was one row, not an array, was written as given — older than #250, in the hunk #267 rewrote. Prisma types the envelope’s data as X | X[] and the write pass unwrapped only the array. Measured (Prisma 7.10.0, SQLite 3.53.2): patient.update with visits: { createMany: { data: { reason: <Uint8Array> } } } stored the plaintext bytes in Visit.reason, and the next read raised NOT_CIPHERTEXT. Nested create and createMany now take separate branches. (2) An updateMany whose where pins a unique key is served. It matches at most one row, so it shares no envelope; refusing it broke Prisma’s usual “update if it exists” and sent callers to sharedEnvelopeScope for nothing. The field map gains an optional per-model uniqueKeys (no version bump: a map without it keeps every updateMany refused). (3) The reject pass’s updateMany remedy no longer leads into the #250 refusal. “Act on their primary keys” now says one update per row, and the candidateScope hint says a payload writing an encrypted column also needs sharedEnvelopeScope. Consequence sweep: docs/13 §1, §2.1 and §6; the adapter’s README and REFERENCE.md. Suite green on SQLite and Postgres 17.

  • 2026-11-03 — #250 fixed: the Prisma adapter refuses a multi-row write that would encrypt a declared column, and sharedEnvelopeScope() is the explicit opt-in. Measured (Prisma 7.10.0, SQLite 3.53.2, the adapter’s fixture): updateMany matching three rows on Patient.email stored one 120-byte envelope, byte for byte on all three; the control, three update calls by id, stored three distinct envelopes. The cause is structural, not an ordering bug: src/visitor/write.ts encrypts data before the engine sees the statement, and Prisma’s updateMany/updateManyAndReturn carry no per-row payload, so there is nothing to encrypt against. The bytes are not a nonce reuse — key, nonce, plaintext and context are identical — but on a column with no blind index the matched rows become visibly equal, which is what randomized encryption is bought to prevent, and it cannot be reconciled with AGENTS.md’s “fresh nonce on every write, including UPDATEs” or with row binding later. Decided (maintainer, of three options: refuse with no hatch; refuse plus scope; allow and document): refuse by default, with an explicit opt-in scope. Every existing hatch admits a failure that is recoverable or measurable after the fact — candidateScope hands back a superset to narrow, cardinalityOverride and on_unindexable record a human approval — whereas a shared envelope is neither, and no later read recovers per-row randomization; so the scope is a separate, deliberate acknowledgement rather than a flag on the statement. Implementation: the refusal is in the write pass at the encryption point (after unwrapSet, and after the undefined and null early-continues, so an omitted field and a NULL — which duplicate no envelope — are not refused), because it depends on the same unwrap rules and runs under the journal that restores the caller’s args; it names the column, the statement, spec §3.1/§4.4 and the alternative (update with a unique where, or the scope). The shape is threaded down the relation graph, so a nested updateMany under an update is refused and names itself rather than the operation above it; nested create/createMany/connectOrCreate.create reset the flag, since each new row has its own payload. src/multiwrite.ts adds sharedEnvelopeScope(fn) and an internal inSharedEnvelopeScope(), modelled on candidates.ts including the dispatch caveat: a Prisma promise is lazy, so the scope awaits inside and a promise the callback merely constructs dispatches after it closes and is still refused; src/index.ts exports the scope only. What the scope does not change: the payload is encrypted once with a fresh msg_seed and nonce, index siblings are still derived for every declared index, and there is no second write path. Pinned: tests/multiwrite.test.ts, 13 tests — refusal of updateMany (indexed column, unindexed column, the { set } form) and updateManyAndReturn, a nested updateMany, and inside an interactive transaction; that each writes nothing (the raw column before and after); that a payload touching no encrypted column, null, and createMany are served without the scope; and the scope’s four tests (one identical envelope on every matched row with the index still derived and lookups still working, no leak out of the callback, a statement constructed outside but first awaited inside, and the construct-inside/await-outside escape). Four updateMany tests in tests/bulk-index.test.ts and one in tests/value-path.test.ts moved inside the scope, keeping their index assertions. Bite checks (green control first, each reverted afterwards): removing the refusal from write.ts fails 8 of the 13 tests/multiwrite.test.ts tests — the six refusals and the two scope tests that need a refusal to observe (no leak, construct-inside/await-outside) — and leaves the five that pin what is not refused or what the scope does; making inSharedEnvelopeScope() always false fails 12 across 3 files (the scope tests, the updateMany index tests that now run inside it, and value-path). Suites: adapter npm test 435 passed and 4 skipped, 14 files, on SQLite only — Postgres was not run locally for this change; npm run typecheck (sources and tests) clean; the TypeScript core was built from this checkout first, and the run used Node 24.22.2 because this machine’s default Node 22 cannot load node:crypto’s argon2 (the package requires ≥ 24.7). Docs corrected in the same change: docs/13 §2.1 (the refusal and the scope, with the mechanism) and §6 (the write row now excludes updateMany, a refusal row and a scope row are added, and the backfill row stops calling batched updateMany a blessed write path); adapters/prisma/REFERENCE.md (coverage rows, plus a sharedEnvelopeScope section beside candidateScope); adapters/prisma/README.md (features, a Multi-row writes section, and a limitation stating the stored equality is not recoverable); tools/backfill/PROCEDURE.md §6.3 rules 2–3 (a backfill uses per-row update and MUST NOT open the scope); docs/15 §1.1 Safe writes; this entry. No spec change: §3.1 and §4.4 are worded per encryption operation and the statement performs one; what changed is that the adapter now refuses a write shape it cannot make per-row, which is §10.2’s throw-rather-than-degrade, not a new rule. Not done: a Postgres leg for the new tests on this machine (CI runs both), and any decision about whether another adapter’s bulk path (Django bulk_update under a shared payload, or a future ORM’s updateMany) needs the same refusal — each frontend answers that as it is built.

  • 2026-10-05 — #254, to-do 1: the Python core refuses an operand of the wrong type on every operation, with one code (#256). After #251, one non-bytes operand got four answers from this core: INVALID_ARGUMENT from decrypt, NOT_CIPHERTEXT from rotate, a raw TypeError from encrypt, and from blind_index(42) the index of 42 NUL bytes with no error. Now: encrypt, decrypt and rotate take bytes, meaning bytes, bytearray or a memoryview that is one-dimensional, contiguous and of format 'B'; blind_index and the public normalize take bytes or str. Anything else is InvalidArgument, in every read mode. is_ciphertext stays total and answers False. What stops working: array.array, mmap and objects with __bytes__ were accepted by encrypt (the first two) and blind_index (all three) and are now refused, as decrypt has refused them since #251; memoryview(...) over the first two is accepted. A view of wider items, a strided view, a multi-dimensional view or a released one is refused too. On dev (so not a regression of this change) the first three reached the AEAD, which raised a raw TypeError or BufferError after encryption_key had been called; len() undercounts a 2-D view against §3.5; and blind_index indexed a wide view’s machine-endian bytes with no error. Found in review. The rule is the format, not each operation’s tolerance, so it also refuses views that worked on dev: a view over a ctypes byte array (format '<B'), a cast('c') view, and a strided view in decrypt’s pass-through and blind_index; .cast('B') or bytes(...) gets through. is_ciphertext still answers True for an envelope held in a '<B', strided or wide view that decrypt now refuses; filed as #257, not decided here. Fixed beside it (review round 2): recognize and is_ciphertext were not total. A released view raised ValueError from both, against spec §3.4 and their docstrings, because _view caught only TypeError; it now catches both. This was on dev too, and it shares a root cause with #257: two notions of a usable byte view in one module. Module graph: the operand predicate (is_byte_string) lives in blindindex, which api already imports; round 1 had put it in envelope, an edge from blindindex that docs/09 §1 forbids. The six edges this core already had outside that rule (four clauses) are now recorded in docs/10 §3 rather than fixed, and so is that section’s stale layout block. Order, decided at implementation: on encrypt and rotate the operand check runs after MODE_VIOLATION and SUITE_PROVISIONAL and before LENGTH_EXCEEDED and the context (the existing rule that refusals following from configuration come first), so readonly still answers MODE_VIOLATION for any operand. On blind_index it runs after the declaration is resolved and outside the on_unindexable handler, since bucket would otherwise file an int with the rows the Unicode pin cannot define. rotate now hands its operand to the write boundary instead of b"", with no §3.5 bound (its operand is an envelope); its context check still precedes recognition. Two readings, not spec text, revisitable at Gate 0b. (1) Spec §11.1 says, normatively, that “rotate of non-envelope input raises NOT_CIPHERTEXT”. rotate(42) now raises INVALID_ARGUMENT, an implementation-local code docs/09 §9 permits and §11.1 does not name. The reading is that the sentence is bounded by §11.1’s own bytes signature: an argument outside it is outside the operation’s domain, not non-envelope input. This changes rotate for such an operand from NOT_CIPHERTEXT to INVALID_ARGUMENT in strict and permissive, as #251 did for decrypt in strict, and it is the second decision resting on that reading. (2) §11.1’s signature block types blind_index(plaintext: bytes); accepting str rests on docs/09 §7.1 (G16 part A), not on §11.1. Both are declared in pinned_decisions.api-boundary-order. Declared: api-boundary-order names the operand step for encrypt/rotate and blind_index, and the two readings. decrypt-order names the operand step #251 added, which the declaration had omitted; it was added here with the #254 thread’s three-way comparison in hand (below). The comparison also suggested replacing this core’s recognition lengths (len<51) with another core’s phrasing; that was declined as a cross-core question for to-do 3. docs/14 §4’s rows for both keys now say a core that refuses an operand before recognition names that step, and that blind_index’s operand step sits in api-boundary-order until to-do 3 decides a key of its own. Left alone, by choice: docs/09 §3.1 and §3.2 have no operand-type step, though both declarations now name one. Adding it is a change to the language-agnostic architecture and belongs to to-do 3. Disclosed exposure. The third comment on #254, posted before this work began and read at its start, states that its author read core/typescript and core/java. It quotes both by file and line: TypeScript’s operand guard in recognize and its isCiphertext throwing on a non-bytes operand; Java’s decrypt-order naming an operand and a context step, and its api-boundary-order test naming an operand step; and a three-way table of where each core validates the context on decrypt. No other core’s tree was opened by this work. The rule and the order for encrypt, rotate and blind_index were decided from spec §11.1, docs/10 §4, docs/09 §3.5 and §7.1 and this core’s own boundary rule. But both declaration keys this change edits are keys where that comment showed another core’s value. So, as docs/28 §1 says of docs/18, agreement between this core and another on decrypt-order or api-boundary-order is not independent evidence. Pinned: core/python/tests/test_parity.py adds 158 test cases and extends the seam test. With core/python/src reverted to dev, 114 fail: every refusal, the released-view totality cases and the seam test. The other 45 pin what is kept: readonly’s MODE_VIOLATION (10), bytes-likes still accepted (4), is_ciphertext total on the operands that already were (21), rotate’s context before recognition (1), decrypt refusing the other buffer types (3), and a refused view accepted once cast or copied (6). Bite checks, by mutation, each between green control runs: is_byte_string accepting any memoryview fails 46 (the view refusals and the released-view ones); _view catching only TypeError fails 5 (the released-view totality cases); describe_operand without its released branch fails 4. Moving blind_index’s check to the first line inside the try fails 1, the bucket test, so one test pins the placement. Deleting it fails 22: the bucket test, and 21 refusals that match on blind_index’s message where the normalizer’s own check now answers. Suites (local scratch venv, CPython 3.13.16 on arm64 macOS; CI runs 3.12): core 546 passed; report 193 of 193; ruff and mypy --strict clean; Django adapter 641 passed and 4 skipped on SQLite, unchanged in behaviour, since it hands blind_index only str or codec bytes (not run on Postgres locally). Not done: to-do 2’s changes in the other cores. TypeScript’s decrypt and isCiphertext answers for these operands are measured in the #254 thread; its rotate and blind_index, and Java beyond decrypt and isCiphertext, are not. Also not done: to-do 3, and a separate issue for the three-way context-step difference the thread found. No vector carries a typed non-bytes operand. Review round 3, addressed 2026-10-16: two nits, both in the documents rather than the code. (1) docs/10 §3 said “four edges” for four clauses, the last of which names three targets; it now says six edges, written as four clauses, and the sentence above that repeated the four now repeats the six. (2) The suites line named an interpreter the review could not reconcile with the CPython 3.14.6 in this thread. The 3.14.6 is the review’s own platform — every comment in the thread that names it is a review comment, and the round-1 comment says the PR used 3.13.16 — while the 3.13.16 is this entry’s: a scratch venv over a separately downloaded CPython 3.13.16 for arm64 macOS, 3.13.16 (main, Oct 3 2026, 00:54:36) [Clang 22.1.3], made because this machine’s python3 is 3.9.6 and the checkout had no venv, so the runs were made on the newest interpreter the session could fetch rather than on the 3.14.6 of the two 2026-09 entries. The review re-ran this table on its own CPython 3.14.6 (Windows) and reports the same 546 passed and 193 of 193 from it, so both totals stand on two interpreters; #257’s “Measured at #256’s head, CPython 3.13.16” is that same scratch venv. The suites line now names its build and its platform.

  • 2026-10-05 — #251 fixed: a binary column’s legacy values that are not bytes are read in every pass-through mode, and the Python core refuses a non-bytes operand instead of coercing it. Measured (Django 6.1.1, SQLite 3.53.1): one value written through the ORM into a plain column, then a real AlterField to Encrypted(inner, storage="binary"). Every rebuilt column is declared BLOB. The values keep their class: TEXT for CharField, TextField, DateField and DateTimeField, INTEGER for the IntegerField family, BooleanField and a whole-valued DecimalField, REAL for FloatField and a DecimalField value with a fraction. Before the fix, strict answered NOT_CIPHERTEXT for all of them, and no permissive read returned the value: TypeError on a str or float, ValueError on -3, and 42 read as 42 zero bytes before the codec refused it. Two defects, one in each layer. (1) Core: decrypt’s pass-through ran bytes(blob) on whatever it was given, and bytes(n) allocates n zero bytes; a BigIntegerField phone number would have asked for gigabytes. decrypt now raises InvalidArgument for an operand that is not bytes, bytearray or memoryview, in every mode and before recognition, so the refusal does not depend on the mode (as rotate’s domain does not). This changes strict for such an operand from NOT_CIPHERTEXT to INVALID_ARGUMENT; no vector carries a non-bytes operand, and the report still passes 193 of 193. (2) Adapter: the read handed the core the raw driver value. Decided at implementation: a non-bytes value in a binary column is never an envelope, because the adapter only writes bytes, so strict refuses it unread with the core’s NOT_CIPHERTEXT (an empty operand), and the pass-through modes read it as the inner field read it before the AlterField. That means the backend’s converters for the inner field (a Col, so a DecimalField is quantized as the compiler would), then the field’s own, then the codec’s §3.6 rendering, handed to decrypt. The issue’s first reading, the text’s UTF-8, would have been right for string and date only: Django keeps a datetime on SQLite as 2026-10-05 12:34:56.123456. A converter that fails or returns None (text that is not a date) raises FieldsealNotSupported, so the backfill records RENDERING_REFUSED rather than INTERNAL. Text in a BinaryField column is refused by the codec rather than base64-decoded by BinaryField.to_python, which would have been #245 through another door. Pinned: tests/test_legacy_storage_class.py, 59 tests. That is twelve inner-type cases each in strict, in permissive and readonly (the value equals the plain field’s read and plaintext_reads is 1), and through a save that a strict client reads back, plus the refusals (four unparseable date and datetime texts in permissive and readonly, and text in a BinaryField column, each still counted as a plaintext read), the §3.4 reserved-version rule on a legacy value, and a both-backend test that bytea yields bytes (psycopg 3 returns bytes, not memoryview). The 47 written first are red with the adapter change reverted, and so is test_in_place_on_sqlite_a_text_class_value_is_converted, which replaces the test that pinned the failure. core/python/tests/test_parity.py adds 19, 15 of them red without the core change. Suites: core 388 passed; adapter 641 passed and 4 skipped on SQLite, 581 passed and 64 skipped on Postgres 16.2 and on 17.11 (CI’s pinned image), re-run on all three with the count fix below; ruff and mypy --strict clean. Not done: the TypeScript and Java cores were not read (the independence rule), so whether either coerces a non-bytes operand is not checked here. Disclosed exposure: a review comment on #251 quoted core/typescript/src/api.ts by line (decrypt types its operand Uint8Array; copyOut cannot coerce a number) and described the Java core’s operand as byte[]. That arrived during this work, was repeated in the reply there and in #254, and changed no code here; the Python change was decided from spec §11.1 and docs/10 §4 before it (cf. docs/28 X-14). #245 (base64 columns) is unchanged. Observed and filed, outside #251 (#252): on Postgres, Django’s AlterField to bytea is USING col::bytea, which parses backslash escapes. Legacy text a\101b becomes the bytes aAb and reads back as 'aAb' with no error, and C:\path fails the migration (invalid input syntax for type bytea). Measured through a real AlterField on 16.2 and 17.11. Review round 1 (#253), five findings, none blocking. (1) docs/12 §7 still said a binary column’s legacy bytes are the §3.6 rendering, unqualified, which #252 shows false on Postgres. §7, the §2 row and REFERENCE.md now flag #252 inline and send Postgres to the two-column shape until it is decided. The issue’s table was completed on 17.11 and 18.6: \x41 becomes A with no error on both; every non-string type fails the migration, except the integers on 18. Those migrate to their big-endian bytes, which the read usually refuses, but bytes that are ASCII digits read back as a different integer with no error (825373492 as 1234). This round first said every such read was refused; the correction is from the second measurement on #252. (2) A whole-valued decimal is stored INTEGER, not REAL; corrected here and in docs/12, and decimal-whole is a case. (3) A legacy value still meets §3.4’s reserved-version rule; stated in docs/12 and pinned by test_a_legacy_value_still_meets_the_reserved_version_rule. (4) The commits were reworded without trailers, to match dev. (5) Whether the TypeScript and Java cores coerce a non-bytes decrypt operand is filed as #254. Review of the #251 analysis, posted after #253 opened: one gap. A value whose rendering is refused never reached decrypt, so the core’s plaintext-read count (spec §10.3), the cut-over gate (spec §11.1, docs/15 §1.1, PROCEDURE.md §8), missed exactly the rows that failed. Before #253 such a read was counted, ahead of the bytes() coercion. The adapter now hands the core an empty operand before raising, and the core’s pass-through counts it. test_text_the_inner_field_cannot_read_is_refused_and_counted covers the comment’s three DateTimeField texts and a DateField one, in both pass-through modes. Those 8 tests and the BinaryField refusal test, 9 in all, are red without the count.

  • 2026-10-05 — WS-R review round 1 on the Django encrypt job (#248): three findings, none blocking; two fixed, one stated and filed. (1) Rows the default manager hides were never converted. The batch selects with raw SQL and sees every row, but the legacy read and the write went through model._default_manager, and a default manager may filter (soft delete). A hidden row was classified pending, then missing from the two-column read or matched by no row in bulk_update, recorded INTERNAL, and left as plaintext, in a run that ended complete. The runner now reads and writes through a FieldsealQuerySet built on the model. test_a_row_the_default_manager_hides_is_converted covers both shapes and was red before the fix (2 of 3 values written, SQLite). The start-time refusal of a plain default manager on an indexed column stays, with its message corrected: the reason is the application’s writes (fieldseal.E008), no longer the backfill’s own. (2) --rows-per-second inf was accepted and turned the limit off, against PROCEDURE.md §5.3: the bucket refilled to full on every call. Limits now requires a finite rate, and test_there_is_no_unlimited_setting covers infinity (red before the fix). (3) Not fixed, stated and filed: on SQLite, in place fails every TEXT-class value. A column changed from CharField to Encrypted(CharField) by AlterField keeps its values’ TEXT storage class inside the rebuilt BLOB column (measured: typeof() is text, the declared type BLOB); the adapter’s permissive read raises TypeError on the str, and each value is recorded INTERNAL and left as it was. Nothing is corrupted. The frontend may not render the value itself (§6.3 rule 1), so the fix belongs to the adapter’s read and is #251; docs/12 §7 states the limit and test_in_place_on_sqlite_a_text_class_value_fails_and_is_left pins it. Also in this round, a flake in BF-16’s own check, not a review finding. The test searches the output and both state tables for each stored index as hex; a 15-bit index is two bytes, and its four hex digits turned up inside a run id, which is a random UUID (seen: 9be8; 1 run in 12 on SQLite). Run ids carry no value and are now taken out before the search. test_the_leak_check_finds_an_index_but_not_inside_a_run_id holds both halves: an index in the output still fails the check, and the same digits inside a run id do not; it is red with the stripping removed, and BF-16 then ran 100 times without a failure. Not measured: any of this on Postgres outside CI.

  • 2026-10-03 — WS-R: the first backfill frontend, the Django encrypt job (manage.py fieldseal_backfill); rotate stays blocked and verify is not built. It implements tools/backfill/PROCEDURE.md version 1 unchanged, inside adapters/django (fieldseal_django/backfill/), with init, encrypt, resume and abandon. Scenarios: sixteen of §10’s ids have a test that cites the id in its name and passes on SQLite and Postgres: BF-01 to BF-10, BF-14 and BF-16 to BF-20. BF-11, BF-12, BF-13 and BF-15k are blocked on #241, as in every frontend; BF-15 waits for verify. BF-06, BF-07 and BF-19 run a second connection on a thread, and the SQLite leg of the suite moved from :memory: to a file database with transaction_mode: IMMEDIATE so that its locking is a deployed SQLite’s. Decided (maintainer): the frontend lives in the adapter, and the AD-1 grep excludes one file. The configuration hash needs SHA-256, and CI’s AD-1 grep fails any import hashlib under adapters/django/src; PROCEDURE.md §4 already says the hash is a change detector over public configuration and not cryptographic code in AD-1’s sense. backfill/confighash.py holds one function and is excluded from the grep by name, and the same CI step checks that there is one file of that name, that it imports hashlib and nothing else, and that it defines that one function. Seen to fail with an import hashlib in another file, with a second confighash.py, and with a second import in the excluded one. The alternative was a separate package under tools/backfill/. Decided (maintainer): tenant-bound tables are deferred. The adapter takes the tenant from a context variable, and config has no member for where a row’s tenant comes from, though it decides the stored bytes (#244). The frontend takes no tenant option and records every value of a tenant-bound column as CONTEXT_UNAVAILABLE, even when a tenant is set in the process. Readings of the procedure, stated in docs/12 §7. (1) The in-place legacy value is read by Encrypted.from_db_value on the bytes the batch selected and locked; §6.3 rule 1 asks for the adapter’s permissive read “of that row and column”, and a second query would read the same locked row. (2) Each value is written by its own bulk_update call inside a savepoint, because Django’s bulk_update runs in atomic(savepoint=False) and a refusal raised while the statement is compiled would otherwise mark the batch for rollback. (3) One key column only; a composite primary key is refused. (4) On SQLite the command refuses to start unless the connection is configured for BEGIN IMMEDIATE, and does not issue it itself. (5) The final report is always printed in both forms, the JSON as the last line. Found on the way, filed, not fixed here. A permissive read of a base64 column decodes legacy text as base64 before the core sees it: Zm9v reads back as foo, other text raises binascii.Error or a codec refusal (#245, reproduced through from_db_value). So in place on a base64 column is refused at start. docs/12 §4’s fieldseal.plaintext_read signal is documented and not implemented (#246), which PROCEDURE.md §8’s flag relies on. docs/12 §7 said the adapter ships nothing for backfill and named no supported shapes; both corrected. Bite checks: seventeen mutations in eighteen runs, each seen to turn the test that names its rule red after a green control on both backends (no FOR UPDATE, on Postgres; a failed claim ignored, on both; max_failures tested per value; current values rewritten; no savepoint; an index parameter taken from the declaration; in place on base64 allowed; a database error’s message printed; a tenant-bound column written; the table left held after completion; no IMMEDIATE check; no acknowledgement; any procedure version resumed; a changed config resumed; no rate limit; the cursor not recorded; the legacy value rendered outside the adapter). The script is session-local, as #242’s was. D-4, a first measurement (#247): 4,000 values, HMAC-indexed, two-column shape, about 2,160 rows/s on SQLite and about 700 rows/s on Postgres on one machine, a batch of 200 taking about 270 ms on Postgres. An Argon2id column is not measured. Not done: verify; the rotate job; tenant-bound tables; replication-lag throttling (the command says so at start); a run of the command against a deployed project rather than the test suite’s call_command.

  • 2026-10-03 — PROCEDURE.md §6.3’s flag, answered for Prisma: every top-level write path on the extended client writes the blind index with the ciphertext. The question was whether Prisma has Django’s #240 defect, asked before the Prisma backfill frontend is built on updateMany. docs/13 §6 had confirmed updateMany for the ciphertext only, and the one test behind the matrix row wrote an unindexed column. Measured on Prisma 7.10.0, SQLite 3.53.2 (better-sqlite3) and Postgres 17.11 (the CI image), on Patient.email (raw bytes) and Patient.nickname (base64 text), both HMAC-indexed: update (bare value and { set }), updateMany (one row, and three rows in one statement), updateManyAndReturn, upsert (update branch and create branch), createMany, createManyAndReturn, and update and updateMany on the transaction client of an interactive $transaction. After each, the sibling column read through the unextended client held the new value’s index (dd90 to aa56 for email under the fixture’s key) and findMany found the row by the new value and not by the old. No path left the index stale; no adapter change. The adapter derives the index in the same visitor step that encrypts the column, whatever operation carries the payload, where Django derived it in a hook some paths never call. Pinned by adapters/prisma/tests/bulk-index.test.ts, 22 tests, one per path and column, and by a new REFERENCE.md matrix row the conformance report scores. Bite checks (green control first, failing tests counted, both databases, same result): skipping the index derivation for one operation name reddens that operation’s tests and no others (update 6, updateMany 6, updateManyAndReturn 2, upsert 4, createMany 2, createManyAndReturn 2), with the stale or NULL index in the message; skipping it everywhere reddens all 22 and nine older tests in value-path.test.ts, the other file this change touches; over the full suite that mutation reddens 88 (SQLite: the 22, those nine, and 57 in l2, l2-refusals and l4). Corrected: PROCEDURE.md §6.3’s flag now excludes Prisma and stays open for every other adapter; docs/13 §6 says what was verified, when, and what was not. Not measured: the nested forms of these writes against the stored sibling, a tenant-bound index, an Argon2id index, and any Prisma version but 7.10.0. Found beside it, not fixed and not decided: an updateMany matching several rows stores one byte-identical envelope on all of them (three rows, SQLite), since the payload is encrypted once before the engine runs the statement; a backfill writes one row’s value per statement and is not affected, and it is recorded in docs/13 §6 and tracked as #250. Also in this change, the known flake: the base64 round-trip test asserted that the stored base64 text does not contain Ada, which a random envelope’s base64 spells by chance and which base64 of the plaintext never contains, so it failed at random and could not fail for cause. It now asserts on the decoded bytes, against a twelve-byte value, and writes through createMany, because create decrypts the row it returns and failed before the assertion could run. Bite: storing the plaintext in a base64 column reddens the test at that assertion.

  • 2026-09-30 — WS-R review round 1 (#239): ten findings on PROCEDURE.md, all adopted; one was High. The High one: §8’s table let an operator “destroy an old key version” on a census of envelopes alone, while spec §5.2 makes crypto-shredding destroy both the DEK and the index keys and PRD SP-18 requires an inventory of every derived artifact; index keys are siblings of the DEK, a rotate run leaves every index column standing by design (§7.3), and the census never reads one. Decided (implementer): the row is split. Retiring one DEK version after a rotate run is gated by the census, and says it retires nothing else; crypto-shredding a scope is a row the procedure states it cannot gate, because version 1 has no job that rebuilds or clears an index column (§1 now lists that too) and the tool sees no cache, queue, replica, export or backup. The rest: one running run per table is now keyed on a new table_uuid column and enforced by a unique constraint over running_table_uuid (NULL when not running), since a read-then-insert let two starts interleave over one table with two clean reports (BF-19); max_failures counts failures per process from its start, so a stopped run can resume, and is listed as an operator setting outside the hash (BF-20); the lock a batch holds through its derivations is stated as a limit in §1 and beside step 4, at the defaults 2–20 seconds over an Argon2id-indexed column and the whole database on SQLite, with batch_size as the bound; config.cursor records each key column’s name with its type, since a replaced primary key of the same type hashed identically and a resume would have skipped every row below the old cursor (the worked example’s hash changed with it); BF-11, BF-12, BF-13 and the census by key (new BF-15k) are marked blocked on #241 and a third reporting state, blocked, is defined beside passed and not applicable; §7.2’s probe now states its assumption, that the key id does not depend on row_id, and §7.3 checks each rotated envelope’s key_id against the probe and stops the run on a mismatch rather than rewriting the table on every run; the Java metrics flag now says what follows, that a Hibernate deployment has no procedure-sanctioned path to strict until the hook lands; MySQL is out of scope for version 1 (a TEXT primary-key column is refused by InnoDB), stated instead of silently invited; and step 3’s batch_seq is “the value this process last read or wrote”, post-increment after a commit and stored after a rollback. Checked by the reviewer and kept: the worked example’s hash, D-1’s audit of the three cores, §6.3’s reserved-version chain, §8’s one-sidedness argument, §5.2’s lock ordering. §6.3’s bulk_update finding is now dated to 51ae712 and points at #242.

  • 2026-09-30 — #240 review round 1 (#242): eight findings, seven adopted, one retracted by the reviewer; the round also found a plaintext write that predates the PR. 1. A caller-built Value stored plaintext. update(note=Value("secret")), and bulk_update with p.note = Value(…), wrote the literal to the column unencrypted, and the row then failed to read. Reproduced here on SQLite (the raw column came back as text) and by the reviewer on both backends, on dev as well as on the PR. Cause: _assert_literal_expression accepted any Value, and only one whose output_field is the encrypted field re-enters it. docs/12 §2’s “Value … served” was true only of the kind bulk_update builds. Decided (implementer): the field refuses a non-NULL Value that does not carry it as output_field; FieldsealQuerySet.update and bulk_update run the refusal before the statement starts, because raised at compile time it leaves the caller’s transaction marked for rollback. Two existing tests built the unsafe shape to exercise the walker and now build Django’s. On the PR, bulk_update had also indexed such an attribute by its repr. 2. bulk_create read update_fields from keywords only; the parameters are now named, so the positional form gets the index too. 3. Two index columns over one source collapsed to one in index_siblings; it now returns all of them, and update, bulk_update, the upsert and the save(update_fields=…) check handle each. 4. A plain save() after only()/defer() reached the update_fields refusal with advice the caller could not follow; that case has its own message. 5. “update_or_create names the sibling itself” is narrowed in docs/12 §6 to columns local to the model; the reviewer measured that the multi-table-inheritance case is refused at startup (E003) under the adapter’s own client. 6. bulk_update derives every index before touching an instance and restores the instances if the write raises. 8. index_siblings is cached per model, and the pre_save receiver returns early for a model with no index. 7 was retracted by the reviewer: update(email=Value(…)) had not worked before, it had written plaintext, so the refusal stays. Checked: suite 494 → 508 passed, 4 skipped, on SQLite and Postgres 17; ruff and mypy --strict clean; fourteen mutations, each turning exactly its named tests red after a green control, on the final tree. Not covered, as before: a manager that is not FieldsealManager. There the field’s Value refusal still fires, from inside Django’s transaction handling.

  • 2026-09-30 — #240: the Django adapter left the blind index stale on every write path that does not run pre_save for it; fixed for the adapter’s manager, with one residue stated. The index is derived in the index column’s pre_save. Measured on Django 6.1.1 (SQLite) with the adapter’s test model before the fix: bulk_update([p], ["email"]), filter(pk=…).update(email=…) and save(update_fields=["email"]) each wrote the new envelope and left email_bidx byte-identical, and filter(email=<new value>) returned no row, without an error. docs/12 §6 had promised a runtime error for the update() case that was never implemented, and marked bulk_update supported on a test over an unindexed column. Found while checking the write path docs/15 §1.1 names for the backfill. Decided (implementer): FieldsealQuerySet.update derives the index from the value and adds the sibling, and refuses an expression on an indexed column and the column and its sibling in one call; bulk_update derives per instance and adds the sibling to the statement; bulk_create adds the sibling to an upsert’s update_fields; and a pre_save signal receiver refuses save(update_fields=…) that names an indexed column without its sibling, since the set cannot be extended from a signal. Setting the index column alone is left as it was: it is explicit, and the L2 tests forge collisions that way. Residue: update and bulk_update through Model._base_manager, or any manager that is not FieldsealManager, still leave the index stale (measured for _base_manager.update); no field hook sees both columns, and the row is in docs/12 §6, the README’s limitations and REFERENCE.md. Checked: 14 new tests in test_index_on_partial_writes.py; suite 480 → 494 passed, 4 skipped, on SQLite and on Postgres 17; seven mutations, one per guard, each turned exactly its named tests red after a green control. The bulk_create upsert case was fixed from the same reasoning and was not measured before the fix; its test went red under its mutation.

  • 2026-09-30 — WS-R opens: tools/backfill/PROCEDURE.md, the shared backfill procedure, version 1 (#239). A document only; no frontend exists, so nothing in it has been run. It makes docs/15 §1 concrete: two state tables in the target database, the keyset cursor over the primary key, a batch that is one transaction (claim batch_seq, lock the selected rows, classify from stored bytes, write, record), the encrypt and rotate jobs, verify, the output docs/15 §1.3 requires, and eighteen shared scenarios (BF-01 to BF-18) for docs/26 §6’s drift risk. The configuration hash has inputs (PROCEDURE.md §4), which docs/15 §1.1 could not give it before G18: the write suite, each column’s logical type and storage, and the validated index declarations, as SHA-256 over a canonical JSON document with a worked example. Found while writing it: no core exports a header parse. docs/15 §3 says docs/09 §4 “already provides EnvelopeHeader; keep it exported”. The type is public in the Python, TypeScript and Java cores, and a function producing one from stored bytes is public in none, so the rotation sweep’s skip test (docs/15 §1.1, docs/09 §3.5) has nothing to call. Recorded as D-1 in PROCEDURE.md §11 and in docs/15 §3; it is #241, and blocks the rotate job and the census by key version. The encrypt job needs only is_ciphertext. Decided (implementer), each open for review in PROCEDURE.md §11: rotate writes the core’s rotate output directly, as spec §11.1 describes the sweep, where docs/26 §4 says “through the adapter’s encrypting write path” (D-2); verify gains a header-only census of every row, because a sample cannot show that no plaintext remains (D-3); the default batch size and rate are unmeasured (D-4); the write key id is read from the header of an encryption of the empty plaintext, so a frontend never holds the provider (D-5). Measured while checking the write path docs/15 §1.1 names: Django’s bulk_update leaves the blind index stale. On Django 6.1.1 and SQLite, with the adapter’s test model, bulk_update([p], ["email"]) encrypted the new value and left email_bidx as it was, because bulk_update does not call pre_save; filter(email=<new value>) then returned no row, without an error. docs/12 §2 and §6 mark bulk_update supported on the strength of a test over an unindexed column. This is a defect in the shipped adapter, outside WS-R; it is recorded in PROCEDURE.md §6.3 and filed as #240. Corrected: docs/15 §1.2’s “Phase 1 ships two thin frontends”, which docs/26 §2.1 recorded as never true. Next: the Django and Prisma frontends (P2-M5), then the Hibernate one, which closes WS-N.

  • 2026-09-29 — WS-N review round 2 (#238): both fixes verified by the reviewer; two low items, one adopted, one measured the other way. Adopted: FS-H010’s sufficiency rests on Hibernate using the query cache only when hibernate.cache.use_query_cache is on, whatever a query’s setCacheable(true) says. The reviewer read that from the 7.4.11 bytecode (JdbcSelectExecutorStandardImpl) and measured it; StartupChecksTest.withTheSettingOffACacheableQueryWritesNoRegion now pins it with a recording region factory present and nothing written, and it was seen to go red, holding the plaintext, with the setting on and FS-H010 removed. Measured the other way: the reviewer read aQueryCacheOverAPlainEntityIsLeftAlone as never reaching the integrator, because its factory is built without the adapter’s settings. The integrator is registered through META-INF/services, so Hibernate loads it for every factory and integrate runs there too: with FS-H010 moved above its early return, the test goes red. It is unchanged, and that mutation is now a bite entry. Bite list 24 mutations, 22 bite, 2 unobservable as stated.

  • 2026-09-29 — WS-N review round 1 (#238): two holes found by the reviewer, both reproduced and fixed. 1. The query cache held plaintext. EncryptedType.disassemble’s message said a query-cache entry was refused, but disassemble/assemble run on the entity second-level-cache path only; the query cache stores results after the type has decrypted them. Reproduced with a map-backed region factory that records every put: a cached select p.email stored the decrypted address. No type hook runs on that path, so it cannot be guarded per query. Decided (implementer): FS-H010, which refuses hibernate.cache.use_query_cache on a session factory with an encrypted attribute; disassemble’s text now says what it does and does not cover; docs/29 §1, §5 and §6 and the README’s matrix carry the row. 2. The finder’s permission was the thread’s, not its statement’s. getResultList() materializes entities, so a query run by application code on the same thread inside that window (@PostLoad, an interceptor, a listener) was translated with the permission and served an unverified index match. Reproduced with a @PostLoad hook. Fix: each finder call sets a token of its own as its query’s comment, which the translator receives in QueryOptions, and the walker grants the permission only to a statement carrying a token open on its thread (FinderScope). The token is a counter, not a secret: it guards against accidental code in the window, not deliberate forgery. Both have regression tests in the CI suite (StartupChecksTest.fsH010; QueryRefusalTest.aQueryRunDuringTheFindersLoadGetsNoPermission and anOpenScopeGrantsNothingToAnotherStatement), and three bite entries; the scope mutation was checked to redden exactly those two query tests. Bite list now 23 mutations, 21 bite, 2 unobservable as stated. Suite green on H2 and Postgres; coverage report 28 rows, 27 pass, 1 not implemented (L3-row).

  • 2026-09-29 — WS-N: the Hibernate adapter, docs/29 first and then adapters/hibernate; built except the backfill frontend, so WS-N stays open. The binding doc was written before the code, as docs/26 §2.1 requires, from a spike against hibernate-core 7.4.11.Final (the latest 7.x release on 2026-09-29) and its sources jar. docs/04 §5 checked against source (docs/29 §1): the Interceptor.onPersist contract is confirmed verbatim; the id being present before the INSERT under sequence and UUID generators, and absent under IDENTITY, is confirmed at the pre-insert event; one claim is corrected: randomized encryption inside an AttributeConverter does not cause spurious updates on 7.4.11 (measured: one INSERT, no UPDATE on a second flush or after a load), because Hibernate compares converted attributes on their domain values. One list is completed: hibernate.query.sqm.translator gives a typed view of every HQL and Criteria statement before SQL exists, which docs/04’s “no comparator hook; StatementInspector regex” left out. It is used for refusals only, and spec §10.1’s “(b) not available” for Hibernate is not changed. Decided (implementer), the architecture (docs/29 §2.1): encryption happens in pre-insert, pre-update and pre-upsert event listeners that replace the state entry with a sealed value, not in the type, because a type’s nullSafeSet is called for writes and query parameters alike and cannot tell them apart. The type binds only a sealed value and refuses anything else, so an unintercepted write fails loudly. L2 (a) is a finder (FieldsealQueries) that re-verifies every candidate under the index’s normalizer; raw HQL or Criteria over an index attribute is refused outside it. L3 is the session’s tenant identifier. L3-row is not built: the write side is easy, the read side has no id where the type decrypts (docs/29 §4). Findings while building: Double.toString is not ECMAScript’s rendering since JDK 19 (the smallest subnormal prints 4.9E-324, not 5e-324), so the codec finds the shortest round-tripping digits itself; checked once against Node 24.16.0 on 220,000 doubles with no disagreement. Hibernate’s own typing refuses like, lower, min, max and length over the VARBINARY column before the walker sees the query; pinned by a test rather than claimed as the walker’s. The bind-time guard is unreachable end to end while the walker is configured (FS-H005 makes it mandatory), so it is tested directly and described as defence in depth. Evidence: the suite is green on H2 and on Postgres 17 (the CI image, locally); the codec/ family passes through the adapter’s codec with the same four capability skips as Django’s; the coverage matrix is scored against the JUnit results by scripts/coverage_report.py, which fails on a missing or uncited test (both seen to fire); scripts/bite_checks.py records 20 mutations, 18 of which turn their named tests red and 2 of which are unobservable as stated; the cross producer’s document (13 envelope, 6 index cases) was decrypted and re-derived locally by the Python, TypeScript and Java consumers, 19 of 19 each. CI gains hibernate-adapter (H2 and Postgres, with the AD-1 grep over src/main) and hibernate in CROSS_PRODUCERS: six producers by three consumers. Open, by name: the backfill frontend (docs/26 §5 item 4 requires it before WS-N closes, and it waits on WS-R’s PROCEDURE.md); L3-row; the no-overclaim read by someone other than the author (docs/07 §4). Nothing is published; no coordinates are settled (docs/27 §0.3).

  • 2026-09-29 — P2-M1 is met: the Java core passes the pinned suite (#236, merged to dev as f52cf92); WS-I’s eight stages are done. docs/26 §3’s exit test, clause by clause: a docs/14 §4 report with fail: 0 at 0.10.0-provisional (193 of 193, skipped: 0); every out_of_band entry pass, the length-bound pair on basis: "seam" and the lone-surrogate entry run directly; all six mandatory pinned_decisions keys; a divergence report with its isolation and single-implementer statements (docs/28); cross-core-result-ids green across three reports (since S6); and the cross job green with the three cores as producers and consumers, self-pairs included (since S7). S8’s exit is docs/07 §4 with docs/26 §4’s additions. Tests green in CI at a named suite version. No [VERIFY] left in docs/27. The honest limitations in core/java/README.md, completed at S8 against PRD §8 condition 4. The no-overclaim read was done by a reviewer other than the author, the maintainer’s reviewer agent, a separate AI session, on #236, and not by a human. It found four problems in prose. Three were fixed: row_id never reaches the index key; the tests show that the end of each field reaches the derivation, not every byte of it; tenant binding is optional. The fourth, that the README’s T2 sentence had the truncation direction backwards, was answered with spec §7.4’s 66% and 33% figures and withdrawn. A second review agreed. Both approved 3023f55. Of docs/26 §4’s additions: docs/27 §6.1 states the platform’s largest byte[] and that it binds before spec §3.5’s bound; docs/28 §1 carries the two statements; the Java Unicode tables are a tools/ucd-gen target covered by --check; the length seam is part of the core. docs/27 names no operating system as supported. Every Java CI job runs on ubuntu-24.04, and its Windows figures are local measurements, not a CI leg. Not claimed: anything about a frozen format (Gate 0b is open), any adapter level, or a release (nothing is published). A vector-suite bump resets P2-M1 (docs/26 §3). Next on docs/26 §5 item 3’s order is WS-N, the Hibernate adapter, with WS-R’s PROCEDURE.md due before WS-N closes.

  • 2026-09-29 — WS-I stage S8: docs/27 as built, and the Java core’s divergence report, docs/28-java-core-report.md; the no-overclaim read is pending, and P2-M1 with it. docs/28 is docs/17 §5 item 4’s deliverable, in docs/18’s shape. The isolation statement names docs/27, docs/18 and docs/10/docs/11 §4–§7 as read inputs, and says that the pins this core shares with docs/18 (D-02, D-04, D-12) are not independent evidence, since docs/18 was within reach. It lists fourteen disclosures, X-1 to X-14, taken from this log’s WS-I entries. Two are recorded for the first time: the WS-I session’s reading of this log’s 2026-09-28 #227 entry and of the #227 and #212 closing comments, which describe how the Python max-age test started its clock; and S8’s own research summary of this log, which quoted the 2026-09-27 #202 entry’s citation of the Python cache. The single-implementer statement is in docs/18 §1’s form (docs/26 §2.2): an AI assistant of the same class as the other cores’ and the generator’s author, so the isolation is by session, not by author, and a misreading shared by all three cannot be found by the protocol. The result: at every stage that ran vectors, every value passed on the first run, so the mismatch list is empty. J-01 to J-20 list every place the spec, the vectors or a design document was silent, contradictory or wrong. Six are open: J-04, a 0x02 envelope of exactly 111 bytes has no vector (the reserved ones are 120 bytes, the under-minimum ones 110); J-09 (#213); J-15, vectors/schema/; J-17 (#209); J-18, G14; and J-19, AAD_MISMATCH under G5. J-11 (which refusals bucket catches) and J-18 could split cores on real data without a vector noticing, and J-09 joins them with a second suite. G14, recorded (#43): docs/27 §5.2 had promised to state at S8 what this core accepts as a KDF info, and nothing tested a large context. It accepts any info up to the Java array limit, past which CanonicalContext raises an OutOfMemoryError (by construction, not tested). LargeContextTest (three tests) runs 70,000-byte tenant_id and row_id through encrypt, decrypt and rotate, and checks that the last byte of tenant_id changes the index key (which never carries row_id) and the last byte of each field changes the record key; only the last byte is varied, so this shows the end of each field reaches the derivation, not every byte of it (#236 review). A bite entry capping info at 1,024 bytes turns the two last-byte tests red, with the refusal becoming TagInvalidError, and leaves the round trip green, because the AAD carries the whole context; checked by hand against the JUnit XML. README, against PRD §8 condition 4: core/java/README.md stated spec §2.2’s N1 and the logs, but not N2 to N4 in substance, nor T2’s degradation for indexed columns or T3 without row binding, nor spec §7.5’s pagination rule; all are stated now. AGENTS.md gains the Java core in its layout and summary, docs 21 to 28 in its list, and the independence rule extended to the Phase 2 cores. Checked on the branch head: ./gradlew build ran 535 tests (172 core, 361 testing, unarmedTest 1, freshJvmTest 1) with none failing; ./gradlew -q vectors gives 193 of 193 at 0.10.0-provisional, three out-of-band entries passing, L0; the site builds and its links check. Bite checks: the full list on the code of the final head is 147 mutations, 144 bite, and 3 change nothing as stated (the suite_id one of S4b, the api’s second gate of S6, and S7’s entropy call). Open: the exit’s no-overclaim read, by someone other than the author (docs/07 §4), is the maintainer’s reviewer on the PR. P2-M1 (docs/26 §3) is recorded as met in a later entry once that read is answered, not here.

  • 2026-09-28 — WS-I stage S7: the Java core in the cross job; S7’s exit is met (docs/27 §8). java is a producer and a consumer in docs/14 §3’s N×N job, which is now five producers (three cores, two adapters) by three consumers. In CI on #232, on its first run, the Java consumer decrypted every envelope case and re-derived every index case of all five documents: 16 and 11 from each core, its own included, 6 and 6 from Django, and 11 and 7 from Prisma. The Python and TypeScript consumers decrypted the Java document. The job’s three-core part is the 3×3 that docs/26 §3 asks of P2-M1; P2-M1 still waits on S8’s divergence report. Decided (implementer): both legs live in the testing module’s test sources, beside the report, as Gradle tasks crossProduce and crossConsume. They read cross/corpus.json and keys/test-keys.json, and nothing in this core read either before. Production path: the producer calls Fieldseal.encrypt, which takes no nonce or seed (PublicSurfaceTest), in a process from which the Gradle task removes FIELDSEAL_TEST_MODE. The producer also refuses to run if the variable reaches it, and checks that no two of its envelopes share a msg_seed or a nonce. What the consumer refuses, beyond a wrong byte: an unknown schema; a v1 document with index_cases, or a v2 one without any (docs/08 §4.7’s silent-skip case); purpose ≠ "index:" + index_id; not exactly one value field, value_bytes off identity, or a value_marker that is not true; an unknown context or declaration field, since either can change the bytes; and a msg_seed or nonce repeated within a document. 0xFF02 is skipped and counted. Decided (implementer): the shared inputs are hash-checked. Both legs read each file only once its length and SHA-256 match MANIFEST.support, and only if its vector_suite_version is the manifest’s. Tests: CrossTest, 26, two of them checking the produced document against docs/08 §4.7’s case set for a core producer (#232 review: the corpus met it, and nothing asserted it); locally ./gradlew build ran 532 tests (169 core, 361 testing, unarmedTest 1, freshJvmTest 1) with none failing. Bite checks: 25 S7 entries; 24 bite, and removing produce’s own call to its entropy check changes nothing, as stated, because production encrypt cannot repeat. Isolation (for S8’s statement): both legs were written from docs/08 §4.7, docs/14 §3 and the two support files. In CI the Java consumer read the other producers’ documents, and its log printed their pair counts and the two adapters’ producer.limitations. The Python and TypeScript consumers’ logs were read only for the lines naming cross-java.json, and their verdicts were not opened.

  • 2026-09-28 — #225, the Java half: the Java report carries index-role-use-budget, a pinned decision backed by a test the report runs. docs/09 §8.3’s “the index role has no use budget” has no bytes to compare, so docs/08 §8 keeps it out of the vector suite, and each core checked it only in its own unit tests. Decided (implementer): #225’s option (a). ConformanceReport.PINNED_TESTS names the tests behind a key; the report runs them through the launcher as it runs the out-of-band tests, and a backing test that did not run or did not pass is a report problem, which withdraws L0 and exits 1. The key’s text cites EnvelopeProviderTest.theIndexRoleHasNoUseBudget: under maxUses = 2 a blind index is derived maxUses + 1 times, then two encryptions use-evict the DEK beside it (the positive control) while the index key still derives, and max-age still retires it. validate requires the key. A backing test is a single Jupiter @Test, because the report’s launcher filters to Jupiter and counts tests. The out-of-band entries and the pins share one check (unmet). docs/14 §4’s rules now say that a test-backed pinned decision whose tests did not all pass blocks the level claim. fieldseal-core-testing’s test task now carries fieldseal-core’s test runtime classpath, as vectors does, so ConformanceReportTest can check that each backing test exists and run it as a control. Checked by hand against ./gradlew -q vectors: renaming the backing test gives “0 of 1 named tests ran” (JUnit reports a failed discovery, not silence), and making index fetches spend the budget fails the test inside the report; both exit 1 with L0 false. Bite checks: four new entries (225 …), and the out-of-band count entry moved onto unmet; the full list on the final head is 121 mutations, 119 bite, 2 unobservable as stated. Review of #230: this entry and the docs/14 §4 rule were missing; the two build.gradle.kts comments said no core test runs in test, which the control contradicts; PINNED_TESTS did not state the shape a backing test must have; test carried coreTests.output where vectors carries the runtime classpath; and PINNED_TESTS was an unordered Map.of. All fixed. Not decided here (maintainer): whether index-role-use-budget becomes a MUST key in docs/14 §4, which needs the Python and TypeScript halves in a separate session under the isolation rule, and whether cross-core-result-ids compares it. Isolation: this session re-read #225’s body, which cites Python and TypeScript files by line; it is already recorded as an exposure in the 2026-09-27 entry for #212’s Java half.

  • 2026-09-28 — #227: the flaky Python index max-age test was the test’s clock arithmetic, not the core; #227 and #212 closed. test_index_key_is_still_evicted_on_max_age (#218) failed twice with “DID NOT RAISE KeyUnavailable” (#219’s CI, run 36351121178; main’s push run for #226, run 36354683095), and #212 was held open over it. Of #227’s two readings, the first holds: the test started its injected clock at time.monotonic() and advanced it by 100.0, and for some starts (x + 100.0) - x rounds to just under 100, so the key was still inside max_age when the test checked. Fresh CI runners start with a small monotonic time, where this is most likely (sampled: about 15% of starts between 10 and 100 s, 7% between 100 and 1000 s). The Python core’s age check applies to both roles and was not changed. Fix (#228, test only): the clock starts at 1000.0, an exact value like those the file’s other clock tests use, and the test also checks the key is still served one second before max_age. Checked: the old test fails on every run with a start of 65468.33824236392; the new one fails when index-role entries are exempted from the age check and when max_age is enforced one second early, and passes on the unmutated core; 369 Python tests pass. Promoted by #229 (75084d8); #227 and then #212 closed by hand, since #212’s last open question was this test. This work was done in a session that read core/python, separately from the WS-I session.

  • 2026-09-27 — #212, the Java half (#221): the Java DekCache counts uses for the DEK role only; the index role has no use budget (docs/09 §8.3, as #218 amended it). EnvelopeProvider.encryptionKey routed both roles through DekCache.takeForEncrypt, which counted a use on every return, so every blind-index derivation spent the index key’s budget, and at maxUses the index path failed closed until the next warm. Decided (implementer): the cache reads the role from the key. takeForEncrypt counts only when key.slot().role() == DEK. The role is part of every cache key (Slot(scope, tenant, role)), so there is no flag for a caller to pass or forget, and the provider is unchanged. Max-age and the capacity LRU apply to both roles as before. Tests, red before the fix and green after: an index key fetched maxUses + 1 times is still served (DekCacheTest); it still ages out, with the eviction recorded as AGE; it is still evicted at capacity; a DEK beside a busy index key is still use-evicted at exactly maxUses; and end to end, under maxUses = 2, blindIndex runs three times, survives the DEK’s use eviction, and fails once max-age passes (EnvelopeProviderTest.theIndexRoleHasNoUseBudget). Three failed before the fix: fetch 4 found no index key, the index key added a second USES eviction, and blindIndex answered KEY_UNAVAILABLE. Bite checks: three new entries (every role counts; no role counts; the index key skips max-age too) turn both test classes red, and the S4b off-by-one entry was re-anchored to the new line. #224 review: the count is now its own statement, not an increment inside a short-circuit condition; the refusal for a missing index key no longer names a budget it does not have, and KeyProviders.envelope’s javadoc and DekCache’s class comment say maxUses is the DEK’s only; two more tests, that an index-role fetch still marks its key recently used (so a hot index key is not the first to go at capacity) and that an index key fetched maxUses + 1 times beside a use-evicted DEK is still served; two more bite entries for the LRU touch and the refusal text. No vector or envelope byte moves. Isolation exposures for S8: #218’s description, read for its Java section, describes how the Python and TypeScript cores decide the budget (once, when an entry is stored). The Java fix reads the role at the count instead, from the key it already carries. And a CI log on #219 named a Python test from #218, test_index_key_is_still_evicted_on_max_age, that failed once; it passed on dev, and its source was not read. And #225’s body, the cross-core follow-up this PR is linked to, cites the Python and TypeScript cache files by line and names their counted flag; it adds nothing to what #218’s description said.

  • 2026-09-27 — WS-I stage S6: the Java core’s testing artifact and conformance report; S6’s exit is met (docs/27 §8). The Java report, at suite 0.10.0-provisional, has 193 results, all passing, and three out-of-band entries, all pass (the length-bound pair on basis: "seam", the lone-surrogate entry on basis: "direct"). It validates against docs/14 §4, and compare_result_ids.py --cores python,typescript,java finds the 193 synchronous and 3 out-of-band ids identical across the three cores. java-core now uploads conformance-java, and cross-core-result-ids needs it and compares three cores. Decided (implementer): how the seam reaches the pipeline. docs/09 §1 lets no module import api, and the encrypt pipeline is the api’s. So the core’s qualified export (docs/27 §3) is a package of its own, internal.testing, holding a gate and a one-shot hook (MaterialsSeam), and the api’s static initializer installs its encryptWithMaterials there. That is encrypt in full, API-boundary order included, with the CSPRNG’s two draws replaced by the caller’s values. The testing module’s FieldsealTesting.encryptWithMaterials(client, plaintext, ctx, msgSeed, nonce) asks the seam for it. The gate is checked in the core as well as in the testing module, because a qualified export does not bind on the class path. An ArchUnit rule this core adds holds internal.testing to errors only. docs/08 §6’s unarmed negative test runs in a Gradle task of its own, with the variable removed from the process environment. Decided (implementer): the report is the build’s own tests, not a second harness. ./gradlew -q vectors runs the vector test classes again through the JUnit launcher and reads each dynamic test’s outcome under its name, which is the result id. The ids required are computed from MANIFEST.files, so a vector no test ran is a failed result rather than an absent one. An unrequired, duplicated or container failure is a problem that fails the run, as is a report that does not validate. A divergence from docs/08 §4.4, recorded: a binary-only harness is to “report stored.hex skipped”. This one says it in harness_notes instead, because stored.hex is an assertion inside a result, not a vector id, and cross-core-result-ids treats any skipped result as lost coverage. Closed from earlier stages: CodecVectorsTest’s restatement of the errors/ recognition half is removed; envelope/’s decrypt direction moved from ClientVectorsTest to EnvelopeVectorsTest as #decrypt; key-material-ownership declares the erasure steps S5 could not observe. Corrected: docs/27 §2 said JDK 21’s java.text.Normalizer is at Unicode 15.1. Measured on Temurin 21.0.12, its character tables are at 15.0: U+31350 (15.0) is defined, and U+2FFC, U+31EF and U+2EBF0 (15.1) are not. Nothing depended on it: this core never uses the platform’s tables for nfc-casefold-v1. Bite checks: 19 S6 entries; 18 bite, and one, the api’s second gate, cannot be reached around the first and changes nothing, as stated. The vectors task was checked by hand: with the commitment label changed by one character it reports 26 failures, claims no L0 and exits 1. #219 review: a problem found after assembly (the vectors/schema/ check, a validation violation) now withdraws the L0 claim in the printed report, which CI uploads even when the step fails; FieldsealTesting initializes the api class, since a class literal does not (JLS §12.4.1) and a null client in a process that had never built one got the seam’s IllegalStateException, which freshJvmTest now checks in a JVM of its own; main’s post-assembly steps are one method, conclude, which the report test calls; and both test tasks remove FIELDSEAL_TEST_MODE and FIELDSEAL_ARM_PROVISIONAL_SUITES where they need them absent, so a developer’s shell cannot turn the gate tests red or skip the SUITE_PROVISIONAL step. The report refuses L0 on any skipped result, which is stricter than docs/14 §4’s example; recorded, not changed. Isolation exposures for S8: the #212 thread, which cites the Python and TypeScript cache and provider code; the other cores’ report counts, which the comparison prints; and the head of #218’s Python cache change, printed by an unfiltered gh pr diff (docs/27 §8, S6).

  • 2026-09-27 — #202: the DEK cache is the envelope provider’s, normatively for every core; docs/09 §1, §2 and §10 are aligned with §8.3, and the consequences of sharing are stated. Decided (maintainer, 2026-09-27): option (a), on the three cores’ unanimous code. Each supplies the §5.5 policy to its envelope provider and creates the cache in the provider’s constructor — Python EnvelopeKeyProvider(cache=…) at keyprovider.py:192,203, TypeScript EnvelopeKeyProviderOptions.cache at keyprovider.ts:289,317, Java KeyProviders.envelope(…, policy) at KeyProviders.java:86,101 — and each validates the thresholds there, raising its construction-time configuration error: Python in CachePolicy.__post_init__ (cache.py:48-61), TypeScript in validateCachePolicy called from the DekCache constructor (cache.ts:53-63,78), Java in the CachePolicy compact constructor with the 2³² bound taken from DekCache.Limits.MAX_USES_BOUND so the two cannot drift. No core validates the thresholds at client construction, which is what docs/09 §2 read as; the Python client has no cache parameter at all (api.py:100-103) and the TypeScript one refuses a cache key in the client config with a ConfigurationError whose message cites docs/09 §2, §8.3 (config.ts:106-110) — the code was already quoting §2 while §1 and §10 contradicted it. Option (b) would have documented a divergence that does not exist and (c) would have contradicted three implementations plus a refusal that exists solely to enforce the opposite. The change is docs-only; no core was changed, and none needed changing. §1’s config line loses “cache policy”; §2’s Fieldseal(config) sketch loses the cache member, which had been self-contradicting in sitting among the client’s fields while annotated “required for EnvelopeKeyProvider”, and a normative paragraph now puts the policy on the provider and forbids a second copy of it, a split between validation and enforcement, and a default where spec §5.5 gives none; §2’s “construction validates everything” is scoped to what the client was given, with the cache thresholds validated where the policy is constructed; §10’s “(immutable config + concurrent-safe cache)” becomes immutability plus a provider safe to share, and its “no global mutable state” bullet now says that two clients over one provider share one §5.5 cache. §8.3 gains the sharing consequences, which are properties of the key rather than choices. The key carries the tenant scope but the capacity LRU is one queue over the whole cache, so a key is unwrapped once, a max_uses budget is spent by every client’s returns of that key, and capacity is one budget covering every tenant the sharing clients serve — one client’s warms can evict a key an unrelated tenant’s client is relying on, which then fails closed with KEY_UNAVAILABLE until warmed again. That last consequence was verified in all three cores rather than generalised from Java’s docs/27 §3: Python cache.py:134-137 evicts next(iter(self._entries)) with no scope in the order, TypeScript cache.ts:97-99 is the same shape, and Java’s DekCache.java:243-245 walks one entries map, its Slot(scope, tenant, role) index (DekCache.java:51,54) making lookup per-slot without partitioning capacity. A core may partition per tenant if its binding document says so. Two defects found and fixed on the way, both of the same kind. docs/10 §4’s example passed cache=CachePolicy(…) to Fieldseal(…), which is a TypeError — run against origin/dev here, Fieldseal.__init__() “got an unexpected keyword argument ‘cache’”; the policy moved to EnvelopeKeyProvider(cache=…) and the corrected snippet was executed. And docs/12 §5 listed a "CACHE" key in the Django settings surface that the adapter does not read: _SETTING_KEYS (apps.py:41-44) has no such key and an unknown one is a startup error, so the documented setting would have failed get_settings() with “unknown FIELDSEAL settings: [‘CACHE’]” — verified by running it. Two things this deliberately does not do. Neither Python nor TypeScript has a test that two clients built from one provider share its cache, its budget and its capacity — Java has three (EnvelopeProviderTest.java:187,204,228) and the other two have none, so the property the new normative paragraph states is undefended in two of three cores; that is filed as its own issue, since a test is a different change from a wording one. And §2’s “§5.5” is the spec’s §5.5 — docs/09 has no §5.5 of its own and its cache section is §8.3 — which the new paragraph says once rather than tightening the reference into an internal cross-reference. Review follow-ups (same day). §2’s client metrics hook drops cache evictions: with the cache on the provider the client has nothing to count, and all three cores already keep the counter on the cache, so §8.3 now says the cache counters are the provider’s. §8.3’s “the refusal should name eviction” is kept as a SHOULD and stated to be advisory — Java’s KEY_UNAVAILABLE names eviction, Python’s and TypeScript’s say “call warm() first”, and all three are conformant — so a test for the sharing property must not assert the message wording. docs/13 §2’s sketch carried a cache?: CachePolicy option the Prisma extension does not have (FieldsealExtensionOptions has no such member); it is removed, and the text says the policy goes to the provider passed in as keyProvider. Python’s missing on_warning and metrics hooks are a separate gap, filed as #223.

  • 2026-09-27 — The Java harness runs the declaration vectors of suite 0.10.0-provisional (#210, #211); java-core is green again. #217 added eleven declaration vectors (docs/08 §4). The Java harness pins its per-shape counts, so java-core went red on dev when #217 merged, as docs/08 said it would. The new step builds each declaration through the public builder with nothing defaulted and hands it to a client’s construction. refused must surface as this core’s ConfigurationError, and accepted must build and report the index back. All eleven passed on the first run, so no core change was needed. Eight bite checks, each breaking one rule, redden all eleven vectors, as read from the JUnit XML under each mutation: the grammar reduced to non-empty (index-id-uppercase, -non-ascii, -33-bytes), an empty id accepted (index-id-empty), the grammar stopping at 31 (index-id-32-bytes-accepted), a low population not gated (gate-low-cardinality-refused), the gate at <= (gate-at-threshold-accepted), skewed not gated (gate-skewed-refused, a second task on the S5 entry), an override without an approver (gate-override-incomplete-refused), and a recorded override ignored (both -override-accepted). The harness also refuses a malformed declaration rather than default it: argon2id without time_cost and memory_kib, hmac-sha512 with parameters, a pinned §7.3 field that is not §7.3’s, or a fractional number (DeclarationVectorsTest, on synthetic vectors, three more bite entries; #222 review). Built from the vectors and docs/08 only, not from #217’s generator or the other cores’ harness changes (docs/27 §8, S5).

  • 2026-09-27 — #210 and #211: the suite pins index-declaration refusals, in a new declaration shape that names no §9 code; suite 0.10.0-provisional, 161 vectors and 193 results. Spec §12 requires context/ to carry index declarations that break the §6.1 index-id grammar. docs/08 §4.3 recorded the four when G11 closed on 2026-08-09, and none was authored, so a core that accepted index:Exact passed the whole suite (#210). Spec §7.6’s cardinality gate had no vector at all (#211). It was checked only by each core’s own unit tests, and a core that gets it wrong fails open. Both issues are configuration refusals with no §9 code, so one shape serves both (docs/08 §4): "assertion": "declaration", a complete IndexDeclaration in inputs, and expected exactly refused or accepted. It is not codec/’s {"refused": true}, which means “adapter does not support the type”, and it is not an errors/ vector, whose code is the point. Placement: the four identifier refusals and a 32-byte accepted twin go in context/, where §4.3 put them, because §6.1 is a context rule. The six gate cases go in blind-index/hmac-sha512.json, because §7.6 is a blind-index rule. They are in that file only because the gate never reads the IDF: low cardinality (P = 2^10 − 1), declared skewed, and an override with an empty approved_by are refused, with accepted twins at P = 2^10 exactly and with a recorded override for each gated half. The generator validates each case against its own reading of §6.1, §7.4 and §7.6. It refuses to emit a refused case that breaks more than its one rule, or an accepted case that breaks any. vectors/schema/ was not created. docs/08 §2 lists it as planned layout, and both harnesses already record its absence in harness_notes, so the shape is defined in docs/08 §4 and enforced by the generator and the TypeScript loader, as every earlier shape is. Harness steps in core/python and core/typescript declare the index to a client at construction. A refusal passes only as ConfigurationError, and any other exception fails. Bite checks: eleven mutations per core, each run against a green control, and each turned the harness red on exactly the vectors it should. Grammar accepts anything → all four identifier refusals. Grammar at {1,33} → index-id-33-bytes. Grammar at {1,31} → index-id-32-bytes-accepted. Uppercase, empty and é each admitted → their own vector. Skew ignored → gate-skewed-refused. Low cardinality ignored → gate-low-cardinality-refused and gate-override-incomplete-refused. Gate at ≤ 2^10 → gate-at-threshold-accepted. Approver unchecked → gate-override-incomplete-refused. Override ignored → both -override-accepted. Not done here: the Java core (docs/27, S5) pins its per-shape counts, so it is red on this suite until its harness gains the declaration step, which the WS-I implementer adds from docs/08 §4’s description of the shape, not from these harnesses (docs/27 isolation). #209 (Unicode 18.0.0) was not folded in: Maven Central’s newest ICU4J on 2026-09-27 is 78.3, which is Unicode 17, so UnicodeAgreementTest would fail at an 18.0.0 pin.

  • 2026-09-27 — #212: the index role has no max_uses budget; only DEK-role returns count. Decided (maintainer, 2026-09-27), superseding the counting rule docs/09 §8.3 carried for every core. Use counting is per cached entry, incremented per DEK-role encryption_key return; the index role has no use budget at all. spec §5.5’s max_uses threshold exists to hold a key below the SP 800-38D ceiling on encryptions under that key, and spec §8 requires encryption_key to return the tenant index key, never the tenant DEK when the context names an index — index key material is never handed to an AEAD and draws no nonce, so the ceiling the threshold defends against cannot reach it, while a configurable max_uses of 1 is reached by any busy index key. max_age and the capacity LRU still apply to both roles: an index key retires on TTL and is evicted under memory pressure exactly as a DEK is, and dropping the use counter must not have dropped the TTL check with it. This is §8.1’s “candidate reads are not uses” one level over, by the same argument — a budget §5.5 defines as a limit on encryptions must not be spent by a path that is not an encryption. Found by the S5 entry below (its finding 3), which is left as the record of what it was found against. How, in the two cores this stage may touch. Whether an entry has a use budget is decided once, in put (counted=False in Python, {counted: false} in TypeScript), where the warm path knows the role, and stored on the entry; get takes no flag and cannot forget. Deciding it per read instead — the first cut of this fix, which passed the flag down from encryption_key — leaves the rule to be reintroduced by any caller that omits it: a derived or async provider, a refresh path, the Java port. The index lookup is still a get and not a third accessor, so max-age enforcement, the copy and the LRU touch stay on one code path. The Python and TypeScript EnvelopeKeyProviders pass counted=False on the index put only. Tests. test_index_key_returns_do_not_deplete_max_uses and test_index_key_is_still_evicted_on_max_age, in core/python/tests/test_providers.py and mirrored one-for-one in core/typescript/tests/providers.test.ts, were run against the unfixed source first and both failed: the third and subsequent blindIndex calls raised KEY_UNAVAILABLE on an index key the use budget had retired, and the max-age case could not report max-age as the cause because max-uses got there first. max_uses is asserted to be exactly unchanged for the DEK, so the fix cannot be a blanket removal of the counter. test_index_traffic_does_not_spend_the_dek_budget is green before the fix, deliberately: the role has always been part of the cache key, so the two entries were always separate and index traffic never touched the DEK’s counter — what #212 changed is the index entry’s own counter, which the other two pin. It is kept because the mutation it names is real, and it is the one case here that bites it: dropping the role from the cache key makes warm fill a single entry and turns it red on assert size == 2, run in both cores. Python 369 tests, TypeScript 565, ruff, mypy --strict, tsc and the build clean, and both vector reports pass in full at 193/193 and 386/386 (the TypeScript figure is 193 twinned by the async pass, docs/11 §2). Those totals are the tree’s, not this change’s: they read 358/543 and 182/182 and 364/364 until this branch was rebased onto #210/#211’s vectors, and the rise is theirs. The Java core is not touched in this stage — docs/09 §8.3 now describes what the Java DekCache should do, and its takeForEncrypt still counts a use for Role.INDEX (the slot already carries the role, so the fix there is one conditional). That is filed as #221, which is where the Java work is tracked; #212 is closed by hand only when all three cores conform.

  • 2026-09-27 — WS-I stage S5: the Java core’s blind indexes, normalizers and Unicode tables; S5’s exit is met (docs/27 §8). Built in core/java: internal/blindindex (the vendored-table loader, this core’s own transcription of UAX #15 and full case folding, the three normalizers of docs/09 §7, and the two IDFs with spec §7.2’s truncation), and in the api package IndexDeclaration, ValidatedIndex, Unassigned, FieldContext.forIndex, and on Fieldseal blindIndex (text and bytes), unindexableMarker, indexes(), the builder’s indexes(...) and the static validateIndexDeclaration, indexRegistryKey, firstUnassigned and UNICODE_VERSION. BouncyCastle becomes the core’s one runtime dependency, for Argon2id only (docs/27 §2). Decided (implementer, 2026-09-27; docs/27 §8 left it to be decided with WS-J, which has not started): the Java Unicode tables are a tools/ucd-gen target of their own, as a text resource. generate.py writes core/java/fieldseal-core/src/main/resources/dev/fieldseal/core/internal/blindindex/ucd-17.0.0.txt from the same parse as its other three targets, so the unicode-tables job’s --check covers it. The resource rather than generated source because a Java string constant is capped at 65,535 bytes and the folding table is larger. One copy per core rather than the alternative docs/27 named, one shared hashed resource for the Phase 2 cores: a shared file would be read by one core from another’s tree, and a per-core target costs WS-J one more line in generate.py. The regeneration left the other three targets byte-identical. Decided, the shape of the API (docs/27 §4): an index is selected by ctx.forIndex(id), and the core derives under "index:" + id from the declaration it finds, so no derivation string is a caller’s (spec §6.1); a value operation refuses a context naming an index; on_unindexable = bucket covers every refusal nfc-casefold-v1 makes (an unassigned code point, a lone surrogate, malformed UTF-8), which no vector distinguishes from the narrower reading; every override is logged through onWarning at construction (spec §7.6); and an Argon2id cost above BouncyCastle’s memory ceiling is refused at declaration. Results. blind-index/ passes in full: 44 vectors, every shape, both cost points of argon2id.json and the four refuse vectors, each primitive vector also run as #pipeline through the public client over text and bytes with a row_id on the caller’s context (68 results). ClientVectorsTest runs the two errors/policy blind_index vectors it had deferred. Every value passed on the first run, so the mismatch list is empty. UnicodeAgreementTest compares the assigned set, NFD, NFC, full folding and nfc-casefold-v1 with ICU4J 78.3 (Unicode 17.0.0; test-only): the assigned set over all 1,114,112 code points, the rest over every assigned one, then over 200,000 seeded random strings, with no disagreement. The lone-surrogate entry’s two refusals are distinct (docs/27 §6.5). The strict-UTF-8 grep docs/27 §4 promised is a test, StrictUtf8GrepTest. Bite checks. 26 S5 mutations each turn every test task they name red (37 red results), after a green control; the count for the whole script on this head is in the PR. Found, not fixed here. (1) docs/08 §4.3 says context/ carries four negative index declarations (index:Exact, index:é, empty, 33 bytes); vectors/context/canonical.json holds none. The core tests the four refusals itself. Fixing the vectors means reading the generator, which this session may not. (2) spec §7.6 gates a column “declared as heavily skewed”, and docs/09 §7’s IndexDeclaration has no field to declare it. Corrected in review, below: the other two cores have the field, and this core now does. (3) The envelope provider counts a use of the index key on every blindIndex, as docs/09 §8.3’s per-encryption_key counting reads, so a busy index key reaches max-uses, a limit spec §5.5 states for encryptions. (4) Unicode 18.0.0 is published: https://www.unicode.org/Public/18.0.0/ucd/UnicodeData.txt answered 200 without a redirect on 2026-09-27, so under docs/09 §7.1’s Pin currency rule the pin is due to move before the freeze. That touches every core and the generator, so it is not WS-I’s. All four go to the S8 divergence report, and are filed as #209–#212. Changed in review (#208). (1) bucket under identity stays a ConfigurationError, as docs/09 §7.2 requires. The review read §7.2’s “can never refuse” as false for identity over text holding a lone surrogate, which it is; but spec §3.6 has an adapter refuse to store such a string at all, so bucket would keep no row findable. §7.2 now says “refuses no well-formed text”, and the core’s predicate and message say the same. (2) The index key’s suite_id is the write suite, so a write-suite change re-keys every index and a registry comparison must include writeSuite(), which the reflection already reports. The spec states neither; filed as #213 and recorded in docs/27 §4. (3) warm sent one provider request per context, so N contexts on a column were N identical unwraps; it now sends each distinct request once, with a test and a bite entry. (4) The public normalize documents spec §7.5’s raw-bytes fallback. (5) skewed. Finding (2)’s premise was wrong: a comment on #211 reports that the Python and TypeScript cores both declare a boolean skewed, gated as P < 2^10 or skewed and relieved by cardinality_override. The Java core lacked it because docs/09 §7’s sketch omitted it. docs/09 §7 now shows the field, and IndexDeclaration and ValidatedIndex carry it, with a test and a bite entry. The field’s shape reached this session through that comment, whose file citations it did not verify (it may not read those cores); S8’s isolation statement names the comment. #211 stays open for the part no core has: a vector pinning the §7.6 gate. Isolation. The implementing session read no other core and not the generator, and did not read #196’s generator diff. It read docs/10 §4–§7 and docs/11 §4–§7, which are binding documents, for the shape of the index API; and in running generate.py it wrote the Python, TypeScript and generator tables, byte-identical to what was committed, without reading them. S8’s isolation statement names both.

  • 2026-09-25 — #192, the three design follow-ups from #190: the Java envelope provider is bound to its own cache when it is made, revising S4b’s decision (3). Decided (maintainer, 2026-09-25). KeyProviders.envelope(wrapper, store, policy[, warmExecutor]) returns a working KeyProvider that owns its DekCache. It replaces an unbound description that refused every call until a client’s builder bound a copy of it to a cache of the client’s own. Two faults followed from the old shape, and both are gone: a caller who kept the provider and called it met a refusal, and two clients built from one provider unwrapped every key twice into two caches. Now every client built with one provider shares its cache, as docs/09 §8.3’s key (provider scope, tenant, version, role) already described, and isolation is a provider of its own. Sharing also shares each key’s max-uses budget and the cache’s capacity: one client’s warms can evict another’s keys, so clients sharing a provider across different tenant sets size capacity for all of them (docs/27 §3). cachePolicy and warmExecutor leave the builder for the factory; PublicSurfaceTest pins the builder and, newly, KeyProviders. The alternatives were a typed description that is not a KeyProvider (fixes the trap, keeps the double unwrap) and waiting for WS-N, as #192 suggested. The grounds for not waiting: nothing outside tests had used the old shape, and a working provider constrains an adapter less than a description only a builder can use. The other two items. warm runs on an executor (#199): by default four shared daemon threads, never the ForkJoin common pool, with a bound and sharing pinned by test; docs/27 §5.5 reconciles the shared pool with docs/09 §10. The DEK cache is indexed by slot (#198), so a read walks its own slot only, and a read now marks its keys recently used, which fixes a key that only decrypted being evicted first. The cache’s one lock remains, a deviation recorded in docs/27 §5.5. #192’s premise that the index would reduce the per-decrypt DEK copies was wrong; they are #200. Not bite-checked: that clients share a provider’s cache is pinned by clientsBuiltFromOneProviderShareItsCache, but no one-line mutation restores a per-client cache, so no bite check covers it.

  • 2026-09-25 — WS-I stage S4b: the Java core’s key providers, DEK cache and client; S4’s exit is met (docs/27 §8). This entry also corrects the S4a entry’s bite-check record. Built in core/java: the keyprovider SPI (KeyProvider, KeyRequest, EnvelopeHeader, KeyMaterial, Wrapper, WrappedKeyStore); in the api package, Fieldseal with its validating builder, FieldContext, CachePolicy, and KeyProviders with the static, derived and envelope providers; and internal/cache’s DekCache. Decided (maintainer, 2026-09-25). (1) unimplemented-registered-suite: naming 0xFF02 in allowedSuites or as writeSuite is a ConfigurationError naming G7; a 0xFF02 envelope is still recognized, and decrypting one is SUITE_NOT_ALLOWED. (2) The DEK cache has no defaults: CachePolicy requires max-age (> 0), max-uses (1 to 2³², a long) and capacity, and says they are security parameters (spec §5.5). (3) The shipped providers are assembled in the api package: the derived provider needs kdf and the envelope provider cache, while keyprovider may reach errors only (docs/09 §1). keyprovider keeps the SPI, and docs/09 and the ArchUnit rules are unchanged. The envelope provider is returned unbound, and the client binds it to a cache of its own built from the builder’s cachePolicy. (4) decryptionKeys receives the call’s context with the header: spec §8 passes the header alone, and a provider deriving per tenant cannot find the tenant in an opaque key_id. Pinned, as built and tested (the S6 report declares them): api-boundary-order is MODE_VIOLATION → SUITE_PROVISIONAL → operand (null, then LENGTH_EXCEEDED) → context → key acquisition; decrypt-order is recognition → LENGTH_EXCEEDED → SUITE_NOT_ALLOWED → context → KEY_UNAVAILABLE → per candidate, commitment then tag → COMMITMENT_INVALID, and AAD_MISMATCH is never raised (docs/27 §4). Results. The two exit tests pass: KeyMaterialOwnershipTest (a provider that hands out its own arrays finds them unchanged after every operation and failure path, and every derived record_key is zeroed) and ApiBoundaryOrderTest. SeamWiringTest completes docs/27 §6.2’s wiring test: synthetic operands of 2³¹ and more are refused through encrypt, decrypt and rotate with zero provider calls and no read past recognition. ClientVectorsTest runs errors/ through the public client (41, 12 and 14 vectors of format, crypto and policy; the two blind_index vectors wait for S5) and decrypts all nine envelope/ vectors. Every one of them passed on the first run, so the mismatch list is empty. Seven harness failures on that first run were the harness’s own: it read a context from the is_ciphertext vectors, which carry none. Not testable yet: that decrypt takes the context’s suite_id from the envelope’s header rather than from writeSuite (docs/09 §3.2 step 4). 0xFF01 is the only suite this core can be configured with, so the two are always equal; the mutation was run and changes no outcome. Bite checks, and a correction. The S4a entry’s bite checks, and those reported in the #189 review round, were run by a script whose Gradle call never launched: cmd /c gradlew.bat from the script failed with “not recognized”, every run exited non-zero, and each was printed as a bite. Only the update() mutation and the length-check result had been confirmed by hand. The script now calls the wrapper by absolute path, runs every task green unmutated before any mutation (a control), and counts a mutation as biting only when Gradle reports failing tests, not a compile or launch failure. Re-run that way, all twelve S4a mutations bite, but one differently than stated: row_id kept in the index-key info is not caught by KdfVectorsTest, only by CanonicalContextTest.indexKeyInfoDropsRowId. Found: the pinned vector kdf/index-key/row-id-dropped has row_id: null in its context, although its description says “the caller’s context carried a row_id”, so it cannot catch a core that keeps row_id in the index-key info, in any core. That is a vector-suite defect. It is not fixed here: fixing it means reading the generator, which this session may not. Filed as #191. For S4b, nineteen mutations each turn their tests red, and the twentieth is the suite_id one above, run to show that it changes nothing. Among the nineteen: the encrypt guard moved one statement later, SUITE_PROVISIONAL checked before MODE_VIOLATION, the AEAD opened without a verified commitment, record_key left unerased on either path, the core erasing a provider’s candidate, strict mode passing non-envelopes through, 0xFF02 accepted, arming that trims whitespace, reads counted as cache uses, max-uses off by one, eviction without erasure, no single-flight, a failed load that poisons, and an envelope provider that unwraps on the value path. One of them, reads counted as uses, did not bite the test as first written, which let the one permitted encryption through whatever the count; the test now spends the whole budget after the reads. Changed in review (#190). Two review comments, both findings lists rather than approvals. What changed: (1) DekCache.load caught only RuntimeException, so an Error from an unwrap left every concurrent joiner of the shared future waiting forever. It now completes the future on any Throwable. (2) warm could not refresh a key that was still cached, so no warm schedule could get ahead of expiry. A load now always re-unwraps (still single-flight) and replaces the entry, restarting its age and use budget. (3) A version the key store stopped listing stayed decryptable until evicted. warm now evicts the versions its store no longer lists (a new eviction cause, RETIRED). (4) A failed warm could change the active write version part-way through a slot. The slot now changes only once its whole list has loaded, so a failed warm never changes which key writes go out under. The two contradictory Javadocs now state one contract. (5) Fieldseal.warm threw synchronously on a null collection or context; every failure now goes through its future. (6) A null element in allowedSuites threw an NPE before AllowList could raise its ConfigurationError. (7) CachePolicy now takes its 2³² bound from DekCache.Limits. (One review argued that api may not reference cache; docs/09 §1 says api → everything, and api already depended on cache.) (8) The harness stopped only on FIELDSEAL_ARM_PROVISIONAL_SUITES=1, not on any value. (9) KeyMaterial and WrappedKey are documented as handles, compared by identity. (10) docs/27 §5.4 lists the per-call DEK copies the core receives and may not erase. (11) The bite-check runner is committed as core/java/scripts/bite_checks.py, with every S4a, S4b and review mutation. On the final head, 37 bite and the one suite_id mutation changes nothing. Three design-level findings are deferred to #192: an executor for warm, the O(capacity) decrypt lookup, and the unbound provider. One review’s list of things it had “checked and found correct” described code this PR does not contain (a for_index refusal, a dek.keyId comparison in decryptionKeys, 123 results with skip_reason codes); none of it is relied on here. Not built yet: the metrics hook of docs/09 §2, and background refresh for the envelope provider, which fails closed and refreshes only through warm. Isolation. The implementing session read no other core and not the generator. As the S4a entry discloses, it has read a #189 review comment describing how the Python and TypeScript cores lay out their commitment modules. In the #190 round it also read a review comment that quotes one line of the Python core (context.py:50, where the index context drops row_id) and says which cores have a test for that drop. The S4b code was written before either comment, and the review fixes touch only the envelope provider, the cache and the client, not context encoding. S8’s isolation statement names both exposures.

  • 2026-09-25 — WS-I stage S4a: the Java core’s crypto primitives, and commitment reaches the KDF without depending on kdf (docs/27 §8). Built in core/java’s internal/: context (canonical_context and the AAD, spec §6.2; the spec §6.1 purpose grammar), kdf (HKDF-SHA-512 over Mac, record_key, index_key), aead (0xFF01 in place, one doFinal each way) and commitment (spec §4.6). Decided (maintainer, 2026-09-25): the question the S1 entry left open resolves by injection. docs/09 §1 lets commitment and blindindex depend on registry and errors only, yet both need HKDF (spec §4.6, §7.3). commitment declares its own functional interface for the suite’s KDF, and api passes in kdf’s; blindindex will do the same at S5. docs/09 §1 and the ArchUnit rules are unchanged, and a Gate 0b change to the commitment construction still touches one module (docs/26 §6). Also decided for S4: S4 ships as two PRs, the primitives (this one) and then the providers, DekCache, config and client (S4b), which carries the key-material-ownership and api-boundary-order exit tests. Results. kdf/ (9 value vectors, 2 distinct), context/ (14, 1) and commitment/ (1, 1) pass through the core, with per-file counts pinned from MANIFEST.files, and all nine envelope/ vectors pass in both directions when composed from the primitives in docs/09 §3.1 and §3.2’s order. The two distinct kdf vectors, which S2 could not run, now do. Every value passed on the first run, so the mismatch list (docs/17 §3) is empty. Still deferred, to S4b: the errors/ outcomes the S3 entry deferred “to S4” (all 12 of crypto.json, 12 of policy.json, 1 of format.json). The primitives can now produce TAG_INVALID and COMMITMENT_INVALID, but which code each vector expects turns on the read mode, the allow-list and the key lookup, which belong to the client. Bite checks. Twelve mutations each turn their tests red: an unsubstituted empty HKDF salt; the row_id presence bit; purpose without its length prefix; an absent tenant_id encoded as empty; row_id kept in the index-key info; the commitment label off by one byte; the AAD’s fmt_ver without its prefix; the record-key salt without key_id; decryption through update(), which open’s allocation test catches (67,119,904 bytes allocated for a 16 MiB plaintext, against a limit of the output plus 1 MiB); the commitment length check removed; the AEAD’s range check removed; and fmt_ver narrowed unchecked. Changed in review. As first pushed, that length check answered “no match” after the derivation, so removing it changed no outcome (MessageDigest.isEqual also answers false on unequal lengths), and the PR said so. Review pointed out that “no match” surfaces as COMMITMENT_INVALID, a code that blames the ciphertext for what can only be the core’s bug. It now throws before the derivation (docs/27 §5.3), and a bite check covers it. Review also brought: open erasing its output on every exit that does not return it (untested: the one exit it adds is reachable only through a misbehaving JCE provider); the AEAD’s offsets checked before the JDK is called, so a bad one is the core’s IllegalArgumentException rather than a JDK array or buffer exception, and a written rule for what internal modules throw (docs/27 §4); fmt_ver range-checked before it is narrowed; the index salt passed as a copy, as the commitment label already was (unobservable: Hkdf does not write to its salt, and kdf has no injected KDF through which a test could); and the CI job’s name, which still said S3. Isolation. The implementing session read no other core and not the generator. After the S4a code was committed, a review comment on #189 described how the Python and TypeScript cores lay out their commitment modules (which module imports which; no values and no logic). The session continuing WS-I has read it, and S4b’s entry carries this disclosure forward (docs/17 §1).

  • 2026-09-24 — WS-I stage S3: the Java core’s envelope codec, registry and error taxonomy (docs/27 §8). Built in core/java: FieldsealError sealed over one final class per spec §9 code, plus INVALID_ARGUMENT and CONFIGURATION_ERROR; the frozen registry, with 0xFF01 built and 0xFF02 registered and unbuilt; the allow-list; and the codec, with the Operand length seam, BufferLimits, spec §3.4’s three-way recognition, and envelope allocation and serialization. How the exit was read. The stage’s exit is envelope/ and errors/ green through the harness, and the wiring test passing and biting. Without the client or a key provider, both of which are S4’s, a codec can reach only part of that, and that part is what was built: envelope/ parses and re-serializes byte for byte; errors/ runs its recognition half (40 of 41 format vectors and 4 of 16 policy vectors, to their expected outcomes); every other decrypt vector is asserted recognized and within the bound, with its outcome deferred to S4. The wiring test’s codec half proves the spec §3.5 guard fires before any byte past the three recognition bytes is read; its zero-provider-calls half needs a provider and is S4’s. Decided here, and declared in decrypt-order at S6: the decrypt-side guard sits immediately after recognition and before the header parse copies any field (docs/27 §6.2 placed it after recognition; this pins it before the parse too). Found: no vector sits on the reserved-version floor (fmt_ver 0x02 at exactly 111 bytes), so a unit test pins it. On HotSpot 21 the largest encryptable plaintext is 113 bytes short of the spec bound, and that case surfaces as an OutOfMemoryError, the VM’s own or, for a total no int holds, the codec’s, which says so in its message; spec §3.5 makes it conformant (docs/27 §6.1). Five bite checks each turn the build red. The implementing session read no other core and not the generator. At the maintainer’s request a separate agent read the two shipped cores’ READMEs, for their section outline only, so that this core’s README follows the same shape; nothing about their implementation reached the implementer.

  • 2026-09-24 — WS-I stage S2: the capability audit resolves every [VERIFY] in docs/27 (§8). CapabilitiesTest exercises the JDK and BouncyCastle directly against the pinned vectors, after the harness’s integrity walk has checked every file’s hash; the core itself still holds no cryptographic code. Confirmed. (1) AES-256-GCM through JCA at docs/27 §5.1’s offsets rebuilds all nine envelope/ vectors byte for byte and decrypts each in place; a flipped tag, ciphertext or AAD bit raises exactly AEADBadTagException; SunJCE refuses a repeated key and IV on one encrypting instance. (2) HKDF-SHA-512 over Mac: SecretKeySpec refuses the empty key, and 64 zero bytes in its place reproduce all three commitment values in commitment/ and the Argon2id salt in each of the 23 blind-index/argon2id.json vectors. The kdf/ value vectors pass; the two distinct ones give a context object, not info, and wait for canonical_context at S4. (3) A CharsetDecoder set to REPORT refuses all four refuse preimages and an RFC 3629 table of malformed input, while new String(bytes, UTF_8) accepts the same preimages silently, which is why docs/27 §4 has CI grep for it. (4) bcprov-jdk18on 1.86 reproduces all 12 raw values in blind-index/argon2id.json, at both cost points it pins. (5) GitHub’s standard Linux runners have 16 GB for public repositories and 8 GB for private ones, and this repository is public. Corrected. (6) docs/27 §6.3 said SunJCE’s GCM decrypt buffers about the operand. Measured on Temurin 21.0.12 over a 64 MiB operand, one doFinal allocates about 22 KB beyond the caller’s arrays, encrypt or decrypt; the same decrypt fed through update() allocates 3.0× the operand. Decrypting in one doFinal is now a stated rule of the binding, and a 1 GiB decrypt needs about 2 GiB of heap, not 3. Answered. (7) BouncyCastle’s builder and its parameters object each copy the salt, and each has a clear(). One more copy per generateBytes call is never erased; that is read from the 1.86 bytecode, and docs/27 §5.4 lists it among what the core cannot promise. (8) bc-fips 2.1.3 has no Argon2 at all. Found, not asked. On a tag failure SunJCE zero-fills the caller’s output range. BouncyCastle caps Argon2 memory at 2²⁴ KiB through a system property. The platform-maximum probe (docs/27 §6.4) runs in its own informational job, java-memory-probe. On Temurin 21.0.12 (HotSpot, G1) the largest byte[] is 2³¹−3, both on Windows x64 and on CI’s ubuntu-24.04 runner, and one byte more fails on the VM’s array limit, not the heap. Three bite checks were run, and each turns the suite red: a 129-byte zero salt, Argon2 version 0x10, and a ciphertext offset of 64. Deviations (docs/17 §5 item 5): BouncyCastle is a test dependency of the testing module until S5 builds the IDF in the core, and the audit’s HKDF is a test-local one; the core’s is S4’s. The session read no other core and not the generator (docs/27, reading path).

  • 2026-09-24 — P2-M0 is met (#184). The promotion put the Java binding doc (docs/27) on main, the last of the four items the 2026-09-22 entry for it named. #184 promoted dev as it stood at merge, which by then also held WS-I stage S1 (#185, the 2026-09-23 entry): the Java core’s scaffold in core/java/ and its CI job. #184’s description, written before #185 merged, called the promotion docs-only; it was corrected on the PR. Neither touched spec/ or vectors/: the one vector-document change is a sentence in docs/08 §4 describing the wrapper’s existing status field.

  • 2026-09-23 — WS-I stage S1: the Java core’s scaffold and CI job (docs/27 §8). Built in core/java/: a Gradle 9.7.1 multi-project with two JPMS modules, dev.fieldseal.core and dev.fieldseal.core.testing, and one package-info.java per docs/09 §1 module, stating its responsibility and what it may depend on. With them, three test classes: ModuleDescriptorTest, which pins the compiled module’s exports and requires; DependencyRulesTest, which holds the main classes to docs/09 §1’s dependency rule with ArchUnit; and VectorHarnessTest. The harness walks MANIFEST.files (lengths, SHA-256, the docs/08 §4 wrapper, id grammar and uniqueness) and runs no vector. CI gains a java-core job. The S1 exit (“CI green, and the ArchUnit test fails on an injected violation”) is kept as a test rather than done once: each rule has a fixture in the test sources that violates it, and the test asserts that the rule reports that fixture and nothing else. Hand bite checks against the final sources: a kdf → keyprovider import in main code fails two tests; widening the kdf rule fails its bite test; an extra exports of an internal package fails ModuleDescriptorTest; and disabling the harness’s hash comparison fails the tamper test. Deviations from docs/27, for S8 to fold in: (1) The qualified export to dev.fieldseal.core.testing (§3), and the testing module’s own exports, wait for S6. javac refuses to export a package that holds no type, and the seam’s package does not exist before S6. (2) The three exported packages therefore hold the types they need in order to exist: ReadMode (spec §10.3), a FieldsealError base and an empty KeyProvider. FieldsealError is unchecked because an ORM converter method cannot declare a checked exception. (3) ArchUnit is used as a library inside JUnit 5 tests, not through its JUnit engine. Versions were resolved from Maven Central on 2026-09-23: JUnit 5.14.4, ArchUnit 1.5.0, Jackson 2.22.3. (4) The harness lives in the testing module’s test sources, where it can see both modules and, from S6, the seam. (5) CI pins Temurin 21.0.12 on push and PR and floats to the latest 21 on the nightly (docs/14 §5). It uses setup-gradle with cache-provider: basic, because the default, enhanced, is a commercial caching service. Open for S4, not resolved here: docs/09 §1 lets commitment and blindindex depend on registry and errors only, but spec §4.6’s commitment is computed with the suite KDF (HKDF-SHA-512), and §7.3’s Argon2id salt is an HKDF-SHA-512 output, while HKDF lives in kdf. As written, the rule means either api passes the derivation in or the primitive lives where both modules may reach it. The ArchUnit rule encodes docs/09 §1 literally, so S4 meets this as a failing build, not as a silent import. Repository: the .gitignore entry internal/, meant for the private scratch folder at the root, matched the Java core’s internal packages at any depth, so a first commit would have left them out. A negation for core/java/*/src/**/internal/ re-includes them; the root folder stays ignored, and a fresh clone builds. Isolation: the session that built S1 did not open, read, grep or list core/python/**, core/typescript/**, core/dotnet/** or tools/vector-gen/**. It read AGENTS.md, docs/27, docs/09 §1, docs/08 §4–§5, docs/14 §2, docs/17, docs/26 §2.2–§4, spec §4.6, §7.3 and §10.3, vectors/, and conformance.yml, which names the other cores’ commands but contains none of their source.

  • 2026-09-22 — The Java binding doc is docs/27-core-java.md, the last of P2-M0’s four items (docs/26 §1). It is the JVM design reviewed on 2026-09-19, made into the tech spec the Java core is built against, in the shape of docs/10 and docs/11. As docs/26 §1 item 3 required, it loses what was true only on the day it was drafted: the #167 premise, the report-contract gap G26 has since closed, and its references to working files outside the repository. Its §0 review record is kept. What changed on the way in, beyond the stripping. (1) The JDK floor is 21, as decided today, so HKDF is written over Mac. The document names a trap the design did not: SecretKeySpec rejects an empty key, while spec §4.6 and §7.3 call HKDF with an empty salt, which RFC 5869 §2.2 turns into 64 zero bytes. The core passes those explicitly, and it is a [VERIFY] against kdf/. Writing it, a first draft wrongly cited §5.3’s record_key as an empty-salt call; record_key is salted with key_id ‖ msg_seed, and the citation was corrected before commit. (2) Under G26 the two length-bound entries record pass with basis: "seam" rather than not-run, and the seam is part of this core (docs/26 §4). The lone-surrogate entry passes directly, since java.lang.String can hold the operand. (3) The design’s milestones M0–M8 are renamed build stages S0–S8, so they cannot be read as docs/26’s P2-M milestones. (4) Joining the shared CI jobs is WS-L’s list, not the design’s “generalise the comparison” step, which WS-L did. Isolation, stated in the document’s own header: the 2026-09-19 reviewer read the TypeScript harness, and the session that wrote this document had read small ranges of both shipped harnesses and the TypeScript registry for G26. Facts about the shipped cores are cited to documents, and that session does not implement the core. P2-M0 (docs/26 §3) is met when this reaches main: the plan (with the .NET framework deferred by name to before WS-J), G26, WS-L, and this document. The next step is WS-I stage S1, in a session that has not read either core.

  • 2026-09-22 — WS-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-22 — G26 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 basis — direct, 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_ARGUMENT — docs/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-22 — G26 (#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-22 — The 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-22 — M5 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-22 — M5’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-22 — The 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-22 — The 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 aba0→b950, Grace@Example.COM 4bea→ecee, mallory@example.com 3498→7bac (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-22 — The 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/10–13 — docs/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-20 — The 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-19 — Both 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-19 — The 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-18 — pip 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-18 — v0.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-18 — v0.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-18 — v0.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-18 — The 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-18 — G25 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-18 — Experimental 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-10 — The 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-10 — The 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-09 — The 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 removed — docs/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.9 — below 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-09 — WS-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-08 — A 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-08 — The 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-06 — G24 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-04 — blindIndexAsync 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-01 — The 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-31 — blind-index/argon2id.json is promoted out of held_out and pinned; suite 0.5.0 → 0.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-31 — The 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-31 — The 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-31 — G22 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-31 — G21 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-27 — Prisma 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-27 — G23 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-26 — G19 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-26 — G20 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-26 — The 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-26 — The 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 rendering — IntegerField(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-26 — M4’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-26 — M4 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-26 — G18 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-25 — G16 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-25 — G16 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-25 — G16 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-25 — G16 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 not — docs/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-24 — G15 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-08 — docs/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-08 — G3 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-09 — G6 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-09 — G9–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-23 — First 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-22 — The 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 0xFF00–0xFFFF, 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-22 — Outreach 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-22 — G2 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-22 — blind-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-22 — M1: 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-22 — The 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-22 — M2: 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-22 — Post-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_id — node: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-23 — The 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-23 — Vector 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-23 — The 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-24 — G15 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-25 — G16 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-25 — Argon2id 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-25 — G17 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-25 — M4 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 pickle — docs/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.