Skip to content

fix(spec)!: judge a flattened list view overlay's legacy options bag at the view write door (door half of #20051) - #20183

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20051-view-overlay-options-kind-door
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20051-view-overlay-options-kind-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 of ViewMetadataSchema (packages/spec/src/ui/view.zod.ts), now declares options:

  • Each options.KIND is judged by the kind's own block schema: kanban, calendar, gantt, gallery, timeline, chart, map and tree. 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 the options. prefix. The kinds are derived from the shape's own type enum and blocks, not listed by hand.
  • The bag is judged key by key (.partial()). objectui's ListView reads options.KIND as 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.
  • The bag is closed. options.foo and options.grid are refused by name at options. They are no longer dropped.
  • The form overlay pins options absent. 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.map bag that objectui pins (InterfaceListPage.mapConfig.test.tsx, "CONTROL: the legacy options.map bag is still forwarded on its own path", .objectui-sha f8a9d0fb0) 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 regenerated registry.ts region), the strictness-ledger counts (one new strict site), and the changeset (minor, breaking, registered).

Measured before the change (origin/main @ 8d1f7ab)

  • H1, the door. On a flat list overlay, timeline.metaFields gives success=false with unrecognized_keys at timeline. options.timeline.metaFields gives success=true, and options is absent from the output. options.foo, options.kanban.groupField and the options.map pin were also all success=true with options stripped.
  • H2, the save. Through the real saveMetaItem (stub engine), a flat overlay carrying options.timeline.metaFields gave success: true, and the stored row equalled the request body byte for byte, options included. saveMetaItem stores the request body. The parse output is used only for three grafts (operator spellings, groups to sections, and the page type default from 586934e).
  • A pre-existing fall-through. A column-less, type-less body with viewKind: 'list' is accepted by the form overlay member. { viewKind: 'list', sort, searchableFields } parses as { type: 'simple', viewKind: 'list', … }. With timeline.metaFields written directly on such a body, the union also answers success=true. See Acceptance notes.

Stored-row census (ruling item 3)

  • objectstack @ 8d1f7ab (examples, dogfood, fixtures, tests): a multi-line search finds 0 view bodies with an options bag holding a kind block. Authored views go through the strict authoring shape, which has always refused options.
  • objectui, at the pin f8a9d0fb0 and at main c3a26ccda: 28 options bag 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.
class rows pass fail: key, and fix
models a stored or authored view (console-merged listViews entry, named view) 8 5 plugin-view ObjectView.tsx docblock options.kanban.groupField: write groupByField. ObjectView.calendarAliasRefused-8355.test.tsx:203 options.calendar.dateField: write startDateField (that test pins the alias as refused on objectui's side already). The finding's options.timeline.metaFields: delete it (objectui#10222 retires the read).
renderer-level ListView props (never reach this door) 21 12 groupField, groupBy, dateField, metaFields, object-bound chart keys (xAxisField, yAxisFields, aggregation). These are the legacy spellings objectui's own refusal pins already retire.
  • Production sys_metadata rows: 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 view is clean in code: it is one branch in saveMetaItem, which already branches on view. What it does to stored bytes was measured through the real saveMetaItem on 8d1f7ab, comparing the stored body against the view schema's parse output:

body what the parse output drops or adds
console personalization overlay (isPinned, sortOrder, row ids) strips isPinned, sortOrder, sort[].id and filter[].id
ADR-0005 addendum (c) auxiliary keys strips isPinned, sortOrder and objectName
ViewItem record with the switcher's row state strips visibility, the inner keys of columnState it does not declare, and config.sort[].id
flat form overlay strips isPinned and sortOrder
column-less list overlay (the form-arm fall-through) destroys the view: strips sort and searchableFields, and adds type: 'simple'
legacy exportOptions: ['csv'] rewrites it to { formats: ['csv'] } and adds type: 'grid'
a timeline block adds scale: 'week'

objectui reads isPinned, sortOrder and visibility back from stored rows: ObjectView.tsx VIEW_ROW_STATE_KEYS and the switcher's tab state, and ViewTabBar groups tabs on visibility. 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 original request.item, NOT parsed.data"), and three pins in this repo guard it. The options, cost and recommendation are in the PM report. The short form:

  • B (recommended): keep the store as it is. The door half above already makes every stored options bag one the door judged.
  • A: persist the parse output. Its prerequisites are the form-arm fix, declaring every console round-trip key on each wire face, and an ADR-0005 amendment (Tier H).
  • C: graft only the parsed options onto the stored body.

Acceptance notes

  • The form-arm fall-through above is a pre-existing defect of the same class. It is reported to the PM for filing, not fixed here: the fix, whether per-arm viewKind literals or list-only guards on the form member, is a design choice with no pinned form. This PR closes it only for bodies that carry options.
  • The ViewItem record member (.strip()) also drops a top-level options unjudged. It is not judged here. objectui's mergeViewsIntoObjects reads a record's config only, so no render reach was found.
  • The claim's Clause-②: no carries no (narrowing) arm, although this diff is an accept-set narrowing. The changeset declares breaking through fix(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 at 1c6a4eb17a on a spec dist built from that commit.

  • packages/spec, full --project local: 541 files, 15819 tests passed. New pin file src/ui/view-overlay-options-bag.test.ts: 22 tests. Reverse verification swapped the base view.zod.ts in, with the on-disk marker count at 0: 19 failed, 3 passed (the three controls). Restore was proved by git hash-object equal to the HEAD blob and an empty git diff HEAD.
  • packages/metadata-protocol, full: 188 files, 2689 tests passed. The save-door pins sit in protocol.graft-folded-form-sections.test.ts, on its engine double that is already pinned: refusal INVALID_METADATA/422 with no row written, and the options.map row round-trips.
  • packages/rest, narrowed to the 17 files that PUT or read view bodies: 510 tests passed, including the new meta-view-overlay-options-bag.test.ts, which runs the real RestServer over the real protocol on SQLite. It asserts 422 with code: INVALID_METADATA and an empty store for both spellings, and 200 with 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).
  • eslint, narrowed to the 6 changed .ts files: all 6 are in the config's population (--print-config resolves), --format json counts 6 files with 0 errors and 0 warnings, and eslint.config.mjs enables no type-aware linting, so the diff cannot move a verdict on an untouched file.
  • NOT MEASURED: the red leg of the metadata-protocol and rest pins against a base dist (it needs two extra spec rebuilds). The base behaviour at that door is the H2 reading above.

Generated by Claude Code

…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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 7 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/ui/forms.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 471f1dfaf516190d2193cf72ca157a20586fd0de — the merge of head 2526e4052e742ee2f2d9a6a93c2e653efb093e1f into base 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0bd11261efd82e05fa09a574ff86ab9a0c6ce33a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 150/150 CONTRACT_REVIEW_TIER
Head-sha: 1c6a4eb17af762af0174f0f6ef578d37199d8238

① Derived judgments

  1. Every options.KIND is judged, and the kinds are derived from the shape — PASS.
    • listViewKindBlocks() intersects overlayTypeValues(ListViewShapeSchema) with the shape's blocks: kinds calendar, chart, gallery, gantt, kanban, map, timeline, tree, with additionalProperties: false.
    • A probe over the head view.zod.ts:
      • options.timeline.metaFields → unrecognized_keys at options.timeline, with a message byte-identical to the direct timeline.metaFields;
      • options.foo → unrecognized_keys at options;
      • options: null or [] → invalid_type.
    • On origin/main all of these parsed, with options stripped (H1 confirmed).
  2. .partial(), key by key — PASS.
    • No refusal is lost for a key that is present: options.gallery.cardSize=huge, options.chart.values=[], options.chart.chartType=radar, options.tree.defaultExpandedDepth=-1, options.map.zoom=99, a padded options.kanban.groupByField, options.kanban.limit (guidance) and options.gantt.interactions.drag (alias). Each is refused at the same sub-path with the same message as the direct spelling.
    • Only a required-key omission differs, by design; objectui pins that population (ObjectView.namedViewProtocolKeys-8980.test.tsx:281-293).
    • Residual, non-blocking: at f8a9d0fb0, ListView.tsx:170,206 reads options.chart and options.tree wholesale. So the changeset sentence 「the renderer reads the bag as a per-key underlay」 is over-general for those two kinds. The view.zod.ts docblock correctly names only the six spreading kinds.
  3. The form overlay pins options absent — PASS.
    • A form without options, or with options: undefined, parses. {}, null and a bag are refused.
    • No legitimate writer sends it: objectui saves go through viewEnvelope or buildPersistedViewBody (app-shell ObjectView.tsx:1065-1073).
    • Admitted side effect: a column-less list body carrying a legal bag is now refused at columns (a branch tie). No console path produces such a body.
  4. Legitimate saves — PASS.
    • The options.map pin body (InterfaceListPage.mapConfig.test.tsx:155-161) parses, and the bag is kept.
    • objectstack census: 0 kind-bearing bags outside the new tests.
    • The objectui census selection is not reproducible from the body (49 / 59 raw hits against 「28 literals」), but its named failures check out: plugin-view ObjectView.tsx:743 options.kanban.groupField, and calendarAliasRefused-8355.test.tsx:203 options.calendar.dateField.
  5. Sentence truth — PASS.
    • protocol.ts:16110-16150 at head stores the request body (after the two existing grafts), and no sentence claims parsed persistence.
    • Imprecision only: the spec test docblock's 「a legal options.KIND … round-trips」 holds at the store; the parse output adds block defaults.
  6. ADR-0087 route and registry — PASS.
    • A semantic D3 entry is the right route: which key a refused row meant is the author's call, so there is no lossless conversion.
    • The registry region equals the entry file verbatim and sits in id order.
    • The ledger strictObject( count goes 57→58, which matches counts.md.
    • CI at head: 39 check runs, none failed.

② Semver level

Correct: minor + fix(spec)!: + **BREAKING** + adr-0087: registered view-overlay-options-bag-judged, under check-changeset-no-major and the ADR-0087 amendment (pre-GA breaks ship minor). Clause-②: no is the right value, since the card widens neither the accept set nor the public surface. The gate reads it as breaking from the other signals.

③ Boundary flags

Implemented-by: claude/issue-20051-view-overlay-options-kind-door
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 06:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Merge-queue kick-out — PR #20183 · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T07:28Z

…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>
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 65/65 CONTRACT_REVIEW_TIER
Head-sha: 2526e4052e742ee2f2d9a6a93c2e653efb093e1f

① Derived judgments

  1. Merge integrity (a)-(c) — PASS, from git. Head unmoved. (a) git diff --name-status 0bd11261ef..2526e405 vs 8d1f7ab7..1c6a4eb17a: identical 8-file lists; per-file diff-of-diffs (index/hunk lines dropped) SAME for all six hand-written files, view.zod.ts included; DIFFER only registry.ts, counts.md. (b) PR-only commits since the old head: two merges, 69c7f3100c (registry.ts only, +41) and 2526e405 (counts.md only, 12/12). Registry delta vs base is the same 41-line entry block, now in id order before view-pagination-page-size-default-50; counts delta is the same +1 (ui/ 179→180, strict 169→170, view.zod.ts 61→62) on main's new totals (453). Head registry: 255 ids, 0 duplicates, each entry file's id exactly once, all 25 entries main added since the fork present. CI at head: "Migration registry matches its entry files" and "Check the strictness ledger matches the code" both success. (c) Main's only view.zod.ts change in 8d1f7ab7..0bd11261ef is pageSize .default(25)→.default(50) (l.1109), outside every PR hunk; the rule-array work touched migration entries only; the 2 commits after the base touch none of view.zod.ts, protocol.ts, migrations. No old-record item rests on a moved line; each below was re-probed anyway.

  2. Every options.KIND judged, kinds derived — PASS. listViewKindBlocks() (view.zod.ts:5644) = overlayTypeValues(ListViewShapeSchema) ∩ shape blocks. Probe on head spec, zod 4.6.1: kinds calendar,chart,gallery,gantt,kanban,map,timeline,tree, additionalProperties:false; enum minus kinds = grid; blocks named after an enum value = kinds. options.timeline.metaFields → unrecognized_keys at options.timeline, keys:[metaFields], message byte-equal to the direct spelling; options.foo → unrecognized_keys at options; null/[]/'map'/1 → invalid_type. Base control on main's spec at 0bd11261ef: every one ACCEPTED with options absent from output.

  3. .partial() key by key — PASS. zod 4.6.1 .partial() throws on refinements (probed), and all eight blocks carry 0 checks direct and wrapped, so nothing is dropped silently. 17 present-key cases (groupField, dateField, limit, padded groupByField, cardSize:huge, chartType:radar, values:[], legacy chart keys, defaultExpandedDepth:-1, parentField:7, zoom:99/0, interactions.drag, gantt.scale, calendar.view) plus 8 unknown-key cases: 0 parity failures. Only required-key omission differs, which the ruling's C-rejection and the objectui per-key pin (namedViewProtocolKeys-8980.test.tsx:281, same at c3a26ccda) justify.

  4. Form overlay pins options absent — PASS. Plain form, options: undefined, and a form with isPinned/sortOrder/sections parse; {}, null, a bag → refused at options with the list-view prescription. Base control: form + bag ACCEPTED (stripped); column-less list body + bag ACCEPTED via the form arm — the fall-through the pin closes. No writer sends it: objectui saves go through buildPersistedViewBody/viewEnvelope (no options); objectstack src has no form body with a kind-keyed bag.

  5. Legitimate saves — PASS. options.map pin body parses, bag kept; partial kanban underlay parses; console-shaped bag parses; options: {} accepted. Residual: a column-less list body with a LEGAL bag now refuses at columns (before: taken by the form arm as type:'simple'); no producer found. kanban:{groupByField} without columns refuses identically at head and base (pre-existing).

  6. Sentence truth — PASS, two imprecisions. saveMetaItem (protocol.ts:16199-16208) stores request.item after exactly the three grafts named; no read-side parse of a view exists; no sentence claims parsed persistence. Imprecise, not false: (i) changeset "the renderer reads the bag as a per-key underlay" is over-general — ListView.tsx reads options.chart (l.206) and options.tree (l.3370) wholesale at the pin and at objectui main; the view.zod.ts docblock names only the six spreading kinds. (ii) spec-test docblock "round-trips" holds at the store; parse output adds defaults (options.timeline gains scale:'week'). Residual: ListView.tsx:3005 (pin) / :3216 (main) spreads schema.options?.grid into grid props — an unjudged channel with no schema; refusing options.grid (disclosed in FROM→TO) is the only ruling-consistent choice; 0 literals in either repo.

  7. ADR-0087 route + generated files — PASS. 0087 D2 (l.153) sends what cannot be converted to D3, fed by "semantic changes authored" (l.175); which key a row meant is the author's, so semantic is right. Registry/counts: item 1. strictObject( in view.zod.ts 57→58.

② Semver level

Correct. minor + fix(spec)!: + **BREAKING** + adr-0087: registered view-overlay-options-bag-judged. check-changeset-no-major.mjs:47 "During the launch window we ship breaking changes as minor"; check-adr-0087-registration.mjs:634-641 reads bang and BREAKING as breaking signals, so the arm is redundant; AGENTS.md:1074 makes the arm optional. Clause-②: no is right — nothing widens. CI "Require an ADR-0087 disposition on a declared-breaking changeset": success.

③ Boundary flags

Implemented-by: claude/issue-20051-view-overlay-options-kind-door
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36310592557 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (5/6) — 失败步骤: Run this shard's tests

    @objectstack/client:test:  FAIL  src/auth-login-register-envelope.test.ts > [#17234] auth.login / auth.register deliver the SessionResponse envelope they declare > ① the declared envelope is delivered
      ↳ 失败原因: @objectstack/client:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/auth-login-register-envelope.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Queue kick-out 2, new signature — PR #20183 · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T10:12Z

  • Signature: the queue build 36310592557 failed in Test Core (5/6) › @objectstack/client › src/auth-login-register-envelope.test.ts › [#17234] … ① the declared envelope is delivered › register() parses as the declared envelope, with the payload under data. First error: Test timed out in 5000ms (:273). A timeout, not an assertion.
  • Ledger: no queue-flake-anchor issue exists for this file. The triage comment 5854919639 reports it as first seen, with 0 other failed queue builds in 24 h. ⇒ A new signature, so under landing-operations.md ⛔ no re-queue on sight.
  • The three facts for one re-queue:
    • ③ First error is a timeout: holds.
    • ② The queue base's same shard is green: holds. Base af32cf9a0e's own queue build 36310549644 passed Test Core (5/6). The PR head's own CI also passed Test Core.
    • ① The failing file's import closure is disjoint from the diff: does not hold strictly. The test imports @objectstack/objectql and @objectstack/spec/api, which reach the spec barrel that carries ui/view.zod.ts. The register path has no call into the view-overlay door, but the fact is about the static closure, so it is not met.
  • Initial reading: most likely a load or timing cliff on a heavy auth register() path, not this PR's regression. That is NOT MEASURED yet.
  • Next: a read-only diagnosis is running. It runs the one test 8× on base af32cf9a0e and 8× on base+PR, then compares pass counts and register() durations. PR-caused ⇒ back to the claimant. Not PR-caused ⇒ one re-queue, with the measurement recorded here. The same signature a second time ⇒ stop, and it goes to the next seat.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Diagnosis of queue kick-out 2, then one re-queue · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T10:55Z

This follows the plan in 5854946276: measure first, then at most one re-queue.

  • The measurement was a read-only local reproduction of the one failing file, packages/client/src/auth-login-register-envelope.test.ts, run 8× in each of two trees.
    • BASE = af32cf9a0e, the queue build's base.
    • MERGED = BASE plus this PR's head 2526e4052e, merged locally as 577902de97 and never pushed.
    • Results: 8/8 pass in BASE and 8/8 pass in MERGED, with zero timeouts. The register() duration was 1074 / 1257 / 1390 ms (min / median / max) in BASE and 1075 / 1245 / 1413 ms in MERGED. The distributions overlap completely.
    • The machine was 4 cores, with the load climbing from 2.9 to 5.5 during the runs.
  • Budget: vitest's default 5000 ms, not overridden in packages/client/vitest.config.ts. A normal register() uses about 20–28% of it. The time goes to the ObjectQL + SqliteWasmDriver bootstrap and better-auth's first sign-up setup.
  • This PR's static link: the spec api barrel re-exports protocol.zod, which imports ui/view. It adds no measured per-call cost, and the medians agree within noise.
  • The same signature, independently: PR fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207, which shares nothing with this PR but the base, failed its own PR-side Test Core (5/6) on this exact test at 10:26Z (5855068736 on fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207). Of the last 50 merge_group CI runs, only this one failed with this signature.
  • Verdict: NOT PR-CAUSED. It is a timing cliff under CI load. Fact ① (the static import closure) is now answered by measurement rather than by the closure alone.
  • Action: ONE re-queue of the unchanged head 2526e4052e. Its same-head at-tier record 5854622689 (PASS 65/65) stands. It merges cleanly against current main 3875ae6773 (checked without the merge driver). If the same signature ejects it again, it stops, and it goes to the next seat.
  • The test's time budget under CI concurrency belongs to packages/client. This seat only records the hit here, ⛔ adds no flake-table row, and leaves the ledger to the queue-triage workflow's cross-PR anchor.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 6a4aec7 Sep 27, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20051-view-overlay-options-kind-door branch September 27, 2026 11:17
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing record — PR #20183 merged through the merge queue at 2026-09-27T11:17Z as 6a4aec71d5 · domain:spec seat 2 (session_01QcAS3qiYYZNezaxZxaUdMV) · 2026-09-27T11:18Z

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants