From 809cbc9e3299b604d6d4578e9988888df94ae884 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:49:09 +0000 Subject: [PATCH 1/2] feat(spec): refuse undeclared keys on address and location values (#13802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-09-01 (option A): LocationValueSchema and AddressSchema (= AddressValueSchema) were all-optional stripping z.objects, so a value with a wrong key set parsed green and the wrong keys vanished — the showcase seed's postal_code (#13388) was accepted, dropped and rendered as an empty ZIP box, and a stored-value scan over the class could only report a clean count. Both are strictObject now; FileValueSchema stays the one deliberate looseObject. The refusal names the key and the rename (postal_code/zipCode -> postalCode, latitude/longitude -> lat/lng). Ordered census first: every in-repo corpus that writes address/location values (8459 files, 196,098 leaf literals, 57 shaped literals) carries zero keys outside the declared sets other than batch D's own tolerance pin, which is repinned here — the repair commit the ruling ordered is empty by measurement (#13388's seed fix landed at #14090). Where the refusal bites is ADR-0104's unchanged evidence gate: defaultValue literals and action params reject at authoring; record writes reject only on a deployment that attested adr-0104-value-shapes (or the env opt-in) and stay warn-first elsewhere; os migrate value-shapes now counts the key; no read path parses these shapes. Strictness-ledger triage row re-verdicted open -> authorable with the census and the migration note, the strip-map row dropped (reverse pin), finding 21 added, counts regenerated; D3 semantic entry address-location-value-unknown-keys-refused registered under 18. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .changeset/address-location-value-strict.md | 74 +++++++++ content/docs/data-modeling/field-types.mdx | 4 +- content/docs/protocol/objectql/types.mdx | 12 +- ...07-unknown-key-strictness-ledger.counts.md | 17 +-- .../2026-07-unknown-key-strictness-ledger.md | 37 ++++- .../src/validation/record-validator.test.ts | 74 +++++++++ .../src/validation/scan-value-shapes.test.ts | 34 +++++ ...st-data-create-address-unknown-key.test.ts | 142 ++++++++++++++++++ .../data/analytics-strictness-batchd.test.ts | 31 ++-- .../spec/src/data/field-default-value.test.ts | 25 +++ packages/spec/src/data/field-value.test.ts | 54 +++++++ packages/spec/src/data/field-value.zod.ts | 89 +++++++++-- ...ess-location-value-unknown-keys-refused.ts | 39 +++++ packages/spec/src/migrations/registry.ts | 35 +++++ 14 files changed, 624 insertions(+), 43 deletions(-) create mode 100644 .changeset/address-location-value-strict.md create mode 100644 packages/rest/src/rest-data-create-address-unknown-key.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts diff --git a/.changeset/address-location-value-strict.md b/.changeset/address-location-value-strict.md new file mode 100644 index 0000000000..0ef9c8c216 --- /dev/null +++ b/.changeset/address-location-value-strict.md @@ -0,0 +1,74 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse undeclared keys on `address` and `location` values — `AddressSchema` / `LocationValueSchema` are strict (#13802) + + + +**BREAKING** accept-set narrowing on two ADR-0104 D1 value contracts, shipped +as `minor` under the repo's launch-window convention for breaking changes; the +migration prescription is registered under protocol major 18. Maintainer +ruling 2026-09-01 on #13802 (director decision batch #26, verbatim 「同意」): +option A. + +`LocationValueSchema` and `AddressSchema` (`AddressValueSchema` is the same +schema) were all-optional **stripping** `z.object`s. Every member being +optional meant a value with a completely wrong key set still parsed green, +and the wrong keys vanished from the parse output — the showcase seed wrote +`postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP +box (#13388), while a stored-value scan over either class could only ever +report a clean count it had no way to earn. Both are now `strictObject`s. +`FileValueSchema` stays `z.looseObject` — the one deliberate loose site, +untouched. + +**What is refused:** any key the shape does not declare, with a prescriptive +message naming the surface, the key, and a rename where one is known +(`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`; +`latitude` → `lat`, `longitude` → `lng`). The zod issue is +`unrecognized_keys` and its `keys` name the offending spellings. + +**What stays accepted:** every declared key byte-identically — +`street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted` +on an address; `lat`, `lng`, `altitude`, `accuracy` on a location. + +**Where the refusal bites — and where it deliberately does not** (the +ADR-0104 posture is unchanged; this changeset narrows the contract, not the +write path's evidence gate): + +- **Authoring, hard reject, unconditional:** a `location` / `address` field's + literal `defaultValue` (`FieldSchema`, #7127) and an action param of those + types (`validateActionParams`, strict by default since 17.0). +- **Record writes, per deployment:** objectql's `validateRecord` rejects the + value (`400 VALIDATION_FAILED`, field code `invalid_type`, message naming + the key) **only** on a deployment that has attested `adr-0104-value-shapes` + or set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` (`OS_ALLOW_LAX_VALUE_SHAPES=1` + re-opens). Everywhere else the write is **admitted** warn-first, logged once + per field, and reported to the admitted-violation sink — exactly as before. +- **`os migrate value-shapes`** now counts an undeclared key as a violation, + so a deployment holding such values cannot attest until they are cleaned at + the producer. That scan is what keeps the strict flip from stranding stored + data. +- **Read paths: none.** No consumer parses these shapes on read; a stored + `{ …, postal_code }` reads back as it was written. No read path was + narrowed, and no consumer-side alias is introduced — `postal_code` is + refused, never read. + +## FROM → TO + +```ts +// before — parsed green; `postal_code` silently gone from the parsed output +valueSchemaFor({ type: 'address' }, 'stored').safeParse( + { street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US' }) +// => { success: true, data: { street, city, state, country } } + +// after — refused, naming the key and the declared spelling +// => { success: false, error: { issues: [{ code: 'unrecognized_keys', keys: ['postal_code'], +// message: 'Unrecognized key(s) on this address value: `postal_code`. Did you mean `postal_code` → `postalCode`? …' }] } } +``` + +Fix: spell the key as the contract declares it — `postal_code` → `postalCode` +in the producer (seed, importer, geocoder adapter, widget). For a location, +`latitude` / `longitude` → `lat` / `lng`; drop device extras such as +`heading` / `speed` or model them as fields of their own. Run +`os migrate value-shapes` to find stored values that carry undeclared keys. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 714c553980..a36909f1d2 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -528,14 +528,14 @@ Name-keyed map of embedded sub-objects (`Record`). Insertion ## Enhanced Types ### `location` -Geographic coordinates. Stored as `{ lat, lng, altitude?, accuracy? }` (`lat` −90..90, `lng` −180..180). No per-type config properties. The key names are `lat`/`lng`, not `latitude`/`longitude` — see `LocationValueSchema` in `field-value.zod.ts` (ADR-0104 D1). +Geographic coordinates. Stored as `{ lat, lng, altitude?, accuracy? }` (`lat` −90..90, `lng` −180..180). No per-type config properties. The key names are `lat`/`lng`, not `latitude`/`longitude` — see `LocationValueSchema` in `field-value.zod.ts` (ADR-0104 D1). The schema is strict (#13802): an undeclared key is refused by name, not silently dropped. ```typescript { name: 'headquarters', label: 'Location', type: 'location' } ``` ### `address` -Structured postal address. Stored as `{ street, city, state, postalCode, country, countryCode, formatted }` (all parts optional). No per-type config properties. +Structured postal address. Stored as `{ street, city, state, postalCode, country, countryCode, formatted }` (all parts optional). No per-type config properties. The schema is strict (#13802): an undeclared key such as `postal_code` or `zipCode` is refused with a rename to `postalCode`, not silently dropped. ```typescript { name: 'billing_address', label: 'Billing Address', type: 'address' } diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 262d74c55d..1f9ab008ba 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -1048,6 +1048,13 @@ billing_address: } ``` +Only these seven keys are accepted — the value contract (`AddressSchema`, +`field-value.zod.ts`) refuses an undeclared key and names it, so `postal_code` +or `zipCode` fails with a rename to `postalCode` instead of being silently +dropped (ADR-0104 D1; strict since #13802). A record write carrying one stays +warn-first until the deployment has attested `os migrate value-shapes`, which +now counts such keys as violations. + **Database mapping:** - SQL driver: a `JSON` column (not a composite type) - MongoDB: Embedded document @@ -1073,7 +1080,10 @@ office_location: `altitude` and `accuracy` (both in metres) are optional additional members. Note the keys are `lat`/`lng` — the `{ latitude, longitude }` spelling was never -consumed by the runtime and has been retired from the value contract. +consumed by the runtime and has been retired from the value contract. Those +four keys are the whole accept set: an undeclared key (`heading`, `latitude`) +is refused by name rather than dropped (`LocationValueSchema`, strict since +#13802), under the same ADR-0104 warn-first write posture as `address`. Proximity / radius ("near") search is **not** a built-in filter operator. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 0fe672c84f..52eccbb1e7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -22,8 +22,8 @@ regenerate. |---|---| | Triaged directories | 5 | | Object sites in them | 437 | -| Still-open (strip) sites | 124 | -| Files carrying at least one | 22 | +| Still-open (strip) sites | 122 | +| Files carrying at least one | 21 | Remaining strip sites by class: @@ -31,7 +31,7 @@ Remaining strip sites by class: |---|---| | authorable — the ruling's forced scope | 1 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 119 | +| wire / open — out of forced scope | 117 | | no door — no carrier, ADR-0049 territory | 3 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 1 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 169 | 157 | 5 | 0 | 7 | -| `data/` | 156 | 74 | 1 | 0 | 81 | +| `data/` | 156 | 76 | 1 | 0 | 79 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **437** | **307** | **6** | **0** | **124** | +| **total** | **437** | **309** | **6** | **0** | **122** | ## File-level triage — site counts @@ -176,7 +176,7 @@ over it is here. ### `data/` — open -**81 strip of 156**, in 12 file(s). +**79 strip of 156**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -186,19 +186,18 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field-value.zod.ts` | 2 | 3 | | `field.zod.ts` | 2 | 13 | | `filter.zod.ts` | 10 | 11 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **81** | **156** | +| **total** | **79** | **156** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 79 | +| wire / open — out of forced scope | 77 | | no door — no carrier, ADR-0049 territory | 2 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index c33746abf5..c3df28574f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -580,6 +580,27 @@ dropped at parse, and nothing failed. `chart.test.ts` is part of #5056 — the campaign's own recurring lesson about a second copy of the truth, arriving this time in its instruments. +21. **An all-optional stripping shape is a value that cannot fail — and an + instrument over it cannot count.** `LocationValueSchema` and `AddressSchema` + had every member optional, so under `.strip` a value with a completely + wrong key set parsed GREEN and the wrong keys vanished from the output. The + showcase's own seed wrote `postal_code` (#13388): accepted, dropped, + rendered as an empty ZIP box — and any stored-value scan over the class + reported a clean count it had no way to earn (#13802). Batch D had + re-verdicted the file `open` on a real door census and a hypothetical cost + (a phone's `heading`/`speed`, a geocoder's `district`); the corpus census the + ruling ordered first found **zero** such producers in-repo, and the + maintainer overruled the verdict (2026-09-01, option A). Closed with + `strictObject`; the refusal names the key and the rename + (`postal_code` → `postalCode`, `latitude` → `lat`). What did NOT move is the + write path's posture: ADR-0104's evidence gate decides where a refusal bites + (attested deployments reject, everyone else stays warn-first), and + `os migrate value-shapes` — which now counts the key — is the instrument that + earns the number. Two method notes: a `Class` verdict can be right about + WHO writes the input and still wrong about what the openness COSTS, and the + honest instrument reading for a shape that cannot fail is "unmeasurable", + never zero. + ## Where this ended up **24 of 25 registered types closed** (from 9 when the line started), and the @@ -705,7 +726,7 @@ column does not move and the `strip` column falls by the count of what left. | `mapping.zod.ts` | authorable (p) | | | `external-catalog.zod.ts` | wire (p) | | | `validation.zod.ts` | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions` → `transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it | -| `field-value.zod.ts` / `seed.zod.ts` | open | `seed` is strict (registered-types batch). **`field-value` re-verdicted `open` 2026-08-14 (#4001 batch D)** — the row's own "record data, very likely open" prediction, now measured: `LocationValueSchema` and `AddressSchema` are ADR-0104 VALUE contracts whose input is record data (end users, importers, device geolocation APIs, geocoders), consumed validation-only (`record-validator`'s `shapeSchemaFor(def).safeParse(value)` — the value is stored verbatim, so `.strip` never actually strips anything), with enforcement posture owned by ADR-0104's own evidence-gated warn-first rollout, not this ratchet. Closing them would reject legitimate stored data — a phone's geolocation payload carries `heading`/`speed`, a geocoder's address carries `district` — exactly the openness their sibling `FileValueSchema` declares with `z.looseObject` ("renderers add their own"). One caveat recorded rather than glossed: a `location`/`address` field's authored `defaultValue` literal validates through the same contract (#7127), so an author's extra key there is admitted silently — that is the value contract serving two doors with one posture, and splitting it strict-for-defaults/open-for-records would fork the ADR-0104 contract | +| `field-value.zod.ts` / `seed.zod.ts` | authorable | `seed` is strict (registered-types batch). **`field-value` strict as of #13802 (2026-09-02) — re-verdicted `open` → `authorable` by maintainer ruling 2026-09-01 (option A, director batch #26, verbatim 「同意」), overruling this row's #4001 batch D reading.** Both value contracts (`LocationValueSchema`, `AddressSchema` = `AddressValueSchema`) are `strictObject` now; `FileValueSchema` stays `z.looseObject` on purpose — the one deliberate loose site, ⛔ untouched. What batch D got wrong was not the door census but the cost model: every member of both shapes is OPTIONAL, so under `.strip` a value with a completely wrong key set still parsed green and the wrong keys vanished — the showcase seed wrote `postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP box (#13388; found by objectui#6812's survey; #5143 had named the same stripping on the widget round-trip) — while any stored-value scan over the class could only ever report zero (#13802's finding: an instrument reporting a number it has no way to earn). The "legitimate extras" (`heading`/`speed`, `district`) were a hypothesis with no in-repo producer. **Corpus census at `a39b02a6`, ordered first by the ruling**: every in-repo corpus that writes address/location VALUES — `examples/**`, `packages/apps/**`, `packages/qa/**`, `packages/**/src` fixtures and tests, `content/docs/**`, `skills/**`, `.changeset/**`, `docs/**` (`.ts/.tsx/.js/.mjs/.cjs/.json/.yaml/.yml/.md/.mdx`; generated `references/`, `releases/`, CHANGELOGs excluded) — a brace-matched scan of 8459 files / 196,098 leaf object literals found 57 address- or location-shaped literals and **0 carrying a key outside the declared sets** other than batch D's own tolerance pin in `analytics-strictness-batchd.test.ts` (repinned to the closure in the same stroke; the SCIM hits are `SCIMAddressSchema`, a different contract). The spelling grep `git grep -n -E "postal_code|zip_code|zipCode|postcode|latitude|longitude|heading *:|speed *:|district *:" -- 'examples/**' 'packages/apps/**' 'packages/qa/**' 'packages/spec/**' 'content/docs/**' 'skills/**'` hits only the retired-form docs, `ListMapConfig`'s field-name keys and pins. So the repair commit the ruling ordered first was EMPTY by measurement — #13388's seed fix had already landed at #14090. **Migration note — where the refusal bites, by call site** (`git grep -n "valueSchemaFor(" -- packages`, non-test): ① **authoring, hard reject, unconditional** — a `location`/`address` field's literal `defaultValue` (`default-value-shape.ts` `checkLiteralDefaultValue` → `FieldSchema`, #7127) and an action-param value of those types (`ui/action-params.zod.ts` `validateActionParams`, D2 strict by default since 17.0); ② **record writes, per deployment** — objectql `record-validator` `validateOne` (`shapeSchemaFor(def).safeParse(value)`, insert and update) rejects with `invalid_type` / `invalid_value_shape` ONLY when `valueShapeStrictEffective` holds (the deployment attested `adr-0104-value-shapes`, or `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`; `OS_ALLOW_LAX_VALUE_SHAPES=1` re-opens), otherwise **warn-first** — logged once per field and reported to the admitted-violation sink (#4769), unchanged; ③ **the scan** — `os migrate value-shapes` (`valueShapeViolation`, the same predicate) now COUNTS an undeclared key, so a deployment holding such values cannot attest until they are cleaned at the producer — the mechanism that keeps ② from stranding stored data; ④ **read paths: none** — no consumer calls `valueSchemaFor(def, 'expanded')` for these types outside `packages/spec` (`git grep "'expanded')" -- 'packages/**/src/**' ':!packages/spec/**'` → 0 hits), drivers return the stored JSON verbatim and renderers read it, so a stored `{ …, postal_code }` still reads back as written. ⛔ Per the ruling no read path was narrowed; the customer-database inventory is the one thing this repo cannot see — a confidence gap recorded here, not glossed: the scan is the instrument that closes it per deployment. ⛔ No consumer-side alias (AGENTS.md #0.1): the `aliases` on both shapes are did-you-mean RENAMES in the refusal message (`zipCode`/`zip`/`postcode` → `postalCode`, `latitude`/`longitude` → `lat`/`lng`), not tolerance — `postal_code` is refused, never read. objectui at the pin (`LocationField.tsx`) `safeParse`s only a widget-built `{ lat, lng, altitude?, accuracy? }` candidate, so its verdicts do not move; its `LocationField.optionalKeys.test.tsx` pins the OLD strip behaviour and flips the day objectui takes a spec carrying this — filed there, not here. D3 semantic entry `address-location-value-unknown-keys-refused` (protocol 18). Re-check: `git grep -n "strictObject(\|looseObject(\|z.object(" -- packages/spec/src/data/field-value.zod.ts` → two `strictObject(`, one `z.looseObject(`, zero `z.object(` | ### `automation/` — file-level triage @@ -1212,7 +1233,6 @@ triage row record which one was taken. | `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step | | `field.zod.ts` | no door | ⛔ **not strictness work — re-verdicted 2026-08-13 (#4001 data batch).** This row's own instruction was "check whether they are record data (→ open) before closing", and the measured answer is the THIRD one: neither authorable nor open. Both remaining strip sites (`LocationCoordinatesSchema`, `CurrencyValueSchema`) are `@deprecated` DEAD EXPORTS that contradict the enforced value contract — `currency` stores a BARE NUMBER everywhere (validator, SQL driver `float` column, import coercion, field-zoo oracle), `location` stores `{lat, lng}` not `{latitude, longitude}`. Carrier: absent — no schema in the tree references either, so unreachability from every authoring root holds by construction and no BFS is needed (the only non-test references are two `type-alias-convention.pin.test.ts` rows). Parse: absent outside their own `field.test.ts` cases. Consumers' vocabulary: absent — zero references in objectui; `field-value.test.ts` pins from the other direction that the enforced contract REJECTS the retired `CurrencyValueSchema` object shape. The ADR-0049 answer this class prescribes already exists: ADR-0104's "Reality wins" section decides both removals ("an exported-but-unconsumed value schema is exactly the inert metadata ADR-0078 forbids"), the JSDoc deprecations are on `main` with "Removal rides the next spec major", and the removal is tracked at **#8562** — this row points there, never at a batch. Closing them instead would be #4583's precisely-validated dead slot, and worse than most instances of it: a `strictObject` `surface` name plus did-you-mean suggestions on a shape authors must NOT use is an invitation dressed as enforcement, on the exact spelling (`{value, currency}` / `{latitude, longitude}`) the real contract rejects. The third named shape the old row carried, `Address`, was never this file's site — `AddressSchema` is DECLARED in `field-value.zod.ts` since #7127 and only re-exported here | | `driver-sql.zod.ts` | wire | **out of scope** | -| `field-value.zod.ts` | open | **out of forced scope — re-verdicted from `mixed (p)` at #4001 batch D**, confirming this row's own prediction by measurement rather than reading: `LocationValueSchema` / `AddressSchema` are record-data VALUE contracts (ADR-0104), validation-only at every consumer, whose extras are legitimate stored data (device `heading`/`speed`, geocoder `district`). Enforcement posture belongs to ADR-0104's evidence-gated rollout, not this ratchet — see the triage row for the full read, including the one authored door (`defaultValue` literals) recorded as a caveat | **Authorable strip in `data/`:** [the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#data--open) splits this @@ -1228,7 +1248,9 @@ authorable on both halves and was CLOSED (8 sites strict — its row leaves this way `driver/memory.zod.ts`'s did, by reaching zero), `seed-loader` re-verdicted `wire` (12 sites — the "authored" half of the old provisional split did not survive producer enumeration), and `field-value` re-verdicted `open` (2 sites — ADR-0104 record-data -value contracts). (`external-lookup` carried `mixed (p)` too +value contracts) — **a verdict overruled at #13802 (2026-09-02)**: the maintainer ruled both +sites closed (option A), the row left this map at zero, and the triage row now carries the +corpus census and the migration note that the closure owed. (`external-lookup` carried `mixed (p)` too until #8075 retired the whole file under ADR-0049 — its per-schema read arrived as a zero-consumer verdict, and the strictness question died with the shapes.) The rest is wire/open and out of the ruling's forced scope; that count fell by one when #4721 closed `query.zod.ts`'s @@ -1645,6 +1667,11 @@ readable in one place: - the `data/` waves — batch A, batch B, batch D and 批 20 — ending with `object.zod.ts` site 14 once its cross-repo hold (#5247 → objectui#4772) was spent. +- one post-campaign flip, **#13802** (2026-09-02): `field-value.zod.ts`'s two value + contracts, closed after the maintainer overruled batch D's `open`. The first row in the + map to move by RULING rather than by measurement — and note what the ruling corrected: + not the door census (batch D's producers were real) but the cost model, which had + priced a hypothetical extra key above a measured silent strip. **The method that survived all of them**: verify who writes the input *before* tightening, per schema and never per file, with a positive control in the same @@ -1738,7 +1765,9 @@ from an abandoned one. no slice of this campaign delivered. - **The 122 non-authorable strip sites**, in the map's 22 file rows (the `view.zod.ts` row is the one that spans both, `1 authorable, 2 wire`). Wire, - open, `no door` and `covered` rows stay in the map by design. The `no door` + open, `no door` and `covered` rows stay in the map by design. *(2026-09-02: + 121 non-authorable in 21 rows — #13802 closed `field-value.zod.ts`'s two `open` sites by + maintainer ruling; the counts file carries the live number.)* The `no door` ones carry the only follow-up in the set, and it is a different ratchet: ADR-0049 removal, tracked at #8562 for `field.zod.ts`'s two. - **The nine untriaged directories.** They were never in the ruling's forced diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index a958cca040..fd4685ea2b 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -426,6 +426,80 @@ describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () => }); }); +/** + * #13802 — an UNDECLARED key on an `address` / `location` value. + * + * Both value contracts were all-optional `.strip` objects, so `postal_code` + * (the showcase seed's spelling, #13388) parsed green with the key silently + * gone. The spec is strict now; what this file pins is that the WRITE PATH's + * ADR-0104 posture did not move with it: warn-first admits the value and + * reports it to the evidence sink, strict rejects it through the same + * `invalid_type` door the other shape violations use, and the refusal names + * the key — never a bare "invalid". + */ +describe('validateRecord — #13802 undeclared keys on address/location values', () => { + const schema = { + fields: { + addr: { type: 'address' }, + geo: { type: 'location' }, + }, + }; + const seedTypo = { + addr: { street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US' }, + }; + const deviceExtras = { geo: { lat: 37.77, lng: -122.42, heading: 90, speed: 3 } }; + const declared = { + addr: { street: '1 Main St', city: 'Seattle', state: 'WA', postalCode: '98101', country: 'US' }, + geo: { lat: 37.77, lng: -122.42, altitude: 10, accuracy: 5 }, + }; + + it('warn-first (the default): the write is ADMITTED and reported to the evidence sink, naming the key', () => { + const admitted: Array<{ gate: string; field: string; type: string; detail: string }> = []; + expect(() => + validateRecord(schema, { ...seedTypo, ...deviceExtras }, 'insert', { + onAdmittedValueShapeViolation: (v) => admitted.push(v), + }), + ).not.toThrow(); + expect(admitted.map((v) => [v.gate, v.field, v.type])).toEqual([ + ['value-shape', 'addr', 'address'], + ['value-shape', 'geo', 'location'], + ]); + // The detail is the prescription an author acts on: the key, and the spelling the contract lands on. + expect(admitted[0]?.detail).toContain('`postal_code`'); + expect(admitted[0]?.detail).toContain('`postal_code` → `postalCode`'); + expect(admitted[1]?.detail).toContain('`heading`'); + expect(admitted[1]?.detail).toContain('`speed`'); + }); + + it('strict (attested deployment, or the env opt-in): rejects with invalid_type and names the key', () => { + const rejects = (data: Record, field: string, keyNamed: string) => { + try { + validateRecord(schema, { ...data }, 'insert', { valueShapeStrict: true }); + expect.unreachable('expected ValidationError'); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + const err = e as ValidationError; + expect(err.fields[0]).toMatchObject({ field, code: 'invalid_type' }); + expect(err.fields[0]?.message).toContain(keyNamed); + } + }; + rejects(seedTypo, 'addr', '`postal_code`'); + rejects(deviceExtras, 'geo', '`heading`'); + // The env opt-in is the same door. + process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1'; + try { + expect(() => validateRecord(schema, { ...seedTypo }, 'update')).toThrow(ValidationError); + } finally { + delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; + } + }); + + it('declared keys — including the optional members — write in both modes, byte-identically', () => { + expect(() => validateRecord(schema, { ...declared }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { ...declared }, 'insert', { valueShapeStrict: true })).not.toThrow(); + }); +}); + /** * #3617 / #3438 — media value shapes enforce per DEPLOYMENT, not per release. * diff --git a/packages/objectql/src/validation/scan-value-shapes.test.ts b/packages/objectql/src/validation/scan-value-shapes.test.ts index 1451290074..1aa9495ba7 100644 --- a/packages/objectql/src/validation/scan-value-shapes.test.ts +++ b/packages/objectql/src/validation/scan-value-shapes.test.ts @@ -11,6 +11,7 @@ const OBJECTS: Record = { name: { type: 'text' }, account: { type: 'lookup', reference: 'account' }, geo: { type: 'location' }, + addr: { type: 'address' }, // Engine-owned: never validated on a write, so never scanned either. owner_cache: { type: 'lookup', reference: 'sys_user', system: true }, }, @@ -142,6 +143,39 @@ describe('scanValueShapes (ADR-0104 D1 / #3438)', () => { ).not.toThrow(); }); + it('#13802: an UNDECLARED key on an address/location value is a finding — the class the scan could not measure', async () => { + // Both value contracts were all-optional `.strip` objects, so a value with + // a completely wrong key set parsed green: the showcase seed's + // `postal_code` (#13388) was a violation this scan structurally could not + // count, and "zero findings" was the only answer the instrument could give. + // Strict since #13802, the same predicate now counts it — and names the key. + const engine = makeEngine({ + contact: [ + { id: 'c1', addr: { street: '1 Main St', city: 'Seattle', postal_code: '98101' } }, // the seed's shape + { id: 'c2', geo: { lat: 37.77, lng: -122.42, heading: 90 } }, // a device extra + { id: 'c3', addr: { street: '1 Main St', city: 'Seattle', postalCode: '98101' } }, // declared — clean + ], + }); + const report = await scanValueShapes(engine, silent); + + expect(report.scannedRecords).toBe(3); + expect(report.blocking).toBe(2); + const addr = report.findings.find((f) => f.field === 'addr')!; + expect(addr).toMatchObject({ type: 'address', count: 1, sampleRecordIds: ['c1'] }); + expect(addr.detail).toContain('`postal_code`'); + expect(addr.detail).toContain('`postalCode`'); + expect(report.findings.find((f) => f.field === 'geo')!.detail).toContain('`heading`'); + expect(valueShapeScanPassed(report)).toBe(false); + + // One predicate: the flagged values are write rejections under strict, the clean one writes. + expect(() => + validateRecord(OBJECTS.contact, { addr: { street: '1 Main St', postal_code: '98101' } }, 'update', { valueShapeStrict: true }), + ).toThrow(ValidationError); + expect(() => + validateRecord(OBJECTS.contact, { addr: { street: '1 Main St', postalCode: '98101' } }, 'update', { valueShapeStrict: true }), + ).not.toThrow(); + }); + it('the scan counts exactly what strict mode rejects — one predicate, not two', async () => { // The anti-drift property: every value the scan flags must also be a write // rejection under the strict gate, and every value it passes must write. diff --git a/packages/rest/src/rest-data-create-address-unknown-key.test.ts b/packages/rest/src/rest-data-create-address-unknown-key.test.ts new file mode 100644 index 0000000000..83507a13e1 --- /dev/null +++ b/packages/rest/src/rest-data-create-address-unknown-key.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13802 — an `address` / `location` value carrying an UNDECLARED key, at the + * REST write door (`POST /api/v1/data/:object`), through a REAL engine + * (`ObjectQL` + sqlite `SqlDriver`) and the real `RestServer` route — the same + * harness `import-integration.test.ts` boots. + * + * Both value contracts were all-optional `.strip` objects, so the showcase + * seed's `postal_code` (#13388) parsed green with the key silently gone. The + * spec is strict now. What this file pins is the ADR-0112 envelope the door + * answers with — `400` + `VALIDATION_FAILED` + the field code — AND the half + * the ruling protected: the write path's ADR-0104 posture did not move. On a + * deployment that has not attested `adr-0104-value-shapes` the write is still + * ADMITTED warn-first, and the stored value reads back exactly as written — + * no read path parses these shapes, so nothing is narrowed on the way out. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +const SITE = { + name: 'site', label: 'Site', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + site_name: { name: 'site_name', type: 'text' as const, label: 'Name' }, + billing_address: { name: 'billing_address', type: 'address' as const, label: 'Billing Address' }, + hq: { name: 'hq', type: 'location' as const, label: 'HQ' }, + }, +}; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); + await engine.init(); + engine.registry.registerObject(SITE as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const create = rest.getRoutes().find( + (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object', + ); + expect(create).toBeDefined(); + return { engine, create }; +} + +// The showcase seed's own spelling (#13388), and a device extra on a location. +const SEED_TYPO = { street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US' }; +const DEVICE_EXTRA = { lat: 37.77, lng: -122.42, heading: 90 }; +const DECLARED_ADDRESS = { street: '1 Main St', city: 'Seattle', state: 'WA', postalCode: '98101', country: 'US' }; +const DECLARED_LOCATION = { lat: 37.77, lng: -122.42, accuracy: 5 }; + +describe('POST /api/v1/data/:object — undeclared keys on address/location values (#13802)', () => { + let engine: any; + let create: any; + beforeEach(async () => { ({ engine, create } = await boot()); }); + + const post = (body: Record) => { + const res = makeRes(); + return create.handler({ params: { object: 'site' }, body } as any, res).then(() => res); + }; + + it('strict deployment: answers 400 VALIDATION_FAILED + invalid_type, naming the key (code AND status)', async () => { + process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1'; + + const res = await post({ id: 's1', site_name: 'HQ', billing_address: SEED_TYPO }); + expect(res._status).toBe(400); + expect(res._json).toMatchObject({ code: 'VALIDATION_FAILED', object: 'site' }); + expect(res._json.fields[0]).toMatchObject({ field: 'billing_address', code: 'invalid_type' }); + // The message carries the prescription — the key AND the spelling the contract lands on. + expect(res._json.fields[0].message).toContain('`postal_code`'); + expect(res._json.fields[0].message).toContain('`postal_code` → `postalCode`'); + // The refused row left NOTHING behind. + expect(await engine.findOne('site', { where: { id: 's1' } })).toBeNull(); + + const geo = await post({ id: 's2', site_name: 'Depot', hq: DEVICE_EXTRA }); + expect(geo._status).toBe(400); + expect(geo._json).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(geo._json.fields[0]).toMatchObject({ field: 'hq', code: 'invalid_type' }); + expect(geo._json.fields[0].message).toContain('`heading`'); + + // …and the declared shapes still write on the same strict deployment. + const ok = await post({ id: 's3', site_name: 'Lab', billing_address: DECLARED_ADDRESS, hq: DECLARED_LOCATION }); + expect(ok._status ?? 200).toBeLessThan(400); + const stored = await engine.findOne('site', { where: { id: 's3' } }); + expect(stored?.billing_address).toEqual(DECLARED_ADDRESS); + expect(stored?.hq).toEqual(DECLARED_LOCATION); + }); + + it('unattested deployment (the default): the write is ADMITTED warn-first and reads back verbatim — no read path is narrowed', async () => { + // ADR-0104's evidence gate, not the schema, decides where the refusal + // bites. This deployment has not attested `adr-0104-value-shapes`, so the + // same body that the strict deployment refused above is admitted here… + const res = await post({ id: 's4', site_name: 'Legacy', billing_address: SEED_TYPO, hq: DEVICE_EXTRA }); + expect(res._status ?? 200).toBeLessThan(400); + + // …and comes back exactly as written — the undeclared keys included. + // Nothing on the read side parses these shapes; a stored value is the + // stored value. `os migrate value-shapes` is the instrument that finds it. + const stored = await engine.findOne('site', { where: { id: 's4' } }); + expect(stored?.billing_address).toEqual(SEED_TYPO); + expect(stored?.hq).toEqual(DEVICE_EXTRA); + }); +}); diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index b587b19820..0a914c5e1e 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -4,7 +4,9 @@ * #4001 batch D — `data/analytics.zod.ts` strictness (all 8 sites), plus the * two re-verdicts the same per-schema read produced (`seed-loader.zod.ts` → * wire, `field-value.zod.ts` → open), pinned so a later sweep stops here - * instead of "finishing" them. + * instead of "finishing" them. The `field-value` half of that pin was + * OVERRULED at #13802 (maintainer ruling 2026-09-01): section 5 below now pins + * the closure, with the reason the batch-D reading did not survive. * * This file is the third of the three places each verdict is recorded (the * others: the JSDoc on each shape, and the `data/` rows in @@ -254,16 +256,23 @@ describe('#4001 batch D — the strict base does not break the request wrapper', // 5. The shapes this batch deliberately did NOT close, with the reason // =========================================================================== describe('#4001 batch D — deliberate non-closures (re-verdicts, not omissions)', () => { - it('`LocationValueSchema` / `AddressSchema` stay tolerant — record-data value contracts (ADR-0104), not authoring surfaces', () => { - // A phone's geolocation payload carries `heading`/`speed`; a geocoder's - // address carries `district`. These are legitimate stored record data, and - // every consumer is validation-only (`record-validator` stores the value - // verbatim), so `.strip` never actually strips anything here. Closing them - // would reject real data; the enforcement posture belongs to ADR-0104's - // evidence-gated warn-first rollout. The day either line goes red, that - // rollout — not this ratchet — is the place the decision was made. - accept(LocationValueSchema, { lat: 1, lng: 2, heading: 90, speed: 3 }); - accept(AddressSchema, { street: '1 Main St', district: 'Central' }); + it('`LocationValueSchema` / `AddressSchema` are CLOSED since #13802 — the batch-D `open` verdict was overruled', () => { + // Batch D pinned these two as tolerant ("a phone's geolocation payload + // carries `heading`/`speed`; a geocoder's address carries `district`") and + // said the day the line went red, the decision would have been made + // elsewhere. It was: maintainer ruling 2026-09-01 on #13802, option A — + // an all-optional stripping `z.object` accepts a completely wrong key set + // and drops it (the showcase seed's `postal_code`, #13388), so a + // stored-value scan over the class could only ever report a clean count. + // The refusal names the key; where it BITES stays with ADR-0104's + // evidence-gated write path (warn-first until the deployment attests + // `adr-0104-value-shapes`), which is why closing the schema did not + // strand stored data. Full read: the ledger's `data/` rows. + expect(reject(LocationValueSchema, { lat: 1, lng: 2, heading: 90, speed: 3 })).toContain('`heading`'); + expect(reject(AddressSchema, { street: '1 Main St', district: 'Central' })).toContain('`district`'); + // …and the declared shapes are byte-for-byte still accepted. + accept(LocationValueSchema, { lat: 1, lng: 2, altitude: 10, accuracy: 5 }); + accept(AddressSchema, { street: '1 Main St', city: 'SF', postalCode: '94105', country: 'US' }); }); it('`seed-loader` shapes stay tolerant — an internal service contract whose every producer is framework code', () => { diff --git a/packages/spec/src/data/field-default-value.test.ts b/packages/spec/src/data/field-default-value.test.ts index 25178d7c86..b671593ebc 100644 --- a/packages/spec/src/data/field-default-value.test.ts +++ b/packages/spec/src/data/field-default-value.test.ts @@ -71,6 +71,21 @@ const CASES: Case[] = [ field: { type: 'user', multiple: true, defaultValue: 'usr_1' }, accepted: false, }, + // #13802 — the one AUTHORED door the strictness ledger recorded as a caveat + // while these value contracts were `.strip`: an author's undeclared key on a + // structured default was admitted silently. Refused by name now. + { + label: 'address + literal carrying an undeclared key (the #13388 seed spelling)', + field: { type: 'address', defaultValue: { street: '1 Main St', postal_code: '98101' } }, + accepted: false, + contains: ['`postal_code`', '`postalCode`'], + }, + { + label: 'location + literal carrying a device extra the contract does not declare', + field: { type: 'location', defaultValue: { lat: 37.77, lng: -122.42, heading: 90 } }, + accepted: false, + contains: ['`heading`'], + }, // ── Literal branch: valid literals stay accepted ────────────────────────── { label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true }, @@ -86,6 +101,16 @@ const CASES: Case[] = [ field: { type: 'user', multiple: true, defaultValue: ['usr_1'] }, accepted: true, }, + { + label: 'VALID address literal (declared keys only)', + field: { type: 'address', defaultValue: { street: '1 Main St', city: 'Seattle', postalCode: '98101', countryCode: 'US' } }, + accepted: true, + }, + { + label: 'VALID location literal (declared keys only)', + field: { type: 'location', defaultValue: { lat: 37.77, lng: -122.42, accuracy: 5 } }, + accepted: true, + }, { label: 'json — explicitly OPEN contract, any literal rides', field: { type: 'json', defaultValue: { anything: ['at', 'all'] } }, diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index 541b4de3b6..d09dab812b 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -269,6 +269,60 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => { ok({ type: 'composite' }, { label: 'x', n: 1 }); }); + it('#13802: location/address values refuse an undeclared key BY NAME — the strip that hid the showcase seed typo', () => { + // Every member of both shapes is optional, so under zod's default `.strip` + // a value with a completely wrong key set parsed GREEN and the wrong keys + // vanished from the parse output — #13388's seed wrote `postal_code`, the + // platform accepted it, dropped it, and rendered an empty ZIP box, while a + // stored-value scan over the class could only ever report zero. Assert the + // ENVELOPE — issue code + the keys it names + the rename it prescribes — + // never a bare `success === false`, which cannot tell this refusal from + // the schema refusing the value for an unrelated reason. + const firstIssue = (def: Parameters[0], v: unknown) => { + const r = valueSchemaFor(def, 'stored').safeParse(v); + if (r.success) throw new Error(`expected REJECTION, got a successful parse of ${JSON.stringify(v)}`); + return r.error.issues[0] as { code: string; keys?: readonly string[]; message: string }; + }; + + // The measured shape (#13388 / #13802's own repro). + const seed = firstIssue({ type: 'address' }, { + street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US', + }); + expect(seed.code).toBe('unrecognized_keys'); + expect(seed.keys).toEqual(['postal_code']); + expect(seed.message).toContain('this address value'); + expect(seed.message).toContain('Did you mean `postal_code` → `postalCode`?'); + + // The spelling the address widget wrote for a release (objectstack#5143): + // a different WORD, reachable only through the curated alias. + expect(firstIssue({ type: 'address' }, { street: '1', zipCode: '98101' }).message) + .toContain('`zipCode` → `postalCode`'); + + // Location: the extras batch D once called legitimate are named, all of them. + const geo = firstIssue({ type: 'location' }, { lat: 1, lng: 2, heading: 90, speed: 3 }); + expect(geo.code).toBe('unrecognized_keys'); + expect(geo.keys).toEqual(['heading', 'speed']); + expect(geo.message).toContain('this location value'); + + // The retired spec-only spelling carries its rename (an alias — edit + // distance cannot reach `latitude` → `lat`). It is ALSO a missing-pair + // rejection; the unrecognized-keys issue is the one that names the fix. + const retired = valueSchemaFor({ type: 'location' }, 'stored').safeParse({ latitude: 1, longitude: 2 }); + expect(retired.success).toBe(false); + const unknown = (retired as { error: { issues: Array<{ code: string; message: string }> } }).error.issues + .find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.message).toContain('`latitude` → `lat`'); + expect(unknown?.message).toContain('`longitude` → `lng`'); + + // Declared keys, byte-for-byte, still parse — including the optional ones. + ok({ type: 'address' }, { street: '1 Main St', city: 'SF', state: 'CA', postalCode: '94105', country: 'USA', countryCode: 'US', formatted: '1 Main St, SF' }); + ok({ type: 'location' }, { lat: 37.77, lng: -122.42, altitude: 10, accuracy: 5 }); + + // The ruling's positive control: `FileValueSchema` is the ONE deliberate + // loose site and is untouched — an extra key still rides through it. + ok({ type: 'file' }, { url: 'https://cdn/x', extra: 1 }, 'expanded'); + }); + it('json/code and computed types are explicitly open', () => { ok({ type: 'json' }, { a: 1, b: [2, 3] }); ok({ type: 'formula' }, 31.5); diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 5a00d209a8..4398631d42 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -32,6 +32,15 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +// A VALUE import into the `field.zod` ↔ `suggestions.zod` ↔ `strict-object` +// evaluation cycle (this module → `strict-object` → `suggestions.zod` → +// `field.zod` → this module). Safe in either entry order, including under +// `OS_EAGER_SCHEMAS=1`: everything a `strictObject(…)` call touches at +// construction is a hoisted `function` declaration (`strictObject`, +// `strictObjectError`, `declarationStore`), and the error map builds its +// suggester lazily on the first ISSUE, never at module scope. Measured with +// this file as the entry module under eager evaluation (#13802). +import { strictObject } from '../shared/strict-object'; import { SystemObjectName } from '../system/constants/system-names'; import type { FieldType } from './field.zod'; @@ -247,13 +256,37 @@ export const ClockTimeValueSchema = lazySchema(() => 'expected HH:MM or HH:MM:SS (wall-clock time of day)')); export type ClockTimeValue = z.input; -/** GPS point — the shape field-zoo stores and renderers read. See header re: the retired `{latitude, longitude}` form. */ -export const LocationValueSchema = lazySchema(() => z.object({ - lat: z.number().min(-90).max(90).describe('Latitude'), - lng: z.number().min(-180).max(180).describe('Longitude'), - altitude: z.number().optional().describe('Altitude in meters'), - accuracy: z.number().optional().describe('Accuracy in meters'), -})); +/** + * GPS point — the shape field-zoo stores and renderers read. See header re: the + * retired `{latitude, longitude}` form. + * + * **Strict as of #13802** (maintainer ruling 2026-09-01, option A). Every + * member is optional except the pair, so under zod's default `.strip` a value + * with a wrong key set still parsed GREEN and the wrong keys vanished from the + * parse output — a stored-value scan over this class could only ever report a + * clean count. An undeclared key is now refused, naming the key and the + * closest declared one (`latitude` → `lat`). Where that refusal bites is the + * ADR-0104 write path's own posture, not this schema's: record writes stay + * warn-first until the deployment's `adr-0104-value-shapes` gate opens, and + * no read path parses this shape (strictness-ledger `data/` row). + */ +export const LocationValueSchema = lazySchema(() => strictObject( + { + surface: 'this location value', + history: 'Until #13802 an undeclared key on a location value was silently dropped — the value ' + + 'parsed green with the key gone, so the mistake surfaced only as a blank on screen.', + // The retired spec-only spelling (see the module header). Edit distance + // cannot reach `latitude` → `lat`; the value contract has refused the pair + // since ADR-0104 D1, so the rename is the one an author actually needs. + aliases: { latitude: 'lat', longitude: 'lng' }, + }, + { + lat: z.number().min(-90).max(90).describe('Latitude'), + lng: z.number().min(-180).max(180).describe('Longitude'), + altitude: z.number().optional().describe('Altitude in meters'), + accuracy: z.number().optional().describe('Accuracy in meters'), + }, +)); export type LocationValue = z.input; /** @@ -267,16 +300,40 @@ export type LocationValue = z.input; * direction is an ESM evaluation cycle whose order-dependent TDZ crash this * move retires structurally (the remaining `FieldType` import above is * type-only and erased at runtime). + * + * **Strict as of #13802** (maintainer ruling 2026-09-01, option A). Every + * member is optional, so under `.strip` a value with a completely wrong key + * set parsed GREEN: the showcase seed wrote `postal_code`, the platform + * accepted it, dropped it, and rendered an empty ZIP box (#13388), while a + * stored-value scan over this class reported a clean count it could not earn. + * An undeclared key is now refused, naming the key and the closest declared + * one (`postal_code` → `postalCode`). Where that refusal bites is the ADR-0104 + * write path's own posture, not this schema's: record writes stay warn-first + * until the deployment's `adr-0104-value-shapes` gate opens, and no read path + * parses this shape (strictness-ledger `data/` row). ⛔ No consumer-side + * alias follows from this — `postal_code` is refused, not read. */ -export const AddressSchema = lazySchema(() => z.object({ - street: z.string().optional().describe('Street address'), - city: z.string().optional().describe('City name'), - state: z.string().optional().describe('State/Province'), - postalCode: z.string().optional().describe('Postal/ZIP code'), - country: z.string().optional().describe('Country name or code'), - countryCode: z.string().optional().describe('ISO country code (e.g., US, GB)'), - formatted: z.string().optional().describe('Formatted address string'), -})); +export const AddressSchema = lazySchema(() => strictObject( + { + surface: 'this address value', + history: 'Until #13802 an undeclared key on an address value was silently dropped — the value ' + + 'parsed green with the key gone, so a misspelled postal code surfaced only as an empty ' + + 'box on screen (#13388).', + // `zipCode` is the spelling the address widget wrote for a release + // (objectstack#5143) — a different WORD for the declared key, which edit + // distance cannot reach. The rename names the key the contract lands on. + aliases: { zipCode: 'postalCode', zip: 'postalCode', postcode: 'postalCode' }, + }, + { + street: z.string().optional().describe('Street address'), + city: z.string().optional().describe('City name'), + state: z.string().optional().describe('State/Province'), + postalCode: z.string().optional().describe('Postal/ZIP code'), + country: z.string().optional().describe('Country name or code'), + countryCode: z.string().optional().describe('ISO country code (e.g., US, GB)'), + formatted: z.string().optional().describe('Formatted address string'), + }, +)); /** Structured address value — adopts the (previously unconsumed) `AddressSchema` as the enforced contract. */ export const AddressValueSchema = AddressSchema; diff --git a/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts new file mode 100644 index 0000000000..ba7ebabfd2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'address-location-value-unknown-keys-refused', + surface: 'stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, ' + + '`LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same ' + + 'contract: a `location` / `address` field\'s literal `defaultValue` and an action param of ' + + 'those types — undeclared keys', + replacement: 'the declared key the rejection names. An address value accepts exactly `street`, ' + + '`city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value ' + + 'exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the ' + + 'offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, ' + + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' + + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', + reason: + 'Maintainer ruling 2026-09-01 on #13802 (option A). Both value classes were all-optional ' + + 'STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the ' + + 'wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the ' + + 'platform accepted it, dropped it, and rendered an empty ZIP box (#13388, objectui#6812; ' + + '#5143 named the same stripping on the widget round-trip), while a stored-value scan over ' + + 'the class could only ever report a clean count it had no way to earn. Closing the two ' + + 'shapes restores declared = enforced and pulls "loose" back to the one deliberate ' + + 'exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write ' + + 'path\'s own evidence-gated posture, deliberately unchanged: a record write carrying an ' + + 'undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` ' + + '(or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays ' + + 'warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` ' + + 'now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. ' + + 'No read path parses these shapes; a stored value reads back as written.', + acceptanceCriteria: + '`os migrate value-shapes` reports zero findings on `address` / `location` fields — every ' + + 'stored value carries only declared keys (`postalCode`, never `postal_code` / `zipCode`; ' + + '`lat` / `lng`, never `latitude` / `longitude` / `heading` / `speed`) — and every ' + + '`address` / `location` `defaultValue` literal and action-param value parses with only ' + + 'declared keys. Declared keys parse byte-identically to before; `FileValueSchema` still ' + + 'admits extra keys.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9682d3cfed..a92f299b91 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5337,6 +5337,41 @@ const step18: MigrationStep = { // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + { + id: 'address-location-value-unknown-keys-refused', + surface: 'stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, ' + + '`LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same ' + + 'contract: a `location` / `address` field\'s literal `defaultValue` and an action param of ' + + 'those types — undeclared keys', + replacement: 'the declared key the rejection names. An address value accepts exactly `street`, ' + + '`city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value ' + + 'exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the ' + + 'offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, ' + + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' + + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', + reason: + 'Maintainer ruling 2026-09-01 on #13802 (option A). Both value classes were all-optional ' + + 'STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the ' + + 'wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the ' + + 'platform accepted it, dropped it, and rendered an empty ZIP box (#13388, objectui#6812; ' + + '#5143 named the same stripping on the widget round-trip), while a stored-value scan over ' + + 'the class could only ever report a clean count it had no way to earn. Closing the two ' + + 'shapes restores declared = enforced and pulls "loose" back to the one deliberate ' + + 'exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write ' + + 'path\'s own evidence-gated posture, deliberately unchanged: a record write carrying an ' + + 'undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` ' + + '(or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays ' + + 'warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` ' + + 'now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. ' + + 'No read path parses these shapes; a stored value reads back as written.', + acceptanceCriteria: + '`os migrate value-shapes` reports zero findings on `address` / `location` fields — every ' + + 'stored value carries only declared keys (`postalCode`, never `postal_code` / `zipCode`; ' + + '`lat` / `lng`, never `latitude` / `longitude` / `heading` / `speed`) — and every ' + + '`address` / `location` `defaultValue` literal and action-param value parses with only ' + + 'declared keys. Declared keys parse byte-identically to before; `FileValueSchema` still ' + + 'admits extra keys.', + }, { id: 'admin-export-wildcard-removed', surface: From bbf14f4ba79738362bd495e40c3fe8c7079bee7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:18:05 +0000 Subject: [PATCH 2/2] fix(spec): keep the address/location refusal text free of issue ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:doc-authoring refused the two strictObject `history` strings for carrying `#13802` / `#13388` — customer-facing refusal text has no tracker to resolve them (maintainer ruling 2026-08-12). The sentences are repaired around the ids, the anchors stay in the JSDoc, and the zod-level pins gain the negative assertion the gate asks for (the message must not match an issue id). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- packages/spec/src/data/field-value.test.ts | 3 +++ packages/spec/src/data/field-value.zod.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index d09dab812b..c139611574 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -292,6 +292,8 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => { expect(seed.keys).toEqual(['postal_code']); expect(seed.message).toContain('this address value'); expect(seed.message).toContain('Did you mean `postal_code` → `postalCode`?'); + // Customer-facing refusal text carries no internal issue id (check:doc-authoring's rule). + expect(seed.message).not.toMatch(/#\d+/); // The spelling the address widget wrote for a release (objectstack#5143): // a different WORD, reachable only through the curated alias. @@ -303,6 +305,7 @@ describe('valueSchemaFor — stored form (field-zoo reality)', () => { expect(geo.code).toBe('unrecognized_keys'); expect(geo.keys).toEqual(['heading', 'speed']); expect(geo.message).toContain('this location value'); + expect(geo.message).not.toMatch(/#\d+/); // The retired spec-only spelling carries its rename (an alias — edit // distance cannot reach `latitude` → `lat`). It is ALSO a missing-pair diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 4398631d42..95d6063a7f 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -273,8 +273,10 @@ export type ClockTimeValue = z.input; export const LocationValueSchema = lazySchema(() => strictObject( { surface: 'this location value', - history: 'Until #13802 an undeclared key on a location value was silently dropped — the value ' - + 'parsed green with the key gone, so the mistake surfaced only as a blank on screen.', + // Customer-facing text: no issue ids (the anchor is the JSDoc above). + history: 'Until this shape was closed, an undeclared key on a location value was silently ' + + 'dropped — the value parsed green with the key gone, so the mistake surfaced only as a ' + + 'blank on screen.', // The retired spec-only spelling (see the module header). Edit distance // cannot reach `latitude` → `lat`; the value contract has refused the pair // since ADR-0104 D1, so the rename is the one an author actually needs. @@ -316,9 +318,10 @@ export type LocationValue = z.input; export const AddressSchema = lazySchema(() => strictObject( { surface: 'this address value', - history: 'Until #13802 an undeclared key on an address value was silently dropped — the value ' - + 'parsed green with the key gone, so a misspelled postal code surfaced only as an empty ' - + 'box on screen (#13388).', + // Customer-facing text: no issue ids (the anchors are in the JSDoc above). + history: 'Until this shape was closed, an undeclared key on an address value was silently ' + + 'dropped — the value parsed green with the key gone, so a misspelled postal code surfaced ' + + 'only as an empty box on screen.', // `zipCode` is the spelling the address widget wrote for a release // (objectstack#5143) — a different WORD for the declared key, which edit // distance cannot reach. The rename names the key the contract lands on.