test: record the gate's second blind spot, and the way across it - #18
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #15, prompted by the null widening in #17. Re-runs the liveness probe on current
mainand answers the question that widening raises: can the consumer gate still see what it was built to see?Additive only: one fixture + docs. No source, no published type, no
lib/change.1. The original mechanism survived #17
Re-ran the
data?: undefinedmutation onc08006f: gate red (TS2578×2 atschema.consumer-unguarded.ts:97,109), control green0. The widening did not hollow out what was already there.2. But the gate cannot see nullability at all — and that is a second, independent blind spot
nullis assignable to every type wherestrictNullChecksis off, which is the condition this gate compiles under, so aT | nullunion collapses and there is nothing left to observe.Unlike the index-signature boundary in §6.3, the mechanism is not broken here — it fires perfectly.
T | nullsimply is not a property-existence fact, and property existence is all this gate can see.The two causes 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.Responsehas no index signature — proven in the same compilation, where an undeclared key on that exact type is stillTS2339— and itsbody: string | nullis still invisible:resp.body.toUpperCase()wherebody: string | nullTS18047led.consumed + 1whereconsumed?: number | nullTS18049resp.undeclaredKey(no index signature)TS2339TS2339Row 3 is the liveness control: the mechanism is demonstrably working in the very run where rows 1 and 2 report nothing.
This is a statement about the gate, not about the types.
T | nullis the correct model for a field the store genuinely holds asnulland is load-bearing for every strict consumer. The only thing being recorded is that a greentypecheck:consumeris not evidence about it.3. The fixture pairs the boundary with the remedy
A nullability guarantee that must hold regardless of the consumer's flags can be restated as presence/absence —
{has: true; value: T} | {has: false}instead ofvalue: T | null— because property existence is config-independent. TheREACHABLEhalf is that restatement, and the gate enforces it.Not a reason to go re-encode existing fields; the honest fix for a permissive consumer is for that consumer to enable
strictNullChecks. But the boundary now states itself and shows the way across it.Mutation validation — three ways, control green in each
value: nullTS2322+TS2578)--strictNullChecks)TS18047,TS18049)TS2578)Mutation 3 also lit up
base_db.consumer-boundary.ts(TS2322+TS2578), so the two boundary fixtures report together on a shared cause. As in §6.3, the blind-spot half must compile — its compiling is the assertion, not an endorsement — and the file says so, so nobody "fixes" it.Gates (2026-08-23T01:31Z, on
c08006f)build0· clean-rebuildlib/drift EMPTY ·lint0·test0(964/964) ·typecheck0·typecheck:consumer0·typecheck:consumer:control0· private-marker check0.Attribution over
origin/main..HEAD: 0 trailers, single identity, author == committer, positive-controlled (same grep returns1on a synthetic trailer).Note on the branch
This reuses the branch from #15, reset onto current
main— #15 was squash-merged, so its content is already inmainand nothing was lost. The diff here is the follow-up alone.