Skip to content

test: record the gate's second blind spot, and the way across it - #18

Merged
ernysans merged 1 commit into
mainfrom
ernysans-consumer-conditions-type-test
Aug 23, 2026
Merged

test: record the gate's second blind spot, and the way across it#18
ernysans merged 1 commit into
mainfrom
ernysans-consumer-conditions-type-test

Conversation

@ernysans

Copy link
Copy Markdown
Member

Follow-up to #15, prompted by the null widening in #17. Re-runs the liveness probe on current main and 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?: undefined mutation on c08006f: gate red (TS2578 ×2 at schema.consumer-unguarded.ts:97,109), control green 0. 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

null is assignable to every type where strictNullChecks is off, which is the condition this gate compiles under, so a T | null union 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 | null simply 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.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:

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

Row 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 | null is the correct model for a field the store genuinely holds as null and is load-bearing for every strict consumer. The only thing being recorded is that a green typecheck:consumer is 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 of value: T | null — because property existence is config-independent. The REACHABLE half 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

mutation gate meaning
presence union regressed to value: null red (TS2322 + TS2578) the restated form is genuinely enforceable, not decorative
flags drift strict (--strictNullChecks) red (TS18047, TS18049) the blind spot closed; update the fixture and §6.4
liveness control's subject gains an index signature red (TS2578) a control that stopped controlling says so

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)

build 0 · clean-rebuild lib/ drift EMPTY · lint 0 · test 0 (964/964) · typecheck 0 · typecheck:consumer 0 · typecheck:consumer:control 0 · private-marker check 0.

Attribution over origin/main..HEAD: 0 trailers, single identity, author == committer, positive-controlled (same grep returns 1 on 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 in main and nothing was lost. The diff here is the follow-up alone.

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.
@ernysans
ernysans merged commit d74a7d9 into main Aug 23, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-consumer-conditions-type-test branch August 23, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant