Spec-gap issue drafts (G1–G26)

On this page

Date: 2026-08-08 · Status: all thirteen posted to the GitHub tracker on 2026-08-08; issue numbers align with gap numbers (Gn = issue #n) for G1–G13 only — G14, filed 2026-08-22, is tracker #43, G15, filed 2026-08-23, is tracker #48, G16, filed 2026-08-25, is tracker #60, G17, filed 2026-08-25, is tracker #67, G18, filed 2026-08-26, is tracker #75, G19, filed 2026-08-26, is tracker #78, G20, filed 2026-08-26, is tracker #80, and G21, G22 and G23, filed 2026-08-27, are trackers #87, #88 and #89 — the intervening numbers having gone to pull requests. These files remain the canonical drafts; edits after posting belong in the tracker.

As of 2026-08-09, eight are resolved — G3, G6, G8, G9, G10, G11, G12, G13 — which is every gap flagged no cryptographic review required. The five that remain (G1, G2, G4, G5, G7) are exactly the five flagged Yes or Partly, so nothing further in this list can close by engineering judgment: they wait on the Phase 0 reviewers (docs/16-reviewer-brief.md). G11 is a partial close — its grammar is settled, its injectivity question belongs to G4.

As of 2026-08-22, those five are provisionally adopted under Gate 0a and remain open. The Phase 0 exit gate has been split (PRD §8) so that reviewer availability no longer blocks implementation. See Provisional adoptions below for what each now says in the spec and what would close it. Nothing about the crypto-review column has been relaxed.

G14 was added on 2026-08-22, found in review of the TypeScript core rather than in the tech-spec suite: the optional context fields are unbounded, so the KDF info is unbounded, and platform HKDFs (Node at 1024 bytes, OpenSSL 3.0–3.5 at 32 KiB, .NET on Linux through OpenSSL) cap it. It needs no cryptographic review and is closable by engineering judgment, but should be seen by the Phase 0 reviewers first because it sizes the extension headroom of the §6.2 encoding Q4 asks about.

G15 was added on 2026-08-23, from the M2 report’s divergence list (docs/18 D-03, D-10, D-13, D-14) once the Python core had been brought to the same pins (PR #47): the UNKNOWN_FORMAT_VERSION set, rotate() on non-envelope input in permissive, the name of the §4.8 arming variable, and the Unicode version, folding variant, post-fold normalization and invalid-UTF-8 behavior of nfc-casefold-v1. Four parts in one issue because each is a one-paragraph normative change with the same closure shape; none needs cryptographic review. Two of its findings are new: 33 code points whose full case folding is not NFC-stable, and a bounded 28-letter / 34-mark divergence between the Unicode 16.0 table the Python core folds with and the 17.0 table the TypeScript core vendors.

G16 was added on 2026-08-25, from the consequences of closing G15 rather than from any core’s behavior: neither core is wrong under the text as it now stands. Three parts. (A) A bytes-only index API cannot enforce §7.1’s own lone-surrogate refusal — TextEncoder substitutes U+FFFD, so encode("a\uD800b") and encode("a\uDC00b") are the same five bytes, and the TypeScript core’s error message points callers at exactly that conversion while the Python core has accepted str all along. (B) encrypt does not normalize and blind_index does, so a post-pin character stores but cannot be indexed, and §7.1’s pointer to “§12 of docs/12 and docs/13” for the refuse-vs-store-unfindable decision is dangling — those sections do not exist. (C) The pin has no currency policy, and bumping it today would silently pin the draft, because Public/18.0.0/ucd/ 302-redirects to Public/draft/ucd/ over plaintext HTTP and the generator follows redirects while logging the requested URL. All four parts are decided. Part D asked whether any of this belongs in the Gate 0b packet and answers no: Part A reads like a reviewer question and is not one (it decides a parameter type, not a construction), and the refusal rule’s breadth turns on a UCD stability question rather than a cryptographic one. G16 therefore has no reviewer dependency in either direction — the first issue since G13 that is entirely the project’s own to close. It also corrects the impact table in internal/product-decision-indexing-edge-cases.md: the 11,328-point block driving the “CJK personal names” case is Seal Script, a historic calligraphic script, and CJK Unified Ideographs gained one code point.

G17 was added on 2026-08-25, from an interface question the PR #55 review raised and nobody filed: docs/09 §8.1 gives the KeyProvider signatures without saying who owns the byte strings they return, and §3.1/§3.2 prescribe erasure without saying what the core is entitled to erase. The issue was filed with a defect claim that proved false and is withdrawn in place — api.ts:162’s ek.key.fill(0) erases a copy, not the provider’s buffer, because #encryptionKey validates and copies (api.ts:182); the regression test the issue asked for passed against unmodified code, which is how it was caught. What survives is narrower and real: the safety of two .fill(0) calls rests entirely on that copy, which exists for validation, is stated nowhere, was tested nowhere, and is one ordinary refactor from being removed — and a core in a language with mutable byte arrays and no immutability backstop reads §8.1 and gets no instruction either way. The Python core does neither, because bytes is immutable — which also makes two of the four erasure points docs/09 §3 prescribes unperformable in that binding, a fact docs/10 does not state even though §8.3 obliges every language document to. Nothing here is observable through the vector suite: buffer lifetime after a call produces no bytes to pin and no error to match, which is the argument for a pinned_decisions key rather than a vector family. No cryptographic review, and — unlike G14 — no bearing on any Gate 0b question.

G18 was added on 2026-08-26, from the first adapter meeting the core’s public surface rather than from either core: docs/09 §2 specifies a client that validates everything at construction and is immutable afterwards, and says nothing about reading any of it back. So docs/12 §5’s E006 — a user-supplied FIELDSEAL["CLIENT"] whose index registry must exactly match the model declarations — could not be written, and shipped in PR #73 as a W004 warning that reports the gap instead. The two cores are not equally affected, and the worse one has no workaround: Python’s _indexes is private by convention, while TypeScript’s #cfg is a hard private field on a frozen instance, so a Prisma or TypeORM adapter cannot implement the check well or badly. The silence has already produced divergence in both layers — the two cores disagree about reflection generally (TypeScript exposes readMode, writeSuite, allowedSuites and provisionalArmed; Python exposes none of the four, its only public attribute being the plaintext_reads counter), and the two adapter designs answer the escape-hatch question opposite ways, docs/13 §2 removing the client option outright and citing the Django check as its reason. The proposed rule is stated as a principle rather than a list — a client MUST be able to report every validated element that affects stored bytes, query results or read behaviour, in resolved form, through accessors that cannot mutate it, and MUST NOT expose its KeyProvider, cache or key material. Deliberately no pinned_decisions key, unlike G17: buffer lifetime was unobservable in principle, whereas an accessor’s presence is directly testable, and a report key would legitimise a divergence this issue exists to end. No cryptographic review, and no bearing on any Gate 0b question.

G19 was added on 2026-08-26, from writing the Django adapter’s §7.5 re-verification filter — the first gap this project has found in the specification rather than in a tech-spec document. §7.5 requires an implementation to “decrypt and compare the actual values” and never says under what equality. On any column declaring nfc-casefold-v1, a row stored as Ada@Example.com and queried as ada@example.com returns 0 rows under byte comparison and 1 row under normalized comparison; both are defensible readings and an adapter cannot ship §7.5 without picking. Two project documents already point opposite ways: docs/12 §3.3 refuses iexact because “case folding belongs to the normalizer, not the query” — a promise that only holds under the normalized reading — while docs/09 §7.2’s bucket paragraph is phrased for the byte one. The argument that decides it: under byte comparison a folding normalizer enlarges every candidate set, coarsens the bucket, and buys no query capability, so it is worse than identity on exactly the columns §7.3 recommends it for — and canonical equivalence (U+00E9 versus e + U+0301) has no sane answer at all, with no ORM lookup to refuse people towards. Proposed: compare normalize(stored) against normalize(queried) under the index’s own normalizer, with adapters documenting that such a column has exactly one equality and MUST NOT offer a second. Unlike G17 and G18 this one is vector-expressible, and belongs there rather than in per-adapter tests, because two adapters resolving it differently is the cross-implementation failure the suite exists to catch. No cryptographic review — it defines an equality over already-decrypted plaintext, downstream of every cryptographic operation — and no bearing on any Gate 0b question.

G20 was added on 2026-08-26, from the PR #79 review round rather than from new code: the sweep that closed the LIMIT family (get()’s 21-candidate window, qs[i], earliest/latest on verifying querysets) asked the same question — what does the database answer from bytes the adapter never showed it? — of ORDER BY, GROUP BY and DISTINCT. The specification already rules at the principle level (§7.10: ORDER BY → No) and §10.2 already contains both the rule and the exact throw sentence — for exactly one ORM, Prisma, written against one surveyed library’s failure mode. The two shipped adapter designs read that silence in opposite directions: docs/13 §5 throws, adapters/django serves the shape silently — measured, not assumed: .order_by("email") returns envelope-byte order; .values("email").annotate(n=Count("pk")) returns four groups of n=1 where the truth is three groups with one n=2, two of them printing the identical key; .earliest("email") returns the wrong row. The GROUP BY case is the reason this is a gap rather than a documentation nit — wrong numbers, and §7.10’s GROUP BY/DISTINCT row covers only the index sibling, not the ciphertext column every ORM compiles from the natural spelling. Proposed: an All ORMs bullet in §10.2’s known-cases list, §7.10’s row split (sibling: yes with the collision caveat; ciphertext column: No), the Django refusal design in docs/12 (queryset refusals on every FieldsealQuerySet, obligations or none, plus checks for Meta.ordering and admin sortable columns), and the interception honesty in docs/04 — ordering resolution has no field hook, so a plain-manager model ordering through a relation is a documented residue in the raw-SQL class. No vector obligations, no cryptographic review, no bearing on any Gate 0b question.

G21 and G22 were added on 2026-08-27, both from the second adapter meeting the documents — the pattern G18/G19/G20 established with the first, and the argument for building a second adapter at all. Neither is closable by this session alone; both are filed and the Prisma adapter ships the conservative reading of each.

G21 — docs/13 §4 grants notIn: an index rewrite that nothing granted it. G13 scoped §10.2’s Prisma bullet to in:, and §7.10’s table has a Membership (IN) row and no row for negated membership. The extension is not merely unstated but wrong, by the asymmetry docs/12:107 already states for Django’s exclude(): §7.4 mandates collisions, so a positive in: returns a superset that §7.5 trims, while a negated one excludes whole buckets — dropping rows that belong in the result, which the database never returns and §7.5 therefore cannot put back. A filter’s false positives are recoverable; an exclusion’s false negatives are not. docs/13 §2.1’s .not entry has the same defect. No vector obligations — an adapter obligation over query shapes, the same shape as G19’s comparison rule.

G23 was added on 2026-08-27, after PR #86 merged, from the two adapters reading one clause opposite ways — the first gap found as a live behavioural divergence rather than in advance of one. §10.2’s All-ORMs bullet (G20) refuses “aggregate/function expressions over a ciphertext column” on the ground that “an aggregate computes on bytes” — true of MIN/MAX/SUM/AVG and false of plain COUNT(col), which computes on null-ness alone and is exact under the NULL-preservation invariant every write path keeps. The Prisma adapter serves _count (measured exact, from the PR #86 review round); the Django adapter refuses Count("email") with the false “envelope bytes” message while docs/12’s own rationale column advises filter(f__isnull=False).count() — the same COUNT, spelled as a WHERE clause. COUNT(DISTINCT col) stays refused under every resolution (it compares envelopes, so it counts rows). Whichever way it resolves, one adapter changes. No vector obligations, no cryptographic review.

G22 — docs/09 §7.1 says cores MUST export the assigned-code-point check (first_unassigned / firstUnassigned) “for adapters that hold the text earlier and can give a better-sited error”, and neither core does. TypeScript re-exports it from normalize.ts but not from src/index.ts, and the exports map makes it unreachable by construction; Python has it in blindindex.py and unicode/__init__.py and not in fieldseal/__init__.py. docs/13 §9 depends on it — the thrown error MUST carry the offending code point and its offset — so the Prisma adapter reaches them by regex over the core’s error message, which makes a user-facing requirement depend on prose. Second time this clause has been under-implemented: G16 part A found the same message actively countermanding §7.1. A widening of both public surfaces; nothing stored or derived changes.

G25 was added on 2026-09-09, from planning the M5 / WS-G demonstration app — the first gap found by asking what two adapters would write for the same value, rather than what one implementation does. Spec §3 pins the byte layer and nothing anywhere pins the logical-type-to-plaintext rendering; the adapter vocabulary itself is unspecified and has no entry for a decimal. Three measured divergences, in three failure classes. date is silent on the way in and fatal on the way back: Prisma reads Django’s b"2026-09-08" successfully as an instant at UTC midnight, and re-writing it — an ordinary read-modify-write — stores b"2026-09-08T00:00:00.000Z", which Django then cannot read at all, so one write through the second stack renders a row permanently unreadable to the first with nothing raised when the damage is done (and before any rewrite, an instant rendered in local time west of UTC is the previous day — an encrypted date of birth, off by one, for a large fraction of the world). Decimal has no vocabulary entry at all, so it must be declared as: "float", and b"12345678901234567.89" comes back as 12345678901234568 and re-writes as that — a silent wrong answer arriving through the one door §10.2 does not cover, the codec rather than the query. boolean is the loud one and the least important: Django writes b"True", Prisma b"true", each refuses the other, and an encrypted boolean cannot be blind-indexed at all (P=2 fails §7.4’s hard floor of 16, which the §7.6 override does not reach), so it is a column nobody can filter on. The issue originally led with boolean because it is the case that breaks today; that was the wrong emphasis and it was corrected the same day. No existing test could catch any of them: the N×N harness has each producer compare against its own recorded plaintext, so every producer grades its own homework at the logical-value layer, and docs/14 §3 names the codec as “the adapter decision no core test can see” without testing it across adapters. Unlike G19–G24 this one is vector-expressible, and belongs in vectors rather than per-adapter tests, because the divergence would otherwise return with the third adapter. No cryptographic review — the rendering happens upstream of every cryptographic operation — and no bearing on any Gate 0b question. Closed on 2026-09-18. Resolved to direction 1, a normative vocabulary and rendering table: new spec §3.6 pins eight logical types (string, bytes, int, decimal, float, boolean, date, datetime), each with one canonical rendering. Readers are as strict as writers, and a platform that cannot hold a value exactly refuses it rather than rounding or truncating. decimal is canonical by value, so 1.50 and 1.5 are one plaintext and one blind-index value. float uses ECMAScript’s Number::toString, with -0 kept. datetime is UTC with six fractional digits. Types outside the vocabulary are refused at declaration. §10.2 names the codec as a site, and §12 adds the codec/ family: 124 vectors under the new MANIFEST.adapter_files, which binds adapters rather than cores (suite 0.7.0-provisional). Both adapters changed. Django maps inner fields to types, refuses the rest and renders canonically; Prisma gains as: "decimal" and as: "date". Nothing had been released, so no deployment needs a backfill.

G26 was added on 2026-09-22, when Phase 2 opened (docs/25 §10) — the first gap filed against the conformance report format rather than the specification or an adapter, and the first found by reviewing a design for a core that does not exist yet. docs/14 §4 names representability as a category of exclusion that “will not go away” and gives it only not-run, which blocks a level claim. The 2³¹-byte operand does not exist in Java or .NET, whose array lengths are int, and the lone-surrogate operand does not exist in Go, so three of the five target languages could never claim L0 for requirements their type systems satisfy by construction. The need was recorded, and deliberately left unfiled, in the closure comment on #167 (2026-09-20). The direction as filed was a pass rule for this class. It was revised the same day after review (tracker comment of 2026-09-22), which found that the TypeScript harness’s not-verified status is a bug and not a synonym, that the proposed Go test reached the strict-UTF-8 clause rather than the surrogate refusal, and that G15’s invalid-UTF-8 vector never shipped. The revised direction: a basis field on every out-of-band entry; a length-bound seam any core may use, named in docs/09 §4; and the lone-surrogate entry split into blind-index/ vectors for its bytes layer and a representability record for its text layer. The maintainer decided the remaining question the same day: that record is pass with basis: representability, conditional on the vectors being pinned. The issue stays open until the amendment and the vectors reach main. It blocks P2-M0, the entry milestone of docs/26-phase-2-plan.md, and needs no cryptographic review.

These are the specification gaps found while writing the Phase 1 tech-spec suite (G1–G11), during its consistency review (G12–G13), in core review (G14–G15), in the fallout of a closure (G16), in an interface question a code review left unfiled (G17), in the first adapter’s collision with the core’s public surface (G18), in the first adapter’s collision with the specification itself (G19), and in the review round of that adapter’s L2 query path (G20), consolidated in docs/07-implementation-plan.md §5. Per CONTRIBUTING.md, every specification change starts as an issue carrying: a justification with a citation, a statement of what it breaks, and test-vector obligations. Each file here is one complete issue body in that shape, titled and labeled per AGENTS.md (“label the issue with the section it touches”).

“Proposed direction” in each issue is a starting point for discussion, not a decision — the ones marked needs cryptographic review must not be closed by engineering judgment alone. That rule is unchanged by the Gate 0a provisional adoptions below: adopting a proposed direction provisionally, in marked spec text, with the tracker issue left open, is not closing it.

#IssueFileSectionBlocksCrypto review
G1#1G01-key-commitment-construction.md§4.6, §3.1all envelope/commitment vectors; ADR-0002Yes
G2#2G02-argon2id-parameters.md§7.3Argon2id blind-index vectorsYes
G3#3G03-truncation-bit-semantics.md§7.2, §7.4all blind-index vectorsNo
G4#4G04-tenant-id-null-encoding.md§6.2context vectors; any absent-tenant envelope vectorYes
G5#5G05-error-precedence.md§9, §3.4, §6.3most of errors/crypto.jsonPartly
G6#6G06-readonly-mode-error.md§9, §10.3errors/policy.json mode casesNo
G7#7G07-xchacha-normative-source.md§4.2suite 0x0002 vector confidencePartly
G8#8G08-blind-index-stored-representation.md§7blind-index storage assertions; adapter DDLNo
G9#9G09-async-blind-index.md§11.1none (API surface; L4 ergonomics)No
G10#10G10-plaintext-length-bound.md§3one boundary behaviorNo
G11#11G11-purpose-grammar.md§6.1, §7.2context negative vectorsNo
G12#12G12-unique-constraint-contradiction.md§7.10, §7.4adapter DDL guidanceNo
G13#13G13-prisma-in-rewrite-vs-reject.md§10.2, §7.10Prisma conformance wordingNo
G14#43G14-context-field-length-bound.md§6.1, §6.2 (§5.3, §7.2)one boundary behavior; max-context vectors in context/, envelope/, kdf/No
G15 ✅#48G15-core-pins-without-spec-text.md§3.1, §3.4, §9, §10.3, §11.1, §4.8; docs/09 §7errors/ fmt_ver and rotate cases; nfc-casefold-v1 portability vectorsNo
G16 ✅#60G16-index-boundary-and-unicode-pin.mddocs/09 §7, §7.1; §7.5, §7.6, §10.2; docs/12, docs/13the adapter obligation §7.1 claims is written down; IndexDeclaration.on_unindexable; text-boundary and unindexable-value vectors in blind-index/; the pin bump that is free before freezeNo — Part D decides it carries no reviewer dependency either
G17 ✅#67G17-key-material-ownership.mddocs/09 §8.1, §3.1/§3.2, §8.3; docs/10, docs/11a defect reachable by a conformant custom KeyProvider; the per-language zeroization honesty §8.3 already requiresNo — no bearing on any Gate 0b question
G18 ✅#75G18-client-configuration-reflection.mddocs/09 §2, §7, §8; docs/10, docs/11; docs/12 §5, docs/13docs/12 §5’s E006, shipped as W004; the same check in any future adapter; docs/15 §1.1’s undefined backfill config hashNo — no bearing on any Gate 0b question
G19 ✅#78G19-reverification-comparison-semantics.md§7.5, §7.2, §7.1; docs/09 §7, §7.2; docs/12 §3.2/§3.3, docs/13Django L2 and Prisma L2 — an adapter cannot implement §7.5 without choosing, and the choice is visible in the result set; one blind-index/ canonical-equivalence vectorNo — no bearing on any Gate 0b question
G20 ✅#80G20-ordering-grouping-over-ciphertext.md§10.2, §7.10; docs/12 §3/§5/§6, docs/04 §1, docs/13 §5the Django adapter serves ORDER BY/GROUP BY/DISTINCT over ciphertext silently (measured: wrong group counts under identical printed keys); §10.2 names the throw for Prisma onlyNo — no bearing on any Gate 0b question
G21 ✅#87G21-negated-membership-over-a-blind-index.md§10.2, §7.10, §7.5; docs/13 §4/§2.1, docs/12 §3.3docs/13 §4 lists notIn: as rewritable; G13 scoped the permission to in: and §7.10 has no negated-membership row. An exclusion’s false negatives never reach §7.5. Closed 2026-08-31 as filed — §7.10 gains the row, §10.2 gains an All ORMs negation bullet, docs/13 §4’s row splits; no adapter code changed, because both already refusedNo — no bearing on any Gate 0b question
G24 ✅#100G24-escape-hatches-disagree-about-negation.md§10.2, §7.10, §7.5; docs/12 §3.3, docs/13 §2.2G21 refused negation over a blind index; the two adapters’ documented bucket-semantics hatches then disagreed about whether the opt-out reaches it — Django’s .candidates() lifted exclude() and its message recommended doing so, Prisma’s candidateScope() refused notIn unconditionally and its message said so. Closed 2026-09-06 in Prisma’s direction, and the sweep found the divergence wider than the filing: Django was also lifting XOR, and Prisma was lifting NOT, none and isNotNo — no bearing on any Gate 0b question
G22 ✅#88G22-first-unassigned-is-a-MUST-neither-core-exports.mddocs/09 §7.1; docs/10, docs/11; docs/12 §10.2, docs/13 §9a normative MUST that neither core’s public surface satisfies; the adapter that the clause names has to parse an error message instead. Closed 2026-08-31 with a correction to the issue’s own premise: neither core returned the offset, so exporting as filed would have left §10.2’s “name the character and its position” unmet — and the Prisma adapter’s offset regex never matched on the nfc-casefold-v1 path, so the shipped message had no position at all. Both checks now return (code_point, offset) in code points, both roots export it with UNICODE_VERSION, both regexes deletedNo — no bearing on any Gate 0b question
G23 ✅#89G23-count-over-an-encrypted-column.md§10.2, §7.10; docs/12 §3/§6, docs/13 §4a live divergence: Prisma serves _count (measured exact), Django refuses Count(field) with a justification that is false for this shape; one adapter changes whichever way it resolvesNo — no bearing on any Gate 0b question
G25 ✅#123G25-logical-type-byte-rendering.md§3, §10.2, §12; docs/09 §7, docs/12 §2, docs/13 §1the central claim, for three logical types in three failure classes: a date read and rewritten through the other adapter becomes unreadable to the first (and is off by a day west of UTC before that); a Decimal has no vocabulary entry, so b"1234…67.89" silently returns …68; a boolean cannot cross at all. Both adapters conformant Closed 2026-09-18 by spec §3.6: eight pinned logical types, canonical rendering, strict readers, and the adapter-bound codec/ familyNo — no bearing on any Gate 0b question
G26 ✅#176G26-unrepresentable-out-of-band-operands.mddocs/14 §4, docs/08 §5; spec §3.5, docs/09 §7.1Phase 2 entry (docs/26 P2-M0), and through docs/25 §10 item 3 the Phase 2 exit criterion: an out_of_band entry whose operand the language cannot represent has no passing status, so the Java and .NET cores (length bound, int-length arrays) and the Go core (lone surrogate) can never claim L0No

G26 closed 2026-09-22, the day it was filed, by #179 and #180, on the direction #176’s two comments settled after an outside review. docs/14 §4: out-of-band status is pass | fail | not-run, and every entry carries a basis (direct, seam, representability). The length bound may be proven through a seam that docs/09 §4 names as architecture. The lone-surrogate entry’s bytes layer became vectors, a new blind-index/ refuse shape (ED A0 80 / ED B0 80 → INVALID_ARGUMENT) that also settles G15’s unshipped obligation, in suite 0.8.0-provisional. A Go or Rust core records the entry’s text layer by representability and passes only under docs/08 §5 item 9’s conditions. New normative text from the implementation, not the thread: docs/08 §5 item 9 bounds such a core’s strict-UTF-8 diagnosis to the byte offset and the malformed sequence (at most four bytes), because error text is logged. Full record in docs/07 §7.

G23 closed 2026-08-27, the day after it was filed, resolved to serve with the review panel’s three adjustments — three independent tracker reviews converged unanimously, and the recorded consensus (#89) is what the maintainer ratified. Spec §10.2’s All-ORMs bullet now discriminates on reads envelope bytes (the honest line “an aggregate computes on bytes” failed to draw) and carries both a conditional permission — the plain, non-distinct, single-column non-null COUNT(col) MAY be served — and a prohibition: an adapter that refuses it anyway MUST NOT justify the refusal as bytes-computation, the false justification being how the two shipped adapters came to read one clause opposite ways. §7.10 gains the “Non-null count → Yes” row. The load-bearing discovery of the closure: the NULL-preservation invariant was enforced and tested in both adapters and stated nowhere — the served IS [NOT] NULL shapes silently rested on it while every NULL mention in the spec concerned context fields — so §10.2 now states it normatively (NULL is never an envelope; a present value, the empty string included, is never NULL; the sibling is NULL exactly when its source is), which is what makes the carve-out fail-closed: an adapter storing a sentinel for absence loses the permission by construction. COUNT(DISTINCT col) stays refused under the discriminator — it compares envelopes, so it counts rows. The Django adapter serves the bare shape only (distinct=True, Count(Length(...)) and filter= forms stay refused; the grouping refusal and the §7.5 SQL-answered rule are untouched), its compute-refusal message names the served shape instead of advising filter(f__isnull=False).count() — the same COUNT, which docs/12 now labels an alternative spelling rather than a workaround — and the shared exactness expectation (two non-null + one NULL → 2; the empty string counts; a value written NULL moves the count) is pinned in both adapters. Django 233 → 248 tests (10 for the carve-out, 6 failing against pre-fix code, plus 5 from the closure’s review round – which found and closed a pre-existing hole one door over: an encrypted column inside an aggregate’s filter= or a When.condition resolved outside the queryset’s filter path and was served with unverified index-bucket matches, measured before fixing); both cores untouched, no vectors, no Gate 0b bearing.

G19 closed 2026-08-26, the day it was filed, adopted as proposed — reading B. Spec §7.5 now states the equality it means: 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 — plus the adapter obligation the issue drafted: a non-identity column has exactly one equality, an adapter documents that a query for ada@example.com can return Ada@Example.com, and MUST NOT offer a second, differently-folded equality beside it. The three closure arguments are recorded in §7.5’s justification paragraph: under a byte-exact reading a folding normalizer enlarges every candidate set and buys nothing (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 verification comparing under a different equality is a filter disagreeing with its own check; and canonical equivalence has no sane byte answer at all. The vector obligation that kept it open is met: suite 0.4.0-provisional adds normalizer-collapses-e-acute — precomposed U+00E9 against decomposed e + U+0301, the exact pair the issue and §7.5 cite — guarded twice in the generator: the two NFC-collapsible pairs (this one and -precomposed) are written as backslash-u escapes, and _vectors asserts every collision pair’s preimages differ before deriving — identical preimages would satisfy the equality vacuously, and neither harness re-checks distinctness (the escape claim was false in this PR’s first commit, caught by review; the assert is the guard that holds for every pair regardless of spelling). Both cores pass 145/145 with unchanged pinned_decisions; the case-variant half was already pinned by normalizer-collapses-case (suite 0.2.0), and the comparison rule itself is an adapter obligation held by per-adapter tests (docs/14 §4 untouched, as the issue predicted). docs/09 §7 and §7.2 carry the cross-references (the bucket paragraph 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, naming mode: "insensitive"’s §5 rejection as the one-equality consequence. The Django adapter needed no code change — it shipped the rule as its prototype in PR #79 (TestNormalizedEquality).

G20 closed 2026-08-26, the day it was filed. Spec §10.2’s known-cases list gains the All ORMs bullet (the Prisma orderBy sentence stays as its named instance) and §7.10’s GROUP BY/DISTINCT row splits: the blind-index sibling keeps its yes-with-collisions caveat; the ciphertext column’s row is No, with the materialize-then-compute fallback. Closed as filed plus one addition: aggregate and function expressions over a ciphertext column 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 — the one failure in the family that is both silent and plausible. The Django adapter implements the family as a second refusal class orthogonal to §7.5 verification: active on every FieldsealQuerySet, obligations or none, and not lifted by .candidates() — order_by (strings, relation paths, F/OrderBy/function expressions), earliest/latest (including the Meta.get_latest_by fallback, which never passes through order_by()), distinct (named fields, and argless over a values() projection naming an encrypted column, in either call order), 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 two declaration doors the compiler applies directly get checks: E009 (Error) for Meta.ordering/get_latest_by, W005 (Warning) for an encrypted column the admin changelist would order by. The residue is documented rather than owned: ordering resolution has no field hook, so a plain-manager model ordering through a relation onto another model’s encrypted column is not reachable — the raw-SQL class (docs/04 §1). 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).

G18 closed 2026-08-26, the day it was filed. docs/09 §2 gains a normative Configuration reflection clause: a constructed client MUST report back every validated element affecting 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, cache or key material. What was broken was a claim, not a mechanism: §2 already said immutability “makes ‘which config produced this ciphertext’ answerable”, and immutability is necessary for that but not sufficient — the sufficient half was never specified. The rule is stated as a principle rather than a field list because the KeyProvider carve-out is what makes it safe to state broadly. Closed as filed with one addition, recorded rather than absorbed: ValidatedIndex, validate_index_declaration/validateIndexDeclaration and index_registry_key/indexRegistryKey all became public, because a caller that can read a client’s registry and cannot resolve its own declarations to the same form still cannot compare against it. Three runtime guarantees turned out to be load-bearing and none came from a type: the TypeScript indexes and allowedSuites accessors both copy (ReadonlyMap/ReadonlySet are erased, and each getter otherwise hands out the collection the value path consults), and validateIndexDeclaration now freezes what it returns (readonly is a compile-time claim; a cast could otherwise rewrite truncateBits on a live index). Python needed none — MappingProxyType over frozen dataclasses has no mutating methods at all, and allowed_suites was already a frozenset. allowedSuites was missed on the first pass and caught in review: it predates G18, so the new clause made an already-existing accessor non-conformant — the hazard of writing a rule for a surface that grew before it. docs/12 §5 restores E006 as an Error and withdraws W004, whose id is not reused and which had shipped with no test; the check now compares both directions, only one of which is loud. docs/13 §2’s no-client-option decision stands but its justification narrows to the design choice it is. No pinned_decisions key and no out_of_band entry, deliberately unlike G17 — buffer lifetime was unobservable in principle, whereas an accessor’s presence is testable per core, and pinned_decisions declares a choice a conformant core may make differently. No vector obligations, no envelope byte, no derived value, no error code. Both reports 144/144 with unchanged key sets; Python 294 → 298 tests, TypeScript 287 → 292, Django adapter 68 → 74. Implementing it also turned up a second defect the issue had not predicted: FIELDSEAL["CLIENT"] was silently switching off E003, because build_client returns a supplied client without ever assembling the registry, so the §7.4 band and §7.6 gate never ran against the models on that path. The E006 comparison resolves the model side anyway, which is what makes the gate reachable there; a refusal is reported under E003.

G17 closed 2026-08-25 — and the defect it was filed on does not exist. docs/09 §8.1 now states that key material returned by encryption_key/decryption_keys is provider-owned (a core MUST NOT mutate or retain it, and MUST copy first if it wants something to erase) and that candidate reads do not count against §8.3’s max_uses; §3 gains a preamble qualifying its four erasure steps by the binding’s mutability and pointing at §8.3’s honesty obligation; docs/10 §5 states the two steps the Python core cannot perform; docs/11 §5 states the #encryptionKey copy as the load-bearing mechanism it always was. No line of either core’s value path changed — the rule codifies what both already do. Part A had claimed the TypeScript core erases the provider’s own buffer; it erases a copy #encryptionKey makes while validating (api.ts:182), and the regression test the issue itself demanded is what established that, by passing against unmodified code. The surviving finding is that this invariant was stated nowhere and tested nowhere while the safety of two .fill(0) calls rested on it. Both cores gained provider-borrows-nothing tests and a key-material-ownership pinned_decisions key (docs/14 §4), which is the only executable form a buffer-lifetime requirement has; the Python harness also gained the required-key assertion the TypeScript one has carried since G15. Python 278 → 294 tests, TypeScript 281 → 287, both 144/144 with identical pinned_decisions key sets.

G16 closed 2026-08-25 — all four parts, and the Unicode 18.0 bump is deliberately not among them. Part A: docs/09 §7.1’s note is now normative in the opposite direction from how it started — an index-derivation API MUST accept the language’s text type, bytes still permitted through clause 5’s strict decode. The obligation it replaces 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 had been countermanding it with an error message naming TextEncoder — the very conversion that substitutes U+FFFD. blindIndex now takes string | Uint8Array. The lone-surrogate refusal could not become a vector: blind-index/ keys its input as hex bytes, an unpaired surrogate has no UTF-8 encoding, and widening the field to text would not help because Go string literals cannot hold a surrogate value and Rust’s String is UTF-8 by invariant. It is the out_of_band entry docs/09/7.1/lone-surrogate-refusal instead, asserting not merely that both surrogates are refused but that they are refused distinguishably. Part B: new normative docs/09 §7.2 — on_unindexable = refuse | bucket, marker truncate(IDF(index_key, 0xFF ‖ "fieldseal-unindexable-v1"), b), whose leading 0xFF is never valid UTF-8 so it cannot collide with any accepted value by construction rather than by luck, and which is derived rather than constant so it does not announce to a reader of the column which rows hold an out-of-pin character. bucket is gated on the same {reason, approved_by, date} ceremony §7.6 requires, and is a ConfigurationError on a normalizer that can never refuse. A design correction is recorded in §7.2 rather than substituted quietly: the query path needs no extra probe, because the marker is derived from normalization and a query for an unindexable value derives it unaided — the bucket is one more §7.4 collision class, not a new mechanism. The adapter obligation landed in docs/12 §10 and docs/13 §9, closing the dangling “§12” pointer this issue was partly filed about. Part C: docs/09 §7.1 gains Pin currency (normative) — before the freeze the pin tracks the latest released Unicode version and moves in place; after the freeze the same move MUST mint nfc-casefold-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. tools/ucd-gen/generate.py now refuses redirects and non-HTTPS hops and verifies that the URL which answered is the URL requested — a live trap, not a hypothetical: Public/18.0.0/ucd/ 302-redirects to Public/draft/ucd/ over plaintext HTTP, urllib followed both by default, and download() logged the URL it asked for, so bumping VERSION would have generated tables from a moving draft, labelled them 18.0.0, and logged a fetch that never happened. New tools/ucd-gen/test_generate.py (five offline checks, no pytest, so a flake cannot mask a regression in the guards) runs ahead of regeneration in the unicode-tables job. The 18.0 bump is not done and could not be; it waits on the release. Part D: none of this goes to the Gate 0b reviewers — 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 a UCD stability question for which a cryptographer is the wrong expert. G16 therefore had no reviewer dependency in either direction — the first issue since G13 that was entirely the project’s own to close. Vector suite 0.2.0 → 0.3.0-provisional (three blind-index/ vectors); both cores 144/144. Landed as PR #61, which also ported the declaration-time gates the Python core turned out to enforce none of — no §7.4 band, no §7.6 cardinality gate, no skew, no override ceremony — although docs/10 §4 had specified exactly that API all along; the implementation had drifted from its own binding document.

G15 closed 2026-08-24. Parts A, B and C as proposed plus three additions (§3.1’s 111-byte floor on a future format version, and byte-exact comparison and the either-arms rule in §4.8); part D’s item 4 closed the other way — nfc-casefold-v1 gains a post-fold NFC, because the proposed composition was measured to leave eight assigned Greek code points failing to collide with their own uppercase spellings. Full reasoning and consequence sweep in docs/07 §7.

Provisional adoptions (Gate 0a, 2026-08-22)

These are not closures and must not be read as any. The Phase 0 exit gate is now split (PRD §8): Gate 0a permits implementation, Gate 0b — two credentialed reviewers — permits freezing. G1, G2, G4, G5 and G7 are the five gaps that cannot close by engineering judgment, and they remain open on the tracker. What changed on 2026-08-22 is only that each issue’s proposed direction is now written into the spec as normative text carrying a [PROVISIONAL] marker, so that vectors and cores have something concrete to be built against.

Every one of these is expected to be what the eventual review changes. Spec §4.8 is the containment: the constructions below are reachable only through provisional suite identifiers (0xFF01, 0xFF02), an implementation refuses to write under one without affirmative out-of-band arming, and anything written that way is identifiable afterwards from stored bytes alone.

#Provisionally adoptedSpec text carrying the markerCloses at Gate 0b via
G1commitment = HKDF-SHA-512(ikm = record_key, salt = "", info = "fieldseal-commit-v1", len = 32), verified constant-time before AEAD open. Written into §4.6 on 2026-08-23 — between 2026-08-22 and then the formula existed only in this draft and the two cores, and this row overstated what the spec said§4.6, §3.1Q2
G2Narrowed 2026-08-22. Argon2id, password = normalized plaintext, salt = HKDF-SHA-512(index_key, info fieldseal-argon2-salt-v1, 16 B), v0x13/t3/m32MiB/p1/out64. Argon2’s K and X forbidden — K was in the original proposal and is ruled out on portability (unreachable in Python), which is an engineering finding the project settled itself§7.3Q3 — now a single question: is salt-only keying sound?
G4A presence bitmap over the optional context fields, making canonical_context injective across absent vs. zero-length. Crypto SE input 2026-08-26 and 2026-08-28 (see G04, options 4–5): the injectivity argument confirmed as stated with no counterexample offered in either round; the extension half drew a recommendation for a static version byte, against which G04 now proposes option 5 — a normative extension rule that changes no byte and regenerates no vector. Still open, still the reviewers’ call§6.2Q4
G5Not an order — a rule about orders. §9 requires each Gate 0a implementation to pin a decrypt-path order and declare it in its conformance report (docs/14 §4 pinned_decisions.decrypt-order); the spec itself carries no state machine. Both shipped cores declare docs/09 §3.2’s order, reporting COMMITMENT_INVALID where context mismatch and key confusion are indistinguishable (corrected 2026-08-23; the row previously read as though §9 pinned the order)§9Q5
G7Suite 0xFF02 retained naming libsodium’s crypto_aead_xchacha20poly1305_ietf_*, so the two-suite mechanics stay exercised; the normative-source question is untouched. CFRG input 2026-08-23 (see G07, option 3): the draft is citable as archived, and §5.3 already supplies the extended nonce, so RFC 8439 ChaCha20-Poly1305 under the derived key is now the proposed direction — still open, still the reviewers’ call§4.2Q6

A provisional adoption is reversed, not amended, if the review rejects it: the spec text is replaced, the affected vector family regenerates, and the provisional suite identifier retires. The [PROVISIONAL] marker in each spec section names its tracker issue and its reviewer question so the reversal path is discoverable from the spec alone.

Closure log

Closures only. Provisional adoptions are recorded in the section above and are deliberately absent here.

#ResolvedResolution
G32026-08-08Adopted as proposed: spec §7.2 pins truncate bit-exactly (leading ⌈b/8⌉ bytes, MSB-first bit numbering, trailing bits of the final byte zeroed); §7.4 cross-references it; §12 requires b mod 8 ≠ 0 vectors. Unblocks blind-index/hmac.json fully; argon2id.json still waits on G2. Tracker #3 to close on push.
G62026-08-09Adopted as proposed, all three items: spec §9 adds MODE_VIOLATION (one code for all modes, raised at the API boundary before any crypto, so it does not prejudge G5’s decrypt-path ordering); §10.3 is rewritten onto two explicit axes — non-envelope read behavior and whether ciphertext-producing operations are permitted — with readonly taking permissive’s pass-through, refusing encrypt/rotate, and explicitly permitting blind_index. The orthogonal-knob alternative is recorded as considered and rejected, and §10.3 states the cost plainly: three named modes cover three of the four combinations the two axes allow, and the omitted one (raise on non-envelope input and refuse writes) is legitimate — a fully migrated read replica would want it — so the spec flags it as a present limitation instead of pretending the combination is useless. Unblocks errors/policy.json in full. Tracker #6 to close on push.
G82026-08-09Adopted as proposed: new spec §7.11 makes the stored form the raw ⌈b/8⌉ bytes in a binary column (MUST), with declared-per-column lowercase hex as the only alternative (MAY), exact byte or string equality, and a binary-collation requirement on the column. §7.2 and §7.8 cross-reference it; §12 and docs/08 §4.4 add an expected.stored assertion. The adapters’ interim recommendation in docs/12/13 is now normative. Unblocks the blind-index/ storage assertions. Tracker #8 to close on push.
G92026-08-09Adopted with a modification: spec §11.1 now permits optional async companions under four normative constraints (sync mandatory and primary; byte-identical output and identical error codes; no sync-implemented-by-blocking-on-async; only L4 adapters may depend on one), and §10 L4 extends to async index derivation. The proposal’s names were not adopted — the spec leaves the shape to the language bindings, since a companion is consumed only by an L4 adapter written against one core in one language and no cross-implementation test can observe it. docs/08 §5 and docs/14 §4 carry the dual-path harness obligation. Tracker #9 to close on push.
G102026-08-09Adopted as proposed, plus a ceiling-not-guarantee clause: new spec §3.5 bounds plaintext at 2³¹−1 bytes on encrypt, decrypt (pre-allocation), and rotate, and §9 gains a dedicated LENGTH_EXCEEDED rather than overloading MODE_VIOLATION. A runtime failing below the bound with an allocation error stays conformant; the exact testable requirement is that 2³¹ bytes yields LENGTH_EXCEEDED. §12 exempts it from the vector rule (a 2-GiB file is not a repository artifact) and docs/14 §4’s new out_of_band block makes the substitute assertion visible in the conformance report instead of absent from it. Tracker #10 to close on push.
G112026-08-09Adopted as proposed, partial close: spec §6.1 pins index-id = [a-z0-9-]{1,32} in ABNF, refused at declaration time, echoed in §7.2. Uppercase being unrepresentable removes case drift rather than adjudicating it. This settles the grammar only — canonical_context injectivity, including the absent-tenant_id encoding, remains G4’s, and §6.1 states that rather than letting this closure imply otherwise. context/ gains four negative declarations (not authored until suite 0.10.0-provisional, 2026-09-27, #210: the closure recorded them and no vector followed, so for seven weeks a core that accepted an out-of-grammar index-id passed the suite). Tracker #11 to close on push, with the carve-out noted in the closing comment.
G122026-08-09Adopted as proposed: spec §7.10’s unique-constraint row becomes No for blind indexes as well as ciphertext, since §7.4 mandates collisions, and prose below the table carries why the constraint is incorrect rather than merely unhelpful, the application-level fallback, and the §13.6 pointer. The fallback’s race is stated concretely — READ COMMITTED lets both transactions insert, so correctness needs SERIALIZABLE or an advisory lock on the index value, which leaks nothing further because the index value is already stored in the clear. Unblocks adapter DDL guidance in docs/12 and docs/13. Tracker #12 to close on push.
G132026-08-09Adopted as proposed, plus a prefix-index guard: spec §10.2’s Prisma bullet scopes the in: MUST — reject unless rewritten to the declared blind index as §7.10 membership with §7.5 re-verification, and reject whenever the rewrite cannot be guaranteed — and states the principle behind it (never silently mis-serve the query, not never serve it correctly). Because scoping the MUST invites the same move on startsWith:, the bullet now forbids silently rewriting it onto a §7.9 prefix index, that rewrite being sound only when the operand length equals the declared prefix length. docs/13’s documented §10.2 deviation is removed. Tracker #13 to close on push.

When an issue closes, sweep the tech-spec suite for the corresponding G<n> markers (grep -rn "G<n>" docs/07* docs/08* docs/09* docs/1[0-5]* docs/adr docs/issues) and update every dependent section — that sweep is part of closing, per docs/07-implementation-plan.md §6. Namespace caveat: docs/00-research-memo.md uses G1–G6 for market gaps (and docs/06-verification-log.md cites them) — a bare repo-wide grep collides with that older namespace; the sweep scope above avoids it.