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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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