Skip to content

Commit 7cf42fe

Browse files
os-zhuangclaude
andauthored
feat(spec)!: mapping/agent/page close, and strictObject stops suggesting removed keys (#4001) (#4530)
The helper bug first, because this campaign put it there. `skill` closed last batch while still carrying `retiredKey` tombstones, and `strictObject` built its "did you mean" candidates from the whole shape — tombstones included. So a `triggerPhrase` typo was answered with "Did you mean `triggerPhrases`?", a key that had been REMOVED, and the author who complied got a second rejection telling them to delete what they had just been told to write. Third occurrence of a shape the ledger already records twice, and the first one in a SHARED helper, where it would have reached every conversion after it. Both helpers were correct alone; only the combination was wrong. Fixed structurally rather than by special-casing tombstones: never suggest a key the schema cannot accept. They stay complementary — `retiredKey` is stronger than `guidance`, since typing the key as `never` also fails `tsc` through a variable, where excess-property checking would not fire. `agent` had two security-shaped removals with no tombstone. `visibility` and `tenantId` were deleted as unenforced security properties — correctly — but without a prescription, because the shape was `.strip` and there was no rejection to hang one on. An author who wrote `visibility: 'private'` believed the agent was hidden; it was listed to everyone, and always had been. The `skill.permissions` class again. Both now name what actually gates an agent. `route` on a page was a fiction the platform's own suite carried. `stack.test.ts` authored `route: '/landing'` for years; `PageSchema` never declared it, and a page is routed by its `name` — which in the map format under test IS the map key, which the test asserted six lines below the key contradicting it. Fifth test found codifying a strip-era fiction, and the likeliest to be reinvented. `mapping` and `page` gain their ADR-0010 envelope. The undeclared-envelope debt list is down to two, from eight. The headline number is now derived instead of tallied — it had already drifted by one, in a campaign whose recurring finding is that hand-maintained coverage measurements go stale. `STILL_STRIP` carries a reverse pin like the envelope list; verified it goes red in both directions before being trusted. Registered types closed: 21 of 25. Still open: action, dashboard, field, view. The warning layer's covered roots drop 6 → 3, verified as a hand-off rather than a hole: `agent.zzz`, `page.zzz` and nested `page.regions[0].zzz` are each now rejected by the parse. A broken walk and a graduation shrink that count identically, so the check is pinned next to the number. Verified: 284 files / 7239 tests, tsc clean, 8 generated artifacts current, all spec gates green, and the CRM/Todo/showcase/platform-objects builds pass. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 38f7e4f commit 7cf42fe

14 files changed

Lines changed: 702 additions & 45 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`mapping`, `agent` and `page` reject unknown keys — and `strictObject` stops suggesting keys that were removed.
6+
7+
**A bug this campaign introduced into its own helper, fixed first.** `skill` closed in the last batch while still carrying `retiredKey` tombstones, and `strictObject` built its "did you mean" candidates from the whole shape — tombstones included. So a `triggerPhrase` typo was answered with *"Did you mean `triggerPhrases`?"*, a key that had been **removed**. An author who complied landed on the tombstone and got a second rejection telling them to delete what they had just been told to write.
8+
9+
Third occurrence of a shape the ledger already records twice — this campaign's fix pointing the way into the failure it exists to kill — and the first one in a *shared* helper, where it would have reached every conversion after it. Fixed structurally: **never suggest a key the schema cannot accept.** Candidates that accept `never` are dropped, so the rule holds without knowing why a key is unwritable. The two helpers stay complementary; `retiredKey` is *stronger* than a `guidance` entry, because typing the key as `never` also fails `tsc` when the config arrives through a variable, where excess-property checking would not fire.
10+
11+
**`agent` had two security-shaped removals with no tombstone.** `visibility` and `tenantId` were deleted as unenforced security properties — correctly, since neither did anything — but deleted without a prescription, because the shape was `.strip` and there was no rejection to attach one to. An author who wrote `visibility: 'private'` believed the agent was hidden. It was listed to everyone, and always had been. This is the `skill.permissions` class: a key that reads as a security control, is not one, and says nothing when you write it. Closing the shape created the channel, so both now name what actually gates an agent (`access` / `permissions`, enforced at the chat route since #1884).
12+
13+
**`route` on a page was a fiction the platform's own test suite carried.** `stack.test.ts` authored `route: '/landing'` for years. `PageSchema` has never declared it — a page is routed by its `name`, which in the map format under test IS the map key, which the test asserted six lines below the key contradicting it. Fifth test found codifying a strip-era fiction as intent, and the most likely to be reinvented, since `route` is the first key anyone reaches for on a page. Tombstoned with `path` and `url`.
14+
15+
Also tombstoned from each file's own comments, now that there is somewhere to put them: `agent.memory.shortTerm` (declared a working-memory window nothing consumed — ADR-0013 D3), `page.recordReview` and `page.blankLayout` (page types with no renderer, removed in framework#2265), and wrong-layer pointers for the page keys that read like real controls — `interfaceConfig.visualization` (the display mode is chosen from `appearance.allowedVisualizations`, and is not a page type), `guardrails.allowedTopics` (there is no allow-list, only `blockedTopics`).
16+
17+
`mapping` and `page` also gain their ADR-0010 protection envelope, which their loaders stamp and their schemas could not hold. **The undeclared-envelope debt list is down to two** (`action`, `field`), from eight.
18+
19+
Registered types closed at the top level: **21 of 25**. Still open: `action`, `dashboard`, `field`, `view`.
20+
21+
That count is now derived and pinned rather than tallied by hand — it had already drifted by one, in a campaign whose recurring lesson is that hand-maintained measurements of coverage go stale. `metadata-type-schemas.test.ts` walks each registered schema for its top-level catchall and carries the open list as a reverse pin, so closing a type fails the test until the list shrinks, exactly like the envelope debt list next to it.
22+
23+
The unknown-key warning layer's covered roots drop from 6 to 3 — verified as a hand-off rather than a hole: `agent.zzz`, `page.zzz` and the nested `page.regions[0].zzz` are each now rejected by the parse. A broken walk and a successful graduation shrink that count identically, so the check is pinned in the test alongside the number.
24+
25+
Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working metadata changes.

content/docs/references/data/mapping.mdx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ description: Mapping protocol schemas
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

8-
Transformation Logic
8+
Shared history for this file (#4001).
99

10-
Built-in helpers for converting data during import.
10+
An import mapping is instructions for moving somebody's data. A dropped key
11+
12+
does not fail the import — it runs, to completion, with a "success" the
13+
14+
author reads as "the data arrived the way I described it". The rows land
15+
16+
untransformed, or unmatched, or duplicated, and the diagnosis starts from a
17+
18+
green run.
1119

1220
<Callout type="info">
1321
**Source:** `packages/spec/src/data/mapping.zod.ts`
@@ -55,6 +63,13 @@ const result = FieldMapping.parse(data);
5563
| **extractQuery** | `{ object: string; fields?: string[]; where?: any; search?: string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }; … }` | optional | Query to run for export only |
5664
| **errorPolicy** | `Enum<'skip' \| 'abort' \| 'retry'>` || |
5765
| **batchSize** | `number` || |
66+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
67+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
68+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
69+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
70+
| **_packageId** | `string` | optional | Owning package machine id. |
71+
| **_packageVersion** | `string` | optional | Owning package version. |
72+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
5873

5974

6075
---

content/docs/references/ui/page.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ Interface-level page configuration (Airtable parity)
8989
| **slots** | `{ header?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … }[]; actions?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … }[]; alerts?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … }[]; highlights?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record<string, any>; … }[]; … }` | optional | Slot override map for slotted pages |
9090
| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX/HTML+Tailwind compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute). For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier). Authoritative over `regions` in both. |
9191
| **requires** | `string[]` | optional | Plugin namespaces the JSX source references (validated at save and load) |
92+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
93+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
94+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
95+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
96+
| **_packageId** | `string` | optional | Owning package machine id. |
97+
| **_packageVersion** | `string` | optional | Owning package version. |
98+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
9299

93100

94101
---

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,53 @@ dropped at parse, and nothing failed.
239239
column being the live one. Fifth instance of finding 9's pattern, and the
240240
first where the blinded instrument was a gate rather than a measurement.
241241

242+
12. **The campaign's own helper started signposting removed keys.** `skill`
243+
(batch 4) closed with `strictObject` while still carrying `retiredKey`
244+
tombstones — and `strictObject` built its candidate list from
245+
`Object.keys(shape)`, which includes them. So:
246+
247+
Unrecognized key(s) on this skill: `triggerPhrase`. …
248+
Did you mean `triggerPhrase` → `triggerPhrases`?
249+
250+
`triggerPhrases` was REMOVED. An author who complied landed on the tombstone
251+
and got a second rejection telling them to delete what they had just been
252+
told to write.
253+
254+
**Third occurrence of finding 7's shape** — this campaign's fix pointing the
255+
way into the failure it exists to kill — and the first one the campaign put
256+
in its own *shared* helper, where it would have reached every conversion
257+
after it. Both helpers were correct alone; only the combination was wrong,
258+
which is the kind of defect no per-schema test looks for.
259+
260+
Fixed structurally rather than by special-casing tombstones: **never suggest
261+
a key the schema cannot accept.** `strictObject` drops candidates that
262+
accept `never` (however wrapped), so the rule holds without knowing why a
263+
key is unwritable. Note the two helpers stay complementary — `retiredKey` is
264+
*stronger* than a `guidance` entry, not redundant with it, because it types
265+
the key as `never` and so fails `tsc` even when the config arrives through a
266+
variable, where excess-property checking would not fire.
267+
268+
13. **`route` on a page — a fiction the platform's own test suite carried.**
269+
`stack.test.ts` authored `route: '/landing'` on a page for years.
270+
`PageSchema` has never declared `route`; a page is routed by its `name`,
271+
which in the map format under test IS the map key. The test asserted exactly
272+
that, six lines below the key contradicting it.
273+
274+
Fifth instance of a test codifying a strip-era fiction as intent
275+
(`position.parent`, `object.namespace`, `compactLayout`,
276+
`skill.permissions`, now `page.route`) — and the most likely of them to be
277+
reinvented, because `route` is the first key anyone reaches for on a page.
278+
Now tombstoned along with `path` and `url`.
279+
280+
Two more from the same file's own comments, and they are the
281+
`skill.permissions` class again: `agent.visibility` and `agent.tenantId`
282+
were **removed as unenforced security properties** and left without a
283+
tombstone, because at the time the shape was `.strip` and there was no
284+
rejection to attach a prescription to. An author who wrote
285+
`visibility: 'private'` believed the agent was hidden; it was listed to
286+
everyone, and always had been. Closing the shape created the channel, so
287+
both got their sentence.
288+
242289
This is the empirical argument for the ratchet: the inference "no metadata in
243290
the repo carries unknown keys" was **false three times over**, and only the
244291
strict gate could prove it. Note the asymmetry in the two schema gaps — both

packages/spec/authorable-surface.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3583,6 +3583,13 @@
35833583
"data/LocationValue:altitude",
35843584
"data/LocationValue:lat",
35853585
"data/LocationValue:lng",
3586+
"data/Mapping:_lock",
3587+
"data/Mapping:_lockDocsUrl",
3588+
"data/Mapping:_lockReason",
3589+
"data/Mapping:_lockSource",
3590+
"data/Mapping:_packageId",
3591+
"data/Mapping:_packageVersion",
3592+
"data/Mapping:_provenance",
35863593
"data/Mapping:batchSize",
35873594
"data/Mapping:errorPolicy",
35883595
"data/Mapping:extractQuery",
@@ -7937,6 +7944,13 @@
79377944
"ui/OfflineConfig:queueMaxSize",
79387945
"ui/OfflineConfig:strategy",
79397946
"ui/OfflineConfig:sync",
7947+
"ui/Page:_lock",
7948+
"ui/Page:_lockDocsUrl",
7949+
"ui/Page:_lockReason",
7950+
"ui/Page:_lockSource",
7951+
"ui/Page:_packageId",
7952+
"ui/Page:_packageVersion",
7953+
"ui/Page:_provenance",
79407954
"ui/Page:aria",
79417955
"ui/Page:assignedProfiles",
79427956
"ui/Page:description",

0 commit comments

Comments
 (0)