Repository navigation
fix(spec)!: judge a flattened list view overlay's legacy options bag at the view write door (door half of #20051) - #20183
Conversation
…the view write door Each `options.KIND` is judged by the kind's own block schema, so an out-of-contract key is refused by name exactly as the direct spelling is, and `options.foo` is refused rather than dropped. The form overlay pins `options` absent so it cannot accept the bag the list overlay refused. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…s-bag control read Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…ve and PUT /meta/view doors Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…underlay of the top-level block Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…r; add its changeset Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…t exist Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 471f1dfaf516190d2193cf72ca157a20586fd0de && git checkout 471f1dfaf516190d2193cf72ca157a20586fd0de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a 2526e4052e742ee2f2d9a6a93c2e653efb093e1f && git checkout -B drift-repro 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a && git merge --no-ff 2526e4052e742ee2f2d9a6a93c2e653efb093e1f
node scripts/docs-audit/affected-docs.mjs --json 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a
|
Contract reviewServed-tier: 150/150 ① Derived judgments
② Semver levelCorrect: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Merge-queue kick-out — PR #20183 ·
|
…ons-kind-door Resolves the one text conflict, packages/spec/src/migrations/registry.ts, by taking origin/main's side (os-regen-merge.sh step 1, class 3): outside the generated regions this branch's side is byte-equal to the merge base 8d1f7ab, so main's side carries every hand-written line (step18's rationale and its page-component-filter-record-to-rule-array conversion id). This branch's own semantic:18 registration is restored by regenerating the region from src/migrations/entries/ in the next commit, never by hand. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
os-regen-merge.sh step 4 for the class-3 conflict in registry.ts: rerun gen:migration-registry over the merged src/migrations/entries/. The only change is this branch's view-overlay-options-bag-judged registration returning to the semantic:18 region, byte-identical to the hunk it had before the merge; nothing outside the generated regions moves. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
…ew-overlay-options-kind-door
os-regen-merge.sh steps 2-4 for the merge of origin/main 0bd1126. The os-regen driver kept this branch's side of the ledger counts file in the merge commit; step 2 took main's side, and gen:strictness-ledger re-derives it from the merged AST. Measured result: 453 sites, 323 strict, 125 strip. That is main's side (452 / 322 / 125) plus this branch's one strict ui/ site (view.zod.ts 61 to 62). No hand edit to any count. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 65/65 ① Derived judgments
② Semver levelCorrect. ③ Boundary flags
Implemented-by: VERDICT: PASS |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36310592557 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue kick-out 2, new signature — PR #20183 ·
|
|
Diagnosis of queue kick-out 2, then one re-queue · This follows the plan in
|
|
Landing record — PR #20183 merged through the merge queue at 2026-09-27T11:17Z as
|
…bjectstack-ai#20227) Fixes objectstack-ai#20085 Clause-②: no ## What this does Retires the view item's `owner` and `hidden` keys under ADR-0049 enforce-or-remove, following the triage direction on the card (comment 5826969296, verbatim: 「retire both keys」) and the `spec-property-retirement` playbook. Both keys sat on the view-item identity layer (`viewItemBaseShape()` in `packages/spec/src/ui/view.zod.ts`). They were accepted by the strict authoring door and by the wire member that the `PUT /api/v1/meta/view` door validates, and `saveMetaItem` stored them verbatim. Nothing read or wrote either one. An author (often an AI) who wrote `hidden: true` or `owner: 'u1'` got a clean save and no effect, and a view marked as one user's was listed for everyone. After this PR, every door that carries a ViewItem record refuses both keys with a prescription. ## Premise, re-measured first (execution note 1) Each reading is taken against the named tree, with a lit control on the same ref. The premise held, so the retirement proceeds. | where | ref | `owner` / `hidden` on a view item | lit control | |---|---|---|---| | objectstack `packages/**`, `examples/**` | `e7f69dbb` | no reader, no writer. Both switcher read paths filter on `viewKind` + `object` and sort on `order`: `GET /meta/view?object=` (`rest-server.ts` ~6594-6607) and `getViewsByObject` (`metadata-manager.ts:1727`). Examples author zero ViewItem records (`viewKind:` count 0); the 5 `owner:` hits in example view files are field-label translations. | `order` is read by both paths; `label:` authored 109 times in the same example files | | objectui at the pin `.objectui-sha` | `f8a9d0fb` | view-receiver `.owner` reads: 0. The 13 view-receiver-shaped `.hidden` hits are all fields, menu items or grid columns (read one by one). All 19 view write calls carry no `hidden` / `owner` patch; the only hidden-like patch key is ListView `hiddenFields`. Zero `.createView(` callers. Zero `sys_view_definition` references. | `isDefault` view reads: 10; `isPinned` 56; `sys_metadata` 124 | | objectui `main` | `25c7d584` | same readings (0 / 13 non-view / 18 write calls, none carrying the keys / 0 / 0) | `isDefault` 10; `isPinned` 56; `sys_metadata` 129 | | cloud `main` | `48d70663` | no code reader or writer. One test double pins a lean `{hidden:true}` personalization PUT as accepted; that is the flattened-overlay door (below), which this PR leaves alone. | `sys_metadata` 160 | Zone 2 assumption 3 (no personal-view feature reads `owner` through another path): confirmed. `sys_view_definition` has `owner` / `hidden` columns and a docblock claiming the switcher merges its rows client-side. objectui references the table 0 times at both refs, and no framework code reads its rows. ADR-0131 D13 (2026-09-04) already records that table and its runbook as inert and retired, with execution paused, and records per-user view scope as a parked v18 direction. The table and the runbook are left to that ADR's execution. Zone 2 assumption 2 (stored data): no producer was measured, but the `PUT /meta/view` door accepted and persisted both keys until now, so a stored row can hold them. The D2 conversion below is therefore owed. An upgrading deployment's stored row carrying either key is stripped at rehydration (`database-loader.ts:825` replays the chain over every stored row as `{ views: [row] }`), then parses clean. Behaviour does not change, because neither key ever had an effect. ## Scope boundary, measured as the order asked - The `owner` / `hidden` at `view.zod.ts:5186-5187` (`flattenedViewOverlayFields()`) are **not** the same key on the same door. They are the flattened-overlay members' own declarations: a lean personalization PUT with no `config`, a different door. They are not retired here. A bound `{ object, viewKind, hidden: true }` overlay still saves, and that is pinned as a boundary. The conversion leaves overlays alone for the same reason. - Not the column `hidden` (`:1060`), not `Hidden override` (`:3193`), not `scope`, not the flattened list-overlay region (`:5433` onward, PR objectstack-ai#20183). ## The route, and why (the open choices settled here, on the four axes) **1. `retiredKey()` on the SHARED shape, not strict deletion plus a `guidance` entry.** `viewItemBaseShape()` feeds two doors. The strict authoring door (`ViewItemSchema`) would refuse a deletion, but the wire member (`ViewItemWireSchema`, `.strip()`) is member 1 of the union that `saveMetaItem`, the `view` registry binding and the assembled-manifest `viewItems` channel all run, and a bare deletion there would be a silent strip (ADR-0104). - Business need: the wire door is where a real PUT lands. - Long-term soundness: one declaration serves both doors (derive-by-reference, the rule `viewItemArmShape()` exists for), with no second copy to drift. - AI-error prevention: `tsc` types the key `never` on `defineViewItem`'s input, and every parse carries the prescription instead of a strip. - Startup-stage restraint: no new mechanism; `retiredKey()` inside `strictObject` shapes is established precedent (`TursoConfig.timeout`, `ListViewSchema.pageName` in this same file). **2. The D2 conversion reaches `viewItems` as well as `views`.** Package export and environment artifacts carry standalone ViewItem records in the assembled-manifest `viewItems` channel. `applyArtifactForwardConversions` replays the chain over that channel, and then the registration loop parses each entry against `AssembledViewArtifactSchema`, which now refuses the keys. Walking `views` alone would have left an artifact assembled before this release failing registration with a 422 over two keys that never did anything. This is the minimal reach that keeps the new refusal from breaking an upgrade; it adds no new surface. **3. The flattened-overlay copies stay.** The order asked that a separate door be retired only if it is the same key on the same door. It is not, so those copies are reported under Acceptance notes rather than removed. ## Contract changes, quoted verbatim `view.owner` prescription (a `retiredKey()`, issue code `invalid_type`, path `["owner"]`): > `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. `view.hidden` prescription (issue code `invalid_type`, path `["hidden"]`): > `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: `GET /meta/view?object=` and the console's view switcher list every item bound to the object, `hidden: true` included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. Removed `.describe` texts (the keys now describe themselves as `[REMOVED]` plus the prescription above in the generated reference): > Owner user id — set when `scope` is `personal`. > Hidden from the switcher (per-user / per-org declutter). The `ViewScopeSchema` TSDoc no longer calls the package layer "hideable from the switcher" or says `personal` is "scoped to `owner`". It now states that per-user scoping is parked and that nothing restricts a `personal` item to one user. ## The retirement kit (the playbook's surface list) | surface | this PR | |---|---| | schema | `retiredKey()` ×2 on `viewItemBaseShape()`, with an in-schema comment on what was removed and why | | D2 conversion | `view-item-owner-hidden-removed` (`toMajor: 18`, `retiredFromLoadPath: true`), record spelling only, `views` + `viewItems`, lossless `stripKeys`, fixture with 3 notices (two on a `views` record, one on a `viewItems` record), a flattened-overlay neighbour kept | | D3 chain | id added to `MIGRATIONS_BY_MAJOR[18].conversionIds`, and the step rationale extended | | `RETIRED_KEYS_BY_MAJOR[18]` | `ui/ViewItem:owner`, `ui/ViewItem:hidden`, `ui/ViewItemWire:owner`, `ui/ViewItemWire:hidden` (four entry files plus `gen:migration-registry`) | | liveness ledger | no row, because a row would be an ORPHAN: the walk stops at the `view` union's container arm (Acceptance notes) | | generated artifacts | `check:generated --fix` proved `content/docs/references/**` stale, and nothing else. `authorable-surface/`, `json-schema.manifest/`, `api-surface/` and the signatures are **byte-identical, as expected on this route**: all four read a def's top-level `properties` or exports, and `ViewItem` / `ViewItemWire` are discriminated unions with no top-level `properties`. `spec-changes.json` and the upgrade guide are unchanged too, because they project up to the current protocol major (17), so no major-18 sibling appears in them either (measured: `object-tenancy-organization-field-removed` 0 hits, `action-inert-keys-removed` 2) | | forms / i18n | no form offers either key (`view.form.ts:54` is the column `hidden`) | | CLI advisory lint | ledger-driven; no row, so nothing changes | | examples / skills / hand-written docs | zero authorings (measured); `tsc` and the tree-scoped pin below hold that | | reconciliation ledger | the `metadata-form-zod-reconciliation.test.ts` comment block now records both keys as retired | | changeset | `@objectstack/spec: minor`, `**BREAKING**`, FROM → TO, the one-line fix, and the ADR-0087 disposition `registered view-item-owner-hidden-removed` | ## Pins (`packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts`) - Every door that carries a record refuses both keys: - the strict authoring door and the `.strip()` wire member: issue code `invalid_type`, path, and prescription; - the `view` registry binding, which is what `saveMetaItem` runs: `invalid_union` whose message is the prescription, and whose viewItem-branch issue locates the key; - the assembled channel. - `defineViewItem` throws, and an `@ts-expect-error` proves the input type is `never` (the file is compiled by `check:test-typecheck`). - CONTROL: the same record without the keys passes all four doors, with `scope` / `isDefault` / `order` intact and no `owner` / `hidden` grown. - BOUNDARY: a flattened overlay's own keys still parse. - Conversion: a stored row rehydrates clean through `applyConversionsToStoredItem` and then parses at the door; `viewItems` is reached and overlays and containers are left alone; the second replay produces 0 notices and returns the same reference; on the load path, a live author is refused rather than rewritten. - Registration: the four `RETIRED_KEYS_BY_MAJOR[18]` rows and the chain id. - **Tree-scoped structural absence** over the declared `@objectstack/spec` radius (`packages`, `examples` non-code, `skills`, `content`, `scripts`, all already in `scripts/cross-package-test-inputs.mjs`). - `owner` / `hidden` are among the commonest key names in this tree, so the matcher is structural rather than textual. It flags one object literal, or one YAML mapping, whose own keys include `viewKind`, `config` and a retired key. - Anti-vacuity cases cover each syntax the walk reads, plus the neighbours that must not match: an overlay, a nested column `hidden`, the `sys_view_definition` row shape, prose, a quoted string, and a sibling YAML item. - Excluded, with stated reasons: the conversion fixture, the gitignored `json-schema/` output, release notes, the pin itself, and `view.zod.ts`. `view.zod.ts` was the one measured hit, and it was the overlay door's own shape (a `config: z.undefined()` guard beside that door's keys). ## Verification (final head `b0b5b381`, after merging `origin/main` at `ce70876e`, which carried objectstack-ai#20183) Heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit code was written to disk before its log was read. The box was shared (lock queue waits of 9–20 min), so wall-clock figures are contended readings. | run | head | reading | |---|---|---| | metadata-protocol dependency closure build (includes `@objectstack/spec` build + `gen:schema`) | `b0b5b381` | exit 0 | | `pnpm --filter @objectstack/spec check:generated` | `b0b5b381` | exit 0, all 15 artifacts current. Earlier, `--fix` regenerated only `content/docs/references/**`, the one artifact it proved stale | | spec `--project local`, full | `b0b5b381` | 544 files / 16035 tests passed | | spec `--project repo`, full (this pin now lives here) | `b0b5b381` | 33 files / 604 tests passed (roster length 33, including this pin) | | spec `typecheck` (`tsc` + `check:scripts-typecheck` + `check:test-typecheck`, so the `@ts-expect-error` is compiled) | `b0b5b381` | exit 0 | | consumer: metadata-protocol, the 22 files that spell `viewKind` (objectstack-ai#20183's new tests included) | `b0b5b381` | 22 / 372 passed | | consumer: objectql, the 24 `viewKind` files (registration loop, assembled `viewItems`) | `60eec227` | 24 / 380 passed | | consumer: lint (vitest ran the whole suite) | `60eec227` | 109 / 4232 passed | | consumer typecheck: `@objectstack/lint`, `@objectstack/metadata-protocol` | `60eec227` | exit 0, exit 0 | | spec source audits `check:liveness`, `check:empty-state`, `check:exported-any`, `check:dual-source-exports`, `check:variant-docs` | `4cb0f252` | all exit 0 | **Reverse verification (cross-package type, one-shot, nothing left behind).** A temp `packages/lint/src/zz-issue20085-dts-probe.ts` called `defineViewItem({ …, hidden: true })` against the REBUILT spec `.d.ts`. `@objectstack/lint` `tsc --noEmit` exited 1 with `src/zz-issue20085-dts-probe.ts(9,3): error TS2322: Type 'true' is not assignable to type 'undefined'.` — the probe's `hidden: true` line. After the probe was removed (verified absent), `@objectstack/lint typecheck` exited 0. Predicted direction: red. Observed: red. **Door measurement (one-shot, not committed).** The existing `protocol.save-union-issues.test.ts` harness drives the real `saveMetaItem` write path over its stub engine. With a probe block appended, in a temp file, deleted after the run: | body saved | result | |---|---| | record with `owner` | `INVALID_METADATA` / `422` / 0 rows persisted, prescription in `issues` | | record with `hidden` | the same | | CONTROL: the same record without either key | saves, 1 row | | BOUNDARY: a bound lean `{ hidden: true, object, viewKind }` overlay | saves, 1 row | **Ablation of the new pin** (`scripts/ablation-replace.mjs`, on committed state). The mutation swapped `owner: retiredKey(VIEW_ITEM_OWNER_RETIRED),` for `owner: z.string().optional(),`: anchor count 1 → 0, blob `9d5445c7` → `fc09981a`. The pin then read 4 failed / 14 passed, exactly the four `owner` door pins (strict, wire, `view` door, assembled). The restore brought the blob back to HEAD `9d5445c7`, with `git diff HEAD` at 0 bytes and `git status --porcelain` at 0 lines. Predicted direction: red. Observed: red. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was re-derived on the ACTUAL changed paths at `b0b5b381` (111 commands; the dispatch-time lead had 72). Every line was run with its exit code captured before any pipe, and the record was reconciled with `--ran`: **111 derived, 107 run, 4 NOT-MEASURED, 0 UNRUN**. All 107 measured commands exited 0: 40 `node scripts/…` and 67 `pnpm …`, including `check:adr-0087-registration` (`registered view-item-owner-hidden-removed (new here …)`), `check:changeset-no-major`, `check:empty-changeset`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:nul-bytes` and the spec `check:*` family. NOT MEASURED — exit 3, `PREREQUISITE NOT MET`. Each reads built output of packages outside the closure built here (client-react; the whole workspace), and the verify lock was queued 9–20 min per attempt, so they were not built locally. This diff touches no package entry point, export, client SDK or tsconfig. CI's build lanes measure them: - `pnpm --filter @objectstack/spec run check:skill-examples` - `pnpm check:dual-build-cjs-loads` - `pnpm check:lean-entry-closure` - `pnpm check:type-check-debt` Also not measured locally, and owned by CI: the rest (closure of 26 packages) and client-react (35) consumer suites, `pnpm lint`, and the CI-only job and type-check lanes that `dispatch-gates` lists as having no local invocation. ## Acceptance notes (observed, not fixed here) 1. **The liveness walk cannot see a view-item key** (execution note 3 on the card). `check:liveness`'s view walk stops at the `view` union's container arm: its `shapeOf` takes the first object member, and the `viewItem` arm is a discriminated union. So no view-item key can hold a ledger row, and a row reads as ORPHAN. That is why this retirement has no `dead` row to keep. Not claimed here. Carrier: none; triage routed it to the `domain:spec` lane. 2. **Same family: the authorable-surface ratchet cannot see a view-item key either.** `build-schemas.ts` collects authorable keys from a def's top-level `properties` only. `ui/ViewItem` and `ui/ViewItemWire` are discriminated unions with none, so `authorable-surface/` has no `ui/ViewItem:*` line, and check (b) never sees these tombstones. The four `RETIRED_KEYS_BY_MAJOR[18]` rows are declared rather than judged; this PR's own pin holds them. Carrier: none; same family as note 1. 3. **The flattened-overlay door keeps its own `owner` / `hidden`, and they are just as inert.** `flattenedViewOverlayFields()` declares both for the lean personalization PUT. The premise table found no reader of either key on any door, and the one-shot door probe saved a bound `{ hidden: true, object, viewKind }` overlay (1 row). Same family as this card; left for the seat to judge, because the order scoped this card to the ViewItem door. Carrier: none. 4. **The assembled channel's refusal loses the branch diagnostics.** `AssembledViewArtifactSchema` is a plain `z.union`. A refused `viewItems:` entry therefore reaches the registration loop's 422 as "First issue: Invalid input", while the `view` door's union surfaces the prescription (measured on a record carrying `hidden`). Pre-existing. Carrier: none. 5. **The earlier view-family conversions do not reach `viewItems`.** `mapViewPayloads` walks `stack.views` only, so, for example, `view-page-mount-removed` never visits a ViewItem record's `config` inside an assembled artifact's `viewItems:` channel. This is read from `conversions/walk.ts`, not exercised end-to-end. This PR's own conversion walks both collections. Carrier: none. 6. **A bound flattened LIST overlay without `columns` parses through the FORM member.** On `b0b5b381`, `{ name, object, viewKind: 'list', hidden: true }` is claimed as `listOverlay` by `selectViewMetadataBranch`, yet the union accepts it through the form overlay (output stamped `type: 'simple'`); adding `columns` gives `type: 'grid'`. `saveMetaItem` stores the original body, so no stored effect was measured. It sits in objectstack-ai#20051's region. Carrier: none. 7. **`sys_view_definition` and its migration runbook describe a switcher merge that does not exist** (objectui has 0 references at its pin and at `main`). ADR-0131 D13 already rules both inert and retired, with execution paused. Nothing to do here. ## ADR interplay ADR-0017 §2 describes `personal` views as "visible only to its `owner`". Its 2026-09-04 amendment (ADR-0131 D13) retired the §3.4 store as inert and parks per-user view scope as a v18 direction. This PR removes the unenforced item-level declaration of that parked direction and leaves the scope model itself alone (`scope` is untouched). If the direction is revived, `owner` returns together with its reader. No ADR text is edited here, which keeps this diff off the governed surfaces. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…Kind names (objectstack-ai#20186) (objectstack-ai#20245) Fixes objectstack-ai#20186 Clause-②: yes (narrowing) A flattened `view` overlay is now judged by the overlay member its `viewKind` names. A column-less list patch, which the console writes on every toolbar save, has its list keys judged, instead of being accepted by the form member with those keys stripped unread. This is route C-prime as ruled in seat answers `5855433719` and `5855548706` on objectstack-ai#20186. ## What was wrong (measured) Both flattened overlay members of `ViewMetadataSchema` shared one `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it refused a column-less `viewKind: 'list'` body. The union then tried the form member, which requires no list key and `.strip()`s every one, and accepted the body: `diagnoseViewMetadata` answered `formOverlay` and the parse output was `type: 'simple'`, a form. I measured this through the real `saveMetaItem` on `origin/main` `4df101c3`, and again at the spec level on `ce70876e` after objectstack-ai#20183 landed; the verdicts were byte-identical. Each of these bodies answered `success: true`, and the row held it exactly as sent: - `{ name, object, viewKind: 'list', sort: 'name desc' }`, a retired `sort` string; - the same body with `timeline: { …, metaFields: [...] }`; - the same body with `searchableFields: 'name'`. The mirror also held: the list member accepted `{ viewKind: 'form', columns: ['name'] }`. The column-less list body is not malformed; the console writes it. objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay, and `updateViewConfig` stamps `object`, `name` and `_isOverride`. That is the maintainer ruling on objectstack-ai#7494 (comment `5261754173`): 「`persistViewPatch` 只存 patch,不存 merged base」. So refusing it (route A) was ruled out. ## What changed — `packages/spec/src/ui/view.zod.ts`, the flattened-overlay region only - **`flattenedViewOverlayFields(kind)`: one `viewKind` per member.** The list member admits `'list'` only and the form member `'form'` only. An absent `viewKind` keeps the objectstack-ai#7741 binding prescription. - **The flattened LIST member judges a patch.** `columns` is optional on this member only; the authoring `ListViewSchema` keeps it required. `type` is declared here without its `.default('grid')`, so the member's checks can see whether the body named one. Three checks follow on the same object schema, in this order: 1. `checkListOverlayTypeNeedsColumns` refuses a column-less body that names a `type`, at `columns`. The issue is aborting, so the union envelope stays `invalid_union`. 2. The existing calendar check. 3. `applyListOverlayTypeDefault` (an `.overwrite()`) puts `type: 'grid'` back, in the key position the default took. `FormViewSchema` already uses the same `.overwrite()` shape for its `groups` → `sections` fold. **No pipe:** both overlay members are still `def.type === 'object'`, and a test pins it. - **The FORM member's `columns`** is a clone of `FormViewSchema`'s own count schema with an error map added. Its constraints are that schema's, and a field list gets a prescription. The member is built with `.safeExtend()`, because overriding a key on `FormViewSchema`, which carries refinements, is refused by `.extend()`. The two differ in that throw only. - `diagnoseViewMetadata` and `selectViewMetadataBranch` needed no change. Each body is now accepted by at most one overlay member, so the branch they name is the member that judged it. The conversions walk (`mapViewPayloads`) already picked an overlay's family from `viewKind`; the parse now agrees with it. ### Refusal and diagnosis texts (new) ```text LIST_OVERLAY_TYPE_NEEDS_COLUMNS (at `columns`, code custom) This list view overlay sets `type` but lists no `columns`. A body that sets `type` is a full inline list config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A patch on the view it shadows (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: remove `type` to save this body as a patch on the view it shadows. FORM_OVERLAY_COLUMNS_IS_A_COUNT (at `columns`, code invalid_type) On a form view `columns` is the NUMBER of body columns (an integer, 1 or more), not a list of fields, and this body says `viewKind: "form"`. A list of fields is a list view's `columns`: if this is a list view, set `viewKind: "list"`; if it is a form, list its fields in `sections: [{ fields: [...] }]`. overlayViewKindArmMismatch(kind) (at `viewKind`; seen only on a direct member parse, the union mutes the unclaimed member) This is the flattened FORM overlay member, which judges `viewKind: "form"` only. A `viewKind: "list"` body is judged by the list overlay member (`VIEW_METADATA_MEMBERS.listOverlay`), and that member's issues are its diagnosis. ``` ### Served JSON Schema (`/api/v1/meta/types/view`) The schema is still an `anyOf` of four members in both directions. Members 0 and 1 are byte-identical. I diffed it against `ce70876e`, and it moves only by the contract: - member 2 (list overlay): `viewKind.enum` is `["list"]`, and `columns` is no longer in `required`. In the output direction `type` is no longer in `required`. The `type` default `grid` stays byte-identical in both directions, via `.optional().meta({ default })`. - member 3 (form overlay): `viewKind.enum` is `["form"]`. `columns` is unchanged. ## Pins: `ViewMetadataSchema` AND `saveMetaItem` - New: `packages/spec/src/ui/view-overlay-viewkind-arm.test.ts` (schema + `diagnoseViewMetadata`). - Added to `packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts` (its ledgered stub engine, the real `saveMetaItem`, and the stored row read back). Every refusal asserts `INVALID_METADATA`, `422`, nothing stored, and the issue's path, code and prescription. | body (flattened, `object` bound) | was | now | |:--|:--|:--| | `viewKind: 'list'`, `sort: [{ field, order }]` (the headline) | ACCEPT on formOverlay, `type: 'simple'` | ACCEPT on listOverlay, `type: 'grid'`, stored verbatim | | the objectui sort / hiddenFields / inlineEdit / columnState / rowHeight patches | ACCEPT on formOverlay | ACCEPT on listOverlay, stored verbatim | | `viewKind: 'list'`, `sort: 'name desc'` | ACCEPT, stored | REFUSE `invalid_type` at `sort` (the 17.5.0 prescription) | | `viewKind: 'list'`, `timeline.metaFields` | ACCEPT, stored | REFUSE `unrecognized_keys` at `timeline` | | `viewKind: 'list'`, `searchableFields: 'name'` | ACCEPT, stored | REFUSE `invalid_type` at `searchableFields` | | `viewKind: 'list'`, `sharing: { enabled: true }` (form block) | ACCEPT, stored | REFUSE `unrecognized_keys` at `sharing` | | `viewKind: 'form'`, `columns: ['name']` (mirror) | ACCEPT on listOverlay | REFUSE on formOverlay, `invalid_type` at `columns`, count prescription | | `viewKind: 'list'`, `type: 'kanban'`, no `columns` (`overlay.list.identity`) | REFUSE (`invalid_union`, bare `Invalid input` at `columns`) | REFUSE (`invalid_union`), `custom` at `columns` with the prescription | | control: real form overlay | ACCEPT | ACCEPT, parse output byte-identical | | control: list overlay with `columns` | ACCEPT | ACCEPT, parse output byte-identical | ### Pin sweep - **Flipped on purpose:** `view-union-diagnostics.test.ts` `put.isPinned`, `put.sortOrder` and `put.pinAndOrder`. The verdict is still ACCEPT, but the pinned parse output moves from `{ type: 'simple', … }` to `{ type: 'grid', … }`. The seat named these three as the intended change. - **Not flipped:** `overlay.list.identity`, which stays REFUSE and stays `invalid_union`. - **No pin anywhere asserted a W1 or W2 body refused.** The swept suites all ran green unchanged, apart from the three `put.*` pins above (numbers under Verification). - The objectstack-ai#20183 pin in `view-overlay-options-bag.test.ts` (a column-less body with a BAD `options` bag stays refused) holds: the list member judges the bag. ### Readers keyed on the old form parse of these rows - **`graftNormalizedOperators`** (the `saveMetaItem` operator graft). On `805af4f2` the form member stripped a column-less list patch's `filter`, so an alias operator (`eq`) was stored as written. Now the list parse normalises it, and the graft writes the canonical operator into the row, as it already did for a list overlay with `columns`. Measured through the real save on this branch: `filter: [{ field: 'name', operator: 'eq', value: 'x' }]` is stored as `operator: 'equals'`. - **`graftFoldedFormSections`**. On `805af4f2` a list patch carrying the form key `groups` was parsed as a form (parse output `sections: [...]`), and the graft stored it as `sections`. Now the list member drops `groups` unread, and the row keeps `groups` as authored (measured through the real save). This is a W1-class body; see the Acceptance notes. - **objectui**, at the pin and at `main`, has no reader of `diagnoseViewMetadata` or of the parse output's `type` for these rows. Its only hits are docblocks naming `VIEW_METADATA_MEMBERS.formOverlay`, for real form overlays. ## Declared widening (W1, W2) — why `Clause-②: yes` Both classes are column-less, type-less `viewKind: 'list'` bodies that were refused and are now accepted. All readings below are on `805af4f2` and on this branch. - **W2**: a list-legal value under a key both members declare with different schemas. - Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', aria: { ariaLabel: 'Leads' } }`. It was REFUSED and is now ACCEPTED on listOverlay. - The class also covers an i18n `description`, the list `sharing` block and a valid legacy `options` bag. Each of those is pinned ACCEPT at both doors, and `aria` is pinned too. - The divergent keys, measured off the members: `type`, `columns`, `description`, `sharing`, `aria`, `options` (and `viewKind` itself). - This is the ruling working: a list body is judged by list rules. - **W1**: an invalid value under one of the 19 form-only keys. - Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' }`. It was REFUSED, because the form member judged `layout`. It is now ACCEPTED, with `layout` dropped from the parse. - ⛔ It is not pinned as desired behaviour; see the Acceptance notes. - The 19 keys: `layout`, `title`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`, `splitSize`, `splitResizable`, `drawerSide`, `drawerWidth`, `modalSize`, `sections`, `groups`, `subforms`, `defaultSort`, `submitBehavior`, `buttons`, `defaults`. ## Census (literal and dynamic producers) - **objectstack** `4df101c3`: 236 literal `viewKind: 'list'` occurrences in 94 files under `packages/**`. Of those, 56 are column-less flattened: 41 tests, 4 changelog lines, and 11 comments or docstrings. Control: 105 flattened-with-columns and 70 `config` records were classified by the same scanner. - **`examples/**`**: 0 `viewKind` at all. Control: 15 files with `listViews` and 14 with `defineView`. - **objectui**: at the pin `f8a9d0fb`, 90 occurrences in 39 files; at `main` `25c7d584e`, 95 in 42. The column-less ones are tests plus 4 type declarations or docstrings. - **cloud** `main` `48d7066`: 60 occurrences in 13 files; all 14 column-less ones are tests. Its two producers emit `config` records. - **Literal source producers: 0 in every tree.** The one real producer is dynamic and was found by reading the code: objectui's `buildPersistedViewBody` plus `updateViewConfig`, called from the `sort`, `hiddenFields`, `inlineEdit` and `columnState` toolbar handlers on objectui `main`. It keeps saving. - No body that was accepted is refused for that producer, so **no ADR-0087 D2 conversion** is needed. - The semantic entry `view-overlay-judged-by-viewkind-arm` records the write-time refusals, and the registry is regenerated. - Stored rows in deployments: NOT MEASURED (no deployment store is reachable). ## Verification All heavy runs went through `os-verify-lock`. The lock is shared, so every timing is a shared-box reading. **Pre-merge, at `750d0c82`** (the branch merged with `805af4f2`), in one locked batch (`VERDICT command-exit 0`, held 984s): - Build of the `metadata-protocol`, `objectql`, `lint` and `rest` dependency closures (the `^...` filter): exit 0. - `spec check:generated`: exit 0, all 15 artefacts current, `api-surface/` included, so no generated artefact moves. - `spec` vitest `--project local`: 545 files passed, 16059 tests passed, 2 todo. - `metadata-protocol` vitest: 189 files passed, 3 skipped; 2715 tests passed, 19 skipped. `metadata-protocol` typecheck: exit 0. - Consumer suites, every test file in the package that names `viewKind`: `objectql` 24 files / 380 tests, `lint` 8 files / 349 tests, `rest` 11 files / 153 tests. All passed. - `spec` vitest `--project repo`: 32 files / 586 tests, measured at `87f9d156` in an earlier locked batch. After that, `packages/spec` changed only by a type annotation (`e272672a`). `spec typecheck` exited 0 on those bytes. **Post-merge, at `859730ef`** (merged with `c02fa127`): - The merge brought objectstack-ai#20227 (view-item `owner`/`hidden` retirement, a disjoint region of `view.zod.ts`) and others. It merged clean, and the migration registry regenerates byte-identical. - The locked rebuild plus `check:generated`, `spec` local vitest and `metadata-protocol` vitest is queued. Until it runs it is NOT MEASURED here, and the dev report carries its reading. **Gates.** I re-derived the list on the actual paths (`dispatch-gates.mjs --commands`: 88 families) and ran every line, plus the 5 artefact-roster gates whose roster sits in a directory this diff touches. Exit codes went to disk before anything was read. - `--ran` reconciliation at `859730ef`: 88 derived, 81 run green, 7 NOT-MEASURED (exit 3, PREREQUISITE NOT MET), 0 unrun. - Five of the seven (`spec` `check:api-surface`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`) read the built `dist`. They were green at `750d0c82`, and the post-merge rebuild re-reads them. - The other two (`check:dual-build-cjs-loads`, `check:type-check-debt`) need the whole-workspace build and are declared to CI. - The 5 roster gates also ran green. - `check:engine-double-contract` and `check:objectql-double-limit` flagged the stub engine my first save-door test file declared. I moved those pins into the existing, ledgered double in `protocol.graft-folded-form-sections.test.ts`, and both gates are green: no ledger change. **Consumer notes.** - objectui's own suites run against its pinned spec version and were not run here. - `examples/**` carries no `viewKind` at all, so it has no objectui-facing overlay fixture to sweep. ## Acceptance notes - **W1, a same-family residual, not filed here.** A column-less list patch carrying an invalid form-only key is now accepted with the key dropped unread. - `reach:` measured at the public door, `saveMetaItem` (the `PUT /api/v1/meta/view` path), on this branch: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' }` answers `success: true`, and the row holds `layout: 'diagonal'` as sent (a throwaway probe, never committed). The same save with `columns: ['name']` added answers identically. - Control on `origin/main` `805af4f2`: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', columns: ['name'], layout: 'diagonal' }` is ACCEPTED on listOverlay, with output `{"name":"crm_lead.all","type":"grid","columns":["name"],"object":"crm_lead","viewKind":"list"}` (`layout` dropped). So this change routes more bodies to the list member's existing handling of undeclared keys; it does not create that handling. - F2 (refusing the 19 form-only keys by name on the list member) was not taken on this card, per seat answer `5855548706`. The seat decides filing at ACCEPT. - Dedupe words: `list overlay form-only key stripped` · `flattened list overlay layout dropped unread` · `W1 form key on list patch`. - The `graftFoldedFormSections` behaviour change above (a list body's `groups` is no longer folded to `sections`) is the same residual seen from the save path. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20051
Clause-②: no
This PR carries the door half of ruling A (objectui#10380, comment 5824043998, maintainer 「其他同意」) and the stored-row census. The persistence half (ruling item 2, "the metadata save persists the parsed body") is not in this PR. It is returned to the PM as a decision, with the readings below, so #20051 stays open for that half.
What changes
ListViewShapeSchema.extend(flattenedViewOverlayFields()).strip(), the list overlay member ofViewMetadataSchema(packages/spec/src/ui/view.zod.ts), now declaresoptions:options.KINDis judged by the kind's own block schema:kanban,calendar,gantt,gallery,timeline,chart,mapandtree. The judge uses the same closed key set, the same per-key schemas and the same unknown-key message as the direct spelling, so an out-of-contract key is refused by name. Only the path gains theoptions.prefix. The kinds are derived from the shape's owntypeenum and blocks, not listed by hand..partial()). objectui'sListViewreadsoptions.KINDas a per-key underlay of the top-level block, with the top-level block winning per key. A bag that carries only the keys the top-level block leaves to it is therefore legal config, and objectui pins that population (ObjectView.namedViewProtocolKeys-8980.test.tsx, "the merge is per-key, not wholesale"). Measured first with the full block schema: it refused those bags for required keys they never meant to carry. See decision Q2.options.fooandoptions.gridare refused by name atoptions. They are no longer dropped.optionsabsent. Without this, a column-less, type-less list body that the list overlay refused over its bag is accepted by the form overlay member and stored unjudged. That was measured on the change before the pin was added.The legacy
options.mapbag that objectui pins (InterfaceListPage.mapConfig.test.tsx, "CONTROL: the legacyoptions.mapbag is still forwarded on its own path",.objectui-shaf8a9d0fb0) stays accepted and round-trips.Also in this PR: an ADR-0087 semantic entry
view-overlay-options-bag-judged(protocol 18,packages/spec/src/migrations/entries/semantic/, with the regeneratedregistry.tsregion), the strictness-ledger counts (one new strict site), and the changeset (minor, breaking,registered).Measured before the change (
origin/main@8d1f7ab)timeline.metaFieldsgivessuccess=falsewithunrecognized_keysattimeline.options.timeline.metaFieldsgivessuccess=true, andoptionsis absent from the output.options.foo,options.kanban.groupFieldand theoptions.mappin were also allsuccess=truewithoptionsstripped.saveMetaItem(stub engine), a flat overlay carryingoptions.timeline.metaFieldsgavesuccess: true, and the stored row equalled the request body byte for byte,optionsincluded.saveMetaItemstores the request body. The parse output is used only for three grafts (operator spellings,groupstosections, and the pagetypedefault from586934e).viewKind: 'list'is accepted by the form overlay member.{ viewKind: 'list', sort, searchableFields }parses as{ type: 'simple', viewKind: 'list', … }. Withtimeline.metaFieldswritten directly on such a body, the union also answerssuccess=true. See Acceptance notes.Stored-row census (ruling item 3)
8d1f7ab(examples, dogfood, fixtures, tests): a multi-line search finds 0 view bodies with anoptionsbag holding a kind block. Authored views go through the strict authoring shape, which has always refusedoptions.f8a9d0fb0and atmainc3a26ccda: 28optionsbag literals, plus the finding's own probe body, were judged by this change. 17 pass and 12 fail. Every failure is an out-of-contract key refused by name, and none fails for a missing key.listViewsentry, named view)plugin-viewObjectView.tsxdocblockoptions.kanban.groupField: writegroupByField.ObjectView.calendarAliasRefused-8355.test.tsx:203options.calendar.dateField: writestartDateField(that test pins the alias as refused on objectui's side already). The finding'soptions.timeline.metaFields: delete it (objectui#10222 retires the read).ListViewprops (never reach this door)groupField,groupBy,dateField,metaFields, object-bound chart keys (xAxisField,yAxisFields,aggregation). These are the legacy spellings objectui's own refusal pins already retire.sys_metadatarows: NOT MEASURED. No deployment's store is reachable from here. A stored row that fails is still read and served exactly as stored. It is refused only on its next save, and the refusal names the key.Decision needed: the persistence half (ruling item 2)
Scoping "persist the parse output" to
viewis clean in code: it is one branch insaveMetaItem, which already branches onview. What it does to stored bytes was measured through the realsaveMetaItemon8d1f7ab, comparing the stored body against the view schema's parse output:isPinned,sortOrder, rowids)isPinned,sortOrder,sort[].idandfilter[].idisPinned,sortOrderandobjectNamevisibility, the inner keys ofcolumnStateit does not declare, andconfig.sort[].idisPinnedandsortOrdersortandsearchableFields, and addstype: 'simple'exportOptions: ['csv']{ formats: ['csv'] }and addstype: 'grid'scale: 'week'objectui reads
isPinned,sortOrderandvisibilityback from stored rows:ObjectView.tsxVIEW_ROW_STATE_KEYSand the switcher's tab state, andViewTabBargroups tabs onvisibility. The row ids are re-stamped on load, so losing them is harmless. ADR-0005 addendum (c) records the opposite decision ("The persisted document is the originalrequest.item, NOTparsed.data"), and three pins in this repo guard it. The options, cost and recommendation are in the PM report. The short form:optionsbag one the door judged.optionsonto the stored body.Acceptance notes
viewKindliterals or list-only guards on the form member, is a design choice with no pinned form. This PR closes it only for bodies that carryoptions..strip()) also drops a top-leveloptionsunjudged. It is not judged here. objectui'smergeViewsIntoObjectsreads a record'sconfigonly, so no render reach was found.Clause-②: nocarries no(narrowing)arm, although this diff is an accept-set narrowing. The changeset declares breaking throughfix(spec)!:and**BREAKING**and registers its ADR-0087 entry, so the gate judges it as breaking either way.Tests and gates
The suites ran at
62be049ab8. The one later commit,1c6a4eb17a, edits a comment in the new spec pin file, and that file was re-run there (22 passed). The gate union ran at1c6a4eb17aon a specdistbuilt from that commit.packages/spec, full--project local: 541 files, 15819 tests passed. New pin filesrc/ui/view-overlay-options-bag.test.ts: 22 tests. Reverse verification swapped the baseview.zod.tsin, with the on-disk marker count at 0: 19 failed, 3 passed (the three controls). Restore was proved bygit hash-objectequal to the HEAD blob and an emptygit diff HEAD.packages/metadata-protocol, full: 188 files, 2689 tests passed. The save-door pins sit inprotocol.graft-folded-form-sections.test.ts, on its engine double that is already pinned: refusalINVALID_METADATA/422with no row written, and theoptions.maprow round-trips.packages/rest, narrowed to the 17 files that PUT or readviewbodies: 510 tests passed, including the newmeta-view-overlay-options-bag.test.ts, which runs the realRestServerover the real protocol on SQLite. It asserts422withcode: INVALID_METADATAand an empty store for both spellings, and200with an unchanged stored bag.packages/runtime, narrowed to the 29 files that reach the save: 510 tests passed.typecheck: spec, metadata-protocol and rest all exit 0.check:generated: all 15 artifacts current.dispatch-gates --ran: 89 derived. 87 exit 0. 2 are NOT MEASURED (check:dual-build-cjs-loads,check:type-check-debt, exit 3, PREREQUISITE NOT MET: they need the whole-repo build CI runs)..tsfiles: all 6 are in the config's population (--print-configresolves),--format jsoncounts 6 files with 0 errors and 0 warnings, andeslint.config.mjsenables no type-aware linting, so the diff cannot move a verdict on an untouched file.dist(it needs two extra spec rebuilds). The base behaviour at that door is the H2 reading above.Generated by Claude Code