Skip to content

Commit 809cbc9

Browse files
committed
feat(spec): refuse undeclared keys on address and location values (#13802)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
1 parent a39b02a commit 809cbc9

14 files changed

Lines changed: 624 additions & 43 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse undeclared keys on `address` and `location` values — `AddressSchema` / `LocationValueSchema` are strict (#13802)
6+
7+
<!-- adr-0087: registered address-location-value-unknown-keys-refused -->
8+
9+
**BREAKING** accept-set narrowing on two ADR-0104 D1 value contracts, shipped
10+
as `minor` under the repo's launch-window convention for breaking changes; the
11+
migration prescription is registered under protocol major 18. Maintainer
12+
ruling 2026-09-01 on #13802 (director decision batch #26, verbatim 「同意」):
13+
option A.
14+
15+
`LocationValueSchema` and `AddressSchema` (`AddressValueSchema` is the same
16+
schema) were all-optional **stripping** `z.object`s. Every member being
17+
optional meant a value with a completely wrong key set still parsed green,
18+
and the wrong keys vanished from the parse output — the showcase seed wrote
19+
`postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP
20+
box (#13388), while a stored-value scan over either class could only ever
21+
report a clean count it had no way to earn. Both are now `strictObject`s.
22+
`FileValueSchema` stays `z.looseObject` — the one deliberate loose site,
23+
untouched.
24+
25+
**What is refused:** any key the shape does not declare, with a prescriptive
26+
message naming the surface, the key, and a rename where one is known
27+
(`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`;
28+
`latitude` → `lat`, `longitude` → `lng`). The zod issue is
29+
`unrecognized_keys` and its `keys` name the offending spellings.
30+
31+
**What stays accepted:** every declared key byte-identically —
32+
`street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`
33+
on an address; `lat`, `lng`, `altitude`, `accuracy` on a location.
34+
35+
**Where the refusal bites — and where it deliberately does not** (the
36+
ADR-0104 posture is unchanged; this changeset narrows the contract, not the
37+
write path's evidence gate):
38+
39+
- **Authoring, hard reject, unconditional:** a `location` / `address` field's
40+
literal `defaultValue` (`FieldSchema`, #7127) and an action param of those
41+
types (`validateActionParams`, strict by default since 17.0).
42+
- **Record writes, per deployment:** objectql's `validateRecord` rejects the
43+
value (`400 VALIDATION_FAILED`, field code `invalid_type`, message naming
44+
the key) **only** on a deployment that has attested `adr-0104-value-shapes`
45+
or set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` (`OS_ALLOW_LAX_VALUE_SHAPES=1`
46+
re-opens). Everywhere else the write is **admitted** warn-first, logged once
47+
per field, and reported to the admitted-violation sink — exactly as before.
48+
- **`os migrate value-shapes`** now counts an undeclared key as a violation,
49+
so a deployment holding such values cannot attest until they are cleaned at
50+
the producer. That scan is what keeps the strict flip from stranding stored
51+
data.
52+
- **Read paths: none.** No consumer parses these shapes on read; a stored
53+
`{ …, postal_code }` reads back as it was written. No read path was
54+
narrowed, and no consumer-side alias is introduced — `postal_code` is
55+
refused, never read.
56+
57+
## FROM → TO
58+
59+
```ts
60+
// before — parsed green; `postal_code` silently gone from the parsed output
61+
valueSchemaFor({ type: 'address' }, 'stored').safeParse(
62+
{ street: '1 Main St', city: 'Seattle', state: 'WA', postal_code: '98101', country: 'US' })
63+
// => { success: true, data: { street, city, state, country } }
64+
65+
// after — refused, naming the key and the declared spelling
66+
// => { success: false, error: { issues: [{ code: 'unrecognized_keys', keys: ['postal_code'],
67+
// message: 'Unrecognized key(s) on this address value: `postal_code`. Did you mean `postal_code` → `postalCode`? …' }] } }
68+
```
69+
70+
Fix: spell the key as the contract declares it — `postal_code` → `postalCode`
71+
in the producer (seed, importer, geocoder adapter, widget). For a location,
72+
`latitude` / `longitude` → `lat` / `lng`; drop device extras such as
73+
`heading` / `speed` or model them as fields of their own. Run
74+
`os migrate value-shapes` to find stored values that carry undeclared keys.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,14 +528,14 @@ Name-keyed map of embedded sub-objects (`Record<string, SubObject>`). Insertion
528528
## Enhanced Types
529529
530530
### `location`
531-
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).
531+
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.
532532
533533
```typescript
534534
{ name: 'headquarters', label: 'Location', type: 'location' }
535535
```
536536
537537
### `address`
538-
Structured postal address. Stored as `{ street, city, state, postalCode, country, countryCode, formatted }` (all parts optional). No per-type config properties.
538+
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.
539539
540540
```typescript
541541
{ name: 'billing_address', label: 'Billing Address', type: 'address' }

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,13 @@ billing_address:
10481048
}
10491049
```
10501050

1051+
Only these seven keys are accepted — the value contract (`AddressSchema`,
1052+
`field-value.zod.ts`) refuses an undeclared key and names it, so `postal_code`
1053+
or `zipCode` fails with a rename to `postalCode` instead of being silently
1054+
dropped (ADR-0104 D1; strict since #13802). A record write carrying one stays
1055+
warn-first until the deployment has attested `os migrate value-shapes`, which
1056+
now counts such keys as violations.
1057+
10511058
**Database mapping:**
10521059
- SQL driver: a `JSON` column (not a composite type)
10531060
- MongoDB: Embedded document
@@ -1073,7 +1080,10 @@ office_location:
10731080

10741081
`altitude` and `accuracy` (both in metres) are optional additional members. Note
10751082
the keys are `lat`/`lng` — the `{ latitude, longitude }` spelling was never
1076-
consumed by the runtime and has been retired from the value contract.
1083+
consumed by the runtime and has been retired from the value contract. Those
1084+
four keys are the whole accept set: an undeclared key (`heading`, `latitude`)
1085+
is refused by name rather than dropped (`LocationValueSchema`, strict since
1086+
#13802), under the same ADR-0104 warn-first write posture as `address`.
10771087

10781088
<Callout type="warn">
10791089
Proximity / radius ("near") search is **not** a built-in filter operator.

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ regenerate.
2222
|---|---|
2323
| Triaged directories | 5 |
2424
| Object sites in them | 437 |
25-
| Still-open (strip) sites | 124 |
26-
| Files carrying at least one | 22 |
25+
| Still-open (strip) sites | 122 |
26+
| Files carrying at least one | 21 |
2727

2828
Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
3232
| authorable — the ruling's forced scope | 1 |
3333
| unresolved — needs a per-schema verdict | 0 |
34-
| wire / open — out of forced scope | 119 |
34+
| wire / open — out of forced scope | 117 |
3535
| no door — no carrier, ADR-0049 territory | 3 |
3636
| no gate — carrier live, no parse | 0 |
3737
| 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
4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
4747
| `ui/` | 169 | 157 | 5 | 0 | 7 |
48-
| `data/` | 156 | 74 | 1 | 0 | 81 |
48+
| `data/` | 156 | 76 | 1 | 0 | 79 |
4949
| `automation/` | 65 | 42 | 0 | 0 | 23 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **437** | **307** | **6** | **0** | **124** |
52+
| **total** | **437** | **309** | **6** | **0** | **122** |
5353

5454
## File-level triage — site counts
5555

@@ -176,7 +176,7 @@ over it is here.
176176

177177
### `data/` — open
178178

179-
**81 strip of 156**, in 12 file(s).
179+
**79 strip of 156**, in 11 file(s).
180180

181181
| File | Strip | Sites |
182182
|---|---|---|
@@ -186,19 +186,18 @@ over it is here.
186186
| `driver-sql.zod.ts` | 2 | 2 |
187187
| `driver.zod.ts` | 9 | 9 |
188188
| `external-catalog.zod.ts` | 4 | 4 |
189-
| `field-value.zod.ts` | 2 | 3 |
190189
| `field.zod.ts` | 2 | 13 |
191190
| `filter.zod.ts` | 10 | 11 |
192191
| `hook.zod.ts` | 5 | 7 |
193192
| `query.zod.ts` | 4 | 5 |
194193
| `seed-loader.zod.ts` | 12 | 12 |
195-
| **total** | **81** | **156** |
194+
| **total** | **79** | **156** |
196195

197196
| Bucket | Sites |
198197
|---|---|
199198
| authorable — the ruling's forced scope | 0 |
200199
| unresolved — needs a per-schema verdict | 0 |
201-
| wire / open — out of forced scope | 79 |
200+
| wire / open — out of forced scope | 77 |
202201
| no door — no carrier, ADR-0049 territory | 2 |
203202
| no gate — carrier live, no parse | 0 |
204203
| covered — no carrier, no parse, guarded at every consumer | 0 |

0 commit comments

Comments
 (0)