Skip to content

test: inventory the two schemas a dropped field could leave unguarded - #19

Merged
ernysans merged 1 commit into
mainfrom
ernysans-schema-key-inventories
Aug 23, 2026
Merged

test: inventory the two schemas a dropped field could leave unguarded#19
ernysans merged 1 commit into
mainfrom
ernysans-schema-key-inventories

Conversation

@ernysans

Copy link
Copy Markdown
Member

The gap

A field deleted from a document schema stops being validated. It falls through to the z.looseObject passthrough, where any value is accepted, while the interface continues to promise it was checked. For Account and EventData, nothing in the pipeline caught that.

This came out of generalizing the linksSchema finding in #17. If an assignability-based proof against an index-signature-carrying type is weak, that should apply to every SchemaOutput in the package — all 16 schemas are z.looseObject. It does.

Why the compile-time proof can't see it

AssertSchemaOutput<TOutput extends TInterface, TInterface> is assignability-based, and z.looseObject infers [x: string]: unknown. That index signature satisfies an optional interface property whether or not the schema still declares it. Only removing a required field is a compile error.

Measured on merged main, before any change here:

mutation tsc vitest
delete optional Price.amount green red
delete optional Account.stockTicker green green
delete optional EventData.views green green
delete required Price.account red red
Price.amount number → string red red

This is the third guard weakened by the same index signature, after the consumer gate's TS2339 and the links type-equality proof. It is pre-existing, not introduced by the null widening.

Why two shapes and not sixteen

12 of the other 14 shapes already carry a literal key inventory — which is why deleting Price.amount turned the suite red. The two exceptions:

  • Account — a toContain spot-check naming 22 of 55 keys
  • EventData — a toContain spot-check naming 18 of 37 keys

A deletion outside those lists passed both the compiler and the suite. Account is the largest and most compliance-sensitive shape in the package.

The fix

An exhaustive literal key inventory for each, alongside the existing spot-checks (kept — they document intent per group). The lists are literal on purpose: deriving them from Schema.shape would shrink along with the deleted field and assert nothing, which is the same vacuity trap as a fixture that passes under two spellings.

Positive control

Five deletions, all turning the suite red, with the unmodified tree green:

  • Account.stockTicker and EventData.views — the two that previously passed both gates
  • Account.useName, Account.postalCode, EventData.duration — fields named in no spot-check

Gates

gate exit
npm run build 0
build-output drift clean
npm test (966 passed, 18 files) 0
npm run typecheck 0
npm run typecheck:consumer 0
npm run typecheck:consumer:control 0
npm run lint 0
check-private-markers.sh 0

Tests only — no src/ change, so lib/ is untouched and the drift check is clean by construction rather than by rebuild.

No Co-authored-by: trailer: 0 over origin/main..HEAD, authorship a single identity matching the committer.

A field deleted from a document schema stops being validated and falls through
to the loose-object passthrough, where any value is accepted, while the
interface continues to promise it was checked. Nothing in the pipeline caught
that for Account or EventData.

The compile-time proof cannot. `AssertSchemaOutput` is assignability-based and
`z.looseObject` infers a `[x: string]: unknown` index signature, which satisfies
an *optional* interface property whether or not the schema still declares it.
Only removing a *required* field is a compile error. Measured: deleting
Price.amount left tsc clean, deleting Price.account did not.

This is the same mechanism already recorded against the consumer gate's TS2339
and against the links type-equality proof. It is a third guard weakened by the
same index signature, and it is pre-existing rather than introduced by the null
widening.

Twelve of the fourteen other shapes were already covered by a literal key
inventory, which is why deleting Price.amount turned the suite red. Account had
only a toContain spot-check naming 22 of its 55 keys, and EventData one naming
18 of 37, so a deletion outside those lists passed both the compiler and the
suite. Measured before the change: deleting Account.stockTicker and
EventData.views left everything green.

Both inventories are literal lists rather than anything derived from
Schema.shape, which would shrink alongside the deleted field and assert nothing.
The existing spot-checks are kept, since they document intent per group.

Positive-controlled: five deletions across both shapes, including the two that
previously passed and three fields named in no spot-check, all turn the suite
red, with the unmodified tree green.
@ernysans
ernysans merged commit d3ee7dc into main Aug 23, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-schema-key-inventories 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