From 79559f2f4f7cf82445bffc81761153ae78408e58 Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 20:31:47 -0500 Subject: [PATCH] test: record the gate's second blind spot, and the way across it The consumer gate proves nothing about nullability. `null` is assignable to every type where strictNullChecks is off, which is the condition the gate compiles under, so a `T | null` union collapses and there is nothing left to observe. Unlike the index-signature boundary the mechanism is not broken here; it fires perfectly, but `T | null` is not a property-existence fact and property existence is all this gate can see. Prompted by the recent widening of persisted fields to accept null. That change is right and load-bearing for strict consumers - this is a statement about the gate, not about the types - but a green typecheck:consumer must not be cited as evidence about it. Measured rather than assumed, and the two blind spots were separated rather than conflated. The obvious reading is that the index signature is doing the hiding, since nullable properties mostly live on document types that carry one. It is not: Idempotency.Response has no index signature - proven in the same compilation, where an undeclared key on that exact type is still TS2339 - and its `body: string | null` is still invisible. Under consumer settings resp.body.toUpperCase() and led.consumed + 1 both compile; under strict they are TS18047 and TS18049. Two independent causes. The fixture pairs the boundary with the remedy, because a nullability guarantee that must hold regardless of the consumer's flags can be restated as presence/absence - {has: true; value: T} | {has: false} - and property existence is config-independent. Not a reason to re-encode existing fields; the honest fix for a permissive consumer is to enable strictNullChecks there. Mutation-validated three ways, control green in each: regressing the presence union back to `value: null` turns the gate red (TS2322 + TS2578), proving the restated form is genuinely enforceable and not decorative; forcing --strictNullChecks turns the blind-spot half red (TS18047, TS18049), which is what should happen if the collapse ever stops; and giving the liveness control's own subject an index signature turns it red (TS2578), so a control that stops controlling says so. Also re-established the original mechanism on current main: reintroducing data?: undefined on ParseFailure still turns the gate red with the control green, so the null widening did not hollow out what was already there. --- .github/copilot-instructions.md | 7 +- .../serialized-models.instructions.md | 9 + .github/instructions/tests.instructions.md | 44 ++++ .../interface/schema.consumer-nullability.ts | 202 ++++++++++++++++++ 4 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 test-consumer/interface/schema.consumer-nullability.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5128249..9f2ec03 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,9 +223,10 @@ Any update to the root `README.MD` must: > > **It is not blanket coverage.** Its mechanism is `TS2339`, which cannot fire on a type carrying an > index signature — so on the 10 declarations extending `BaseFirestore`, green means "cannot be -> checked", not "is safe". That boundary is itself encoded as a test in -> `test-consumer/interface/base_db.consumer-boundary.ts`; see -> `.github/instructions/tests.instructions.md` §6.3. +> checked", not "is safe". A second, independent blind spot: `T | null` collapses where +> `strictNullChecks` is off, so the gate proves nothing about nullability either. Both boundaries are +> themselves encoded as tests in `test-consumer/interface/`; see +> `.github/instructions/tests.instructions.md` §6.3 and §6.4. > **CI gate:** `.github/workflows/nodejs.yml` runs on `push`/`pull_request` to `main` across Node > `22.x` and `24.x`, executing `npm ci` → `npm run build` → build-output drift check → diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index c07b7be..c23ce08 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -237,3 +237,12 @@ cannot report it. The boundary is *"types that admit arbitrary keys"*, not *"nul nullability guarantee restates cleanly as a presence union, an index signature does not restate at all. Before relying on omission, check which side of that line your type is on; the boundary is encoded as a test in `test-consumer/interface/base_db.consumer-boundary.ts`. + +A **second, independent** boundary sits alongside it: `T | null` is invisible to the consumer gate, +because `null` is assignable to everything where `strictNullChecks` is off. That does not make +`T | null` wrong — it is the right model for a field the store holds as `null`, and it is +load-bearing for strict consumers — but it does mean the gate proves nothing about it. Where a +nullability guarantee must hold **regardless** of the consumer's flags, restate it as +`{has: true; value: T} | {has: false}`; property existence is config-independent. Measured and +encoded in `test-consumer/interface/schema.consumer-nullability.ts`, together with the proof that +the two boundaries are independent rather than one cause. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 8433581..3173529 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -277,5 +277,49 @@ both were mutation-validated: Do not "fix" the blind-spot half by making it error. Its **compiling is the assertion**, and it is not an endorsement. +### 6.4 The second blind spot: `T | null` is invisible to this gate + +> 🔴 **The gate cannot observe nullability at all.** `null` is assignable to every type where +> `strictNullChecks` is off — which is the condition the gate compiles under — so a `T | null` union +> collapses and there is nothing left to check. + +Unlike §6.3 the mechanism is not broken here; it fires perfectly. It is simply that `T | null` is not +a *property-existence* fact, and property existence is all this gate can see. + +**The two boundaries are independent, and were measured separately** — the obvious reading conflates +them, because the nullable properties mostly live on document types that carry an index signature +too. If the index signature were doing the hiding, the nullability question would be moot. It is not: +`Idempotency.Response` has **no** index signature — proven in the same compilation, where an +undeclared key on that exact type is still `TS2339` — and its `body: string | null` is still +invisible. Same file, same declarations, both settings: + +| read | consumer settings | strict settings | +|---|---|---| +| `resp.body.toUpperCase()` where `body: string \| null` | **compiles** | `TS18047` | +| `led.consumed + 1` where `consumed?: number \| null` | **compiles** | `TS18049` | +| `resp.undeclaredKey` (no index signature) | `TS2339` | `TS2339` | + +The third row is the liveness control: the mechanism is demonstrably working in the run where the +first two report nothing. + +**This is a statement about the gate, not about the types.** `T | null` is the right way to model a +field the store genuinely holds as `null`, and it is load-bearing for every strict consumer. Just +never cite a green `typecheck:consumer` as evidence about nullability. + +**The remedy, where a nullability guarantee must hold regardless of the consumer's flags:** restate +it as presence/absence — `{has: true; value: T} | {has: false}` rather than `value: T | null`. Property +existence is config-independent, so the restated form is enforceable where the union form is not. +That is not a reason to go re-encode existing fields; the honest fix for a permissive consumer is for +that consumer to enable `strictNullChecks`. + +`test-consumer/interface/schema.consumer-nullability.ts` encodes the boundary **and** the remedy as a +paired fixture. Mutation-validated, control green in each: + +| mutation | gate | meaning | +|---|---|---| +| presence union regressed to `value: null` | **red** (`TS2322` + `TS2578`) | the restated form is genuinely enforceable | +| flags drift strict (`--strictNullChecks`) | **red** (`TS18047`, `TS18049`) | the blind spot closed; update the fixture and this section | +| the control's subject gains an index signature | **red** (`TS2578`) | the liveness control has stopped controlling | + The rule this enforces is in [`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/test-consumer/interface/schema.consumer-nullability.ts b/test-consumer/interface/schema.consumer-nullability.ts new file mode 100644 index 0000000..d251cdb --- /dev/null +++ b/test-consumer/interface/schema.consumer-nullability.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * The consumer-conditions gate's **second coverage boundary**: nullability. + * + * Companion to `base_db.consumer-boundary.ts`, which records the first one. + * Both exist for the same reason — a green run of `npm run typecheck:consumer` + * must never be read as blanket coverage — but they are **independent**, and + * the difference matters: + * + * - **Index signature** (`base_db.consumer-boundary.ts`): the gate's mechanism + * cannot fire, because every property access is legal by construction. + * - **Nullability** (this file): the gate's mechanism fires correctly, but + * `T | null` is not a *property-existence* fact. Under `strictNullChecks: + * false` — the condition this gate compiles under — `null` is assignable to + * every type, so the union collapses and there is nothing left to observe. + * + * These were measured **separately**, because the obvious reading conflates + * them: the properties widened to `| null` mostly live on document types, which + * extend {@link BaseFirestore} and so carry an index signature too. If the + * index signature were doing the hiding, the nullability question would be moot. + * It is not. `Idempotency.Response` has **no** index signature — proven in the + * same compilation by the `REACHABLE` half below, where an undeclared key on + * that exact type is still `TS2339` — and its `body: string | null` is *still* + * invisible here. Two independent blind spots, not one. + * + * Measured under the two settings, same file, same declarations: + * + * | read | consumer settings | strict settings | + * |---|---|---| + * | `resp.body.toUpperCase()` where `body: string \| null` | **compiles** | `TS18047` | + * | `led.consumed + 1` where `consumed?: number \| null` | **compiles** | `TS18049` | + * | `resp.undeclaredKey` (no index signature) | `TS2339` | `TS2339` | + * + * The third row is the liveness control: the mechanism is demonstrably working + * in the very run where the first two report nothing. + * + * ## This is a statement about the gate, not a defect in the types + * + * `T | null` is the correct way to model a field a document store genuinely + * holds as `null`, and widening to it is a real, load-bearing improvement for + * every consumer that compiles strictly. This file does not argue with that. It + * records that **this gate cannot see it**, so nobody cites a green run as + * evidence about nullability. + * + * ## The remedy, which is why the halves are paired + * + * A nullability guarantee that must survive a permissive consumer can be + * **restated as presence/absence**: `{has: true; value: T} | {has: false}` + * instead of `value: T | null`. Property existence is config-independent, so the + * restated form is enforceable where the union form is not. The `REACHABLE` + * half below is that restatement, and it compiles as a compile error exactly as + * intended — so this file states the boundary *and* demonstrates the way across + * it. + * + * That is not a recommendation to go re-encode existing fields. The honest fix + * for a permissive consumer is for that consumer to enable `strictNullChecks`. + * The restatement is for a guarantee that must hold *regardless*. + * + * Both directions are self-announcing. If a `BLIND SPOT` assertion starts + * erroring, the collapse has stopped happening — the compiler changed, or the + * project's flags drifted strict — and this file plus + * `tests.instructions.md` §6.4 should be updated to say so. + */ + +import type {Idempotency, Ledger} from '@furcata/core-node/model'; + +/* ------------------------------------------------------------------------ * + * BLIND SPOT — `| null` collapses under the settings this gate compiles with. + * Everything here must compile; that compiling IS the recorded limitation. + * ------------------------------------------------------------------------ */ + +declare const shippedResponse: Idempotency.Response; + +/** + * `Idempotency.Response.body` is declared `string | null`. Reading a method off + * it without a null check is `TS18047` under strict settings and **legal** + * here. + * + * No `@ts-expect-error`, deliberately: this must compile, and it compiling is + * the assertion. The type is right; the gate simply cannot observe it. + */ +const nullUnionIsInvisible: string = shippedResponse.body.toUpperCase(); + +declare const shippedLedger: Ledger.Interface; + +/** + * The same on an optional-and-nullable property of a document type + * (`consumed?: number | null`), which is `TS18049` under strict settings — + * `possibly 'null' or 'undefined'` — and legal here. + * + * This one carries an index signature as well, so it is doubly invisible. It is + * kept because it is the shape most of the widened properties actually have, + * and the single-cause case above is what proves the two causes are separable. + */ +const optionalNullUnionIsInvisible: number = shippedLedger.consumed + 1; + +/* ------------------------------------------------------------------------ * + * REACHABLE — the same guarantee restated as presence/absence, which the gate + * can enforce. This half is the positive control AND the remedy. + * ------------------------------------------------------------------------ */ + +/** + * Synthetic payload. Deliberately meaningless: this repository is public. + */ +interface SyntheticPayload { + /** + * Arbitrary numeric field, present only so there is something to read. + */ + amount: number; +} + +/** + * Present branch of the restated form. + */ +interface PayloadPresent { + /** + * Discriminant. + */ + has: true; + /** + * The value, reachable only after narrowing. + */ + value: SyntheticPayload; +} + +/** + * Absent branch. The property is **omitted**, not declared `value: null` and not + * declared `value?: undefined` — omission is what makes the guarantee rest on + * property existence and therefore hold under either null-checking setting. + */ +interface PayloadAbsent { + /** + * Discriminant. + */ + has: false; +} + +/** + * The restated guarantee. + */ +type PayloadSlot = PayloadPresent | PayloadAbsent; + +/** + * Resolves to `true` when reading `K` off `T` type-checks, and `false` when it + * is a compile error. + * + * Asserted in the must-compile direction, per `tests.instructions.md` §6.1: an + * assertion that something *fails* can be satisfied by a substitute failure, + * and a must-compile assertion cannot, because it is not satisfied by an error + * at all. + * + * @template T The type whose key set is being examined. + * @template K The key being tested for reachability. + */ +type KeyIsReachable = K extends keyof T ? true : false; + +declare const absentValueReachable: KeyIsReachable; + +/** + * The load-bearing half: restated as presence/absence, the absent case genuinely + * hides the value, and the gate can prove it under permissive settings. + * + * Change {@link PayloadAbsent} to carry `value: null` or `value?: undefined` and + * this becomes `true` where `false` is required, so the gate goes red — which is + * precisely the regression the union form cannot be made to report. + */ +const absentSlotHidesValue: false = absentValueReachable; + +declare const slot: PayloadSlot; + +// @ts-expect-error Deliberately SHALLOW per tests.instructions.md 6.1. The restated form makes the unhandled case a property-existence error, which fires regardless of the null-checking setting — unlike the `| null` form above. +const unguardedSlotRead: unknown = slot.value; + +/** + * The liveness control for the blind-spot half, and the proof that the two + * boundaries are independent. + * + * `Idempotency.Response` carries **no** index signature, so an undeclared key on + * it is a compile error — in the same compilation where its `body: string | null` + * above reported nothing. Without this line, the blind-spot assertions could be + * silent because the gate was broken rather than because it cannot see + * nullability. + */ +// @ts-expect-error A type with no index signature rejects an undeclared key. If this stops erroring, this type has gained one and has left the gate's coverage entirely. +const responseRejectsUnknownKey: unknown = shippedResponse.thisKeyIsDeclaredNowhere; + +/** + * Every binding above is referenced here so that none of them can be dropped + * as unused by a future tool, and so the file has an export and is a module. + * The array is never evaluated; this project compiles with `noEmit`. + */ +export const nullabilityBoundaryChecked: unknown[] = [ + nullUnionIsInvisible, + optionalNullUnionIsInvisible, + absentSlotHidesValue, + unguardedSlotRead, + responseRejectsUnknownKey, +];