From 69ed7b9d62008d763a8ca52c23dd848050053509 Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 20:51:48 -0500 Subject: [PATCH] test: inventory the two schemas a dropped field could leave unguarded 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. --- test/model/Account.test.ts | 80 ++++++++++++++++++++++++++++++++++++ test/model/EventData.test.ts | 57 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/test/model/Account.test.ts b/test/model/Account.test.ts index 4b81db7..70f07d6 100644 --- a/test/model/Account.test.ts +++ b/test/model/Account.test.ts @@ -889,6 +889,86 @@ const validAccount = (): Record => ({ describe('Account.Schema', () => { describe('field inventory', () => { + /** + * Exhaustive key inventory. + * + * The `toContain` checks below name 22 of these 55 keys, which makes them a + * spot-check rather than an inventory: a field deleted from the schema and + * not named in one of those lists is invisible to them. It is also invisible + * to {@link Account.SchemaOutput}, because that proof is assignability-based + * and `z.looseObject` infers a `[x: string]: unknown` index signature — an + * *optional* interface property is satisfied structurally whether or not the + * schema still declares it, so only the removal of a *required* field is a + * compile error. + * + * A field silently dropped from the schema is not a cosmetic loss. The field + * stops being validated entirely and falls through to the loose-object + * passthrough, where any value is accepted, while {@link Account.Interface} + * continues to promise it was checked. This assertion is what makes that a + * failure, and it is deliberately a literal list rather than anything + * derived from `Schema.shape`, which would delete itself alongside the field + * and pass. + */ + it('should declare exactly these 55 fields, so a silently dropped field fails here', () => { + expect(Object.keys(Account.Schema.shape).sort()).toEqual([ + 'alias', + 'appToPersonUseCase', + 'area', + 'authorizedRepresentative1', + 'authorizedRepresentative2', + 'automaticHeader', + 'backup', + 'bca', + 'brandType', + 'businessIndustry', + 'businessName', + 'businessRegionsOfOperations', + 'businessRegistrationIdentifier', + 'businessRegistrationNumber', + 'businessType', + 'city', + 'companyType', + 'counted', + 'country', + 'created', + 'description', + 'domain', + 'domainOk', + 'domainTimestamp', + 'estimatedVolume', + 'expiry', + 'id', + 'image', + 'imageURL', + 'language', + 'links', + 'name', + 'pending', + 'postalCode', + 'ready', + 'sampleMessage1', + 'sampleMessage2', + 'sampleMessage3', + 'sampleMessage4', + 'sampleMessage5', + 'sender', + 'sending', + 'status', + 'stockExchange', + 'stockTicker', + 'street1', + 'street2', + 'tollFreeUseCase', + 'type', + 'uid', + 'updated', + 'useCaseDescription', + 'useCaseDescriptionCTA', + 'useName', + 'utcOffset', + ]); + }); + it('should declare the account fields alongside the inherited audit and queue fields', () => { const keys = Object.keys(Account.Schema.shape); for (const field of ['id', 'backup', 'created', 'updated', 'expiry']) { diff --git a/test/model/EventData.test.ts b/test/model/EventData.test.ts index 96ec324..18525e7 100644 --- a/test/model/EventData.test.ts +++ b/test/model/EventData.test.ts @@ -303,6 +303,63 @@ const validEvent = (): Record => ({ describe('EventData.Schema', () => { describe('field inventory', () => { + /** + * Exhaustive key inventory. + * + * The `toContain` checks below name 18 of these 37 keys, so a field deleted + * from the schema and absent from those lists passes them. It also passes + * {@link EventData.SchemaOutput}: that proof is assignability-based, and the + * `[x: string]: unknown` index signature `z.looseObject` infers satisfies an + * *optional* interface property whether or not the schema declares it. + * + * A dropped field stops being validated and falls through to the + * loose-object passthrough, accepting any value, while + * {@link EventData.Interface} still promises it was checked. The list is + * literal on purpose — deriving it from `Schema.shape` would shrink with the + * deletion and assert nothing. + */ + it('should declare exactly these 37 fields, so a silently dropped field fails here', () => { + expect(Object.keys(EventData.Schema.shape).sort()).toEqual([ + 'account', + 'amount', + 'area', + 'backup', + 'blocks', + 'booked', + 'checkout', + 'clicks', + 'country', + 'created', + 'currency', + 'description', + 'duration', + 'endTime', + 'expiry', + 'frequency', + 'geohash', + 'hosts', + 'id', + 'language', + 'latitude', + 'limit', + 'location', + 'longitude', + 'media', + 'name', + 'placeId', + 'placeName', + 'runHour', + 'startTime', + 'status', + 'type', + 'uid', + 'updated', + 'users', + 'utcOffset', + 'views', + ]); + }); + it('should declare the event fields alongside the inherited audit and place fields', () => { const keys = Object.keys(EventData.Schema.shape); for (const field of ['name', 'account', 'blocks', 'startTime', 'endTime', 'runHour', 'limit', 'amount']) {