From 6ce4773156b7fa8ed5b71a38323e1f217f615945 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 21:33:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20the=20view=20surface=20closes=20?= =?UTF-8?q?=E2=80=94=20the=20last=20batch=20of=20#4001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Views are the surface an author iterates on visually, which is why a dropped key hides here: the view still renders, just not the way it was described. `FormFieldBase` / `FormSection` / `FormButtonConfig` closed years ago under ADR-0089 D3a; the other forty-odd shapes kept the posture those three were rescued from. `defineView`'s guard was another one-door workaround — its own comment says "`ViewSchema` strips unknown top-level keys, so a flat list view would parse to an empty container". Fifth bespoke guard this campaign has found built around silent stripping, and like all of them it covered exactly one door. The rejection now lives in the parse, reaches both, and carries the wrap instruction. The guard stays for what strict cannot see: `defineView({})`. Three shapes deliberately left open, each with its reason in the file rather than a silent skip: `FormSectionSchema` (already closed with its own error map and a transform), `UserFiltersSchema` (deliberately strips page-only keys, with a test asserting it — changing that needs its own verification), and the flattened Studio overlay in `ViewMetadataSchema`, which must stay open. That last one is the `.extend()` trap the ledger warned about: closing `ListViewSchema`/`FormViewSchema` silently closed the overlay too, turning a shape the platform itself writes into a 422. Both `.strip()` back. It was caught by a test whose name is the whole contract — someone wrote "preserves auxiliary Studio round-trip keys without a strict-mode 422" before this campaign existed. The container's `name`, `label` and `object` were tombstoned in the first draft and should not have been: the platform writes all three. Fourth false guidance claim in three batches. What fixed it was not more care but a different method — scan every real payload of the shape in the repo and keep only the entries no real payload contradicts. Six of nine survived. A tombstone asserts "nothing legitimately writes this key", which is an empirical claim, and the codebase can be asked. `view` is the end state, not the last item of debt: its registered schema is a union of three runtime shapes and a union is only as closed as its most open member — the Studio overlay, a wire shape wearing the same type name. So the campaign finishes at 24 of 25 closed with the 25th a documented permanent exception, recorded beside the reverse pin so nobody closes it by force. Verified: full monorepo suite 132/132 tasks green, spec 7241 tests, tsc clean, 8 generated artifacts current, all 10 spec gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --- .changeset/view-strict-final.md | 37 +++ content/docs/references/api/protocol.mdx | 15 +- content/docs/references/ui/view.mdx | 3 + .../2026-07-unknown-key-strictness-ledger.md | 108 ++++++++- packages/spec/authorable-surface.json | 6 + packages/spec/liveness/view.json | 16 ++ .../src/kernel/metadata-type-schemas.test.ts | 16 ++ packages/spec/src/ui/view.test.ts | 20 +- packages/spec/src/ui/view.zod.ts | 228 +++++++++++++++--- 9 files changed, 408 insertions(+), 41 deletions(-) create mode 100644 .changeset/view-strict-final.md diff --git a/.changeset/view-strict-final.md b/.changeset/view-strict-final.md new file mode 100644 index 0000000000..090a58efd0 --- /dev/null +++ b/.changeset/view-strict-final.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +--- + +The view surface closes — the container, both view kinds, and the ~28 config shapes under them. This is the last batch of #4001. + +Views are the surface an author iterates on visually, which is exactly why a dropped key hides here: the view still renders, just not the way it was described. `FormFieldBaseSchema`, `FormSectionSchema` and `FormButtonConfigSchema` were closed years ago under ADR-0089 D3a; the other forty-odd shapes in the file kept the posture those three were rescued from. + +**`defineView`'s guard was another one-door workaround.** It rejects a container that defines no views, and its comment says why: "`ViewSchema` strips unknown top-level keys, so a *flat* list view would parse to an empty container". That is the fifth bespoke guard this campaign has found built around silent stripping, and like all of them it covered exactly one door — `defineView`. Through the metadata door (Studio, the API, an agent) a flat view produced an empty container in silence. The rejection now lives in the parse, so it reaches both, and carries the wrap instruction rather than only the symptom. The guard stays for the case strict cannot see: `defineView({})`, which has no unknown keys and still registers nothing. + +**The container carries its own identity and object binding, and the first draft tombstoned all three.** `name`, `label` and `object` were in that guidance list, telling authors they "belong to a single VIEW, not to the container" — which rejected shapes the platform itself writes: `saveMetaItem` sends the name, artifact-shipped containers (`service-ai/ai_traces`) carry it, the validation sweep injects it, and a stack-level `views: [...]` entry needs `object` to say which object its views belong to (`getViewsByObject()` reads that binding). All three are now declared — `object` as live with its consumer cited, `name`/`label` as dead *body* keys with the row column live, exactly as `translation` needed in batch 5. + +Caught by the full monorepo suite — `@objectstack/objectql`, then `@objectstack/cli` — never by `packages/spec`. That is the fourth false guidance claim in three batches (`action.permissions`, `action.location`, `view.name`/`label`, `view.object`), and the fix that finally worked was not more care but a different method: **scan every real container payload in the repo and keep only the guidance entries no real payload contradicts.** Six of the nine survived. That check costs one command and should have run before the guidance, not after three CI failures. + +The rule worth carrying: **a rejection's prose is behaviour, not documentation.** It tells an author what to do next, and a confidently wrong one is worse than none, because there is no reason to doubt it. + +**Three shapes are deliberately left open, each with its reason in the file rather than a silent skip:** + +- **`FormSectionSchema`** already closed under ADR-0089 D3a with `strictVisibilityError` and a `.transform()` that normalizes the `visibleWhen`/`visibility` pair. Converting means re-expressing that map as `guidance` and re-proving the transform — a refactor of working, tested behaviour, not a strictness change. +- **`UserFiltersSchema`** deliberately *strips* `tabs`/`showAllRecords`, which are page-only keys (ADR-0047), with a test asserting the drop. The likely right end state is a rejection saying "tabs are page-only" — but that is a behaviour change with a real consumer question behind it (something may pass a page-shaped block through relying on the strip to narrow it). The campaign's own rule is verify-then-enforce, and this batch did not verify it. Named as the one open shape in the file. +- **The flattened Studio overlay** in `ViewMetadataSchema` must stay open: it carries auxiliary round-trip keys (`isPinned`, `sortOrder`, …) that `saveMetaItem` persists verbatim. + +**That last one is the trap the ledger warned about, arriving on schedule.** `.extend()` inherits strictness, so closing `ListViewSchema`/`FormViewSchema` for authoring silently made the overlay strict too — turning a shape *the platform itself writes* into a 422. Both members now `.strip()` back, with a comment saying the `.strip()` is load-bearing rather than leftover. + +## `view` is the end state, not the last item of debt + +The registered `view` schema stays `strip`, and it always will: it is a union of three runtime shapes and a union is only as closed as its most open member. That member is the Studio overlay above — a wire shape wearing the same type name. + +So the campaign's final number is **24 of 25 registered types closed, with the 25th a documented permanent exception**. That is recorded in `metadata-type-schemas.test.ts` beside the reverse pin, so nobody "finishes the job" by force. What closed is everything an author writes; what stayed open is the thing the ledger's classification rule exists to distinguish — arriving here as the campaign's answer rather than as an exception to it. + +## Where the campaign ends up + +- **Registered types closed: 24 of 25** (from 9 when this line started), the last one exempt with a stated reason. +- **The ADR-0010 undeclared-envelope debt list is empty**, from the eight the structural walk opened it with. +- **The unknown-key warning layer has one covered root left** — `view`, and only its open member. When a layer built to warn about strip-mode metadata has almost nothing left to warn about, that is the ratchet finishing. + +Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working view changes. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bd77bbecb9..473c04d649 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -397,7 +397,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (snake_case) | -| **data** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | View definition to create | +| **data** | `{ name?: string; label?: string; object?: string; list?: object; … }` | ✅ | View definition to create | --- @@ -410,7 +410,7 @@ const result = AiAgentCapabilities.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name | | **viewId** | `string` | ✅ | Created view identifier | -| **view** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | Created view definition | +| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` | ✅ | Created view definition | --- @@ -885,6 +885,9 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | +| **label** | `string` | optional | Human-readable label shown in metadata lists. | +| **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | | **list** | `{ name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | | **listViews** | `Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | @@ -920,7 +923,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name | -| **view** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | View definition | +| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` | ✅ | View definition | --- @@ -1041,7 +1044,7 @@ const result = AiAgentCapabilities.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name | -| **views** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }[]` | ✅ | Array of view definitions | +| **views** | `{ name?: string; label?: string; object?: string; list?: object; … }[]` | ✅ | Array of view definitions | --- @@ -1410,7 +1413,7 @@ const result = AiAgentCapabilities.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (snake_case) | | **viewId** | `string` | ✅ | View identifier | -| **data** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | Partial view data to update | +| **data** | `{ name?: string; label?: string; object?: string; list?: object; … }` | ✅ | Partial view data to update | --- @@ -1423,7 +1426,7 @@ const result = AiAgentCapabilities.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name | | **viewId** | `string` | ✅ | Updated view identifier | -| **view** | `{ list?: object; form?: object; listViews?: Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>; formViews?: Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` | ✅ | Updated view definition | +| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` | ✅ | Updated view definition | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 72e8b22023..818d3be9c3 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -663,6 +663,9 @@ End-user quick-filter configuration (Airtable "User filters" parity) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | +| **label** | `string` | optional | Human-readable label shown in metadata lists. | +| **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | | **list** | `{ name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | | **listViews** | `Record; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index c3672acf5c..6df2d82d0b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -330,6 +330,85 @@ dropped at parse, and nothing failed. rejects first, so the same curation that used to power a warning now powers a rejection. That is the intended end state for every entry in it. +16. **The `.extend()` trap this file warned about, arriving on schedule.** + `view` (the final batch). `ViewMetadataSchema`'s third and fourth union + members are `ListViewSchema.extend(...)` / `FormViewSchema.extend(...)` — the + flattened Studio overlay that carries auxiliary round-trip keys (`isPinned`, + `sortOrder`, …) which `saveMetaItem` persists verbatim. `.extend()` INHERITS + strictness, so closing the two authoring schemas silently closed the overlay + too, turning **a shape the platform itself writes** into a 422. + + Worth noting how it was caught: not by reading, but by a test whose name is + the whole contract — *"preserves auxiliary Studio round-trip keys without a + strict-mode 422"*. Someone wrote that test before this campaign existed, + naming the exact failure a future ratchet would cause. Both members now + `.strip()` back, with a comment saying the `.strip()` is load-bearing. + +17. **Five bespoke guards, all built around silent stripping, all covering one + door.** With `defineView`'s the campaign has now found the whole family: + #1535's `create()`-only object guard (found by #4522), #3778's ten-key + translation preprocess (batch 5), `retiredKey` (kept — it is *stronger* than + guidance, not a workaround), the `defineView` empty-container check, and the + `metadata-create-seeds` gate that could only fail in one direction. + + The pattern is stable enough to state as a rule: **when a schema strips, the + fix someone reaches for is a guard at the door they happen to be standing + at.** It works, it is tested, and it leaves every other door open — because + the author of the guard was solving their bug, not auditing the surface. + Closing the shape is the only fix that covers doors nobody has thought of + yet. And what was worth keeping from each guard was never the detection — + that generalizes for free once the default flips — but the prose. + +18. **A `guidance` entry is a claim about the schema, and this campaign shipped + four false ones — all in its last two batches.** `action.permissions` said + an action has no permission key when `requiredPermissions` is declared and + enforced with a 403 (following it invited deleting a working gate); + `action.location` aliased the CORRECT key to a nonexistent one; + `view.name` / `view.label` / `view.object` tombstoned the container's own + identity and object binding, rejecting shapes the platform itself writes. + + None was caught by writing the schema. One came from checking the docs-drift + report — the advisory "107 files, FYI" list — against the schema. The rest + came from CI running packages `packages/spec` alone does not: + `metadata-protocol`, `objectql`, `cli`. + + **What finally worked was not more care, it was a different method.** Scan + every real payload of that shape in the repo, and keep only the guidance + entries no real payload contradicts. On `view` that was one command, it took + six of nine entries through and killed three, and it would have caught all + of them before the first CI failure. The generalisation: + + > A tombstone asserts *"nothing legitimately writes this key."* That is an + > empirical claim about the codebase, and the codebase can be asked. + + Two rules fall out. **Prose in a rejection is behaviour, not documentation** + — it tells an author what to do next, and a confidently wrong one is worse + than none because there is no reason to doubt it. And **the blast radius of + closing a registered type is every package that parses that type**, not the + package that declares it. + +## Where this ended up + +**24 of 25 registered types closed** (from 9 when the line started), and the +25th — `view` — is a **documented permanent exception**, not the last item of +debt. Its registered schema is a union of three runtime shapes and a union is +only as closed as its most open member; that member is the Studio overlay above, +a wire shape wearing the same type name. Everything an author writes is closed. +The distinction is exactly the one the classification rule at the top of this +file exists to draw, arriving at the end as the answer rather than as an +exception to it. + +**The ADR-0010 undeclared-envelope debt list is empty**, from the eight the +structural walk opened it with — after replacing a probe that had been hiding +seven. The empty set is kept rather than deleted, so the declaration check now +runs over every type with no exemptions. + +**The warning layer has one covered root left.** A layer built to warn about +strip-mode metadata, with almost nothing left to warn about, is the ratchet +finishing. Its tests say so in place: change the floor to 0 and assert the empty +set *deliberately* — never delete the test, because an empty result nobody chose +is indistinguishable from a derivation that broke. + This is the empirical argument for the ratchet: the inference "no metadata in the repo carries unknown keys" was **false three times over**, and only the strict gate could prove it. Note the asymmetry in the two schema gaps — both @@ -450,9 +529,32 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). ## Next steps (verify-then-enforce, one shape at a time) -1. Let the warning layer run in the wild for a release, then schedule the v18 - strict close-out on what it actually reports — which is the whole point of - having built it. Nothing more to do here until there is field data. +1. ~~Let the warning layer run in the wild for a release, then schedule the v18 + strict close-out on what it actually reports.~~ **ANSWERED — the wait was + discharged by a decision, not by data.** Recording it here as this step asks. + + The question below was never answered because it could not be: this is a + pre-1.0 product with no third-party authors to report from, so "wait for + field data" was waiting for a signal that would never arrive — the third + outcome in the list, and the one it warns is indistinguishable from success. + + The call was to proceed anyway, on the grounds that a breaking change is + acceptable when the upgrade is mechanical and documented: every rejection + carries its own prescription, so an AI reading the error has the migration in + front of it. That is a stronger position than the wait assumed, and it is + *why* the tombstone/guidance discipline in the Standard wiring is not + optional decoration — it is the thing that made shipping without field data + defensible. + + What the wait was meant to buy — evidence that closing shapes does not break + real metadata — arrived from a different instrument instead: every batch was + verified against the dogfood apps (`examples/app-crm`, `app-todo`, + `app-showcase`, `platform-objects`), which parse their metadata at build. The + answer that mattered turned out to be reachable without waiting. + + The original decision point is left below, struck through rather than + deleted: the reasoning is still correct for a product that *does* have field + reporting, and the third outcome is still the one to check for. **This wait has a decision point, deliberately.** "Wait for field data" with no way to tell when it has arrived is how a ratchet stops without anyone diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index a1bba850c4..3ea00869a6 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1197,8 +1197,11 @@ "api/GetUiViewResponse:_provenance", "api/GetUiViewResponse:form", "api/GetUiViewResponse:formViews", + "api/GetUiViewResponse:label", "api/GetUiViewResponse:list", "api/GetUiViewResponse:listViews", + "api/GetUiViewResponse:name", + "api/GetUiViewResponse:object", "api/GetUiViewResponse:protection", "api/GetViewRequest:object", "api/GetViewRequest:viewId", @@ -8255,8 +8258,11 @@ "ui/View:_provenance", "ui/View:form", "ui/View:formViews", + "ui/View:label", "ui/View:list", "ui/View:listViews", + "ui/View:name", + "ui/View:object", "ui/View:protection", "ui/ViewFilterRule:field", "ui/ViewFilterRule:operator", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 6dc75b8766..7c5c960833 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -2,6 +2,22 @@ "type": "view", "_note": "ViewSchema (object view container: list/form/listViews/formViews). Seeded from docs/audits/2026-06-viewschema-property-liveness.md (objectui consumer cross-reference) and re-verified against objectui@fb35e48 (2026-07-16) — several audit-era DEAD findings have since gone LIVE (submitBehavior, sharing.lockedBy, ViewData providers on the list path) and are classified from the current reads, not the stale audit lines. objectui paths cited as 'objectui: ' prose (not resolved against this repo). Sub-sub-key divergences (userActions.buttons, addRecord.mode/formView, tabs[].order) can't be drilled (one level only) — captured in the parent entry's note and tracked in the enforce-or-remove worklist (#2998 Track B / ADR-0049). 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. widget.performance (no ledger file of its own) was verified dead the corrected way — zero readers in either repo; objectui virtual scrolling reads the LIVE top-level virtualScroll — and removed with them.", "props": { + "name": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "Container item identity, declared in #4001 batch 6e and dead as a BODY key — the honest reading of a copy nobody reads. The live one is the sys_metadata row column: the door supplies it (saveMetaItem({type:'view', name})) and readers that resolve a view by name use the row, or a ViewItem's own name (rest-server.ts:5422 reads view.name on a ViewItem, not on a container). Declared anyway, and deliberately not authorWarn'd, because the platform's own writers send it — artifact-shipped containers (service-ai/ai_traces) and the metadata-validation sweep both do, so tombstoning it rejected shapes we write ourselves. Exactly the correction translation/name needed in batch 5." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "Display metadata on the container with no runtime consumer. Not authorWarn'd: the README's rule is that pure display annotation (description, tags, icon) must not warn, and the job.label / translation.label precedent is this exact case — docs-shaped, deliberately kept, ADR-0033 exempt from enforce-or-remove." + }, + "object": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/engine.ts:1653 (getViewsByObject() / GET /meta/view?object=)", + "note": "How a stack-level `views: [...]` entry says which object its views belong to — the container is 'view definitions for a specific object' (this file's own note on ObjectListViewSchema), and the object index is what reads the binding. Declared in #4001 batch 6e: it was undeclared and therefore stripped, so a container's object binding was dropped on every parse. Found by the CLI's migrate e2e, whose fixture carries it." + }, "list": { "children": { "name": { diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index d53f05c58c..2987d11600 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -223,6 +223,22 @@ describe('registered metadata types', () => { * `STILL_STRIP` carries the same reverse pin as `UNDECLARED_ENVELOPE`: closing a * type fails this suite until the list shrinks, so the list cannot outlive the * debt and start exempting types that no longer need exempting. + * + * ## `view` is the end state, not the last item of debt + * + * 24 of 25 are closed and the 25th will not be, for a reason worth stating so + * nobody "finishes the job" by force. The registered `view` schema is a UNION of + * three runtime shapes, and the third — the flattened overlay — is deliberately + * `.strip()`: it carries Studio's auxiliary round-trip keys (`isPinned`, + * `sortOrder`, …) that `saveMetaItem` persists verbatim, so closing it would 422 + * a shape the platform itself writes. A union is only as closed as its most open + * member, so `view` reads `strip` and always will. + * + * What DID close is everything an author writes: `ViewSchema` (the container), + * `ListViewSchema`, `FormViewSchema`, and the ~28 config shapes under them. The + * open member is a wire shape wearing the same type name — which is exactly the + * distinction the ledger's classification rule exists to draw, arriving here as + * the campaign's final answer rather than as an exception to it. */ const STILL_STRIP = new Set(['view']); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index d73fdc2cb5..e9468fc7f8 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2099,13 +2099,29 @@ describe('defineView', () => { expect(() => defineView({})).toThrow(/defines no views/); }); - it('should throw on a flat list view — stripped keys must not silently produce an empty container', () => { + it('should throw on a flat list view, now at the PARSE and so at both doors', () => { + // The `defineView` guard was written because `ViewSchema` stripped unknown + // top-level keys, so a flat view parsed to an empty container. Like every + // other bespoke guard this campaign has found, it covered ONE door — + // `defineView` — while the metadata door (Studio, the API, an agent) got the + // empty container in silence. + // + // Closing the shape (#4001) moves the rejection into the parse, so it reaches + // both, and it carries the wrap instruction rather than only the symptom. + // Assert the prescription, not which layer produced it. expect(() => defineView({ name: 'all_tasks', label: 'All Tasks', type: 'grid', columns: ['name', 'status'], - } as never)).toThrow(/defines no views/); + } as never)).toThrow(/belongs to a single VIEW, not to the container/); + }); + + it('still throws on an EMPTY container — the case strict cannot see', () => { + // `defineView({})` has no unknown keys, so the parse is happy and zero views + // register. That is why the guard stays rather than retiring with the + // stripping it was written to work around. + expect(() => defineView({} as never)).toThrow(/defines no views/); }); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0a50209e4a..c835f6181d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { strictObject } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility'; @@ -19,6 +20,20 @@ import { BulkActionDefSchema } from './bulk-action.zod'; */ import { HttpMethodSchema, HttpRequestSchema } from '../shared/http.zod'; import { lazySchema } from '../shared/lazy-schema'; + +/** + * Shared history for this file (#4001). + * + * Views are the surface an author iterates on visually, which is exactly why a + * dropped key hides here: the view still renders, just not the way it was + * described. `FormFieldBaseSchema` / `FormSectionSchema` / `FormButtonConfig` + * were closed years ago (ADR-0089 D3a); the other forty-odd shapes in this file + * kept the posture those three were rescued from. + */ +const VIEW_HISTORY = + 'Until #4001 closed these shapes an unknown key was dropped silently — the view still ' + + 'rendered, without whatever the key was meant to configure.'; + export { HttpMethodSchema, HttpRequestSchema }; /** @@ -143,7 +158,10 @@ export function normalizeFilterOperator(op: unknown): string { * ] * ``` */ -export const ViewFilterRuleSchema = lazySchema(() => z.object({ +export const ViewFilterRuleSchema = lazySchema(() => strictObject({ + surface: 'this view filter rule', + history: VIEW_HISTORY, +}, { /** Field name to filter on */ field: z.string().describe('Field name to filter on'), /** @@ -189,7 +207,10 @@ export const ColumnSummarySchema = lazySchema(() => z.enum([ * `type` reuses `ColumnSummarySchema`, so both forms share one aggregation * vocabulary and cannot drift apart. */ -export const ColumnSummaryConfigSchema = lazySchema(() => z.object({ +export const ColumnSummaryConfigSchema = lazySchema(() => strictObject({ + surface: 'this column summary configuration', + history: VIEW_HISTORY, +}, { type: ColumnSummarySchema.describe('Aggregation function'), field: z.string().optional().describe('Field to aggregate (defaults to the column field)'), }).describe('Column footer summary configuration')); @@ -201,7 +222,10 @@ export const ColumnSummaryConfigSchema = lazySchema(() => z.object({ * badge in front of the record name — so a list can carry two signals in one * column without spending a second column on it. */ -export const ColumnPrefixSchema = lazySchema(() => z.object({ +export const ColumnPrefixSchema = lazySchema(() => strictObject({ + surface: 'this column prefix', + history: VIEW_HISTORY, +}, { field: z.string().describe('Field whose value renders before the cell value'), type: z.enum(['badge', 'text']).default('text').describe('How the prefix value is rendered'), }).describe('Compound-cell prefix configuration')); @@ -210,7 +234,10 @@ export const ColumnPrefixSchema = lazySchema(() => z.object({ * List Column Configuration Schema * Detailed configuration for individual list view columns */ -export const ListColumnSchema = lazySchema(() => z.object({ +export const ListColumnSchema = lazySchema(() => strictObject({ + surface: 'this list column', + history: VIEW_HISTORY, +}, { field: z.string().describe('Field name (snake_case)'), label: I18nLabelSchema.optional().describe('Display label override'), width: z.number().positive().optional().describe('Column width in pixels'), @@ -239,14 +266,20 @@ export const ListColumnSchema = lazySchema(() => z.object({ /** * List View Selection Configuration */ -export const SelectionConfigSchema = lazySchema(() => z.object({ +export const SelectionConfigSchema = lazySchema(() => strictObject({ + surface: 'this selection configuration', + history: VIEW_HISTORY, +}, { type: z.enum(['none', 'single', 'multiple']).default('none').describe('Selection mode'), })); /** * List View Pagination Configuration */ -export const PaginationConfigSchema = lazySchema(() => z.object({ +export const PaginationConfigSchema = lazySchema(() => strictObject({ + surface: 'this pagination configuration', + history: VIEW_HISTORY, +}, { pageSize: z.number().int().positive().default(25).describe('Number of records per page'), pageSizeOptions: z.array(z.number().int().positive()).optional().describe('Available page size options'), })); @@ -267,7 +300,10 @@ export const RowHeightSchema = lazySchema(() => z.enum([ * Grouping Field Configuration * Defines a single grouping level for record grouping. */ -export const GroupingFieldSchema = lazySchema(() => z.object({ +export const GroupingFieldSchema = lazySchema(() => strictObject({ + surface: 'this grouping field', + history: VIEW_HISTORY, +}, { field: z.string().describe('Field name to group by'), order: z.enum(['asc', 'desc']).default('asc').describe('Group sort order'), collapsed: z.boolean().default(false).describe('Collapse groups by default'), @@ -277,7 +313,10 @@ export const GroupingFieldSchema = lazySchema(() => z.object({ * Grouping Configuration Schema (Airtable-style) * Supports multi-level grouping for grid/gallery views. */ -export const GroupingConfigSchema = lazySchema(() => z.object({ +export const GroupingConfigSchema = lazySchema(() => strictObject({ + surface: 'this grouping configuration', + history: VIEW_HISTORY, +}, { fields: z.array(GroupingFieldSchema).min(1).describe('Fields to group by (supports up to 3 levels)'), }).describe('Record grouping configuration')); @@ -285,7 +324,10 @@ export const GroupingConfigSchema = lazySchema(() => z.object({ * Gallery View Configuration (Airtable-style) * Configures card layout for gallery/card views. */ -export const GalleryConfigSchema = lazySchema(() => z.object({ +export const GalleryConfigSchema = lazySchema(() => strictObject({ + surface: 'this gallery configuration', + history: VIEW_HISTORY, +}, { coverField: z.string().optional().describe('Attachment/image field to display as card cover'), coverFit: z.enum(['cover', 'contain']).default('cover').describe('Image fit mode for card cover'), cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'), @@ -297,7 +339,10 @@ export const GalleryConfigSchema = lazySchema(() => z.object({ * Timeline View Configuration (Airtable-style) * Configures timeline/chronological views. */ -export const TimelineConfigSchema = lazySchema(() => z.object({ +export const TimelineConfigSchema = lazySchema(() => strictObject({ + surface: 'this timeline configuration', + history: VIEW_HISTORY, +}, { startDateField: z.string().describe('Field for timeline item start date'), endDateField: z.string().optional().describe('Field for timeline item end date'), titleField: z.string().describe('Field to display as timeline item title'), @@ -310,7 +355,10 @@ export const TimelineConfigSchema = lazySchema(() => z.object({ * View Sharing Configuration (Airtable-style) * Defines who can see and modify a view. */ -export const ViewSharingSchema = lazySchema(() => z.object({ +export const ViewSharingSchema = lazySchema(() => strictObject({ + surface: 'this view sharing', + history: VIEW_HISTORY, +}, { type: z.enum(['personal', 'collaborative']).default('collaborative').describe('View ownership type'), lockedBy: z.string().optional().describe('User who locked the view configuration'), }).describe('View sharing and access configuration')); @@ -319,7 +367,10 @@ export const ViewSharingSchema = lazySchema(() => z.object({ * Row Color Configuration (Airtable-style) * Defines how rows are colored based on field values. */ -export const RowColorConfigSchema = lazySchema(() => z.object({ +export const RowColorConfigSchema = lazySchema(() => strictObject({ + surface: 'this row color configuration', + history: VIEW_HISTORY, +}, { field: z.string().describe('Field to derive color from (typically a select/status field)'), colors: z.record(z.string(), z.string()).optional().describe('Map of field value to color (hex/token)'), }).describe('Row color configuration based on field values')); @@ -348,7 +399,10 @@ export const VisualizationTypeSchema = lazySchema(() => z.enum([ * * @see Airtable Interface → "User actions" panel */ -export const UserActionsConfigSchema = lazySchema(() => z.object({ +export const UserActionsConfigSchema = lazySchema(() => strictObject({ + surface: 'this user actions configuration', + history: VIEW_HISTORY, +}, { sort: z.boolean().default(true).describe('Allow users to sort records'), search: z.boolean().default(true).describe('Allow users to search records'), filter: z.boolean().default(true).describe('Allow users to filter records'), @@ -365,7 +419,10 @@ export const UserActionsConfigSchema = lazySchema(() => z.object({ * * @see Airtable Interface → "Appearance" panel */ -export const AppearanceConfigSchema = lazySchema(() => z.object({ +export const AppearanceConfigSchema = lazySchema(() => strictObject({ + surface: 'this appearance configuration', + history: VIEW_HISTORY, +}, { showDescription: z.boolean().default(true).describe('Show the view description text'), allowedVisualizations: z.array(VisualizationTypeSchema).optional() .describe('Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"])'), @@ -378,7 +435,10 @@ export const AppearanceConfigSchema = lazySchema(() => z.object({ * * @see Airtable Interface → "Tabs" panel */ -export const ViewTabSchema = lazySchema(() => z.object({ +export const ViewTabSchema = lazySchema(() => strictObject({ + surface: 'this view tab', + history: VIEW_HISTORY, +}, { name: SnakeCaseIdentifierSchema.describe('Tab identifier (snake_case)'), label: I18nLabelSchema.optional().describe('Display label'), icon: z.string().optional().describe('Tab icon name'), @@ -398,7 +458,10 @@ export const ViewTabSchema = lazySchema(() => z.object({ * * @see Airtable Interface → "User filters" panel (Dropdowns element) */ -export const UserFilterFieldSchema = lazySchema(() => z.object({ +export const UserFilterFieldSchema = lazySchema(() => strictObject({ + surface: 'this user filter field', + history: VIEW_HISTORY, +}, { field: z.string().describe('Field name on the source object (must exist — checked by reference diagnostics)'), label: I18nLabelSchema.optional().describe('Display label override (defaults to the field label)'), type: z.enum(['select', 'multi-select', 'boolean', 'date-range', 'text']).optional() @@ -424,6 +487,22 @@ export const UserFilterFieldSchema = lazySchema(() => z.object({ * * @see Airtable Interface → "User filters" panel (Elements: tabs / dropdowns) */ +/** + * Deliberately left STRIP by #4001, pending its own verification. + * + * `tabs` and `showAllRecords` are valid on a PAGE's user filters and not on an + * object list view's (ADR-0047), and `object-list-view.test.ts` asserts they are + * dropped here rather than rejected. That is the "correct on a neighbouring + * surface" shape this campaign usually answers with a `guidance` entry — so the + * likely right end state is a rejection saying "tabs are page-only" rather than + * a silent drop. + * + * Not done here because it is a behaviour change with a real consumer question + * behind it: something may pass a page-shaped userFilters block through this + * schema deliberately, relying on the strip to narrow it. The campaign's own + * rule is verify-then-enforce, and this batch did not verify it. Left as the one + * open shape in this file, named rather than quietly skipped. + */ export const UserFiltersSchema = lazySchema(() => z.object({ // `toggle` is DEPRECATED (ADR-0047 §3.4a): it overlaps `tabs` (presets) and // `dropdown` (per-field values) without adding expressive power, needs @@ -446,7 +525,10 @@ export const UserFiltersSchema = lazySchema(() => z.object({ * * @see Airtable Interface → "+ Add record" button */ -export const AddRecordConfigSchema = lazySchema(() => z.object({ +export const AddRecordConfigSchema = lazySchema(() => strictObject({ + surface: 'this add record configuration', + history: VIEW_HISTORY, +}, { enabled: z.boolean().default(true).describe('Show the add record entry point'), position: z.enum(['top', 'bottom', 'both']).default('bottom').describe('Position of the add record button'), mode: z.enum(['inline', 'form', 'modal']).default('inline').describe('How to add a new record'), @@ -456,7 +538,10 @@ export const AddRecordConfigSchema = lazySchema(() => z.object({ /** * Kanban Settings */ -export const KanbanConfigSchema = lazySchema(() => z.object({ +export const KanbanConfigSchema = lazySchema(() => strictObject({ + surface: 'this kanban configuration', + history: VIEW_HISTORY, +}, { groupByField: z.string().describe('Field to group columns by (usually status/select)'), summarizeField: z.string().optional().describe('Field to sum at top of column (e.g. amount)'), columns: z.array(z.string()).describe('Fields to show on cards'), @@ -468,7 +553,10 @@ export const KanbanConfigSchema = lazySchema(() => z.object({ * when used as a `type: 'chart'` ListView. Distinct from the full-featured * `ChartConfigSchema` in `chart.zod.ts` (which is for embedded reports). */ -export const ListChartConfigSchema = lazySchema(() => z.object({ +export const ListChartConfigSchema = lazySchema(() => strictObject({ + surface: 'this list chart configuration', + history: VIEW_HISTORY, +}, { /** * Narrowed from `ChartTypeSchema` with `.extract()` rather than retyped. * Same five members as before — this is a de-duplication, not a widening — @@ -494,7 +582,10 @@ export const ListChartConfigSchema = lazySchema(() => z.object({ /** * Calendar Settings */ -export const CalendarConfigSchema = lazySchema(() => z.object({ +export const CalendarConfigSchema = lazySchema(() => strictObject({ + surface: 'this calendar configuration', + history: VIEW_HISTORY, +}, { startDateField: z.string().describe('Field providing the event start date/time'), endDateField: z.string().optional().describe('Field providing the event end date/time (defaults to a single-day event)'), titleField: z.string().describe('Field displayed as the event title'), @@ -504,7 +595,10 @@ export const CalendarConfigSchema = lazySchema(() => z.object({ /** * Quick filter dimension for the Gantt toolbar. */ -export const GanttQuickFilterSchema = lazySchema(() => z.object({ +export const GanttQuickFilterSchema = lazySchema(() => strictObject({ + surface: 'this gantt quick filter', + history: VIEW_HISTORY, +}, { field: z.string().describe('Record field / dot-path the dimension filters on'), label: z.string().optional().describe('Trigger label (falls back to the field label)'), options: z.array(z.union([ @@ -523,7 +617,10 @@ export const GanttQuickFilterSchema = lazySchema(() => z.object({ * parent/child hierarchy (parentField/typeField), planned-vs-actual baselines, * dynamic grouping, a resource/workload view, hover tooltips and quick filters. */ -export const GanttConfigSchema = lazySchema(() => z.object({ +export const GanttConfigSchema = lazySchema(() => strictObject({ + surface: 'this gantt configuration', + history: VIEW_HISTORY, +}, { startDateField: z.string().describe('Field providing the task start date'), endDateField: z.string().describe('Field providing the task end date'), titleField: z.string().describe('Field displayed as the task title'), @@ -565,7 +662,10 @@ export const GanttConfigSchema = lazySchema(() => z.object({ * category trees, BOMs, nested comments). When `parentField` is omitted the * renderer auto-detects the object's `tree`/self-reference field. */ -export const TreeConfigSchema = lazySchema(() => z.object({ +export const TreeConfigSchema = lazySchema(() => strictObject({ + surface: 'this tree configuration', + history: VIEW_HISTORY, +}, { parentField: z.string().optional().describe('Single-parent pointer field (auto-detected from the object schema when omitted)'), labelField: z.string().optional().describe('Field rendered indented in the first column (defaults to "name")'), fields: z.array(z.string()).optional().describe('Additional fields rendered as flat columns alongside the label'), @@ -590,7 +690,10 @@ export const NavigationModeSchema = lazySchema(() => z.enum([ /** * Navigation Configuration Schema */ -export const NavigationConfigSchema = lazySchema(() => z.object({ +export const NavigationConfigSchema = lazySchema(() => strictObject({ + surface: 'this navigation configuration', + history: VIEW_HISTORY, +}, { mode: NavigationModeSchema.default('page'), /** Target View Config */ @@ -646,7 +749,10 @@ export const NavigationConfigSchema = lazySchema(() => z.object({ * } * } */ -export const ListViewSchema = lazySchema(() => z.object({ +export const ListViewSchema = lazySchema(() => strictObject({ + surface: 'this list view', + history: VIEW_HISTORY, +}, { name: SnakeCaseIdentifierSchema.optional().describe('Internal view name (lowercase snake_case)'), label: I18nLabelSchema.optional(), // Display label override (supports i18n) type: z.enum([ @@ -970,6 +1076,13 @@ export const FormFieldSchema: z.ZodType = lazySchema( /** * Form Layout Section + * + * Deliberately NOT converted to `strictObject` by #4001: this shape already + * closed under ADR-0089 D3a, with `strictVisibilityError` — the bespoke map that + * resolves the `visibleWhen` / `visibility` pair — and a `.transform()` that + * normalizes them. Converting would mean re-expressing that map as `guidance` + * and re-proving the transform: a refactor of working, tested behaviour rather + * than a strictness change. Same call as the widget map in `dashboard.zod.ts`. */ export const FormSectionSchema = lazySchema(() => z.object({ /** @@ -1023,7 +1136,10 @@ export const FormSectionSchema = lazySchema(() => z.object({ * Leaf of the {@link FormViewSchema} `buttons` block (#2998). * `.strict()` per ADR-0089 D3a so a typo'd key errors instead of vanishing. */ -export const FormButtonConfigSchema = lazySchema(() => z.object({ +export const FormButtonConfigSchema = lazySchema(() => strictObject({ + surface: 'this form button configuration', + history: VIEW_HISTORY, +}, { show: z.boolean().optional().describe('Whether the button is rendered (renderer default applies when omitted)'), label: I18nLabelSchema.optional().describe('Button label (i18n-capable; renderer default when omitted)'), }).strict()); @@ -1049,7 +1165,10 @@ export type FormButtonConfig = z.infer; * ] * } */ -export const FormViewSchema = lazySchema(() => z.object({ +export const FormViewSchema = lazySchema(() => strictObject({ + surface: 'this form view', + history: VIEW_HISTORY, +}, { type: z.enum([ 'simple', // Single column or sections 'tabbed', // Tabs @@ -1261,7 +1380,47 @@ export const ObjectListViewSchema = lazySchema(() => * } * } */ -export const ViewSchema = lazySchema(() => z.object({ +export const ViewSchema = lazySchema(() => strictObject({ + surface: 'this view container', + history: VIEW_HISTORY, + // The one mistake this shape actually sees: a FLAT list view written where a + // CONTAINER goes. `defineView` has guarded it since the container was + // introduced, with a comment saying why — "`ViewSchema` strips unknown + // top-level keys, so a flat list view would parse to an empty container". + // Another bespoke guard built to work around silent stripping, and like every + // other one this campaign has found, it covered ONE door: `defineView`. The + // metadata door (Studio, the API, an agent) got the empty container in + // silence. Closing the shape covers both; the guard stays for the case strict + // cannot see — `defineView({})`, an empty container with no unknown keys at + // all, which is still zero views registered. + // + // `name`, `label` and `object` are NOT in this list, and the first draft had + // all three — wrongly. A container carries its own identity and its object + // binding: `saveMetaItem` sends the name, artifact-shipped containers do + // (`service-ai/ai_traces`), the validation sweep injects it, and a + // stack-level `views: [...]` entry needs `object` to say which object it + // belongs to (this file's own note on `ObjectListViewSchema` calls the + // container "view definitions for a specific object", and `getViewsByObject()` + // is what reads that binding). Tombstoning them rejected shapes the platform + // itself writes. + // + // The list below is what survived an EMPIRICAL check rather than a confident + // one: every container literal in the repo was scanned for these nine keys, + // and only `name` / `label` / `object` ever appear on one. That check should + // have come before the guidance, not after three CI failures — a guidance + // entry is a claim about the schema, and claims need verifying like code. + guidance: Object.fromEntries( + ['type', 'columns', 'data', 'viewKind', 'filters', 'sort'] + .map((k) => [k, `\`${k}\` belongs to a single VIEW, not to the container. Wrap it: \`defineView({ list: { type, data, columns, … } })\`, or name it — \`defineView({ listViews: { my_view: { … } } })\`. The container's own keys are \`list\`, \`form\`, \`listViews\`, \`formViews\`.`]), + ), +}, { + // Item identity. The container is a registered metadata item, so the door + // supplies these — declared for the same reason `translation` needed them + // (#4001 batch 5): undeclared, they were stripped, and the metadata door + // quietly discarded an item's own name on the way through. + name: z.string().optional().describe('Item name — supplied by the metadata door; for an object-scoped container it is the object name.'), + label: I18nLabelSchema.optional().describe('Human-readable label shown in metadata lists.'), + object: z.string().optional().describe('Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`.'), list: ObjectListViewSchema.optional(), // Default list view (views mode — dropdown userFilters allowed, no tabs; ADR-0047) form: FormViewSchema.optional(), // Default form view listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047)'), @@ -1583,8 +1742,17 @@ export const ViewMetadataSchema = lazySchema(() => // 3. Flattened runtime overlay — inline ListView / FormView config + identity. // The list member is tried first; a flattened form (no required // `columns`, disjoint `type` enum) then matches the form member. - ListViewSchema.extend(flattenedViewOverlayFields()), - FormViewSchema.extend(flattenedViewOverlayFields()), + // + // `.strip()` is load-bearing, not leftover. `.extend()` INHERITS + // strictness, so closing `ListViewSchema`/`FormViewSchema` for authoring + // (#4001) silently made this overlay strict too — and this member exists + // precisely to carry Studio's auxiliary round-trip keys (`isPinned`, + // `sortOrder`, …) that `saveMetaItem` persists verbatim. Strict here is a + // 422 on a shape the platform itself writes. The ledger names this as the + // trap to watch while batching: a response-side extension of an authoring + // schema must strip back, or an upstream field addition becomes a crash. + ListViewSchema.extend(flattenedViewOverlayFields()).strip(), + FormViewSchema.extend(flattenedViewOverlayFields()).strip(), ]), );