Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/address-location-value-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
"@objectstack/spec": minor
---

feat(spec): refuse undeclared keys on `address` and `location` values — `AddressSchema` / `LocationValueSchema` are strict (#13802)

<!-- adr-0087: registered address-location-value-unknown-keys-refused -->

**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.
4 changes: 2 additions & 2 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -528,14 +528,14 @@ Name-keyed map of embedded sub-objects (`Record<string, SubObject>`). 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' }
Expand Down
12 changes: 11 additions & 1 deletion content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.

<Callout type="warn">
Proximity / radius ("near") search is **not** a built-in filter operator.
Expand Down
17 changes: 8 additions & 9 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ 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:

| Bucket | Sites |
|---|---|
| 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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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 |
Expand Down
Loading
Loading