Skip to content

Commit 6ce4773

Browse files
committed
feat(spec)!: the view surface closes — the last batch of #4001
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY
1 parent 80334c7 commit 6ce4773

9 files changed

Lines changed: 408 additions & 41 deletions

File tree

.changeset/view-strict-final.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The view surface closes — the container, both view kinds, and the ~28 config shapes under them. This is the last batch of #4001.
6+
7+
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.
8+
9+
**`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.
10+
11+
**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.
12+
13+
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.
14+
15+
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.
16+
17+
**Three shapes are deliberately left open, each with its reason in the file rather than a silent skip:**
18+
19+
- **`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.
20+
- **`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.
21+
- **The flattened Studio overlay** in `ViewMetadataSchema` must stay open: it carries auxiliary round-trip keys (`isPinned`, `sortOrder`, …) that `saveMetaItem` persists verbatim.
22+
23+
**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.
24+
25+
## `view` is the end state, not the last item of debt
26+
27+
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.
28+
29+
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.
30+
31+
## Where the campaign ends up
32+
33+
- **Registered types closed: 24 of 25** (from 9 when this line started), the last one exempt with a stated reason.
34+
- **The ADR-0010 undeclared-envelope debt list is empty**, from the eight the structural walk opened it with.
35+
- **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.
36+
37+
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.

content/docs/references/api/protocol.mdx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ const result = AiAgentCapabilities.parse(data);
397397
| Property | Type | Required | Description |
398398
| :--- | :--- | :--- | :--- |
399399
| **object** | `string` || Object name (snake_case) |
400-
| **data** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` || View definition to create |
400+
| **data** | `{ name?: string; label?: string; object?: string; list?: object; … }` || View definition to create |
401401

402402

403403
---
@@ -410,7 +410,7 @@ const result = AiAgentCapabilities.parse(data);
410410
| :--- | :--- | :--- | :--- |
411411
| **object** | `string` || Object name |
412412
| **viewId** | `string` || Created view identifier |
413-
| **view** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` || Created view definition |
413+
| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` || Created view definition |
414414

415415

416416
---
@@ -885,6 +885,9 @@ const result = AiAgentCapabilities.parse(data);
885885

886886
| Property | Type | Required | Description |
887887
| :--- | :--- | :--- | :--- |
888+
| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
889+
| **label** | `string` | optional | Human-readable label shown in metadata lists. |
890+
| **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=`. |
888891
| **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<string, any> }; … }` | optional | |
889892
| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | |
890893
| **listViews** | `Record<string, { 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<string, any> }; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
@@ -920,7 +923,7 @@ const result = AiAgentCapabilities.parse(data);
920923
| Property | Type | Required | Description |
921924
| :--- | :--- | :--- | :--- |
922925
| **object** | `string` || Object name |
923-
| **view** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` || View definition |
926+
| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` || View definition |
924927

925928

926929
---
@@ -1041,7 +1044,7 @@ const result = AiAgentCapabilities.parse(data);
10411044
| Property | Type | Required | Description |
10421045
| :--- | :--- | :--- | :--- |
10431046
| **object** | `string` || Object name |
1044-
| **views** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }[]` || Array of view definitions |
1047+
| **views** | `{ name?: string; label?: string; object?: string; list?: object; … }[]` || Array of view definitions |
10451048

10461049

10471050
---
@@ -1410,7 +1413,7 @@ const result = AiAgentCapabilities.parse(data);
14101413
| :--- | :--- | :--- | :--- |
14111414
| **object** | `string` || Object name (snake_case) |
14121415
| **viewId** | `string` || View identifier |
1413-
| **data** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` || Partial view data to update |
1416+
| **data** | `{ name?: string; label?: string; object?: string; list?: object; … }` || Partial view data to update |
14141417

14151418

14161419
---
@@ -1423,7 +1426,7 @@ const result = AiAgentCapabilities.parse(data);
14231426
| :--- | :--- | :--- | :--- |
14241427
| **object** | `string` || Object name |
14251428
| **viewId** | `string` || Updated view identifier |
1426-
| **view** | `{ list?: object; form?: object; listViews?: Record<string, { 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<string, any> }; … }>; formViews?: Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>; … }` || Updated view definition |
1429+
| **view** | `{ name?: string; label?: string; object?: string; list?: object; … }` || Updated view definition |
14271430

14281431

14291432
---

content/docs/references/ui/view.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,9 @@ End-user quick-filter configuration (Airtable "User filters" parity)
663663

664664
| Property | Type | Required | Description |
665665
| :--- | :--- | :--- | :--- |
666+
| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
667+
| **label** | `string` | optional | Human-readable label shown in metadata lists. |
668+
| **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=`. |
666669
| **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<string, any> }; … }` | optional | |
667670
| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | |
668671
| **listViews** | `Record<string, { 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<string, any> }; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |

0 commit comments

Comments
 (0)