Skip to content

DO NOT MERGE — verification run for #25697: PR A + the ui/ subtree import of objectui main cd70b7818d - #25718

Closed
objectstack-fleet[bot] wants to merge 10000 commits into
mainfrom
claude/project-thread-dwhq6r-25697-verify
Closed

objectstack-fleet[bot] wants to merge 10000 commits into
mainfrom
claude/project-thread-dwhq6r-25697-verify

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

⛔ DO NOT MERGE — ⛔ 不要合并、不要批准、不要翻 ready、不要挂 auto-merge

Verification run for objectstack#25697 (PR A = #25710). This branch is PR A's head plus the ui/ subtree import of objectui main at cd70b7818d with the #25678 spike's adaptations, so that the four * UI jobs run with ui/ present and their minutes can be read. It is never enqueued and is closed as soon as its runs are read; the real import happens in sub-issue 5 (cutover), on the maintainer's own merge. Part of #25697.

这是 #25697 的验证分支:PR A 的 head 加上 objectui main(cd70b7818d)的 ui/ subtree 导入,只为让四个 UI job 在 ui/ 存在时跑一次、读出分钟数。读完即关,永不入队;真正的导入在子单 5(切换)由维护者亲手合并。

What is on the branch (6 commits on top of PR A's 597471c)

  1. b97bdad2a9 subtree import of objectui main @ cd70b7818d (history kept)
  2. 32d50453cb 40 @objectstack/* ranges in 32 ui/ manifests → workspace:^
  3. 0dad5c2e11 drop ui/pnpm-lock.yaml, ui/pnpm-workspace.yaml, park ui/turbo.json as ui/turbo.objectui.json, remove the preinstall pin gate
  4. 4ddd900062 root pnpm.overrides (react 19.3.0 line, qs/shell-quote/tmp/uuid floors) + @radix-ui/react-focus-scope@1.1.16 patch
  5. 717defb0ee lockfile recompute (one react/react-dom/@types line at 19.3.0, one zod 4.6.7)
  6. b2ba3a42da cherry-pick of PR A's 7ca50a8a7b

What this run must show (read on #25697)

  • Type Check UI, Test UI (n/16), Lint UI, Build & E2E UI running (not skipped) and their cold minutes; a second push reads turbo-warm minutes.
  • Counter-probe (pushed after the first run is read): one commit renaming size → buttonSize on ActionButtonPropsSchema (packages/spec/src/ui/component.zod.ts) must turn Type Check UI red at ui/packages/types/src/ui-action.ts:900; then reverted.

Expected red on this tree (the import commit's bill, carried to sub-issue 5 / 4b — NOT fixed here)

Measured locally by the dev over 142 root check:* gates: check:turbo-task-graph and check:workspace-manifest-cycles self-tests (DECLARED_WATCH_HINTS lack ui/**/package.json), check:published-files self-test (EXPORTS_CENSUS_FLOOR 50 vs 112 publishable), check:undeclared-dep-imports (9, Console devDependencies on spec/client), check:type-check-coverage and check:type-check-debt (ui/tsconfig.json is JSONC), check:cross-package-test-inputs self-test (the awaiting-import excuse ends at the import, by design), check:merge-driver (shadcn:update generator, ui manifests), check:agent-test-spelling (ui/AGENTS.md:210), check:declared-population-live (git ls-files ENOBUFS), check:adr-anchors self-test (ADR-0075 cited by 7 Console files, no such record here), check:manifest-repository-directory self-test (ui/.objectstack-pin/**), check:pnpm-flag-spellings self-test, check:tier-file-adoption (47 packages), check:test-source-alias (dynamic import() in app-shell tests), check:vendor-export-contract (@object-ui/auth better-auth ^1.7.2 vs root 1.7.12), check:duration-unit-keys (9 ms-described keys without a unit in the name, all in ui/packages/types/src/zod: autoPlay, autoResponseDelay, timeout, delay, duration, delayDuration, openDelay, closeDelay, refreshInterval).

Clause-②: no
Metadata-protocol: none

🤖 Generated with Claude Code

https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU

objectstack-fleet Bot and others added 30 commits October 19, 2026 11:09
#13040)

Fixes #13038
Clause-②: no

## What changed

The chat's phase line (the collapsed step summary, `ToolPhaseSummary` in
`packages/plugin-chatbot/src/ChatbotEnhanced.tsx`) no longer shows or
counts the build agent's `load_tools` step.

- `foldToolPhaseSteps` skips plumbing calls, so the step text has no
"Load more tools" (zh 「加载更多工具」) entry and the step count drops by one.
- The expanded rows under the chevron skip it too, so the step is shown
nowhere in the phase.
- A phase that so far called only plumbing draws no line, instead of "0
steps" over an empty step list. The phase clock still starts with the
turn, because the hooks run before the early return.
- Every other step, count and row reads exactly as before.

## Why a client list, measured first

The ruling asked for a declared signal first. There is none:

- The spec's `ToolSchema` (`packages/spec/src/ai/tool.zod.ts`, read at
the objectstack commit this repo pins, `2c9bf9430a`) is strict. Its keys
are `name`, `label`, `description`, `parameters`, `outputSchema`,
`objectName` and `protection`. None of them marks a tool as internal.
- Cloud's `loadToolsTool`
(`packages/service-ai-studio/src/tools/load-tools.tool.ts`) is a
`defineTool` with only `name`, `label`, `description` and `parameters`.
The plan's `unlockToolName` names it, but only on the server, in
`BUILD_TOOL_OFFER_PLAN`. Nothing carries that to the client.
- The tool part the chat receives (AI SDK v6, mapped by `mapMessages.ts`
into `ChatbotEnhancedToolInvocation`) carries `toolCallId`, `toolName`,
`args`, `result`, `errorText`, `state`, `approval` and ObjectStack's
lifted envelopes. None of them marks plumbing.

Declaring it would need a new spec key or a tool-contract field. The
ruling did not call for that. So, as the ruling allows, a module-private
set `PLUMBING_TOOL_NAMES` holds `load_tools` only, and a comment gives
the reason. Nothing is exported: no new export, prop, type member or
language-pack key.

## Tests

Final head `fe4cad5307`.

- New
`packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.plumbingStep-13038.test.tsx`,
rendered through the shipped zh pack:
- (a) The card's data turn with `load_tools` reads 「4 步」 and 「列出已有对象 ×2
· 查看对象的结构 ×2 · 汇总相关数据 · 查找相关记录 ×3」. No tool-loading step appears in the
line or in the expanded rows.
- (b) CONTROL: the headline, step text and expanded rows are byte-equal
to the same turn without `load_tools`.
- (c) A settled phase and a running phase of only `load_tools` draw no
`tool-phase` element, no "0 步" and no 「加载更多工具」.
- `pnpm exec vitest run --maxWorkers=1` on the new test file and
`ChatbotEnhanced.buildChat-12724.test.tsx` at `fe4cad5307`: 2 files, 13
tests passed.
- The whole `packages/plugin-chatbot/` directory at `8e5efb70d8`: 77
files, 763 tests passed. The only later commit rewrites two code
comments in English.
- `pnpm --filter @object-ui/plugin-chatbot run type-check` passed at
`fe4cad5307`, after the dependency closure was built.
`tsconfig.test.json --listFiles` includes the new test.

Ablation, once per behaviour. Each mutation went through
`ablation-replace.mjs`, which checks that the anchor hit and the blob
changed. Each restore was proven by the blob hash equalling HEAD's
(`fd91c5c2c156`) and by an empty `git diff HEAD`.

- Drop the fold's skip: (a) and (b) go red. The headline reads 「5 步」
where 「4 步」 was expected. (c) stays green, because the rows filter still
empties it.
- Drop the rows filter: (a), (b) and (c) all go red.
- Drop the early return: (c) goes red, and (a) and (b) stay green.

Gates. The exit codes are recorded, and every one is 0:

- The pre-tsc battery: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`
and `control-bytes`.
- `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`,
`check-changeset-presence.mjs` and `check:new-line-citations`.

Lint was narrowed to the two changed source files. The run was `eslint
--no-inline-config --format json` at `fe4cad5307`: 2 files, 0 errors and
11 warnings. The warnings sit on code this PR does not touch. The
population is the root `eslint.config.js`, which lints both paths.
`eslint.config.js` sets no `parserOptions.project` or `projectService`,
so linting is not type-aware. That means this diff cannot change the
verdict on any file it does not touch. The full `pnpm lint` is declared
to CI.

## Acceptance notes

- A phase where `load_tools` sits only beside tools that have their own
card (the plan, a draft) now draws no line. The host already draws none
for a turn of only carded tools, so a lone card is not named twice. No
test pins this case.
- A `load_tools` call that fails no longer shows a failed row in the
phase. The agent's next step, or its answer, carries that failure.
- `processVisibility: 'debug'` still lists every tool, `load_tools`
included. That is by design, and this PR leaves it unchanged.
- If a tool definition someday declares its plumbing role, the private
set should give way to that declaration. That would be a spec or
tool-contract decision. This PR does not make it.

Changeset: `.changeset/13038-tool-loading-step-hidden.md`,
`@object-ui/plugin-chatbot` patch.

Implemented by session
`https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…record in the console (objectui#13037) (#13041)

Fixes #13037
Clause-②: no

## What this does

An assistant answer can now link a record so that a click opens that
record's console page, in the same tab, with no external-link dialog.
Every other link keeps streamdown's link-safety dialog and new tab.

### The record-link form (for cloud's `data_explorer` rule line)

- Form: `/record/OBJECT/ID`, written in markdown as `[Wayne
Enterprises](/record/account/acc_42)`.
- Grammar (`RECORD_LINK_PATTERN` in
`packages/plugin-chatbot/src/recordLink.ts`), matched against the whole
href:

```text
^\/record\/([a-z_][a-z0-9_]*)\/([A-Za-z0-9_-]{1,128})$
```

- OBJECT is the spec's object-name grammar (`^[a-z_][a-z0-9_]*$`). ID is
1 to 128 letters, digits, `_` or `-`.
- A query string, a fragment, an encoded slash, a trailing slash or any
further path segment fails the match. Such a link is not navigated and
nothing is passed to the host; it stays a default safe link (dialog plus
new tab).
- `./record/OBJECT/ID` reaches the click as the same form, because
rehype-harden rewrites it to `/record/...`. A bare `record/OBJECT/ID` is
dropped by rehype-harden and renders as text.
- The agent writes no mount and no package id. The host resolves both.

### How it works

- Premise checked first: streamdown@2.5.0's default link component,
which carries the dialog, is not exported. Overriding `components.a`
would therefore replace link handling for every link. The interception
uses streamdown's own `linkSafety.onLinkCheck` instead, and only for the
record form. For every other href it returns the caller's check, or
`false` when there is none, so streamdown opens its dialog exactly as
before.
- On a record-form click, `onLinkCheck` hands `{ objectName, recordId }`
to the host. It then returns a promise that never settles. streamdown
awaits that check and on `true` opens a new tab, on anything else the
dialog. A record link wants neither, so the click ends there. Each click
gets a fresh promise, so nothing accumulates.
- The host opener is the chat's existing `onOpenRecord`, the same
handler the `open_record` card calls (`openRecord` in `AiChatPage.tsx`).
It resolves the app segment from the object's owning package and calls
the router's `navigate`, so the router basename (`/_console/` or root)
applies. There is no second copy of that resolution.
- `ChatbotEnhanced` supplies `onOpenRecord` to the answer's
`MessageResponse` through a module-private context
(`RecordLinkContext`). A context change reaches the answer even though
`MessageResponse` is memoised on `children` and `isAnimating`. When the
host gives no `onOpenRecord`, the context is `null` and the `linkSafety`
config is passed through unchanged.
- No new export: `recordLink.ts` is imported by `elements/message.tsx`
and `ChatbotEnhanced.tsx` only. It sits outside `elements/`, which holds
vendored upstream files only (pinned by `the vendored directory stays
vendored` in `spec-symbol-batch6.test.ts`). No prop, type member or i18n
key was added.

## Tests

- `packages/plugin-chatbot/src/__tests__/chatRecordLink-13037.test.tsx`
uses the real `ChatbotEnhanced` and streamdown:
- (a) A record link calls `onOpenRecord({ objectName, recordId })` with
no dialog and no `window.open`. The `./record/...` spelling does the
same.
- (b) An external link and a same-origin `/apps/crm/account` link in the
same answer both open the link-safety dialog, showing their url, and the
host is not called.
- (c) `?tab=related`, `#notes` and `/edit` variants open the dialog and
are never handed to the host.
- (d) CONTROL: with no host opener, the record form keeps the dialog. A
plain markdown answer renders with no links.
  - The grammar unit pins accepted and rejected hrefs.
-
`packages/app-shell/src/console/ai/__tests__/AiChatPage.recordLink-13037.test.tsx`
uses the real `ChatPane` and `ChatbotEnhanced` in a `BrowserRouter`.
Clicking the link lands `window.location.pathname` on
`/_console/apps/com.acme.crm/account/record/acc_42` with basename
`/_console`, and on `/apps/com.acme.crm/account/record/acc_42` with
basename `/`. No dialog opens and `window.open` is not called.

### Ablations

Each ablation used `scripts/ablation-replace.mjs`, which proves the
mutation on disk and proves the restore by blob hash against `HEAD`,
with `git diff HEAD` empty. They ran at `a677efb104`. The later commit
only moved the helper file, with the logic byte-identical.

- Interception removed (`withRecordLinks(linkSafety, openRecordLink)`
replaced by plain `linkSafety`): 4 tests fail, both (a) cases and both
basename cases. The first attempt was refused by the tool before
running, because the replacement text was not unique, so it was a no-op.
It was re-run with a unique marker.
- Strict end anchor removed from the pattern: the grammar unit and (c)
fail.
- Non-record links answered `true` instead of the default: (b) and (c)
fail.
- Every restore returned the HEAD blob (`3204883137ed` for
`message.tsx`, `cfabaf48ce6d` for the helper).

### Gates (local, at `9fd11fbd7e`)

- Closure build `turbo run build --filter=@object-ui/app-shell^...`:
28/28 tasks.
- `type-check` for `@object-ui/plugin-chatbot` and
`@object-ui/app-shell`: exit 0. The new test files are in the
`tsconfig.test.json` file list.
- `vitest run packages/plugin-chatbot/` plus the app-shell test: 78
files, 769 tests passed.
- The full `packages/app-shell/` directory is left to CI.
- The full pre-tsc battery, plus `check:new-line-citations`,
`check-hand-rolled-comment-mask`, `check-changeset-fixed` and
`check-changeset-presence`: all exit 0.
- `eslint --no-inline-config` on the 5 changed files: 0 errors. There
are 15 warnings, all on lines this PR does not touch. Type-aware linting
is not enabled, so the diff cannot move any untouched file's result.

## Acceptance notes

- Only the full-page console chat (`AiChatPage` / `ChatPane`) passes
`onOpenRecord` today. A `ChatbotEnhanced` host without it, such as the
floating chatbot, keeps the default dialog for the record form, which
then opens `/record/...` in a new tab where nothing serves it. That is
the same behaviour every link has there today. Carrier: none.
- Cloud's rule line on objectstack-ai/cloud#3072 can name the form and
the regex above once this is in cloud's `.objectui-sha`.

Changeset: `.changeset/13037-chat-record-link.md`
(`@object-ui/plugin-chatbot` patch). The app-shell change is test-only.

Implementation session:
`https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…alf in $select (objectui#13036) (#13042)

Fixes #13036
Clause-②: no

## What

A `referenceVia` pointer column (objectui#12608) resolves its record
from the row: `resolveRecordPointer` reads the sibling the field names
and answers null when it is absent, so the column then draws the stored
id. Neither projection builder asked for that sibling, so a list showing
`record_id` without `object_name` drew the raw record id.

Both builders now harvest the `referenceVia` sibling of every
**projected** pointer column:

- `ListView` `selectFields`: for each column that survived the FLS gate
(`cols`), the declared field's `referenceVia` goes through
`addSpeculative`, the same declared-field + FLS-readable gate the
grouping, row-colour and deadline harvests take.
- `ObjectGrid` `getSelectFields`: a new `referenceViaFields` harvest,
read from the shown columns that pass `passesProjectionGate`, then
`isProjectableField` and `passesProjectionGate`, unioned into
`extraFields`. This is the projection a grid-owned grouped fetch
(`gridOwnsGroupedFetch`) and a standalone `object-grid` send.

A sibling the viewer may not read is not asked for, and that row keeps
the stored text (the half-addressed control). A sibling the object does
not declare is not sent. `resolveRecordPointer` and the cell are
unchanged; no new export.

## Pins

-
`packages/plugin-list/src/__tests__/ListView.referenceViaSelect-13036.test.tsx`:
(a) a list whose `columns` omit the type half asks for it on
`ListView`'s fetch, and the rows handed to the grid resolve to the
record through `resolveRecordPointer` (plugin-list does not depend on
plugin-grid, so a stub grid stands in); control: a genuinely blank
sibling stays half-addressed; an FLS-unreadable sibling is not selected;
an FLS-unreadable pointer column harvests nothing; an undeclared sibling
is not sent; a sibling that is a column is sent once.
-
`packages/plugin-grid/src/__tests__/ObjectGrid.referenceViaSelect-13036.test.tsx`:
the real grid draws the record (link named `Acme renewal`) on its own
fetch and (b) on every group row page of a server-grouped grid; (c)
control: a genuinely blank sibling keeps the stored text; an
FLS-unreadable sibling is not selected and the row keeps the stored
text; plus the same undeclared / denied-pointer / sent-once rows.

## Ablation (once per behaviour, through `ablation-replace.mjs`, restore
proven by blob hash)

- `ListView.tsx`: deleting the harvest line turned 2 of 6 red (the (a)
pin and the blank-sibling control's `toContain('object_name')`).
Restored: blob after restore == HEAD (`81f118cf6701`), `git diff HEAD`
empty.
- `ObjectGrid.tsx`: dropping `...referenceViaFields` from `extraFields`
turned 3 of 7 red (own fetch, server-grouped fetch (b), blank-sibling
control). Restored: blob == HEAD (`4c2fbdbb6281`), `git diff HEAD`
empty.

The tests import the components from source (`../ListView`,
`../ObjectGrid`), so no rebuild sits between the mutation and the run.

## Verification (at `6d2f81b3f3`)

- New tests: plugin-list 6/6, plugin-grid 7/7.
- Projection-family tests: plugin-grid 13 files / 139 tests pass;
plugin-list 13 files / 109 tests pass.
- `type-check` for `@object-ui/plugin-list` and `@object-ui/plugin-grid`
exit 0, after the dependency closure build; `tsc -p tsconfig.test.json
--listFiles` lists each new test.
- `eslint` on the 4 changed files, per package as their `lint` scripts
run it: 0 errors; no warning falls on the changed source lines.
- Exit 0: `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`,
`check:handler-key-reads`, `check:side-effects-array`,
`check:element-data-source-declaration`, `check:spec-symbols`,
`check:action-forward-parity`, `check:designer-field-key-parity`,
`check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check-hand-rolled-comment-mask`, `check-changeset-fixed`,
`check-changeset-presence`, `check:new-line-citations`.
- Declared to CI: the full `packages/plugin-list/` and
`packages/plugin-grid/` directory runs. A local `packages/plugin-grid/`
run hit the 590s foreground limit before it reported, so it is NOT
MEASURED here.

## Acceptance notes

- #24501 stays blocked until a `.objectui-sha`
pin carries this change.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

Co-authored-by: Claude <noreply@anthropic.com>
… `patch`, and no action renderer forwards a row `patch` (objectui#13022) (#13033)

Fixes #13022

Clause-②: no

An `operation: 'update'` action's Undo snapshot now covers the `patch`
of the declared action its `name` names, plus the collected params. No
action renderer forwards a page action row's own `patch` any more.

This is the runner-and-renderer half of the card. The published-input
half is pin-coupled and rides objectui#12971: the `patch` input that
`action:button` and `action:icon` publish,
`action-button-icon-inputs-11168.test.tsx`'s input list, and
`registry-inputs-spec-parity.test.ts`. At today's pin
(`@objectstack/spec` 18.0.0-next.3) the spec still declares the row
`patch`, and the parity test's reverse direction requires the input.
**This PR goes ready only after objectui#12971 lands and this branch has
merged `main`.**

## Why

#24442 (`69f342b924`) retires a page action
row's own `patch` in `@objectstack/spec` 18, on `action:button`,
`action:icon` and the `action:group` / `action:menu` member. A row's
`operation: 'update'` runs the declared action its `name` names. The
platform action route resolves that declaration by name and writes the
declaration's `patch` under the params it is sent. The row's own `patch`
has reached no request since objectui#12941 (PR #12944). The runner read
it for the Undo snapshot only, so the snapshot covered the wrong fields.
Once no row carries a `patch`, a row's snapshot covered the collected
params and missed the fields the route wrote.

## What changed

- `packages/core/src/actions/ActionRunner.ts`
- New `declaredUpdatePatch(action, context, objectName)`. It reads the
`patch` of the declaration the action's `name` names from the
`objectActions` the host publishes for the dispatch's object (new
`readContextObjectActions`). It is scoped like
`readContextObjectFields`: only the context's own object.
- When the host published no list for that object, or the list does not
name the action, it reads the def's own `patch`. A host that dispatches
an object's action whole hands that in: `ObjectGrid` rows,
`DeclaredActionsBar`, `RelatedRecordActionsBridge` and `page:header`.
- `executeUpdateOperation` builds `written` from that `patch`, which
gives `writtenFields` and `redoData`. The request is unchanged: `{
...action, params: { ...collected } }`.
  - A nameless action skips the lookup.
  - The `ActionDef.patch` and method docs say what the key is now.
- `useConsoleActionRuntime` and `RecordDetailView` publish
`objectActions: objectDef.actions` in the runner context, beside
`objectFields`.
- `action-button.tsx`, `action-icon.tsx`, `action-group.tsx` and
`action-menu.tsx` drop `patch: schema.patch` / `patch: action.patch` and
the comments that described the forward. `operation` is still forwarded.
- `scripts/check-action-forward-parity.mjs` gets four `JUSTIFIED`
entries, `action:button:patch` and the three siblings (issue 13022).
Without them the gate reds, because the runner still reads
`action.patch` off a whole-dispatched declaration. The entry says why
the omission is correct.
- `predicate-fields.ts`: doc only (see "Premise" below).
- Docs: `packages/core/README.md` gains "Which fields an `operation:
'update'` Undo covers", which documents the host's `objectActions`.
`content/docs/guide/layout.md` no longer lists `patch` among the
forwarded keys and says the node runs its named declaration.
- `.changeset/13022-action-row-declared-patch.md`: patch for
`@object-ui/core`, `@object-ui/components` and `@object-ui/app-shell`.

## Call graph used (dispatch Zone 2, item 2)

The declaration was **not** in hand at the capture site. A page row
reaches `executeUpdateOperation` as the def the renderer composes from
the node. `action:bar` draws a declared action through the same
`action:button` / `action:menu` renderers, and a spread-based host hands
the declaration itself. The runner context held `objectName` and
`objectFields` only.

The shortest thread mirrors objectui#11122's `objectFields`. The hosts'
`objectDef.actions` (the list the route resolves names against) goes
into the context, then through `ActionProvider` to `new
ActionRunner(context)`, then `execute`, then `executeUpdateOperation`,
then `declaredUpdatePatch`. That puts it next to `captureUpdateUndoData`
at the capture site.

## Premise (dispatch Zone 1 and Zone 2, measured on `origin/main`
`47508f3b`)

- **`undoableWrittenKeys` (predicate-fields) does not read a row.** Its
inputs are `listViewPredicates`' `rowActionDefs` (the host-resolved row
defs), `bulkActionDefs` and `objectActions`. All of them are definitions
their writer applies whole, and no page action row reaches it. So it
already reads the declared `patch` that the `$select` harvest owes the
row. Only its doc changed, to say so. The runner change reads the same
declarations on the console hosts.
- **Item 1, the request body is unchanged.**
`action-row-declared-patch-13022.test.tsx` has the test "the request a
row sends carries no field value, with or without a `patch` on the row".
It asserts `{ recordId: 't1', params: {} }` through the real
`createServerActionHandler`. It stays green with the forward put back
(ablation 2 below), so dropping the forward moves no request byte.
`action-button-undoable-11168` "on a record page" pins the same body.
- **Item 3, a nameless `operation: 'update'` row today.** Measured with
a throwaway probe through the real runner and
`createServerActionHandler` (not committed). The answer is `{ success:
false, error: 'Action has no name — a server action must declare one'
}`, with 0 requests and an error toast, and the action never reaches the
Undo capture. Unchanged here.

## Tests (union after the final commit, head `a5cd587d`)

- `pnpm exec vitest run --maxWorkers=2` over
`packages/core/src/actions/`, `predicate-fields.test.ts`,
`packages/components/src/renderers/action/`, every
`useConsoleActionRuntime*` test, nine `RecordDetailView` action/undo
tests, both `AppContent.undo*` tests, `plugin-grid`
`undoProjectedRow-10404`, `plugin-form` `affordanceGrantMap-12082`, both
`check-action-forward-parity` script suites and `apps/console`
`registry-inputs-spec-parity` / `public-block-binding-reach` /
`public-contract`. Result: **102 files, 1761 tests passed**.
- New `ActionRunner.declaredPatch-13022.test.ts` (core, 7 tests) covers:
  - the declaration's `patch` is snapshotted for a row with none;
  - the declaration wins over a def's own `patch`;
  - a declaration with no `patch` gives a params-only snapshot;
  - a written field the row lacks gives no Undo, with the warning;
- with no published list, a whole-dispatched declaration is read whole;
  - a dispatch to another object does not read this object's list;
  - control: no `patch` and no list gives no Undo.
- New `action-row-declared-patch-13022.test.tsx` (components, 9 tests)
covers:
- each of the four surfaces hands the runner `operation` and no `patch`;
  - the request body, with and without a row `patch`;
  - Undo covers the named declaration's `patch`, plus a collected param;
  - a row's own `patch` is never covered;
  - control: a host with no list gives a patch-only row no Undo.
- Updated pins:
- `action-forward-precedence.test.tsx`: `patch` leaves both key-order
lists.
- `action-button-icon-inputs-11168.test.tsx`, the `operation: update`
case only: the def carries `operation` and no `patch`.
- `action-button-undoable-11168.test.tsx`: the host publishes the
declarations, and the rows carry no `patch`.

## Ablation (both legs through
`objectstack/scripts/ablation-replace.mjs`, on committed `9f41095f`)

1. **The snapshot change.**
- Mutation: `const patch = declaredUpdatePatch(action, this.context,
objectName);` became `const patch = patchMembers(action.patch);`, the
pre-change read.
   - On disk: anchor 1 to 0, blob `79aef211708b` to `1cb9d3ac99ea`.
- Expected red; observed red: **6 failed / 10 passed of 16**. They are
the 3 core and 3 components Undo cases above. The request-body,
no-forward, cross-object and control cases stayed green.
- Restored: blob back to `79aef211708b`, equal to HEAD, `git diff HEAD`
empty, and the rerun gave 16/16.
2. **The renderer forward.**
- Mutation: `patch: schema.patch,` put back into `action:button`'s
forward.
- The first attempt was refused by the tool, because the replacement
contained the anchor. Nothing ran, and the file was restored. It was
re-run with a two-line anchor.
   - On disk: anchor 1 to 0, blob `70dbbd6e83d5` to `c825cec7358a`.
- Expected red; observed red: **3 failed / 14 passed of 17**: the two
`action:button` key-order pins and "action:button hands the runner
`operation` and no `patch`". The request-body pin stayed green, which is
item 1. "the `patch` a row carries itself is never covered" also stayed
green, because the published declaration wins in the runner as well.
- Restored: blob `70dbbd6e83d5` equals HEAD, and `git diff HEAD` is
empty.

No build or `dist` leg: the tests import `../ActionRunner` relatively,
and vitest aliases `@object-ui/core` to `src`.

## Gates (local, exit codes captured before any pipe)

- `check:action-forward-parity` 0: 26 justified omissions, 0 known gaps.
- `check:new-line-citations` 0 (0 new citations).
- `check:test-path-roots` 0.
- `check:control-bytes` 0.
- `check:changeset-claims` 0.
- `changeset:check` 0.
- `node scripts/check-changeset-presence.mjs` 0: 13 source files of 3
released packages, 1 changeset.
- `check:handler-key-reads` 0.
- `check:pending-changeset-literals` 0.
- `check:spec-symbols` 0.
- `check:doc-fences` 0, `check:doc-json` 0, `check:doc-types` 0,
`check:doc-example-ids` 0.
- `node scripts/check-governed-queue-guard.mjs --test` on the diff: NOT
GOVERNED.
- `eslint` on the changed files, as the package `lint` runs it: 0
errors, and no warning in the new files.
- `tsc` type-check:
- `pnpm --filter @object-ui/core type-check` 0, with both programs.
`tsconfig.test.json --listFilesOnly` lists the new pin.
- `@object-ui/components` `type-check` 0. `--listFilesOnly` lists all 4
changed test files.
  - The dependency closures were built first.
- The new README snippet compiles under `--strict --noUnusedLocals`
against core's built types. Control: a typo reds it.

Not measured locally, declared to CI:
- The `@object-ui/app-shell` type-check, because its ~30-package closure
was not built. Its diff is two context spreads.
- `check:doc-snippets`, `check:doc-examples` and `check:readme-exports`,
which answered "could not run" on an unbuilt tree. Their CI job builds
the closure first.

The shared verify lock was held for over an hour by another seat's
full-suite measurement. These runs were narrowed to the files above and
run outside the lock. The full farm is CI's.

## Acceptance notes

- **The residual direction, not changed here.** A host that publishes no
declarations for the dispatch's object reads the def's own `patch`. A
row carries none, so a row on such a host keeps today's params-only
snapshot. Two cases reach it:
- a page row under a runtime with no bound object, such as `PageView`,
whose runtime binds no `objectName`;
  - a row whose `objectName` names an object other than the host's.

Both console record surfaces, `ObjectView` and `DeclaredActionsBar`
publish. Making "no declaration found" refuse Undo instead would also
refuse it for every whole-dispatched param-only action on such a host.
The seat may want that ruled; it is in the report.
- **Between this PR and objectui#12971**, `action:button` /
`action:icon` still publish a `patch` input that the block no longer
forwards. The spec at today's pin still declares it, and objectui#12971
removes it with the pin bump.
- `objectActions` rides `ActionProvider`'s `JSON.stringify(context)`
memo key, beside `objectFields`.

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

---
_Generated by [Claude
Code](https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ource's shared read (objectui#13039) (#13048)

Fixes #13039
Clause-②: no

## What changed

`ObjectChart`'s category option-colour / dimension-label effect read
`/api/v1/meta/object/NAME` with its own raw `fetch`, outside the data
layer's request-key cache. Every object-bound chart therefore sent one
request of its own, beside the cached `getObjectSchema` read that the
same chart's group-by label leg, the metric tile and the data table
already make.

The effect now reads through the data source's `getObjectSchema`
whenever a data source that answers it is in scope (the `dataSource`
prop, else the `SchemaRendererContext` one). The ObjectStack adapter's
`MetadataCache.get` joins concurrent reads of one name, so N charts and
tiles over one object make one `meta/object/NAME` request.

- **Shape.** The data source answers the bare object document. The raw
leg's unwrap (`item`, else `data`, else the body) yields the same
document, so `loadDimensionFieldMeta` gets one shape from either leg. No
change to category or option resolution, dotted dimension paths
included.
- **Where the raw read stays (the fallback).** In the same effect, in
`loadObjectSchema`: when no data source is in scope, or the one in scope
has no `getObjectSchema` (a host stub, a standalone embed). The dataset
definition read (`/api/v1/meta/dataset/NAME`) is unchanged and stays
raw.
- `dataSource` joins the effect's dependency list, beside `apiFetch`.

No export, prop or type member is added.

## The dataset route: `useDatasetDimensionMeta` (round 2, seat ruling
`6267898178`)

The card measured HotCRM's sales dashboard, where every widget is
**dataset-bound** (`dataset: 'opportunity_metrics'`). Those widgets
render through `plugin-dashboard`'s `DatasetWidget`, which reads object
metadata through `useDatasetDimensionMeta` in `@object-ui/react`
(`packages/react/src/hooks/useDatasetDimensionLabels.ts`). That hook
made its own raw `fetch` of `/api/v1/meta/object/NAME`, one per
non-metric widget.

The hook now reads through `SchemaRendererContext`'s
`dataSource.getObjectSchema` when the context's data source answers it.
The raw fetch stays as the fallback, in that hook's `loadObjectSchema`,
when there is no such source. `dataSource` joins the effect's
dependencies. `ConsoleShell` mounts `SchemaRendererProvider` with the
adapter, so in the console the dataset widgets of one dashboard share
one read. `plugin-report`'s `useDatasetDimensionLabels.ts` only
re-exports the hook and is untouched.

**The seat widened the file surface** to this hook by ruling
`6267898178` (the claim `6267319279`, amended in place).

## Pins

New,
`packages/plugin-charts/src/ObjectChart.sharedSchemaRead-13039.test.tsx`
and
`packages/plugin-dashboard/src/__tests__/sharedObjectSchemaRead-13039.test.tsx`.
The test data source joins concurrent reads of one name, as
`MetadataCache.get` does. Each pin counts what that source sent plus
every `/meta/object/` URL that reached the global `fetch`. The raw route
answers the same documents, so a pin can go red only on which route
carried the read.

- (a) three `ObjectChart`s over one object make one request;
- (b) `ObjectChart` plus `ObjectMetricWidget` over one object make one
request;
- (c) CONTROL: two objects make one request each;
- (c) CONTROL: a chart with no data source resolves its colours through
the raw fallback;
- (c) CONTROL: a dotted dataset dimension (`account.industry`) still
walks to the owning object, both reads shared.

**Ablation**, run after the fix was committed (`b7c83e1a97`), through
`ablation-replace.mjs` with the `loadObjectSchema` condition replaced by
`false` (the raw read restored). The anchor went from 1 to 0 and the
blob from `bb784a014b97` to `cdfcc66cf48a`. Result: (a), (b), the
two-object control and the dotted control went red on the route count
(`expected [ …(3) ] to deeply equal []`). The no-data-source control
stayed green, as expected. After the restore, the blob is back to
`bb784a014b97` == HEAD and `git diff HEAD` is empty.

**Changed existing pin:** `ObjectChart.fieldOptionLabelRefetch.test.tsx`
(the no-refetch-loop pin). Its data source counts `getObjectSchema`
calls without joining them, so one data load now reads 2 (the
option-colour effect and the group-by label leg), constant across
re-renders. The pin asserts that 2, named, in place of 1.

**Round-2 pins:**
-
`packages/plugin-dashboard/src/__tests__/DatasetWidget.sharedSchemaRead-13039.test.tsx`:
three `DatasetWidget`s over one object make one request.
- Its controls: two objects make two requests; with no context source
the raw fallback still relabels; labels are unchanged.
- Ablation at `07a99a7226`: blob `417fdeb4284a` → `2b2a860e0931`. The
one-object pin and the two-object control went red. Restored to
`417fdeb4284a` == HEAD.

**Changed existing pin:**
`DatasetWidget.starvationFingerprints-7696.test.tsx`. Its context source
now answers `queryDataset` only, so the file keeps measuring the raw
route the objectui#7696 report rode. The widget still takes the full
source through its prop.

**Round-2 verification at `b8714c36c8`:**
- `type-check` for `react`, `plugin-dashboard`, `plugin-report` and
`plugin-charts`: exit 0.
- Reader run, 33 files: green after the fingerprint fixture fix.
- The gate battery below: all 0.

## Verification (round 1, final runs at HEAD `163bbfac67`)

- Closure build `pnpm --filter '@object-ui/plugin-dashboard^...' build`:
exit 0.
- `type-check` for `@object-ui/plugin-dashboard` (at `7d45561172`, its
tree unchanged since) and `@object-ui/plugin-charts` (re-run at
`163bbfac67`): exit 0. Both `tsconfig.test.json` programs list the new
test files.
- vitest at `163bbfac67`: the two new pin files, the refetch pin and
`ObjectChart.optionColors.test.tsx`, 4 files, 14 tests passed.
ObjectChart family in `plugin-charts` (32 files): 206 passed, 2 failed
before the refetch pin was updated, then green in the consumer run.
Consumer and dashboard meta-reading tests (36 files across charts,
dashboard, app-shell, console, plugin-list, plugin-view, react,
components, timeline): 561 passed.
- Not run locally, declared to CI: the full `packages/plugin-charts/`
and `packages/plugin-dashboard/` directories.
- eslint on the 4 changed files: 0 errors. The warnings are pre-existing
(`no-explicit-any`, `only-export-components` and the like), none on the
changed lines. The config is not type-aware, so this diff cannot move
the verdict on any file it does not touch.
- Battery: `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`,
`check:handler-key-reads`, `check:side-effects-array`,
`check:element-data-source-declaration`, `check:spec-symbols`,
`check:action-forward-parity`, `check:designer-field-key-parity`,
`check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check-hand-rolled-comment-mask`, `check-changeset-fixed`,
`check-changeset-presence`, `check:new-line-citations`: all exit 0.

## Acceptance notes

- The changeset names `@object-ui/plugin-charts` and `@object-ui/react`,
`patch`. `plugin-dashboard`'s only changes are tests, so nothing it
publishes changes.
- The card's other rows (team and environment pages, `security/explain`)
are carried by objectui#13044 and objectui#13045 per the claim. They are
not touched here.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ollow its contract (objectui#12971) (#13043)

Fixes #12971

Clause-②: yes (narrowing)

**Scope:** move `.objectstack-sha` from `2c9bf9430a` (the
`@objectstack/spec` 18.0.0-next.3 release commit) to objectstack `main`
`f4755a68a797`. Then follow every contract change between the two that
turns something red here, with no tolerant fallback. The only exemptions
are the bookings the seat ruled on objectui#12971 (record 6267645900,
decisions D1 to D4).

The held cards' feature work is not done here: objectui#12932,
objectui#12931, objectui#12929, objectui#12484 and objectui#13015. This
PR makes their commits available.

The Undo snapshot and the renderers' row `patch` forwarding are
objectui#13022's (PR #13033). That PR landed first by decision D3 and
was merged into this branch.

**Status: CI is green on head `2abf91da`** (33 success, 3 skipped;
listed below). This body was refreshed once through the relay. It
replaces the body written at `9310c0ea`.

## The pin

- `f4755a68a797d5a000cb8bd1c942cb2a9862108e` was the newest objectstack
`main` commit green at the move: 150 check-runs completed, none failed.
- The first move went to `2ec6827962`, whose packages read
`18.0.0-next.4`. objectstack never published or tagged next.4, so
`e2e/live/ci/backend.env` had no published version to pair with.
`ci-cd-pipeline-doc.test.ts` holds that file equal to the lockfile's
spec version.
- objectstack then released `18.0.0-next.5` at `820fd5aef0`.
`scripts/bump-objectstack.sh` moved the pin again, to `f4755a68a7`,
which is past that release and reads next.5.
- `OBJECTSTACK_VERSION=18.0.0-next.5` now resolves the tag
`@objectstack/cli@18.0.0-next.5`, and Live E2E passes on it.
- Ancestry: `git merge-base --is-ancestor SHA f4755a6` exits 0 for
all of these:
- `586705d85d`, `1e001c7df`, `e15897c0f1`, `ef65646843` and
`adb7910f81`;
  - `69f342b924`, `3a734e0888` and `820fd5aef0`;
  - the old pin `2c9bf9430a` (201 commits apart).
- On the same shallow checkout, `5b14b7f3ad` (newer than the pin) exits
1 as the negative control.
- `pnpm-lock.yaml` is unchanged: the seven packages declare the same
dependency names.
- After merging `main`, which brought objectui#13008's committed
manifests, `.objectstack-pin/*/package.json` was rewritten by
`objectstack-pin.mjs build --write-manifests`. Only the packed versions
move.

## Reds at the new pin, and what this PR does

| red | spec cause (objectstack) | done here |
|---|---|---|
| parity: `action:button` / `action:icon` publish `patch` |
`69f342b924`, a row's own `patch` is a tombstone | the inputs blocks
stop publishing it. Both are pinned by name as harvested tombstones; the
`patch` member pin and the published-key list entry go |
| parity: carried 60 to 61, judged 40 to 41 | `194a42d1f7`, `list-view`
gains a `ComponentPropsMap` row | `list-view` joins the covered set |
| parity: `list-view` publishes 6 of 44 live keys, 6 member pins owed |
`194a42d1f7` | **D1 = A**: 38 keys and 6 member pins booked OWED to
objectui#13047 under objectui#11111 decision 3 = B, expiry 2026-11-18.
The caps rise to exactly those (unpublishedKeys 38, memberPins 6,
member-pin ceiling 6). Nothing declared |
| parity: `emptyState` unpublished on `record:details` /
`record:highlights` / `record:quick_actions` | `586705d85d` | **D2 =
A**: three `UNPUBLISHED_EXEMPTIONS` entries owned by objectui#12932 (the
objectui#12370 precedent) |
| parity ledger: `marketplace:installed-list`, `record:json` |
`14a8bd2693`, `030a7b83e8` | both read RETIRED UPSTREAM |
| `block-config.test.ts`: stale `record:json` exclusion | `030a7b83e8` |
exclusion removed |
| `record-highlights-layout-9187` key list | `586705d85d` | `emptyState`
added |
| `spec-object-refinements-7715` page census | `ef65646843`,
`3a734e0888` | `checkPageMainRegionWidth` attached on both page nodes.
`checkPageRecordBindingPlacement` attached on the page-kind node and
recorded not attachable on the `page` node. Two describe blocks compare
the spec's own issue, with controls |
| `passing-keys-arms-11440`, `validate-passing-keys-11440` |
`3a734e0888` | `width: 'large'` removed from the `main` region |
| `imported-defaults-8317` | the two checks | registered as refinement
exceptions |
| `ObjectTree.schemaTyped-8655` | `194a42d1f7` | `list-view` added to
the `navigation` / `tree` rows |
| `MetadataService.specKeyObjectPayload` / `.retiredObjectEnabled` |
`31e7d0ba27`, `defaultRowScope` | key count 45 to 46 |
| `navigation-spec-parity`, `nav-target.navEntryTypes-11790` |
`26501ae24f` (objectstack#24332), nav-item base `dataSource` | mirrored
by reference in `NavigationItemSchema` (it stripped the key before) and
the `NavigationItem` type; `NAV_ENTRY_BASE_KEYS` keeps it on a retype |
| `ci-cd-pipeline-doc` (live-e2e pin) | the bump | `backend.env` set to
`18.0.0-next.5` (see the pin section) |
| `check:installed-pin-claims` (25 files) | the bump | each sentence's
fact re-measured (node probe, identical at both new pins) and restamped
`18.0.0-next.5`. Two fixture lines in `check-lockfile-integrity.mjs`
ledgered `historical` |
| Type Check: `action-button-undoable-11168` TS2353 | `69f342b924` |
**D3 = A**: objectui#13022 (PR #13033) landed first and rewrote the
test. Merged here; the inputs blocks (this PR) and the forwarding lines
(#13033) both kept |

## Clause-② (D4 = B): yes, both directions

Narrowings, refused with the spec's own message on `safeValidateSchema`
and `StrictAnyComponentSchema` (`objectui validate`):

- `main` region `width: 'small' | 'medium' | 'large'` on a `page` node
or a page-kind node, at the region's `width`: "Region `main` is refused
with `width: 'large'` (since @objectstack/spec 18): a `main` region
takes only `width: 'full'`."
- a `record` binding on a page-kind node, at `record`, for example:
"`record` binds the row of an `app` page, and this page is `type:
'home'`, which has no row to bind."
- the `patch` input on `action:button` / `action:icon` is no longer
published.

Widenings, the spec's keys by reference, which the strict face refused
as undeclared before:

- `emptyState` on the three bound record blocks. The renderer half is
objectui#12932.
- `record` on a `page` node, for any `pageType`. The renderer half is
objectui#12931.
- `dataSource` on navigation entries. The spec marks it planned.

The changeset (`.changeset/12971-objectstack-pin-main.md`, `minor`)
carries the same line and both lists.

## Ablation

`checkPageMainRegionWidth` was detached at both sites with
`ablation-replace.mjs`: anchor x2 to x0, blob `1e46e7fa45cd` to
`ed50b46f958d`.

- Result: `Tests 3 failed | 97 passed`. The three failures are exactly
the `main`-width refusal rows; the controls stayed green.
- Expected direction: red. Observed: red.
- Restore: blob back to the HEAD blob `1e46e7fa45cd`, and `git diff
HEAD` is empty.

## Verification at head `2abf91da`

- CI, all success: Build & E2E, Build Docs, Bundle Analysis, Changeset
Bump Policy, Changeset Claim Re-read, Changeset Declaration, Changeset
Fixed Group Check, Changeset Overwrite Report, Governed Surface Queue
Guard, Line Citation Gate, Lint, Live E2E (informational), Repo Gates,
Test, Test shards 1 to 16, Test (dist pins), Type Check, label.
- CI skipped: the two coverage jobs and dependabot.
- Local: `pnpm type-check --concurrency=2 --continue` gives `Tasks: 81
successful, 81 total`.
- Local targeted run after the merge, 1203 of 1203 tests:
  - `packages/components/src/renderers/action/__tests__/`;
  - `ActionRunner.declaredPatch-13022`;
  - `registry-inputs-spec-parity`;
  - `check-action-forward-parity` (test and gate);
  - `spec-object-refinements-7715`;
  - `imported-defaults-8317`.
- `check-governed-queue-guard --test`: NOT GOVERNED.
- `changeset:check`, `check:changeset-claims`,
`check-changeset-presence`, `check:pending-changeset-literals`,
`check:installed-pin-claims` (OK) and `check:lockfile-integrity` (clean)
all exit 0. `check:lockfile-dedupe` reads deduped.

## Acceptance notes

- Filed from this work: #24534. objectui's
`NavigationItemSchema` accepts `visible: 'data.x'` without `dataSource`,
which the spec refuses. The spec's check is not exported, so it cannot
be attached here yet.
- `RecordDetailView.recordOpenWaves-13010.test.tsx` and
`page-single-h1.test.tsx` still author `width: 'large'` on `main`.
Nothing validates them.
- The installed-pin-claims gate and the live-e2e pin compare version
strings, not commits.

---
_Generated by [Claude
Code](https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…n uses it (objectui#13045) (#13051)

Fixes #13045
Clause-②: no

## What changes

A record page no longer asks `POST /api/v1/security/explain` for a
verdict nothing on screen uses.

- **`RecordDetailView` header (`@object-ui/app-shell`).** The `update`
verdict is not asked when the object's `userActions.edit` is the static
`false`, and the `delete` verdict is not asked when `userActions.delete`
is the static `false`. The hook's existing `enabled` argument carries
this, read from the same `objectDef.userActions` entry the header gate
(`resolveRecordHeaderActionGates`) reads. A per-record predicate form,
`true`, or an absent entry still asks, and Edit and Delete still follow
the verdict.
- **`DetailView` (`@object-ui/plugin-detail`).** Seat ruling A (comment
6267444006). Each verdict is asked only when the host shows its button:
`update` only with `showEdit`, `delete` only with `showDelete`, read
from the same `gatedSchema` the buttons read. The console record page's
`record:details` body shows neither button. Its two asks used to join
the header's in-flight request (objectui#11699), so with only the header
change they went out on their own and the measured count did not drop
(os-dev report 6267417403).

`askVerdict`'s fetch, cache and fail-open contract are unchanged. No
export, prop or type member is added.

## Pins

-
`packages/app-shell/src/views/RecordDetailView.hiddenAffordanceVerdicts-13045.test.tsx`
mounts the real page (synthesized, so its `record:details` body is
mounted) over the real verdict hook and counts explain requests by
operation:
  - (a) `edit: false, delete: false`: no request.
- (b) `delete: false` alone: only `update` is asked, and it still gates
Edit, in both the deny and the allow direction.
- (c) CONTROL, with `userActions` absent, `true` or in predicate form:
both are asked, and a denying or allowing verdict hides or offers both
affordances.
-
`packages/plugin-detail/src/__tests__/DetailView.verdictOnlyForShownButton-13045.test.tsx`:
- A standalone `DetailView` with `showEdit` / `showDelete` asks both,
and its buttons follow the verdict (deny and allow).
- With one button shown, only that verdict is asked. With neither shown,
nothing is asked.
- `DetailView.test.tsx` › "probes the record-level explain endpoint for
update and delete, and nothing else" now sets `showEdit` / `showDelete`,
since without them nothing is asked by design.

## Ablation (once per term, each restored and proven by blob hash)

All files resolve through the vitest `src` alias, so no build sits
between a mutation and a run. Each leg checked its anchor on disk before
running, then restored with `git checkout HEAD -- ABS_PATH`. After each
restore `git diff HEAD` was empty and the disk `hash-object` matched the
HEAD blob.

| term removed | red | restore blob |
|---|---|---|
| header `&& !editOffByObject` | 1/14: (a) | `f692ac19fc` |
| header `&& !deleteOffByObject` | 3/14: (a), both (b) | `f692ac19fc` |
| DetailView `&& !!gatedSchema.showEdit` | 3/14: (a), DetailView "only
showDelete", "neither" | `255743e45a` |
| DetailView `&& !!gatedSchema.showDelete` | 5/14: (a), both (b),
DetailView "only showEdit", "neither" | `255743e45a` |

## Gates, read at `8c64b0f648`

- Closure builds `turbo run build --filter=@object-ui/plugin-detail^...`
and `--filter=@object-ui/app-shell^...`: exit 0.
- `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test
config includes `src/**/*.test.tsx`) for `@object-ui/plugin-detail` and
`@object-ui/app-shell`: exit 0.
- Vitest, `--maxWorkers=1`, under the shared lock:
  - The 3 new or edited files: 46/46.
- plugin-detail family (every test that imports `DetailView`; 51 files):
50 passed, 1 skipped.
- app-shell family (every test that imports `RecordDetailView`; 126
files in three runs): 43 + 42 + 41 files passed, 335 + 295 + 255 tests.
- The full `packages/plugin-detail/` directory hit the 10-minute
foreground cap at `--maxWorkers=1`. Both package directories in full are
left to CI.
- Lint: `eslint --no-inline-config --format json` over the 5 changed
`.ts`/`.tsx` files gave 0 errors. The file count comes from the JSON
output. The root `eslint.config.js` sets no `parserOptions.project`, so
with no type-aware linting this diff cannot change the verdict on any
file it did not touch. Repo-wide `pnpm lint` is left to CI.
- Pre-tsc battery, all exit 0: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, and `scripts/check-hand-rolled-comment-mask.mjs`.
- `scripts/check-changeset-fixed.mjs` and
`check-changeset-presence.mjs`: exit 0.

Changeset: `.changeset/13045-skip-hidden-verdicts.md`, with
`@object-ui/app-shell` patch and `@object-ui/plugin-detail` patch.

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

## Acceptance notes

- The ruling covers the static boolean `false` only. An object-form
entry `{ enabled: false }` with no predicate also hides the affordance,
but it still asks, as ruled ("non-boolean still asks").

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…the provider and the adapter cache (objectui#13044) (#13053)

Fixes #13044
Clause-②: no

## What changes

One page open now asks the server for one metadata URL once, whichever
of the two client caches asks first.

**Object documents (`GET /api/v1/meta/object/NAME`).**
`MetadataProvider`'s published by-name `object` read now goes through
the adapter's existing public `getObjectSchema`. The provider (page
object scope, `useMetadataItem`) and the adapter's readers (record form,
`useSettledSchema`, the field widgets) share one `MetadataCache` entry,
one in-flight request, and the revalidating `cache: 'no-cache'` fetch.

**The published view list (`GET /api/v1/meta/view`): shared in flight
only.**
- It has one `MetadataCache` key, `list:view`. The adapter wraps its own
client's bare `meta.getItems('view')`, the same way it already wraps
`meta.saveItem` for advisories.
- A call joins a request that is already in flight. Nothing is stored,
so `listViews` stays uncached and a refresh re-reads it.
- Measured console order (pin): the eager read lands first, then
`listViews` and `listViewOverrides` share one request. That is **2**
`meta/view` requests per open, down from 3. A reader asking during the
eager read joins it, for 1.

## Which shape, and why (the ruling asked)

I picked the narrower of the two shapes for each resource:

- **Objects: the provider reads through the data source's existing
cached method** (`getObjectSchema`). I did not do the reverse (routing
the SDK's `getItem('object', NAME)` into the adapter cache). That would
hand every `getClient().meta.getItem('object', …)` caller the adapter's
normalized document. `MetadataService.saveFields` uses that read as the
base of a read-modify-write, so the adapter's normalization would leak
into the saved body.
- **The view list: the adapter's client joins in-flight reads.** There
is no settled entry, per seat ruling `6268839381` (d). A settled list
would need every refresh and every in-SPA view write to drop it, and two
of those paths are outside this surface:
  - `refreshData` in `ObjectView.tsx`;
- the `MetadataClient` publish and save paths in `views/metadata-admin`
and `views/studio-design`.

The residual second request is the follow-up card. The client wrap adds
no member.

## What each reader sees (unchanged)

- **Provider object readers:**
- They get a `structuredClone` of the adapter's answer, wrapped back in
the `{ item }` envelope, so `extractItem` and the objectui#7650
normalization run exactly as before.
- The copy carries no sortability projection, because the symbol is
non-enumerable and `structuredClone` drops it. A change made to one copy
never reaches the other.
- A 404 reaches the provider's catch as the transport's own error, taken
from `MetadataNotFoundError`'s `originalError`, so
`isMetaItemAbsentError` still tells an absence from a failure.
- **Adapter readers:** unchanged. They keep the revalidation, the
sortability projection and the error wrapping.
- **View-list readers:** every caller gets its own copy of the shared
answer. A raw client caller is never answered from the settled entry. It
only joins a request that is on the wire.

## Invalidation

- `MetadataCache.invalidate` (and `clear`) now also detach the request
in flight. A request sent before an invalidation can neither answer a
later caller nor overwrite a newer answer.
- Every provider path that re-reads objects also drops the adapter entry
for each name the provider read through it: `refresh` / `refreshScopes`,
`invalidate('object'…)`, an organization switch, and a preview-mode
change.
- `invalidateViewKeys` (the one view-write seam) now drops `list:view`
as a third key. Any view write through the adapter's client (`saveItem`
/ `deleteItem` / `publishItem` / `rollbackItem`) also detaches the list
request in flight.
- An in-SPA organization switch or session (user) switch clears the
adapter's whole metadata cache, in-flight reads included, because the
provider now reads objects through it. The first resolution is not a
switch.
- The preview client path is untouched and never reads or fills the
shared keys.

## Acceptance notes

- **Card acceptance "the 环境 list reads `meta/view` once":** 2 per open
after this PR (was 3). The settled-list fix is the follow-up card the
seat files.
- **Not filed, inference only:** `PackagesPage` `publishDrafts` emits no
metadata-refresh pulse. It rides that follow-up card.
`MetadataProvider`'s own caches are not dropped on a session switch;
that predates this PR.

## Pins


`packages/app-shell/src/providers/__tests__/MetadataProvider.oneMetadataRead-13044.test.tsx`
drives a real `ObjectStackAdapter` (SDK client included) through a fake
`fetch` and counts requests by URL:

- (a) provider-first, adapter-first in flight, adapter-first settled,
and the `readObjectScope` names path each give one `meta/object/NAME`
request;
- (b) console order: the eager view read, then `listViews` +
`listViewOverrides`, gives 2 `meta/view` requests; a reader asking while
the eager read is held joins it (1). Later reads and `refresh('view')`
re-ask; the seam and a direct client `saveItem` detach the in-flight
read;
- (c) CONTROL:
  - a provider invalidation and `refresh('object')` re-ask;
  - a view write through the seam re-asks;
  - the provider's `refresh('view')` re-asks;
- preview reads go to the preview client, with zero adapter requests and
nothing cached;
- each side keeps its shape (normalized, sortability only on the adapter
copy, independent copies);
  - an absence is still an answer and a bare 404 is still a failure.

`packages/data-objectstack/src/cache/MetadataCache.share-13044.test.ts`
pins `share` (in flight only) and the in-flight detach.


`packages/app-shell/src/providers/__tests__/MetadataProvider.switchDropsAdapter-13044.test.tsx`:
an organization switch, an organization switch with a list read in
flight, a session switch, and a first-resolution CONTROL.

**Ablations** were each run on a committed HEAD through
`ablation-replace.mjs`, which landed the mutation on disk and restored
it, with the restore proven by blob hash equal to HEAD and an empty `git
diff HEAD`:

- The provider join was removed (its `readObjectThroughAdapter(name)`
arm swapped back to `getClient().meta.getItem`). The (a) pins and the
invalidation control went red: 5 failed / 6 passed. Restore blob
`f7dcdb9485ee` matched HEAD. The first attempt was a no-op: the tool
refused it because the replacement text was already in the anchor, and
nothing ran.
- **Round 2** (on committed `3f58d692ab`):
- (b) `installViewListShare()` removed: 2 failed / 15 passed. Restored
to `8ef74198e009`.
- (c) the switch `clearCache` removed: 3 failed / 14 passed. Restored to
`65727bb246e7`.
- (w) the client view-write detach removed: 1 failed / 16 passed.
Restored to `8ef74198e009`.

## Updated pins (deliberate)

- `viewInvalidationSeam.pin.test.ts`,
`viewCacheInvalidation.pin.test.ts` and `deleteView.homes.test.ts`: the
seam's ordered key list gains `list:view`.
- `viewCacheInvalidation` keeps its control that `listViews` reads the
transport on every call. The seam pin names the two lines that drop
`list:view`: the client write wrap and the seam.
- The provider test doubles gain `getObjectSchema` and
`invalidateCache`, answering from their own by-name door. That is four
in `providers/__tests__`, plus eight in `console/__tests__`,
`hooks/__tests__` and `views/record-preview`; the surface was widened by
ruling `6268839381`.
- **Round 3 (after CI on `1ca5197`):** every hand-built adapter double
in a test that renders the real `MetadataProvider` (not `vi.mock`'d) now
answers `getObjectSchema`. That is 26 more files, found by a per-double
brace-match sweep rather than a per-file text grep.
- `pageObjectReads-12980` now pins exactly one by-name read per object
name. A mutation that adds a second read turns it red.

## Local verification

**Round 3, HEAD `62c5028755`** (test-only; the production blobs are
equal to `1ca5197`):
- Empirical run of every test under `packages/**` and `apps/**` that
names `MetadataProvider`, `AdapterProvider`, `ConsoleShell`,
`AppContent`, `ConnectedShell` or `getClient`: 397 files, 3543 tests
passed, with 0 `getObjectSchema is not a function`.
- Closure build plus type-check: exit 0.
- `eslint` on 47 files: 0 errors.

**Round 2, HEAD `1ca519725b`:**
- Build plus type-check of `data-objectstack` and `app-shell`: exit 0.
- Targeted vitest, including the 8 doubles: 62 files, 519 tests passed.
- `eslint` on the 21 changed files: 0 errors.
- The battery below, plus `check:test-path-roots` and
`check-changeset-no-major`: all 0.

**Round 1, HEAD `f8ebd98de6`:**

- Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit
0.
- `type-check` for `@object-ui/data-objectstack` and
`@object-ui/app-shell` (`tsc --noEmit && tsc -p tsconfig.test.json`):
exit 0.
- Targeted vitest (23 data-objectstack files that touch the changed
areas + `app-shell/src/providers/__tests__/`): 39 files, 371 tests
passed.
- `eslint` on the 12 changed files (package config): 0 errors.
- The pre-tsc battery (`check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`), `check-hand-rolled-comment-mask`,
`check-changeset-fixed`, `check-changeset-presence` and
`check:new-line-citations`: all exit 0.
- Whole-package directories for `packages/app-shell` and
`packages/data-objectstack` are declared to CI. They were not run
locally (shared-container lock rule).

Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…i#12931) (#13060)

Fixes #12931
Clause-②: no

## Summary

An `app` page that declares `PageSchema.record`
(#24265, landed as `ef65646843`) now reads
that ONE row, and every component `visibleWhen` on the page reads it as
`record`. No root is added: a count is a `summary` roll-up field on the
row (`record.env_count >= 2`) and a tier is the object's own field
(`record.plan != 'free'`).

## How

- **`PageView.tsx`.** `appPageRecordBinding` picks the binding up on
`type: 'app'` only, and only when it names an object. `AppPageRecord`
returns its children untouched when there is none: no gate, no read, no
scope. With a binding, `ElementDataSourceGate` resolves it, as
`ParentRecordBinding` in `@object-ui/plugin-detail` resolves a block's
one-row binding. That covers the named view, the filter AND-combined
with it, the sort, and the family's panels for an unresolvable view, a
refused filter or no adapter. `AppPageRecordRead` then reads `$top: 1`
through the page's adapter, under the caller's identity. The filter's
placeholder tokens (`{current_org_id}`) resolve through
`useResolvedFilter` over `useFilterScope`, as every bound block's do.
The read is keyed on the data-invalidation bus for the bound object, so
a write re-reads in place.
- **`ExpressionProvider.tsx`.** `PageRecordScope` publishes the ambient
predicate scope plus `record`, and nothing else. `buildExpressionScope`
is unchanged. Its docblock now says why `record` is not a tier root and
where an app page gets it. The row goes into the ambient predicate
scope, not into `RecordContextProvider`. That context is the record
page's, and the `record:*` blocks read their body from it. Placed there,
the page row would become the record of every unbound record block on
the page, a binding the spec does not give. A record block that binds
its own row still wins for its own subtree, because
`buildNodeEvaluationScope` writes `record` after the ambient spread.

## The states (the card's rulings)

- **Pending.** The page body is not drawn until the read settles. This
is the convention of the record blocks' one-record binding:
`BoundRecordProvider` draws nothing under the row until it lands. Here
it is drawn as the page's own loading state (`page-record-loading`). No
predicate is evaluated before the row arrives, so no block can show and
then hide, or the reverse.
- **0 rows.** `record` is bound as `null`, so `record == null` holds and
a field read on it faults. A fail-soft `visibleWhen` renders the
component, the same verdict the spec gives an unbound row. A refused
read (`classifyLoadError` answers `forbidden`) reads the same way, as
`BoundRecordProvider` treats it.
- **Read error.** The page body is not drawn, so it fails closed. The
record page's own failed-read state stands in its place
(`empty.recordLoadFailed`, same copy and keys) with a Retry that re-runs
the same read.
- **No binding, or not an `app` page.** The ambient scope reaches the
page as the same object, and nothing is read.

## Verification (app-shell at `bc03904d99`, unchanged at head
`43062b27e1`)

- `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...'
run build`: exit 0. Then `pnpm run type-check` in `packages/app-shell`
(`tsc --noEmit && tsc -p tsconfig.test.json`; the second config includes
`src/**/*.test.tsx`): exit 0.
- New
`packages/app-shell/src/views/__tests__/PageView.appPageRecord-12931.test.tsx`:
13 passed. It renders the real `PageView`, the `app` page renderer,
`element:text` and `SchemaRenderer`, over a fake adapter whose `find` is
counted.
  - (a) tier, on a `free` row and on a `team` row.
  - (b) count, at 1 and at 2.
  - The parsed document gives the same verdicts as the stored one.
- Exactly one row read per page open, with `$top: 1` and the org token
resolved.
- Controls: no binding (ambient scope identical, zero reads); a `home`
page carrying a binding (no read); the scope is the ambient roots plus
`record` and nothing else; loading; 0 rows; a 403 read; a failed read
and its Retry.
- The test families that name the touched files: the 20 test files that
import `PageView` (including
`scripts/__tests__/page-key-read-census.test.ts` and
`vite-declared-lazy-views.test.ts`, which read its source) and the 7
`providers/__tests__` files on `ExpressionProvider`. 27 files, 250
tests, all passed, run with `--maxWorkers=1`.
- Ablations, each through `scripts/ablation-replace.mjs` (the
objectstack checkout's copy) with its trap restore. In each, the
mutation landed as anchor 1 → 0 with the blob changed, and the restore
was proven blob == HEAD with `git diff HEAD` empty:
- binding removed (`({ ...ambient, record })` → `({ ...ambient })`): 8
failed, 5 passed, including both (a) pins, (b), the parsed pin, scope,
loading, 0-row and 403;
- pending removed (the body renders before the row): 1 failed, the
loading pin;
- fail-closed removed (a failed read renders the body): 1 failed, the
read-error pin.
- `eslint --no-inline-config` over the three changed sources: 0 errors.
The narrowing is a measurement, not a skipped run: the population is the
changed `.ts`/`.tsx` files the root `eslint.config.js` lints (3),
counted from `--format json`. Type-aware linting is not enabled there,
so this diff cannot move any untouched file's verdict.
- Gate battery: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `spec-symbols`, `action-forward-parity`,
`designer-field-key-parity`, `icon-record-names`, `i18n-keys`,
`i18n-drift`, `i18n-designer-parity`, `control-bytes`,
`check-hand-rolled-comment-mask`, `check-changeset-fixed`,
`check-changeset-presence` and `check:new-line-citations` all exited 0.
- **`check:element-data-source-declaration` exits 0 at `43062b27e1`.**
`PageView.tsx` is a named `HOST_EXEMPTIONS` entry (seat ruling
`6272155387`):
- **The file:** a page host resolving the spec-declared
`PageSchema.record` (#24265). It registers no
block.
- **Self-limiting:** the exemption lapses once the comment-masked file
calls `ComponentRegistry.register(` or a bare `register(`.
- **Stale entries:** an entry whose file no longer consumes the gate
exits 2.
- **Pinned** by the new
`scripts/__tests__/check-element-data-source-declaration.test.ts`, 6
passed, on the real `PageView.tsx` text.
- **Ablation:** with the self-limit removed, 2 failed / 4 passed (both
host-plus-registration pins). Restored with blob == HEAD `fc6b1adfd0d7`.
- Round 2 at `43062b27e1`: `type-check:scripts`, `check:entry-guard`,
`check:test-path-roots`, `check:control-bytes`,
`check:new-line-citations` and `check:unreferenced-sources` all exit 0,
and `eslint` on the 2 changed scripts reports 0/0. The script gained the
canonical `isEntrypoint` guard so its test can import `judgeSite`.
- Declared to CI: the full `packages/app-shell` directory, and the
repository-wide lint.

## Hand-off

- objectstack's liveness ledger row for `PageSchema.record`
(`liveness/page.json`, `planned`) leaves `planned` only through an
objectstack PR. That is not this PR's to move. The renderer half it
waits for is this PR.
- The spec's `PageRecordBindingSchema` docblock says zero rows "leaves
`record` unbound". This PR binds `null`, per the card's ruling
("`record` is null and predicates read it as absent"). A field read gets
the same verdict either way: it faults and the component renders. The
two differ only on `record == null`. Seat ruling `6272155387` Q2 A keeps
`null`. The docblock wording rides the objectstack liveness hand-off
(the `PageSchema.record` row leaves `planned` once this lands).

## Acceptance notes

- The row is read without `$expand` and without the record page's
field-level-security strip. Predicates read stored values, and the
server strips denied fields itself.
- The interface-mode branch (`interfaceConfig.source`) is not wrapped. A
`record` binding is placed on `type: 'app'` pages, and that branch
renders `InterfaceListPage`, not regions.
- Nothing is added to the package entry. `PageRecordScope` is a module
export of `ExpressionProvider.tsx` and is not re-exported from
`@object-ui/app-shell`'s index.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…3058)

Fixes #13049
Clause-②: yes (`@object-ui/plugin-form` publishes `optionListDefault`,
so the public form reads the one rule objectui#11914 put on the create
form instead of a second copy)

## What changed

- **`apps/console` `FormPage.tsx` (`readPrefill`)** — the public
`/f/:slug` page now seeds a field's option-list default
(`options[].default: true`) by calling `@object-ui/plugin-form`'s
`optionListDefault`, the same function the create form's
`schemaDefaultValues` calls (objectui#11914). No hand-copied second
reading. It reads the served object field (`RenderableField.meta`),
whose options still carry the flag; the row's own `options` are
normalized to value/label and have dropped it.
- **Same precedence as the create form:**
- only when `defaultValue == null` (the engine's own presence test), so
a literal `defaultValue` wins and a runtime `defaultValue` is never
replaced by the flag;
- only on a create (no `record`), because the create form never seeds an
edit form;
  - a `prefill_FIELD` query value still outranks it;
- multi-valued fields (`isMultiValueField`) seed the array of marked
options. That logic is inside `optionListDefault`.
- **`packages/plugin-form`** — `optionListDefault` is exported from
`schemaDefaults.ts` and published on the entry, with its parameter type
`OptionListFieldLike` (`type` / `multiple` / `options`, all `unknown`),
following objectui#7324's rule that a published function's parameter
type is nameable. `schemaDefaultValues` / `seedCreateValues` stay
withheld: the public page needed only the option-list reading, and the
`defaultValue` gate is the caller's. The `index.tsx` note says why this
one name is now published, and names its consumer.
- **Published-surface listings updated:**
`packages/plugin-form/README.md` (the entry import list, plus one
paragraph under "What a create form opens with") and the entry pin
`entrySurface-6059.test.tsx` (the `PUBLISHED` list, and a test that a
field held under `OptionListFieldLike` flows through the function).
- `.changeset/13049-public-form-option-default.md` —
`@object-ui/plugin-form` `minor` and `@object-ui/console` `patch` (named
explicitly per the contract review `6270745821`, following
`.changeset/11092-flow-label-reader.md`). Round 2 also merged `main`
(`952d6bff54`).

No server or spec change.

## Pins
(`apps/console/src/components/FormPage.optionDefault-13049.test.tsx`)

- **SINGLE** — a required select whose options mark Low `default: true`
(no `defaultValue`) renders with Low in the combobox. An untouched
submit posts `priority: 'low'` with no "Required:" refusal.
- **MULTI** — `select` + `multiple: true` with two marked options posts
`['email', 'chat']`.
- **CONTROLS**
  - a literal `defaultValue: 'high'` wins over the flag;
- a CEL `defaultValue` is not replaced (the key is absent from the
post);
  - `?prefill_priority=medium` wins;
- with no marked option nothing is posted and "Required: Priority"
fires;
  - `readPrefill` with a record does not seed from the flag.

## Ablations (on commit 052b82360b, through objectstack
`scripts/ablation-replace.mjs`, wrap mode, restore proven by blob hash)

1. Remove the call: `? optionListDefault(f.meta) : undefined;` becomes
`? undefined : undefined;`. Blob 18fa75e went to 5e2e42b01383. Red:
SINGLE, MULTI, and the edit-form control (its create leg). The 4 other
controls stayed green. Restore: blob equals HEAD (18fa75e) and `git
diff HEAD` is empty.
2. Remove the `defaultValue == null` gate: `!record && f.defaultValue ==
null ? optionListDefault` becomes `!record ? optionListDefault`. Blob
went to d990bc68b046. Red: the literal-`defaultValue` control and the
runtime-`defaultValue` control. The 5 others stayed green. Restore: blob
equals HEAD and `git diff HEAD` is empty.

## Verification (head 052b82360b)

- Closure build: `turbo run build --filter=@object-ui/console^...` →
34/34 successful.
- `type-check` for `@object-ui/plugin-form` and `@object-ui/console` →
exit 0. Both new and edited test files are in their tsc scope (counted
with `--listFilesOnly`).
- Targeted vitest (`--maxWorkers=1`) → 11 files / 187 tests passed. The
files are the new pin, plus every `plugin-form` test that imports
`schemaDefaults` or the entry (`entrySurface-6059`,
`initialRecordMerge-9760`, `optionDefaultSeed-11914`,
`parameterTypesNameable-7324`, `sectionGroupResolverPublished-8641`,
`serverDefaultHint-12108`, `spec-symbol-4650`), plus the console's
`FormPage.serverDefaults`, `FormPage.test.ts` and `FormPage.submit`.
- Whole package directories are **declared to CI**.
`packages/plugin-form/` with `--maxWorkers=1` under the shared lock hit
its 500s timeout (exit 124, NOT MEASURED), and `apps/console/` was not
run locally.
- ESLint narrowed to the 5 changed lintable files: 0 errors.
`FormPage.tsx` has 20 warnings, the same as its `origin/main` blob.
Type-aware linting is not enabled in `eslint.config.js` (no
`projectService` / `parserOptions.project`), so this diff cannot move
the verdict on any untouched file.
- Pre-tsc battery: every check below → exit 0.
- `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`,
`check:handler-key-reads`, `check:side-effects-array`
- `check:element-data-source-declaration`, `check:spec-symbols`,
`check:action-forward-parity`, `check:designer-field-key-parity`,
`check:icon-record-names`
- `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`,
`check:control-bytes`
- `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`,
`check-changeset-presence.mjs`, `check:new-line-citations`
- `check:spec-floors` → exit 1 (`no-artifact`: it wants the whole
workspace built). Recorded, not fixed, as the dispatch expected.

## Acceptance notes

- `normalizeOptions` in `FormPage.tsx` drops `default` from the row's
options. That is why the seed reads `meta`, not `options`. No behaviour
change beyond this card.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…when their binding answers no row (objectui#12932) (#13059)

Fixes #12932
Clause-②: yes (the three bound record blocks publish the `emptyState`
input that `@objectstack/spec` declares at objectui's pin
`f4755a68a797`, #24266 landed as `586705d85d`)

## What this does

This is the renderer half of #24266.
`record:details`, `record:highlights` and `record:quick_actions` now
draw their authored `emptyState` when the block's `dataSource` binding
answers no row.

- **Where it is drawn.** `BoundRecordScope` in
`packages/plugin-detail/src/renderers/recordBinding.tsx` reads the
block's `emptyState`. It hands the drawing to `BoundRecordProvider` as
its `absent` slot. That slot was added for `record:related_list`'s
`parentDataSource` and already draws in the block's place when the
settled read has no row. So the condition is the one the spec's describe
states. The block carries a binding and the read settled with no row. A
read refused as forbidden reads as no row today, so the empty state is
drawn for that too, as the spec's describe says.
- **What it does not touch.**
  - While the read is loading, the block shows its loading state.
  - A failed read draws the error panel (objectui#11902 stays as it is).
  - A bound row draws the row.
  - An unbound block never reaches this read, so it renders as before.
- **The drawing.** `BoundRecordEmptyState` uses the same pieces
`ObjectGrid` uses for its own authored `emptyState`:
  - `DataEmptyState`;
  - `resolveIcon` for `icon`;
- the spec's `resolveI18nLabel` for `title` and `message`, against the
display locale;
  - the shared `EmptyStateLink` for `link`.

The only difference is the default heading. When `title` is left out, it
reads `detail.recordNotFound` ("Record not found"), the words
`record:details` already uses. The grid's "No results found" would
describe a table.
- **Published.** One `BOUND_RECORD_EMPTY_STATE_INPUT` (`type: 'object'`)
is added to the `inputs` of all three registrations in
`packages/plugin-detail/src/index.tsx`. The three
`UNPUBLISHED_EXEMPTIONS` booked to this card in
`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` are
struck. Once published, each `emptyState` is a structured input, so the
gate's member-shape census requires a `MEMBER_PINS` row for each of the
three. All three rows point at the new test file. Only these three rows
were edited; the `list-view` rows (objectui#13047) are untouched.

## Defaults: what a block with no `emptyState` draws

Each block keeps today's default, as ruled:
- `record:details` draws "Record not found".
- `record:highlights` draws nothing.
- `record:quick_actions` draws its empty bar. On a runtime view that is
nothing, since objectui#12995 / objectui#13000. On a design or preview
surface it is the dashed placeholder.

`record:highlights` was **not** given a loud default. The spec's own
describe of this key at the pin says "nothing on `record:highlights`" is
the 0-row placeholder. The card's "the default stays the loud renderer
text" reads to me as "do not make the default blank", not "change the
strip". The report raises it as an open question.

## Tests (all at `45225e8700`)

New file:
`packages/plugin-detail/src/renderers/__tests__/recordBinding.emptyState-12932.test.tsx`,
25 tests. It goes through the real registrations and `SchemaRenderer`,
with the key in the `properties` bag.

- **(a)** For each block, 0 rows plus an authored `emptyState` draws the
title and message. A forbidden read draws them too.
- **(b)** For each block, 0 rows without `emptyState` draws the block's
own default, and no empty-state node.
- **(c) Controls**, for each block:
  - a bound row draws the row;
- a failed read draws the `-datasource-error` panel, not the empty
state;
- an unbound block on a record page draws the page's record. With no
record, it draws its own default, and the adapter is never read.
- **Members** (on `record:details`):
  - a missing `title` gives the default heading;
  - a `title` locale map draws the `en` entry;
  - `icon` resolves (`svg.lucide-server`);
  - `link` draws `EmptyStateLink`.

On the first run, the unbound control was red because of a fixture slip:
a default parameter turned `undefined` back into the binding. The red
came from the empty state being drawn on what the test thought was an
unbound block. That shows the control can fail in the direction it
guards.

**Ablation 1: the draw.** `absent` was replaced with
`absent={undefined}` in `BoundRecordScope`, using
`ablation-replace.mjs`. The anchor went from 1 to 0 hits, and the blob
went from `3c8e41896558` to `38121555a8d8`.
- 10 tests went red: all six (a) tests and all four member tests.
- 15 stayed green: (b), (c) and the input pins.
- Restore was proven: the blob equals HEAD (`3c8e41896558`) and `git
diff HEAD` is empty.

**Ablation 2: the publication.** The input's `name` was changed to
`emptyStateAblated`. The blob went from `e6a73373f33d` to
`4ffafd522a8a`.
- 14 tests went red: in the parity gate, the "publishes every top-level
key", "declares no input the spec does not accept" and member-pin tests
for all three blocks, plus the census and the check that every member
pin names a key that is still structured; in the new file, the three
"publishes the `emptyState` input" tests.
- Restore was proven: the blob equals HEAD (`e6a73373f33d`) and `git
diff HEAD` is empty.

## Gates (local, at `45225e8700`)

- `vitest run --maxWorkers=1`, under the shared lock:
  - the new file: 25 of 25 passed;
- the parity test plus the objectui#12294 binding test plus the new
file: 3 files, 307 tests passed;
- `packages/plugin-detail/src/renderers/__tests__/`, the five
`plugin-detail` registry-input pins and the one other test importing the
package entry: 103 files, 1089 tests passed;
- `sdui-intrinsics-compile-11075` and `sdui-manifest-build-11403`: 13
tests passed.
- `@object-ui/plugin-detail` `type-check` (source and
`tsconfig.test.json`, after building the dependency closure): exit 0.
- `@object-ui/console` `type-check`: **NOT MEASURED**. The console's
dependency closure (about 40 packages) is not built in this worktree.
The run failed only on unresolved `@object-ui/*` imports (TS2307 /
TS2882). None of the failures is on an edited line, and those lines are
object-literal data rows. Declared to CI.
- eslint on the four changed source and test files: 0 errors. The
warnings are the files' existing `react-refresh` and `no-explicit-any`
classes.
- The battery all exited 0: `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`,
`check:handler-key-reads`, `check:side-effects-array`,
`check:element-data-source-declaration`, `check:spec-symbols`,
`check:action-forward-parity`, `check:designer-field-key-parity`,
`check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`,
`check-changeset-presence.mjs` and `check:new-line-citations`.
- `check:spec-floors`: exit 1, `no-artifact`. It needs the whole
workspace built. Recorded, not fixed.
- The full `plugin-detail` and `apps/console` directories are declared
to CI.

## Patch round (seat ruling `6271857155`), heads `dad3a01f72` /
`1e38ac6195`

- **Q1, A:** a bound `record:highlights` with 0 rows and no `emptyState`
keeps drawing nothing. The spec describe at the pin names nothing as its
placeholder, so no change.
- **Spec floor:** `@object-ui/plugin-detail` moves `@objectstack/spec`
from `^18.0.0-next.3` to `^18.0.0-next.5`, the first published release
carrying objectstack `586705d85d`.
  - `npm view` shows next.4 was never published.
- `git merge-base --is-ancestor 586705d` gives exit 1 against the
`@objectstack/spec@18.0.0-next.3` tag and exit 0 against `…next.5`.
- The packed tarballs agree: next.5 has 7 hits for
`BOUND_RECORD_BLOCK_EMPTY_STATE_DESCRIPTION`, next.3 has 0.
- The lockfile does not move, because the importer resolves the spec
through the pin override.
- **Authoring type:**
- A tsc probe: `RecordDetailsComponentProps` and
`RecordHighlightsComponentProps` refused a typed `emptyState` (TS2353).
Both now declare `emptyState?: EmptyState`, by reference from
`@objectstack/spec/ui`. The reverse leg `{ heading }` is refused, naming
the spec's member set.
- `@object-ui/types` has no `RecordQuickActionsComponentProps`;
`record:quick_actions` is typed through the zod face, which takes the
spec row by reference and already carries `emptyState`.
  - The changeset gains `'@object-ui/types': minor`.
- **Ledger:** `scripts/check-spec-symbol-derivation.mjs` loses one
`CLAIM_DEBT` line (`RecordHighlightsComponentProps`), regenerated by the
script's own `--claim-ledger`. Its spec-alignment claim is now
structural, and the ratchet requires the debt row to go.
- **Docs:** the `plugin-detail` README (bound record blocks, with an
example) and `content/docs/guide/data-source.md` (the 0-row paragraph)
document `emptyState`: when it is drawn, its members, the default title
and the per-block defaults.
- **Gates:**
  - `packages/types` full directory: 388 files, 10551 tests.
  - Targeted: 24 files, 756 tests.
  - The script's own suites: 12 files, 343 tests.
  - Build and type-check of `types` and `plugin-detail`: exit 0.
  - `check:spec-symbols`: 0 after the burn-down.
- Scoped `check:spec-floors`: plugin-detail 0 findings. For types, 2
pre-existing `floor-too-low` findings, filed separately by the seat.
- `check:doc-snippets` / `check:doc-examples`: NOT MEASURED
(precondition: the doc-imported packages are not built); declared to CI.

## Acceptance notes

- **Spec describe.** The spec's
`BOUND_RECORD_BLOCK_EMPTY_STATE_DESCRIPTION` still opens with its
"PLANNED, not yet rendered" marker. The seat hands off that edit and the
`liveness/page.json` note to objectstack's `domain:spec` seat at
landing.

---------

Co-authored-by: Claude <noreply@anthropic.com>
…es, not at the drop (objectui#13054) (#13066)

Fixes #13054
Clause-②: no

## What changed

A kanban card dropped on another column used to be announced as "Moved
CARD to COLUMN." at the drop, before the write answered. When the server
refused the move, a screen-reader user still heard that the card had
moved. This PR moves the announcement to when the write settles.

- **`kanbanDndAnnouncements.ts`:** `onDragEnd` returns nothing for a
cross-column drop. dnd-kit allows that and leaves its region as it was.
Same-column drops and cancels keep their sentences.
- **`ObjectKanban.tsx`:** `persistCardMove` now returns what the write
answered. On success it returns `moved`. On refusal it returns `refused`
with the exact string it passes to `toast.error`, computed once and used
for both. `handleCardMove` returns that outcome, or `deferred` on the
`requiredWhen` prompt path, where nothing is written at the drop. The
no-write early returns (no `groupBy`, no `update`, the uncolumned
sentinel) return `moved`, so those boards keep announcing as before.
- **`KanbanImpl.tsx`:** the board reads what `onCardMove` returns and
writes the settle sentence into **one board-owned, visually hidden
status region**: `role=status`, `aria-live=assertive` (matching
`DndLiveRegion`), `aria-atomic`. Accepted gives the existing
`kanban.dnd.moved` sentence. Refused gives the toast's sentence word for
word. Deferred gives nothing, because the dialog and its toast carry
that move and the dialog is not routed through the board. A mover that
returns nothing, and a board with no mover (local data), announces moved
at once, as today. Each drop clears the region, and only the latest
drop's answer may fill it.

### Why a board-owned region, not dnd-kit's

dnd-kit 6.3.1 fills its `LiveRegion` only from its own `useAnnouncement`
inside `Accessibility`, on its drag events
(`announce(announcements.onDragEnd(...))`). Nothing outside it can write
that region after the drop.

### Why "nothing" at the drop, not the `kanban.dnd.dropped` sentence

The ruling allows "the existing `kanban.dnd.dropped` shape, or nothing".
The existing sentence is "Dropped CARD. It stays in COLUMN." and names
the card's home column. For a cross-column drop that the server goes on
to accept, that sentence is false. Saying nothing keeps the drop neutral
without a new pack key, so no language pack changed.

### Types and exports

`KanbanBoardProps.onCardMove` widens to `void` or a promise of
`KanbanCardMoveOutcome` (or void); angle brackets are spelled out
because GitHub strips tag-shaped text. `KanbanCardMoveOutcome` is a
module-private type, and `ObjectKanban` derives its copy from the prop.
I measured whether this is on the package entry: `KanbanBoardProps` is
not exported from `src/index.tsx`, whose type exports are `KanbanCard`,
`KanbanColumn`, `CardTemplate`, `InlineFieldDefinition`,
`ObjectKanbanComponentProps`, the quick-add, card-template, swimlane and
reorder hook types, and `KanbanRendererProps`. The published
`KanbanRendererProps.onCardMove` is untouched and still `void`. The
outcome crosses that hop at runtime, because a promise-returning
function is assignable to a void return. No new export. Clause-② stays
no.

## Pins

New `src/__tests__/moveOutcomeAnnouncement-13054.test.tsx` drives the
real `ObjectKanban`, board, `DndContext` and sensors with pointer
events, using the 12716 test geometry. It records every text either live
region holds over the whole drag.

- **(a) Refused:** the data source rejects with "Invalid task status
transition.". No region ever holds a sentence starting "Moved". The
board region ends with that sentence, and it equals the `toast.error`
argument. The card reverts to Backlog.
- **(b) Accepted:** the write is held open. After the drop, the board
region is empty and nothing has said "Moved". After the write resolves,
the board region ends with "Moved Fix the widget to In Progress.".
- **(c) Controls:** a same-column drop and a cancel keep today's dnd-kit
sentences, write nothing, and leave the board region empty. **The
keyboard leg is n/a.** The board builds no `KeyboardSensor`
(`dndInstructions-12837.test.tsx` pins it absent), so no keyboard move
path exists. Seat ruling Q2.
- Also: a `deferred` outcome announces nothing, and a board with no
mover announces moved at once.

Existing tests adjusted:
- `dndAnnouncements-12716.test.tsx`: its two cross-column drops now read
the board region, and dnd-kit's region still holds the last "Moving …
over …" sentence. Its mover no longer returns `push`'s number.
- `ariaLabelTemplates-12838.test.tsx`: its overlay selector excludes the
new assertive region by id, just as it already excluded `DndLiveRegion`.

### Ablations (each restored, proven by blob hash)

Both ran through `ablation-replace.mjs` in wrap mode on the new test
file.

1. **Restore the synchronous moved sentence** in `onDragEnd`
(`kanbanDndAnnouncements.ts`): the mutation landed with blob
aa93008 to b20824375e95. Result: 3 failed and 2 passed. Pin (a)
fails with the received region texts `["Moved Fix the widget to In
Progress.", ...]`, against an expected `[]`. Restored: the blob equals
HEAD (aa93008) and `git diff HEAD` is empty.
2. **Report a refusal as moved** in `persistCardMove`
(`ObjectKanban.tsx`, `return MOVED;` in place of the refused outcome):
blob 045b5ee to 2668ad9e8654. Result: 1 failed and 4 passed, and
the failure is pin (a). Restored: the blob equals HEAD (045b5ee)
and `git diff HEAD` is empty.

The test imports `../KanbanImpl` and `../ObjectKanban` by relative path,
so it runs against source. No dist was involved.

## Verification (at 7a8ed2ed4e)

| Check | Result |
|---|---|
| `@object-ui/plugin-kanban` `type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`), after building the dependency closure with turbo
(12/12) | exit 0 |
| Targeted vitest: the new file, 12716, 12837, 12838,
rejectedMoveRollback, 8826, 7804, markedRefusalToast,
requiredWhenPrompt, moveGrant-12082, plugin-form
affordanceGrantMap-12082 | green after the 12838 selector fix |
| `pnpm exec vitest run --maxWorkers=1 packages/plugin-kanban/` under
the verify lock | 78 files and 525 tests passed, exit 0 |
| `pnpm lint`, narrowed to the 6 changed `.ts`/`.tsx` files with `eslint
--no-inline-config --format json` | 6 files, 0 errors; warnings per file
equal to the base, 0 new |
| Pre-tsc battery: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`; plus `check-hand-rolled-comment-mask`,
`check-changeset-fixed`, `check-changeset-presence`,
`check:new-line-citations` | all exit 0 |

The eslint config enables no type-aware linting, so this diff cannot
change lint verdicts on untouched files. The whole package directory and
the rest of the farm are left to CI.

## Acceptance notes

- `useCrossSwimlaneMove`'s `onCardMove` is a different channel,
cross-swimlane, and is untouched.
- Changeset: `.changeset/13054-kanban-move-announcement.md`,
`@object-ui/plugin-kanban` patch.

Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

## Patch round (head `4bbb0aeec6`, after merging `origin/main` as
`21f7b8b671`)

- **Census adjusted:**
`examples/schema-catalog/test/kanban-column-cards-6939.test.tsx`. CI on
`7a8ed2e` was red because the census gained exactly one empty `DIV`:
this PR's move status region, measured three ways.
- The old readings are kept as `*_PRE_13054` (SUPERSEDED); the new
literals are 69/91 and 45/58.
- A delta case asserts +1 `DIV` with unchanged text and hash, and
measures that the node is the region.
- The 6939 property (the repair moved the validator, not the renderer)
still holds.
- **Sweep:** every kanban node-count, snapshot and live-region selector
test across `examples/**`, `packages/**` and `apps/**`: 9 files, 645
tests passed. No other file needed a change.
- **Verification:** the census file passes 19/19. `eslint` 0/0,
`check:control-bytes`, `check:new-line-citations` and the changeset
checks all exit 0.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…he composer, not over it (objectui#12978) (#13067)

Fixes #12978

Clause-②: yes

## What changed

On the full-page AI build chat (`/ai/build`), with a pending draft,
`ChatPane` floated `PendingDraftsBar` as a sibling of the chat column
(`pointer-events-none absolute bottom-20`). That box sat on the
composer's first input line, so the placeholder and the first typed line
were hidden. The bar is now a row of the composer's own area, laid out
in flow directly above the input: the conversation gives up the room
instead, at every width.

- `@object-ui/plugin-chatbot`: `ChatbotEnhanced` gains one optional
prop, `promptHeaderSlot` (a `ReactNode`). It is rendered in flow at the
top of the prompt area, above the input and outside its form, in a
wrapper with `empty:hidden`, so content that renders nothing takes no
space. A host that does not pass it renders exactly as before. README
section added under "Surface chrome".
- `@object-ui/app-shell`: `ChatPane` hands the bar to `promptHeaderSlot`
instead of floating it. Same condition as before (`isBuildSurface &&
!onBoundPackageStudio`), so the package's own Studio still draws no bar
(objectui#12965 stands).
- The console dock mounts the same `ChatPane`, so it gets the same row;
`layout/ChatDock.tsx` is not edited.

**Public face (Clause-②):** the delivered diff widens
`ChatbotEnhancedProps` by the one optional prop `promptHeaderSlot`. No
export is added or removed and no existing prop changes. The changeset
bumps `@object-ui/plugin-chatbot` minor and `@object-ui/app-shell`
patch. The prop is not reachable from schema metadata: the
`chatbot-enhanced` registration passes the node's props through
`toDomProps` and names its schema keys one by one.

## Why a new prop: the two existing slots, measured

- `headerSlot` is in flow but sits above the conversation, at the top of
the column, not by the composer.
- `promptOverlaySlot` is `absolute bottom-full` (meant for slash-command
palettes). Handing the bar to it (a one-off edit, restored) clears the
input, but in Chromium its box covers the bottom 54 px of the
conversation's viewport (bar against the `role="log"` box: 648 by 54 at
1280), where the last message's tail, its action row and the
scroll-to-bottom row sit. The in-flow row reads 0 there.

There was no in-flow place between the conversation and the input, so I
added the narrowest one, as the dispatch allowed.

## Measured boxes (Acceptance 2)

Chromium headless, zh-CN, 900 px high (390 x 844 for the phone). The
REAL full-page `AiChatPage` and the REAL `ChatDockPanel` with its real
default body, served by the worktree's console Vite dev server, with
`fetch` stubbed in the page: one `build` agent, two turns, and one draft
for the bound package in `GET /api/v1/meta/_drafts`. "Before" puts the
two edited files back to `main` at `50ba1bb`; "after" is the fix. Boxes
are left top right bottom, in CSS px.

**Control:** `before-fullpage-1280` reproduces the card's live reading
box for box (bar 401 766 1169 820, textarea 450 769 1120 833, 670 by 51,
no first-line point on the input). So this harness puts the bar and the
input exactly where the real page did.

| reading | bar | textarea | intersection (x by y, area) | first-line
points on the input |
| --- | --- | --- | --- | --- |
| full page 1280, before | 401 766 1169 820 | 450 769 1120 833 | 670 by
51, 34170 | 0 of 5 |
| full page 1280, after | 449 706 1121 760 | 450 769 1120 833 | 670 by
0, **0** | 5 of 5 |
| full page 1010, before | 305 766 994 820 | 315 769 985 833 | 670 by
51, 34170 | 0 of 5 |
| full page 1010, after | 314 706 986 760 | 315 769 985 833 | 670 by 0,
**0** | 5 of 5 |
| dock 1280, before | 877 766 1264 820 | 862 769 1279 833 | 387 by 51,
19737 | 0 of 5 |
| dock 1280, after | 861 706 1280 760 | 862 769 1279 833 | 417 by 0,
**0** | 5 of 5 |
| dock 1010, before | 607 766 994 820 | 592 769 1009 833 | 387 by 51,
19737 | 0 of 5 |
| dock 1010, after | 591 706 1010 760 | 592 769 1009 833 | 417 by 0,
**0** | 5 of 5 |
| phone 390, before | 16 710 374 764 | 17 713 373 777 | 356 by 51, 18156
| 0 of 5 |
| phone 390, after | 16 650 374 704 | 17 713 373 777 | 356 by 0, **0** |
5 of 5 |

After the fix, the placeholder 「问问数据,或让我改改这个应用…」 (208 px) fits whole in
its 646 px (full page) and 393 px (dock) content width, with the bar 9
px above the input. The textarea's own box does not move; the
conversation's bottom moves from 768 to 706. With no draft, the textarea
box and the conversation box are identical before and after (bottom
768): the empty slot takes no space. A 768 px reading is clean as well.

After, full page 1280 and 1010:

![after, full page,
1280](https://github.com/objectstack-ai/objectui/raw/baab26059420ee8386ed77f0aca664f8ab7e08df/evidence/12978/after-fullpage-1280.png)
![after, full page,
1010](https://github.com/objectstack-ai/objectui/raw/baab26059420ee8386ed77f0aca664f8ab7e08df/evidence/12978/after-fullpage-1010.png)

After, console dock 1280 and 1010:

![after, dock,
1280](https://github.com/objectstack-ai/objectui/raw/baab26059420ee8386ed77f0aca664f8ab7e08df/evidence/12978/after-dock-1280.png)
![after, dock,
1010](https://github.com/objectstack-ai/objectui/raw/baab26059420ee8386ed77f0aca664f8ab7e08df/evidence/12978/after-dock-1010.png)

**Where the evidence lives:** commit `baab260` of this branch, directory
`evidence/12978/`. It holds every screenshot (before and after), each
reading as JSON, the rig's README, the throwaway harness page and the
Playwright driver. The next commit, `b24726f`, deletes the directory, so
this pull request's diff does not carry it (`git rev-parse
8d6c70a^{tree} b24726f^{tree}` prints the same tree twice). No other
branch was created.

## Tests (Acceptance 1 and 3)

-
`packages/app-shell/src/console/ai/__tests__/AiChatPage.draftsBarInFlow-12978.test.tsx`
(new). It uses the real full-page `AiChatPage`, the real `ChatDockPanel`
default body (the dock resolves the `build` agent itself), and the real
`ChatPane`, `ChatbotEnhanced` and `PendingDraftsBar`, with one draft
pending. happy-dom runs no layout, so comparing boxes there would pass
whatever the layout was. The test reads the structure that decides the
boxes instead: (1) the nearest element holding both the bar and the
input does not hold the conversation (`role="log"`); (2) nothing between
the bar and that element, or between the input and it, is out of flow
(`absolute`, `fixed` or `sticky`, by class or inline style); (3) the
bar's row comes before the input's row; (4) the bar is outside the
input's form. Both cases (full page, dock) pass. The boxes themselves
are the browser table above.
-
`packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.promptHeaderSlot-12978.test.tsx`
(new) pins the prop's own contract: the same four readings, plus no row
without the prop and no leak onto the DOM as an attribute.
- `AiChatPage.studioDraftsBar-12965.test.tsx`: its `ChatbotEnhanced`
stub now draws `promptHeaderSlot`, so its Studio-absent and
full-page/dock-present readings keep reading the bar.
- **Ablation:** I committed the fix, then put `AiChatPage.tsx` back to
`50ba1bb` with the slot prop kept (trap-guarded). The
`promptHeaderSlot=` anchor went from 1 to 0 on disk, and the new test
went red in both cases (`Tests 2 failed (2)`), on exactly the old
geometry: `holdsConversation: true` and `outOfFlow: ["div absolute"]`.
The restore was proved by blob hash against HEAD and by `git diff HEAD`
reading 0 lines.
- **Type face, reverse check:** the README snippet compiles `--strict`
against the rebuilt `dist` typings (exit 0). A misspelled
`promptHeaderSlotX` is refused with TS2322, and the compiler suggests
`promptHeaderSlot`, so the rebuilt `.d.ts` was the one read.
- `pnpm --filter @object-ui/app-shell test`: `Test Files 1593 passed | 1
skipped (1594)`, `Tests 14897 passed | 9 skipped (14906)`, exit 0 (the
AbortError traces in its stderr are happy-dom iframe teardown, not
failures). `pnpm --filter @object-ui/plugin-chatbot test`: `Test Files
79 passed (79)`, `Tests 772 passed (772)`, exit 0. Both ran on tree
`987ea4f` (commit `8d6c70a`), which is also HEAD `b24726f`'s tree.

## Gates (local subset; the farm is CI's)

Each exit code was captured before any pipe. On `@object-ui/app-shell`
and `@object-ui/plugin-chatbot`: `lint` 0 (warnings only, none in the
changed files), `type-check` 0 (`tsconfig.test.json --listFiles`
includes both new test files), `test` 0. Repo checks:
`check:new-line-citations` 0 (`0 new citation(s)`),
`check:control-bytes` 0, `check:vi-mock-specifiers` / `-inherit` /
`-override-shape` 0, `check:test-path-roots` 0,
`check:pending-changeset-literals` 0, `check:changeset-claims` 0,
`check-changeset-presence.mjs` 0, and `check-changeset-no-major.mjs` 0.

Not fully measured locally:
- `check:readme-exports` exits 1 on a precondition: seven packages are
unbuilt here (app-shell, cli, plugin-ai, plugin-gantt, plugin-map,
plugin-markdown, plugin-timeline), and none of them is this README.
`--list` judges the new snippet's self-imports `real`.
- `check:doc-snippets` was not run: it needs the whole doc build
closure, so CI runs it. The new snippet was compiled by hand, as above.
- No i18n key or user-facing string is touched.

## Acceptance notes

- **Out of scope, reported for the seat: the phone preview pill covers
the input.** At 390 x 844, after previewing a built app and closing the
preview, `ai-chat-mobile-preview-open` (`absolute inset-x-0 bottom-24`,
box 144 712 246 748) covers the textarea by 101 by 35, and the middle
first-line point hits the pill. That reading is the same before and
after this change. Before, the pill also sat on the bar (101 by 36);
after, it does not touch the bar. Not changed here. Screenshots:
`before-fullpage-390-pill.png` and `after-fullpage-390-pill.png` on
`baab260`.
- `promptOverlaySlot` has no consumer anywhere in this repository; noted
only.
- The dock readings use a stand-in console frame around the real
`ChatDockPanel` (a 56 px header and a main area). The composer's
geometry is the dock body's own, but the frame is not `ConsoleLayout`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…idden, header and members (objectui#13055) (#13069)

Fixes #13055
Clause-②: yes

## What

A field group whose `visibleWhen` cannot be evaluated is now HIDDEN on
the form and on `record:details`, header and members both. Evaluation
fails on a parse error, an unknown field or root, or an evaluation
error. `@objectstack/spec` declares this slot fail-closed:
`ObjectFieldGroupSchema.visibleWhen` says "else hidden (fail-closed)".
Before this change both surfaces failed open. The one-time diagnostic
stays and now names the default taken: `safe default (false)` and "the
group is hidden".

Only this origin changes. These all keep today's fail-open:

- an authored form-view `FormSection.visibleWhen` (declared to fault
open),
- a field's own `visibleWhen` / `readonlyWhen` / `requiredWhen`
(ADR-0137 D3),
- every other predicate slot.

There is no global fallback flip.

## How: the fault direction travels with the predicate (seat ruling
6272824447, Q1 = A)

On the form, a field group's predicate and a form-view section's own
predicate both land in one slot: a `section-divider` row's (or a tab's)
`visibleWhen`. The renderer cannot honour both directions from the slot
alone, so the direction rides with the predicate.

- **One key, a closed enum: `visibleWhenFault?: 'hidden' | 'shown'`.**
Absent means today's fail-open.
- It is declared on the `@object-ui/types` row types that carry it:
`ObjectFormSection`, `FormField` (`section-divider` rows only, ignored
elsewhere) and `FormFieldTab`.
- It is mirrored on the zod faces (`FormFieldSchema`, the module-private
`FormFieldTabEntrySchema`).
- Why this name: it sits beside the slot it qualifies, `visibleWhen`,
and uses the tree's existing fault vocabulary (`faults.visibleWhen`,
`FieldRuleFaults`, `onFault`). Its values name the outcome ('hidden' /
'shown') rather than a boolean, so a reader never has to ask what `true`
means.
- It is a runtime hand-off, not an authoring key. The spec's strict
`FormSectionSchema` refuses it in authored metadata.
- **Stamp.** `deriveFieldGroupSections`
(`plugin-form/src/fieldGroups.ts`) writes `'hidden'` beside every group
predicate it copies. Both routes a group reaches the form pass through
it: the no-sections `fieldGroups` fallback, and an authored `{ group }`
reference via `resolveSectionGroupReferences`.
- **Carry.** The key is copied beside `visibleWhen` at every key-by-key
rebuild:
- `ObjectForm` x5 (tabbed, split, drawer and modal maps, plus the simple
grouped arm),
- `ModalForm` (the group map, the tab copy, the stacked and derived
projections),
  - `DrawerForm` x2,
  - `SplitForm`,
  - `TabbedForm` (the tab group, the degraded gate row, the tab copy),
  - the console `FormPage` section rebuild.

`projectSectionDivider` copies it onto both row shapes and omits it when
absent.
- **Read.** In `@object-ui/components` `renderers/form/form.tsx`,
`isFailClosedSectionGate` takes a stamped divider's predicate off the
triad call, and `sectionGateVisible` evaluates it with fallback `false`.
This happens at every site that judges a divider:
  - `renderFormField`,
  - `hiddenSectionFieldNames` (the claimed members),
  - `conditionallyHiddenFieldNames`,
  - `readonlyFieldNames`, which only drops it from the triad.

The warning is deduped per predicate text, first site wins, so every
site uses the fail-closed locator. `hiddenFieldTabKeys` takes
`!failClosed` as the tab's fallback. The console `FormPage`'s
`isSectionVisible` does the same for its own section gate.
- **Detail page.** `record-details.tsx` `sectionPredicateVisible` now
calls `evalFieldPredicate(..., false, ...)`. It only ever judges `{
group }` references, so there is no fail-open origin to tell apart
there.

There is no pane gate to change: `FormFieldPane` declares no predicate
slot. The split layout gates through the same `section-divider` rows
inside each pane, which carry the key.

## Q2 = A: fault-hidden members behave exactly like FALSE-hidden members

Their values still submit and their client-side validation is skipped,
per the 2026-08-27 maintainer ruling cited in `form.tsx`
(objectui#6236). The hide goes through the same
`hiddenSectionFieldNames` set a FALSE verdict uses.
`section-gate-fail-closed-13055` pins the equivalence, running the same
two assertions over a FALSE-hidden group and a FAULT-hidden group.

## Pins

- **(a)**
`packages/components/src/renderers/form/__tests__/section-gate-fail-closed-13055.test.tsx`:
- parse fault (`record.kind ==`) and unknown field
(`record.no_such_field == 'x'`) hide heading and members;
- the diagnostic contains `safe default (false)` and "the group is
hidden";
  - a stamped tab is hidden: trigger, panel and members.
- **(a)**
`packages/plugin-form/src/__tests__/fieldGroupFaultClosed-13055.test.tsx`,
end to end through `ObjectForm`:
  - the fieldGroups fallback;
- a `{ group }` reference on the simple, tabbed, split, drawer and modal
layouts.

  Both faults hide the group on each.
- **(a)** `record:details`:
`record-details.groupVisibleWhen-11630.test.tsx` now asserts fail-closed
on both surfaces for a bare unbound identifier, a parse error and an
unknown field (new row). `record-highlights.groupGate-12720.test.tsx`'s
fault leg now reads zero members on the page.
- **(b) controls:**
- a well-formed `record.kind == 'pro'` shows on `pro` and hides on
`basic`, on every layout and on the renderer;
- an unstamped divider or tab (a form-view section's own predicate) that
faults still shows, with `safe default (true)`;
  - a divider stamped `'shown'` still shows;
- an authored form-view section's faulting `visibleWhen` still shows on
all five layouts;
- a faulting FIELD-level `visibleWhen` still shows its field, even with
the key on it.
- **(c) enumeration pin:**
`packages/plugin-form/src/__tests__/sectionFaultCarry-13055.test.ts`.
- It scans every non-test source of `plugin-form` plus the console
`FormPage.tsx`, comments masked with `scripts/js-comment-mask.mjs`, for
`visibleWhen: RECV.visibleWhen,` copies.
- Every section-receiver copy must be followed by `visibleWhenFault:
RECV.visibleWhenFault,`.
  - An unclassified receiver fails loudly.
  - Per-file site counts keep the scanner live.
  - It also pins the stamp and the projection behaviourally.

## Ablations (each mutation through `scripts/ablation-replace.mjs`,
which proves the anchor landed and restores by blob hash)

1. **Drop the stamp** (`fieldGroups.ts`), running
`fieldGroupFaultClosed-13055` + `sectionFaultCarry-13055`: 7 failed and
11 passed. Every (a) leg went red (fallback plus the five `{ group }`
layouts) and so did the stamp pin. Every (b) control, the enumeration
and the projection pins stayed green. Restore: blob `a58736697288` ==
HEAD, `git diff HEAD` empty.
2. **Drop the key from one rebuild arm** (`SplitForm.tsx`), running
`sectionFaultCarry-13055`: 1 failed and 5 passed. The red line was the
carry pin, naming `packages/plugin-form/src/SplitForm.tsx` and its next
line. Restore: blob `e0568fd88907` == HEAD, diff empty.
3. **record:details back to fail-open** (`record-details.tsx`, fallback
`false` to `true`), running the 11630 and 12720 suites: 4 failed and 12
passed. The three fault rows and the 12720 fault leg went red; the
well-formed and control rows stayed green. Restore: blob `f9f875acbd5a`
== HEAD, diff empty.
4. **Renderer gate back to fail-open** (`form.tsx` `sectionGateVisible`,
fallback `false` to `true`), running `section-gate-fail-closed-13055`: 4
failed and 9 passed. Red: the two (a) divider legs and the two Q2 FAULT
legs. Green: every control and the Q2 FALSE legs. The stamped-tab leg
also stayed green, because the tab gate is a separate fallback
(`!failClosed` in `hiddenFieldTabKeys`), not `sectionGateVisible`.

## Local verification (final head `dd2154e753`)

**Build and type-check**
- Closure build: `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-form^...' --filter '@object-ui/plugin-detail^...'
--filter '@object-ui/console^...' build`, exit 0.
- Type-check, exit 0 for each:
  - `@object-ui/types`,
- `@object-ui/components`, `@object-ui/plugin-form`,
`@object-ui/plugin-detail` and `@object-ui/console` (four tsc runs, one
invocation).

**Targeted tests at `dd2154e753`** (`vitest --maxWorkers=1`): 7 files,
63 passed. The 7 are the new tests plus the edited 11630, 12720,
`object-form-section-style-keys-undeclared` and
`form-field-zod-coverage`.

**Families** (`--maxWorkers=1`), with results:

| Family | Files | Tests |
| --- | --- | --- |
| `packages/components/src/renderers/form/` | 75 passed | 529 passed, 17
skipped |
| `plugin-form` tests naming visibleWhen / section / fieldGroups | 104
passed | 1603 passed, 1 skipped |
| `plugin-detail` `record-details*` + 12720 | 22 passed | 168 passed |
| console `FormPage*` | 29 passed | 281 passed |
| `packages/types/src/__tests__/` | 388 passed | 10551 passed |

The `types` directory run is the one that caught the
`form-field-zod-coverage` census, fixed in `dd2154e753`. The full
`plugin-form`, `components`, `plugin-detail` and `console` directories
are declared to CI, not run here.

**Lint, narrowed to the changed files** (`pnpm exec eslint --format
json`, 19 files by the json count): 0 errors, 269 warnings, exit 0.
- Population: the root `eslint.config.js` is the only config. No touched
package has its own; each package's `lint` is `eslint .` under it.
- Invariance: type-aware linting is not enabled in that config (no
`projectService` / `parserOptions.project`), so this diff cannot move
the verdict of any untouched file.

**Gate battery**, exit 0 for each:
- `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`
- `check:handler-key-reads`, `check:side-effects-array`,
`check:element-data-source-declaration`
- `check:spec-symbols`, `check:action-forward-parity`,
`check:designer-field-key-parity`
- `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-designer-parity`, `check:control-bytes`
- `check:component-surface-parity`, `check:new-line-citations`,
`check:test-path-roots`
- `check:changeset-claims`, `check:pending-changeset-literals`,
`check:doc-types`, `check:vi-mock-specifiers`
- `node scripts/check-hand-rolled-comment-mask.mjs`
- `node scripts/check-changeset-fixed.mjs`, `node
scripts/check-changeset-presence.mjs`, `node
scripts/check-changeset-no-major.mjs`

`check:spec-floors` exits 1. It prints its `no-artifact` remedy (it
wants a whole-workspace build) and is recorded, not fixed, per the
dispatch.

## Acceptance notes

- **Not this card:** authoring still accepts a syntactically broken
group `visibleWhen` (`record.kind ==`) with a 200. A parse-time check on
this slot is a separate authoring-side question.
- `content/docs/plugins/plugin-form.mdx` "Conditional tabs" says a tab
predicate "fails open".
- The `plugin-form` README rule table says "Fail open" for the triad
(still true for the triad).

A tab built from a field group now fails closed. The docs follow-up can
be a separate docs edit.
- `app-shell`'s metadata-admin `SchemaForm` calls
`resolveSectionGroupReferences` but resolves `{ group }` against no
object field groups (its own header: such a section resolves empty). It
never carries a group predicate, so it is untouched.
- **Pre-existing, unchanged:** the split layout draws (and so gates) a
section's row only when it has a `label` or `description`. A headingless
field group on `formType: 'split'` therefore has no gate at all,
fail-open or fail-closed. This is noted in the docs' "Section rows"
paragraph.
- **Sentence for the seat's contract review:** a key with these
semantics invites an author to write it by hand on a form-view section.
The strict spec face refuses it, while the `@object-ui/types` zod mirror
now accepts it, the supported route `zod-mirror-parity` allows for a
declared key.

`Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

## Docs patch round (head `015326cf23`)

- **Docs updated in `699ac1e640`** (claim surface amended to include
them). `content/docs/plugins/plugin-form.mdx` ("Conditional tabs") and
`content/docs/guide/metadata-diagnostics.md` (the runtime-fault
paragraph) both now state:
- a tab or section built from an object field group fails CLOSED, per
`ObjectFieldGroupSchema.visibleWhen`;
- a form-view section or tab predicate, and a field's own `visibleWhen`,
keep failing open.
- **Merged `origin/main`** (3 commits, no conflicts).
- **Verification:** targeted tests 9 files, 78 passed.
`check:doc-types`, `check:doc-snippets`, `check:doc-examples`,
`check:doc-json`, `check:doc-fences`, `check:control-bytes` and
`check:new-line-citations` all exit 0 (the snippet, example and JSON
gates after the scoped build each names).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
… not a blank create form (#13073)

Fixes #13063
Clause-②: yes

## What was wrong

A `type: 'form'` action placed at `record_related` and fired from a
related-list row ("Log Time" on a project's Tasks list) opened the
declared form as a blank CREATE form for the child object.
`RelatedRecordActionsBridge`'s `runRowAction` retargeted the row action
through the DEFINITION only (`objectName`, `recordId`,
`params._rowRecord`) and ran it on the page's shared runner.
`executeForm` reads the record and its object from the runner context
only, and that context names the parent page. It ignores
`action.objectName` on purpose (objectui#4292). So
`crossesObjectBoundary('task.edit', 'project')` answered true and the
runner navigated to the bare `/forms/NAME`.

## What changed

- **`@object-ui/core` `ActionRunner.execute(action, callContext?)`.**
The runner had no per-call context path: `execute` took the action only,
and the context changed only through `updateContext`, which mutates the
shared runner. So this adds an optional second parameter to the existing
method (no new export). With `callContext`, the dispatch runs on a
one-shot runner over `{ ...context, ...callContext }`. That runner
shares the original's handlers, scripts, confirm, toast, modal,
navigation, param-collection and result-dialog handlers, and its
translator. The runner's own context is not touched. Two keys follow the
merge:
- `ctx`, the `ActionProvider` mirror, so `${ctx.record.…}` reads the
same record as `${record.…}`;
- `objectFields` / `objectActions`. When `callContext` names another
object and brings none of its own, the base pair is dropped, so the page
object's definitions never answer for the child's record. That is what a
retargeted action already got from `readContextObjectFields` /
`readContextObjectActions`, which answer only for the context's own
`objectName`.
- **`@object-ui/app-shell` `RelatedRecordActionsBridge`.** A row
dispatch hands the clicked row as its execution context, `{ record: row,
objectName: CHILD }`, through `useAction().runner.execute(def,
rowContext)`. The definition keeps `objectName`, `recordId` and
`_rowRecord`, because the `api` / `flow` / `script` handlers and the
update path read them. A toolbar dispatch (no row) keeps the page
context exactly as before.
- `executeForm` is unchanged and still ignores `action.objectName`
(objectui#4292).

## Other row-action types, measured by reading the dispatch path on
`origin/main` 083e807

| type | where it reads the row and the object | after this change |
|---|---|---|
| `update` (`operation: 'update'`) | `action.objectName`, then the
context. Row from `params._rowRecord`. Field map and declared `patch`
answer only for the context's object | same row and object. Field map
and declarations are `undefined`, as before (dropped instead of
mismatched) |
| `api` | registered handler (record page and console runtime):
`_rowRecord`, `action.objectName`, `action.recordId` from the definition
| unchanged. Pinned: the clicked row on the definition, and the row in
the handler's context argument |
| `flow` / `script` | registered handler: `resolveServerActionRecordId`
reads `params.recordId`, then `_rowRecord`, before any context. Object
is `action.objectName` first | unchanged |
| `url` / `navigation` | builtin. Target templates evaluate against the
runner context (`${record.…}`, `${ctx.recordId}`) | **now the row**.
Before, a row's `url` template read the PARENT record. This is the
ruling's one sanctioned change: a `record_related` row action sees the
row as its context |
| `form` | builtin `executeForm`: context only | **the defect, now the
row** |

## Pins

New
`packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.rowFormContext-13063.test.tsx`.
It mounts the real `ActionProvider` with the record page's context
shape, the real bridge, the `record:related_list` renderer and the
runner's own `form` executor.
- (a) "Log Time" from the row navigates to
`/forms/task.edit?recordId=t1&recordObject=task`.
- (b) controls:
  - the same action from the child's record page gives the same URL;
- a page-level form action on the parent still opens
`?recordId=P-1&recordObject=project`, after a row dispatch too, so no
context leaks onto the shared runner. A page-level action at a child
view still stays a create;
- the `api` row action (`archive`) reaches its handler with `objectName:
task`, `recordId: t1`, `_rowRecord: row` on the definition.

New
`packages/core/src/actions/__tests__/ActionRunner.callContext-13063.test.ts`
covers:
- the merge, and that it is one-shot;
- shared handlers and confirm;
- the identity alias surviving;
- the `ctx` mirror following the merge;
- the object-definition pair being dropped for another object and kept
for the same one.

## Ablation: the row back on the definition only

`node scripts/ablation-replace.mjs` (objectstack checkout) replaced
`runner.execute(def as ActionDef, rowContext)` with `runner.execute(def
as ActionDef)` in the bridge. Result: anchor x1 to x0, blob
`84152195036a` to `c1ce37eefa57`.
- The pin file went `Tests 2 failed | 2 passed (4)`.
- (a) failed with `Received "/forms/task.edit"`, the defect's bare
create URL.
- The `api` control failed only on its context assertion (`objectName:
"project"`, `record.id: "P-1"`); its definition assertions held.
- The two other controls stayed green.
- Restore: `blob after restore 8415219… == blob at HEAD
8415219…`, and `git diff HEAD` was empty.

## Verification (head 3dafcd4657)

- Union vitest (`--maxWorkers=1`): 52 files, `Test Files 52 passed (52)`
/ `Tests 604 passed (604)`. The set is the two new pins, every test file
naming `RelatedRecordActionsBridge`, every `ActionRunner*.test.ts` in
core, and `packages/react/src/context/__tests__`.
- Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit
0. Then `pnpm --filter @object-ui/core type-check && pnpm --filter
@object-ui/app-shell type-check`: exit 0.
- `eslint --no-inline-config` over the 4 changed TS/TSX files: 0 errors.
Warning counts equal base on both source files (core 12, app-shell 13).
Type-aware linting is off (`--print-config` shows no
`parserOptions.project` / `projectService`), so these edits cannot move
any verdict on a file they do not touch.
- Lock-free battery, every one exit 0: `check:phantom-deps`,
`self-import`, `unreferenced-sources`, `doc-example-readers`,
`handler-key-reads`, `side-effects-array`,
`element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `new-line-citations`, `check-hand-rolled-comment-mask`,
`check-changeset-fixed`, `check-changeset-presence`.
- `check:spec-floors`: exit 1, `no-artifact`. It needs a whole-workspace
build; recorded, not fixed.
- Declared to CI: the full `packages/core/` and `packages/app-shell/`
test directories.

## Acceptance notes

- The bridge calls `useAction().runner.execute` rather than the
provider's `execute`, because the provider's wrapper takes one argument
and `@object-ui/react` is outside this card's file surface. The
wrapper's `loading` / `error` / `result` state is not set for
related-row dispatches. Measured: no `useAction()` consumer in
`packages/` or `apps/` reads those three fields. An optional second
argument on `ActionProvider`'s `execute` would let the bridge go back
through the wrapper. Carrier: none.
- `url` row templates now bind the row (table above). It is the
sanctioned change, so no separate pin was added; the `form` pin covers
the context path both executors read.

Session `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ys so (objectui#13015) (#13071)

Fixes #13015
Clause-②: yes (narrowing) (`ImportJobUndoResult`, exported from
`@object-ui/types`, gains the required `failures` member. The new
`grid.import.undoPartial` count family widens `@object-ui/i18n`'s
`TranslationKeys`, and `failures` is now required of a custom
`DataSource`'s `undoImportJob` answer.)

## What this does

The undo route answers `200` with `success: false` and one `failures`
entry per row a control refused. That shape landed in
#24431, which objectui's pin `f4755a68a797`
carries. The job stays undoable for exactly those rows. The wizard never
read the answer.

- **The hook reads the answer.** `useImportJobUndo`
(`packages/plugin-grid/src/ImportWizard.tsx`) now takes the result of
`ds.undoImportJob(jobId)`. On `success: false` it hands the answer to
the list that ran Undo. A throw is handed over as before. The object's
readers still get `notifyDataChanged`, because a partial undo reverted
the other rows.
- **Both lists draw it.** The History list and the cancelled-result
screen render one shared `ImportUndoOutcomeNotice`. It shows:
- how many rows were not undone (`failed`), and that undoing again
retries only those rows (`grid.import.undoPartial`, a count family in
all ten packs);
- each refusal sentence the answer's `failures[].error` carries, the
app's own words. Rows with the same sentence share one line, because one
hook typically refuses many rows alike (an undoable job holds up to
5,000 rows). After ten distinct sentences the rest are counted with the
existing `grid.import.moreErrors`.
- **Undo stays offered** after a partial undo, because the re-read job
is still `undoable` and not `revertedAt`. That is correct per the
contract, so the button is not hidden. Undoing again clears the notice
as it starts.
- **A full undo is unchanged.** `success: true` shows nothing new and
flips the row to "Undone" as before.
- **`ImportJobUndoResult` is bound to the spec.** It is now
`UndoImportJobResponse` from `@objectstack/spec/api`, by reference, the
way `ImportRowResult` and its siblings at the top of
`packages/types/src/data.ts` already are. The hand-written copy it
replaces said it mirrored the server and lacked `failures`; that drift
is this card's defect.

### Bounded in-place fix in the same hook (named here, with evidence)

In the History list, a **thrown** undo also showed nothing. The hook
wrote the message into the panel's `error` slot, then called the reload,
and `load()` clears `error` as it starts, in the same tick. The partial
notice would have been wiped the same way, so the undo outcome now has
its own slot (`undoOutcome`), and the thrown message goes there too.

- Evidence: a one-off probe against the base file (`c2191654a`): render
the History list, an undo that rejects with a sentence, wait for the
reload, and assert the sentence is on screen. Result: `1 failed`,
`expect(received).toBeInTheDocument()`. The probe was deleted, and the
file was restored to HEAD (blob equal, `git diff HEAD` empty).
- The four conditions hold. It is the same defect class (an undo's
outcome the user never sees, in the same list). It is mechanical (one
state slot the reload does not touch). The file is under this claim. It
adds no new gate surface. The committed pin is `History list: an undo
that throws shows its message after the list reloads`.

## Premises measured (PM hypotheses)

- **H1 held.** At `c2191654a` the hook ran `await
ds.undoImportJob(jobId); notifyDataChanged(...)` and its only error path
was the `catch`. It had two callers: the History list and the
cancelled-result screen.
- **H2 held, so the producer side does not move.** The server's route
(objectstack `f4755a68a797`, `packages/rest/src/data-action-routes.ts`)
answers a flat `res.json({ success, jobId, object, deleted, restored,
failed, failures })`. The installed client at the pin
(`node_modules/.cache/objectstack/client`, `18.0.0-next.5`) has
`unwrapResponse` unwrap only a body that has `data`, and its `fetch`
throws only on `!res.ok`. `@object-ui/data-objectstack`'s
`undoImportJob` returns the client's answer unchanged. So `failures`
already reached the wizard at runtime; only the type and the UI dropped
it.
- **H3: derived, not hand-mirrored.** `check:spec-symbols` does not
require it: the local name differs from the spec's, and the comment
named no `@objectstack/spec` symbol. But the tree's convention (the
header block of `packages/types/src/data.ts`, "Spec-owned names are
bound here, not re-declared") and commandment #0 point the same way, and
the hand copy is exactly what drifted. Reading `failures` through the
spec makes `@object-ui/types` the first consumer of the release that
first publishes it, so the floor moves (below).
- **H4 held.** After a partial undo, both lists re-read the job and it
still offers Undo. The fix is the message, not hiding Undo.

## Spec floor

`@object-ui/types` moves `@objectstack/spec` from `^18.0.0-next.2` to
`^18.0.0-next.5`.

- R = `18.0.0-next.5`, the first published release carrying
`UndoImportJobResponseSchema.failures` (objectstack `adb7910f8`, PR
#24431). The registry lists `18.0.0-next.0` to
`18.0.0-next.3` and `18.0.0-next.5`; `18.0.0-next.4` was never
published.
- `git merge-base --is-ancestor adb7910` against the
`@objectstack/spec@18.0.0-next.5` tag commit gives exit 0. Against the
`…next.3` tag commit it gives exit 1. The control leg (the `…next.2` tag
commit against `…next.3`) gives exit 0, so the exit 1 is a real
non-ancestor on that checkout.
- The packed tarballs agree: `failures: z.ZodArray` has 2 hits in
next.5's `dist/api/index.d.mts` and 0 in next.2's.
- The lockfile does not move: the importer resolves the spec through the
pin override (`file:../../.objectstack-pin/spec`).
- `@object-ui/plugin-grid` keeps its own floor. It reads `failures` only
through `@object-ui/types`, and its artifact names no
`UndoImportJobResponse`.
- Scoped `check:spec-floors` over `@object-ui/types`: **0 findings** at
`^18.0.0-next.5`. The run also clears the two `floor-too-low` findings
`@object-ui/types` had at next.2 (`checkPageMainRegionWidth`,
`checkPageRecordBindingPlacement`); next.5's `dist/ui/index.d.mts`
carries `checkPageMainRegionWidth` (1 hit, 0 in next.2). That is
objectui#13065's subject; this PR closes no card but this one. The
whole-repo run exits 1 only on 14 `no-artifact` findings for packages
outside this branch's build closure.

## Tests

All runs below are at `13af833` (the source has not changed since
`92728ce`).

- New file
`packages/plugin-grid/src/ImportWizard.undoPartial-13015.test.tsx`,
which drives the real wizard with a stubbed data source: 4 tests, `4
passed`.
- History list, partial answer (`success: false`, `failed: 3`, three
`failures` with two distinct sentences). It shows "3 rows were not
undone. Undo again to retry only those rows.", lists both sentences once
each, survives the reload, keeps Undo offered, and emits one data
change.
- History list: a second Undo answering in full clears the notice and
shows "Undone".
  - History list: a thrown undo shows its message after the reload.
- Cancelled-result screen, the same partial answer: count, both
sentences and retry sentence shown, and Undo still offered, because the
re-read job is undoable.
- `pnpm exec vitest run packages/plugin-grid/`: `Test Files 229 passed
(229)`, `Tests 2077 passed (2077)`.
- `pnpm exec vitest run packages/i18n/ packages/types/` (the ten-pack
key parity, plural-category and count-family pins, plus the `types`
suite): `Test Files 472 passed (472)`, `Tests 11870 passed | 13 skipped
(11883)`. The skips are the env-gated `describe.runIf(DRIVEN)` timezone
suites, the only conditional markers in those two packages.

### Ablations (one-off, through `ablation-replace.mjs`, each restored to
HEAD with blob equality and an empty `git diff HEAD`)

The subject resolves to `packages/plugin-grid/src` (a relative import),
and `@object-ui/types` resolves to `src` through the root vitest alias.
No build was needed.

1. Deleted the read, `if (!answer.success) onOutcome({ kind: 'partial',
answer });`. Anchor 1 to 0. Result: `3 failed | 1 passed`. The three
partial-answer pins went red; the throw pin stayed green.
2. Made the History reload clear the undo slot too (`setLoading(true);
setUndoOutcome(null); setError(null);`), which is the base mechanism.
Anchor 1 to 0. Result: `3 failed | 1 passed`. The three History pins
went red; the cancelled-screen pin stayed green. The first attempt at
this leg was refused by the tool before any test ran, because the
replacement contained the anchor. It is declared here and was not
counted.

### Reverse type check

A probe under `packages/plugin-grid/src`, type-checked through the
rebuilt `packages/types/dist`, now refuses both old-shape literals. The
old hand shape without `failures` gives `TS2741 Property 'failures' is
missing`. An `action: 'update'` gives `TS2322 Type '"update"' is not
assignable to type '"delete" | "restore"'`. The probe was removed.

## Gates (local, at `13af833`, each exit captured before any pipe)

- Build: turbo over the closure of `@object-ui/plugin-grid` and
`@object-ui/data-objectstack`, plus `@object-ui/types` (concurrency 2).
Exit 0, 13 tasks.
- `type-check`: `@object-ui/types` exit 0, `@object-ui/plugin-grid` exit
0, `@object-ui/data-objectstack` exit 0. The last is the downstream
sweep: the consumers that name `ImportJobUndoResult`.
- `plugin-grid`'s `tsconfig.test.json` program contains the new test
file (`--listFiles`: 1 hit). It reads `packages/types/dist/data.d.ts`.
- `@object-ui/app-shell` names only `undoImportJob: undefined`, an
optional method whose type did not change. It is declared to CI.
- `lint`: `@object-ui/plugin-grid` exit 0 (0 errors), `@object-ui/types`
exit 0 (0 errors). The warnings predate this branch.
- `pnpm check:i18n-keys` 0 · `check:i18n-drift` 0 (0 en values changed,
3 keys added) · `check:i18n-dead-keys` 0 (the new keys are not listed).
- `pnpm check:spec-symbols` 0 · `check:spec-floors` 1 (whole repo;
`@object-ui/types` 0 findings, as above).
- `pnpm check:new-line-citations` 0 (`0 new citation(s)`) ·
`check:control-bytes` 0.
- `check-changeset-presence` 0 · `check-changeset-no-major` 0 ·
`check-changeset-fixed` 0.
- `check-governed-queue-guard --test` over the 15 changed paths: `NOT
GOVERNED`.
- `pnpm install --frozen-lockfile --offline` 0 (`Lockfile is up to
date`): the floor move leaves the lockfile valid.
- The full farm is CI's.

## Acceptance notes

- **Docs.** No page under `content/docs/**` or any package README
describes the import undo today. This claim's file surface does not
cover docs, so none was added; that is noted here and not filed.
- **Record ids are not shown.** Each `failures` entry also carries `id`,
`action` and `code`. The notice shows the refusal sentence the card asks
for, not the opaque record id.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ours on a page node (objectui#13047) (#13072)

Fixes #13047
Clause-②: yes

## What this does

`@objectstack/spec` 18 gave `list-view` a `ComponentPropsMap` row
(#22444), the list view's own
`ListViewSchema`, and PR #13043 booked the 38 keys of that row the
registration did not publish, plus 6 member pins, as OWED to this card
(objectui#11111 decision 3 = B).

Each of the 38 keys was measured on a page node: authored in the node's
`properties`, rendered through the real `SchemaRenderer` and the real
`list-view` registration, and asserted at what it draws, what it
queries, or what it hands the child view that draws it. Every row has a
control that differs only by that key. Every fixture is first parsed by
the spec's own `list-view` row.

- **Published (33):** `pagination`, `searchableFields`, `label`,
`description`, `userFilters`, `userActions`, `compactToolbar`,
`appearance`, `exportOptions`, `allowPrinting`, `showRecordCount`,
`emptyState`, `hiddenFields`, `fieldOrder`, `rowHeight`, `resizable`,
`selection`, `navigation`, `grouping`, `rowColor`,
`conditionalFormatting`, `inlineEdit`, `rowActions`, `bulkActions`,
`bulkActionDefs`, `kanban`, `calendar`, `gantt`, `gallery`, `timeline`,
`map`, `tree`, `chart`. Each description names the spec's member shape.
`label` / `description` declare both I18nLabel arms; the array keys
whose contract admits one member kind declare `of`.
- **Unpublished by measurement (5)**, each recorded in the parity gate's
`UNPUBLISHED_EXEMPTIONS` with its reason:
- `name`: read by nothing on a page node (same query, same DOM, same
grid node with and without it).
- `requiredPermissions`: no audience gate on this block; a capability
nobody holds still draws and queries the list.
- `sharing`: draws a Share button with no click handler; nothing
enforces the config.
- `addRecord`: draws an Add button that calls the host's `onAddRecord`,
and a page node has no host to pass one, so the click does nothing.
- `filterableFields`: the row declares it as the legacy shorthand of
`userFilters.fields`; the list draws no quick-filter bar from it and
reads it as the Filter panel's field list instead.
- **One inert member, published with the key that carries it:**
`appearance.allowedVisualizations` narrows the view switcher, which a
page node does not draw (`showViewSwitcher` is a host prop, not a row
key), so an authored `type` still draws. `appearance.showDescription`
acts, so the key is published and its description says the member does
nothing on a page.

`list-view` and the `view:list` alias (same renderer) now share one
input list, `LIST_VIEW_INPUTS`. The descriptions of the six keys already
published are filled in. No reader, prop, export or type member is
added; nothing changes at render time.

## Ledger

In `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`:

- The 38 `list-view` OWED rows in `UNPUBLISHED_EXEMPTIONS` and the 6 in
`MEMBER_PIN_EXEMPTIONS` are struck.
- `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys` 38 to 0, `memberPins` 6
to 0, the per-owner total for objectui#13047 44 to 0,
`MEMBER_PIN_EXEMPTION_CEILING` 6 to 0.
- The `OWED_TO` / `owedEntries` helpers left with their last entries.
The comment where they stood says a future bump writes them back.
- `MEMBER_PINS` gains 33 `list-view` entries: the 6 booked (`data`,
`columns`, `filter`, `sort`, `options` in the new file; `dataSource` in
`ListView.elementDataSource.test.tsx`, read end to end), and one for
every structured key published.

## Measurement level, said plainly

`plugin-grid`, `plugin-kanban` and the other view packages are not
dependencies of `plugin-list`, so the child views are stand-ins that
record the node `ListView` builds, as every other test in this package
does. For the grid's row keys and each kind's own block, the measurement
is the hand-off: the member reaches the node of the view that draws it,
on the page path. What each child view draws from its own node is pinned
in that view's package under its own published key. The toolbar, query
and chrome keys are measured at the DOM or the adapter's query.

## Tests (all at `215edf59d5`)

-
`packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx`
(new): 49 passed.
- `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: 260
passed.
- Family runs through `os-verify-lock`, `--maxWorkers=1`:
  - the 12 plugin-list files that import the registration: 130 passed;
  - 8 console registry/manifest files: 443 passed;
  - 3 app-shell block-config / page-block files: 68 passed;
  - 3 sdui-parser files: 130 passed.
- `type-check`: `@object-ui/plugin-list` (both projects;
`--listFilesOnly` shows the test project compiles the new file) and
`@object-ui/console`, each exit 0 after a turbo build of its dependency
closure.
- `eslint --no-inline-config` over the 3 changed source files: 0 errors
(3 pre-existing `react-refresh/only-export-components` warnings on the
barrel's export line). The root config enables no type-aware linting, so
the change cannot move a verdict on an untouched file. Repo-wide `pnpm
lint` is declared to CI.
- Gate battery: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `check-hand-rolled-comment-mask.mjs` and
`check-changeset-fixed.mjs` all exit 0. `check:spec-floors` exits 1 with
`no-artifact` (it wants a whole-workspace build), as it does on main.
- The full `packages/plugin-list` and `apps/console` directories are
declared to CI.

## Ablation (the parity gate can fail)

`allowPrinting`'s input line was deleted from
`packages/plugin-list/src/index.tsx` with `ablation-replace.mjs` (anchor
1 to 0, blob `2ed1ea1932b1` to `780b8c1b9265`). The parity file then
went red on exactly one test: `list-view publishes every top-level key
its spec props schema declares`, `expected [ 'allowPrinting' ] to deeply
equal []` (1 failed, 259 passed). The tool restored the file, and the
restore was checked by blob hash: `git hash-object` =
`HEAD:packages/plugin-list/src/index.tsx` = `2ed1ea1932b1`, and `git
diff HEAD` is empty. The alias resolves `@object-ui/plugin-list` to
`src`, so no build sat between the mutation and the run.

## Acceptance notes

Renderer gaps the seat may card (not built here, per the dispatch):

- `requiredPermissions` on a page `list-view`: the record blocks gate on
it and this block does not.
- `addRecord` on a page `list-view`: the Add button needs a host
`onAddRecord` that a page node never has.

A contract question on `filterableFields`: the row calls it the
shorthand of `userFilters.fields`, and the renderer reads it as the
Filter panel whitelist. Either the spec describes it wrong or the
renderer reads it wrong; that is the seat's call.

`sharing` draws a Share button with no handler wherever `ListView`
renders, the object list page included. Not measured on that page here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…he splash (objectui#13052) (#13070)

Fixes #13052
Clause-②: no

## What changed

A deep link to an object outside the app's package (`/apps/APP/OBJECT`)
reads that object by name through the app's object scope. A served
refusal used to leave that scope unsettled for good, and the first-paint
gate drew `LoadingScreen` with no exit. Now every by-name scope read
ends in a state the route draws inside the shell.

- **A definite refusal is an answer** (ruling 1). `MetadataProvider`'s
by-name read (`readItem`) classes `OBJECT_PACKAGE_DISABLED` as an answer
beside the plain absence. The name lands in `missing`, the scope
settles, and the scope names the refusal per name. The route draws the
existing "Object Not Found" view with one new sentence,
`console.objectView.objectPackageDisabled`, in all ten packs.
`RESOURCE_NOT_FOUND` draws "Object Not Found" as before.
`isMetaItemAbsentError` is untouched and still answers only
`RESOURCE_NOT_FOUND`.
- **The first-paint gate always has an exit** (ruling 2). A failed
by-name read (5xx, network drop, a 4xx whose code this build does not
know) is recorded once as `failed` on the scope entry. The read bumps
once. `settled` stays false, so `useObjectPresence` still reads
`unknown`, as the objectui#13009 pins require. Once nothing else is in
flight, the scope read reports the name in `failed`. `appObjectScope.ts`
counts a failed route read as done, so the splash lifts. The route slot
then draws a failed-read state with Retry ("Couldn't load records" plus
the generic load-error sentence and `common.retry`, all existing keys).
- **No retry loop.** A name marked `failed` is skipped by later
`readObjectScope` calls, so the re-render that the failure causes asks
nothing. Retry calls `invalidate('object', NAME)`, which drops the
failed mark and the adapter's entry, so the next render asks exactly
once. `refresh()` and a scope refetch also re-ask. This changes one
objectui#13009 pin. Before, an incidental later read re-asked a failed
name. Now only Retry, `invalidate` or `refresh` does. The pin is updated
to say so and to drive `invalidate`.
- **Page routes** (`page/:pageName`): if a bound object's read fails,
the page route also stops waiting and draws the same failed state.

### Where the outcome travels

`readObjectScope`'s declared return type lives in `@object-ui/react`,
which is outside this card's file surface. Two fields carry the outcome
beside that shape: `failed` (names) and `refused` (name to code). The
producer is `MetadataProvider` and the reader is `appObjectScope.ts`,
both in `@object-ui/app-shell`. They use a package-local hand-off type
that is not exported. A provider without these fields (a test double, a
host's own) reads as having none, which is the behaviour before this
card.

`ConsoleObjectViewProps` is a ruled, pinned shape
(`ObjectView.consoleProps.types.test.tsx`), so it is not widened. The
not-found view moves into a module-level `ObjectNotFoundState` in
`ObjectView.tsx`. `ObjectView` draws it for an absence, and the console
route draws it with `packageDisabled` for the refusal. Nothing is added
to the package entry: no export, prop or type member.

## Pins

New: `MetadataProvider.scopeRefusal-13052.test.tsx` uses a REAL
`ObjectStackAdapter` through a fake `fetch`.
`AppContent.disabledPackageDeepLink-13052.test.tsx` uses the real
`MetadataProvider` under the real `AppContent`.

- (a) `404 OBJECT_PACKAGE_DISABLED` in the flat body (the server's
today) and in the wrapped `{error:{code}}` body. The scope settles and
names the refusal. The route leaves the splash and draws
`object-package-disabled` inside the console layout. One by-name read.
- (b) CONTROL: `404 RESOURCE_NOT_FOUND` draws `object-not-found` and
names no refusal.
- (c) 503 and a network drop draw `route-object-read-failed` inside the
shell, with one read and none on re-render. Retry asks exactly once
more. Served, it renders the object. Failing again, it draws the failed
state again, one read per click.
- (d) `404` with an unknown code (`SOME_FUTURE_REFUSAL`) draws the
failed state.
- CONTROL: a served object renders normally, read by name once.

Ablation (`ablation-replace.mjs` WRAP mode, run at `fab9fb8416`). Each
restore was proven by blob hash: after restore the blob equals HEAD
(`08bda91cec30`) and `git diff HEAD` is empty.

- A: drop the refusal classification (refusal read as failed). The three
(a) tests go red. (c) and (d) stay green: a refusal then draws the
failed state, so it does not hang.
- B: restore the old failed branch (`names.delete` and no bump, i.e. the
hang). All nine (c) and (d) tests go red. (a) stays green.

## Verification (final HEAD `fab9fb8416`)

- `type-check` for `@object-ui/app-shell` and `@object-ui/i18n` (closure
built first with `--filter '@object-ui/app-shell^...' build`): exit 0.
- vitest, 41 files (470 tests) passed. They cover the new and edited
pins, every test naming `readObjectScope` / `appObjectScope` /
`useObjectPresence`, the AppContent scope, splash and page-route suites,
the ObjectView props pin plus two ObjectView suites, the product-neutral
ratchet, and 16 i18n pack-wide suites (key parity, de quote census,
console namespace and more).
- `de-quote-pairing-3876` counts the new de sentence's paired quote
span: census 90 to 91, recorded in the test like its predecessors.
- Pre-tsc battery (`check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `new-line-citations`, `check-hand-rolled-comment-mask`,
`check-changeset-fixed`, `check-changeset-presence`): exit 0 each.
`check:spec-floors`: exit 1 `no-artifact` (it needs a whole-workspace
build), recorded and not acted on, as the dispatch said.
- eslint over the 18 changed .ts/.tsx files (inline config honored, as
each package's `lint` runs it): 0 errors.
- NOT MEASURED: the full `packages/app-shell/` and `packages/i18n/`
directories (the dispatch bars whole-directory runs on the shared lock)
and the other ObjectView suites. These are declared to CI.

## Acceptance notes

- The `failed` / `refused` hand-off rides beside `@object-ui/react`'s
`readObjectScope` return type. If another provider should honour it, the
place to declare it is that contract (`packages/react`, outside this
surface). Carrier: none named.
- Behaviour change for other scope readers (sidebar `requiresObject`
gates, `useObjectPresence`): a failed name now stays `unknown` until
Retry, `refresh` or `invalidate`, instead of being re-asked by the next
incidental render.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…shards without false reds in the aggregate Test gate (objectui#13064) (#13077)

Fixes #13064

The aggregate `Test` gate (`scripts/check-test-shard-results.mjs`) went
red three ways on 2026-10-19 with every shard green. This PR repairs all
three in that one script. No workflow, required-check or settings edit;
`ci.yml` is read, never edited.

## What changes

1. **A transient read is asked again, bounded and logged.** A 5xx, a
connection that fails before any answer (`TypeError: fetch failed`,
cause `ECONNRESET` measured), and a request past `REQUEST_TIMEOUT_MS` (a
`TimeoutError` measured) are retried at most `TRANSIENT_RETRY_LIMIT`
times, `TRANSIENT_RETRY_WAIT_MS` apart, one log line each. A 5xx still
standing after the last retry stays exit 2. 401, 403, 404, 429 and every
other 4xx exit 2 at once. A truncated page exits 2 at once, because the
body is parsed outside the retry; mid-body it rejects as `TypeError:
terminated`, measured. A missing `GITHUB_TOKEN` exits 2 before any
request. The header always claimed that last one, but until now the
request simply went out without an `authorization` header. Every verdict
is still taken from a 200 body.
2. **On `GITHUB_RUN_ATTEMPT` above 1, a shard absent from the current
attempt is judged by its latest earlier attempt.** Earlier attempts are
read on their own `attempts/{k}/jobs` route, latest first, only while a
shard is still absent. A carried record whose `run_attempt` disagrees
with its route is refused. A shard in no attempt stays `ABSENT` and red.
A carried `failure`, `cancelled` or `skipped` stays red. A shard the
re-run re-ran is judged by its re-run. The own-job control stays on the
current attempt, and nothing is carried into a reading that fails it.
3. **Staleness past the first re-read bound no longer ejects an
all-green group on its own.** When `TEST_MATRIX_ROLLUP` reads `success`
(ci.yml already hands `needs.test.result` to this step under that name)
and the only disagreement after `REREAD_LIMIT` re-reads is present
shards with no conclusion, the re-read continues `STALE_REREAD_LIMIT`
more times, `STALE_REREAD_WAIT_MS` apart. The rollup only decides
whether to wait longer. A pass still needs every shard's own `success`,
and a `completed_at` without a conclusion is not one. Any other
disagreement (a `failure`, an absent shard) ends the extension at once.

`main` is now `run(argv, deps)` over the real environment, network and
clock, so the pins drive the same composition CI runs.

## Hypothesis 1, measured on run 39276047959 (attempts 1 and 2), REST,
after the fact

| Route | Answer |
|---|---|
| `attempts/1/jobs` | 200, `total_count` 24, all `run_attempt` 1, 16
shards `success`, own job `Test` present (`failure`) |
| `attempts/2/jobs` | 200, `total_count` 24, all `run_attempt` 2, 16
shards `success`, own job `Test` present (`failure`) |
| `attempts/3/jobs` | 404 (the attempt does not exist) |
| `jobs?filter=all` | 200, `total_count` 48 = both attempts mixed, 24
duplicate names, attributable only by `run_attempt` |
| `jobs?filter=latest` | 200, `total_count` 24, attempt 2 only |
| `jobs` (no filter) | same as `filter=latest` |

Attempt 2 lists 24 jobs today, but when the gate read it, it listed only
the re-run subset: the card's log shows 16 shards `ABSENT`. The
carried-forward copies were materialized later. Each attempt-2 copy
carries a job id above the attempt-2 `Test` job's id, and the two
skipped coverage copies show a `started_at` of 15:41:56Z and 15:51:04Z,
after that `Test` job finished at 15:35:11Z. Hence the per-attempt
route. `filter=latest` is the current attempt alone, the very thing that
was empty. `filter=all` would be keyed by a per-job field alone. The
script header records why and keeps the own-job control.

Run 39286083342's job log (MCP `get_job_logs`, read once) is the item-3
measurement. At the first read (16:42:49Z), nine shards had no
conclusion, three of them reading `queued`. Seven settled during the
eight re-reads. `Test (shard 7/16)` and `Test (shard 12/16)` still read
`in_progress` at 16:44:59Z, beside `TEST_MATRIX_ROLLUP: success`.

## Budgets and their bound

| Constant | Value | Why |
|---|---|---|
| `REREAD_LIMIT` x `REREAD_WAIT_MS` | 8 x 15 s (unchanged) | the first
re-read stretch, for every null shard |
| `STALE_REREAD_LIMIT` x `STALE_REREAD_WAIT_MS` | 9 x 30 s | both
stretches together sleep 6 min 30 s, which covers the recorded 6 min 22
s even if it starts at the gate's own first read |
| `TRANSIENT_RETRY_LIMIT` x `TRANSIENT_RETRY_WAIT_MS` | 3 x 10 s |
bounded retry of a transient read |
| `REQUEST_TIMEOUT_MS` | 20 s | one request's own bound |
| `GATE_BUDGET_MS` | 7 min 30 s | hard wall for the whole gate step |

The bound is the `test-aggregate` job's `timeout-minutes: 10`. Every
wait starts only if it and one more full request fit before
`GATE_BUDGET_MS`, and each request's timeout is cut to what is left, so
the gate step cannot outlive its job. Past that point the job would
report `cancelled`, which the queue reads as a red. The remaining 2 min
30 s covers set-up, checkout and post steps (about 10 s in each of the
three recorded runs) plus the report-only headroom step's single
`fetchRunJobs()` read, which takes no retry and is bounded by
`REQUEST_TIMEOUT_MS`. Pinned: `GATE_BUDGET_MS + REQUEST_TIMEOUT_MS + 2
min` is at most `timeout-minutes`, and both stretches together are at
least the recorded staleness and fit inside the budget with one request.

## Pins added (26, in
`scripts/__tests__/check-test-shard-results.test.ts`)

Transient read: `⭐ one 502 then 200 gives a verdict` · `⛔ a 5xx that
persists past the bound stays exit 2` · `a connection reset and a
request timeout are retried the same way` · `a re-read that meets a 5xx
is asked again too` · `⛔ 401, 403, 404 and 429 are answers: exit 2 at
once, never asked again` · `⛔ a truncated page is exit 2 at once, never
asked again` · `⛔ a missing token is exit 2 before any request` · `⛔ no
retry starts that the gate budget cannot finish`.

Re-run attempts: `⭐ attempt 2 with every shard only in attempt 1, all
success, passes` · `⛔ attempt 2 with a shard failure in attempt 1 and
not re-run stays red` · `⛔ a carried cancelled or skipped stays red` ·
`⛔ a shard present in no attempt stays ABSENT` · `a shard the re-run
re-ran is judged by its re-run, both ways` · `takes each absent shard
from the LATEST earlier attempt that has it, and stops reading once none
is absent` · `⛔ the own-job control is the current attempt's, and
nothing is carried into a reading that fails it` · `refuses a carried
record whose run_attempt is not the attempt it was read from` · `⛔ an
earlier attempt that cannot be read is exit 2, never a pass`.

Staleness: `⭐ shards still in_progress past the first bound settle
inside the extension while the rollup reads success` · `⛔ without a
success rollup the same answer stays red after the first bound` · `⛔ the
rollup never passes a shard: one that never answers stays red,
completed_at and all` · `⛔ no extension once anything but a missing
conclusion disagrees` · `⛔ no re-read starts that the gate budget cannot
finish` · `run reads the rollup from TEST_MATRIX_ROLLUP, the name ci.yml
hands it in under`.

ci.yml wiring: `keeps the whole gate inside the job, with room for the
steps around it` · `sizes the two re-read stretches against the recorded
staleness and the gate budget` · `hands the gate the matrix rollup under
the name it reads`.

No network in tests: a scripted `fetchImpl` and a virtual clock that
only `sleep` moves.

## Ablation (one behaviour reverted at a time, then restored;
implementation committed first)

Each mutation went through `ablation-replace.mjs` in WRAP mode. Each one
landed on disk (anchor count 1 to 0, blob changed). Each restore was
proven by blob hash equal to HEAD and an empty `git diff HEAD`.

| Behaviour | Mutation | Red leg | Green leg |
|---|---|---|---|
| transient retry | `if (retry >= retries)` to `if (retry >= 0)` | exit
1, 5 failed, 48 passed | exit 0, 53 passed |
| carry from earlier attempts | `k >= 1` to `k >= current` | exit 1, 5
failed, 48 passed | exit 0, 53 passed |
| stale-API extension | `rollup === 'success'` to `rollup === 'ablated'`
| exit 1, 4 failed, 49 passed | exit 0, 53 passed |

Every red leg failed exactly the pins of its own behaviour, and nothing
else.

## Local gates (final head `90cbaeb`)

- `pnpm test scripts/__tests__/check-test-shard-results.test.ts
scripts/__tests__/shard-headroom.test.ts
scripts/__tests__/test-file-timings.test.ts` → exit 0, 3 files, 78
passed. The other two files are the ones that name this script or its
importer `shard-headroom.mjs`.
- `pnpm run type-check:scripts` (`tsc -p tsconfig.scripts.json`) → exit
0. `--listFiles` confirms both changed files are in the program.
- `pnpm exec eslint` on both files → exit 0.
- `check:new-line-citations`, `check:control-bytes`,
`check:entry-guard`, `check:test-path-roots`,
`check:pre-install-import-graph` → exit 0 each.
- `node scripts/check-governed-queue-guard.mjs --test` on both paths →
exit 0, NOT GOVERNED.
- `node scripts/check-changeset-presence.mjs` → exit 0, no changeset
owed. The precedents objectui#13031 and objectui#13035 for tooling-only
`scripts/` changes carry none either.
- The full farm is this PR's CI.

Rollback: revert the squash commit. Nothing outside the two files moves.

## Acceptance notes

- `ci.yml`'s comment on `TEST_MATRIX_ROLLUP` ("Printed, never trusted")
and the step's echo ("deliberately does NOT trust") are still true for
the verdict. The gate now also reads the rollup to decide whether to
wait longer. `ci.yml` is governed and outside this claim, so it is left
alone. Carrier: none.
- `content/docs/guide/ci-cd-pipeline.md` describes the gate without the
retry, the attempt carry or the extension. Nothing it says became false,
so it is left alone. Carrier: none.
- `scripts/shard-headroom.mjs` (report-only) shares `fetchRunJobs()`. It
keeps its single attempt but is now bounded by the 20 s request timeout;
before, it had none of its own. On a re-run attempt it still reads only
the current attempt and prints NOT MEASURED. That is an observation, not
a gate. Carrier: none.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…t a timed DOM scrape (objectui#13062) (#13078)

Fixes #13062
Clause-②: no

## What changes

`MasterDetailLines` (in `packages/plugin-form/src/MasterDetailForm.tsx`)
evaluated the line grids' `parent.*` rules against a DOM scrape of the
header. The scrape ran at mount and on `[120, 360, 800]` ms timers. A
header whose record arrived after the last tick left `parent = {}`, so a
stored paid invoice opened with its `readonlyWhen: parent.status ==
'paid'` line cells editable.

Now the lines read the header's **record**:

- **Load.** The header `ObjectForm` reads through a data source that
observes its own `findOne(objectName, recordId)`. That read's answer is
published to the lines when it lands, however late. Each call passes
through unchanged: same arguments, same promise back (a rejection still
reaches the header), and `this` is the caller's data source. A
superseded read does not land, and a read of any other record (a
lookup's display read, say) is not observed. A data-invalidation re-read
lands the same way.
- **Live edits.** These still come from the host's bubbling `input` /
`change` events, which scrape the header and lay the scrape over the
record. A status flip still locks or unlocks the cells at once. A
landing record drops earlier edits, because the form's values were
replaced.
- **Create form.** It opens on `initialValues` / `initialData`.
- **Timers.** Removed. No polling.
- **Totals stack.** The Subtotal / Tax / Total stack used to show once
the scraped header contained the tax-rate control, so it depended on the
same timers. The header now carries the field when the author declared
it (`sections` members, else `fields`) or, with neither declared, when
the header object's `getObjectSchema` answer lists it. That read is
observed the same way.

The feed and the observing data source are keyed on primitives held in a
ref (objectName, recordId, mode, formKey, opening values), never on a
memo's identity (AGENTS.md #10). A landing record re-renders only the
lines, never the header `ObjectForm` (#1581).

The grids' rule evaluation is unchanged. Only its `contextRecord` input
changes. There is no new export, prop on any public schema, or type
member.

### Why the live path is still an event scrape (ruling fallback)

The header exposes no state channel to this host:

- `ObjectForm` forwards no value callback to its host.
- The form renderer's `FormSchema.onChange` exists, but by design it
keeps a `defaultValues` reset (the record landing) off the channel
(#2968, #5235). Forwarding it would carry user edits but not the load.
- Adding a forward means changing `ObjectForm.tsx` and the public
`ObjectFormSchema` in `@object-ui/types`. Both are outside this card's
file surface.

So, per the ruling: the record comes from the header's own read when it
lands, and live edits keep today's event path.

## Pins (`MasterDetailForm.lateHeaderRecord-13062.test.tsx`)

The header `findOne` is a deferred promise, answered 1100 ms of real
time after the line row is on screen.

- (a) A stored `status: 'paid'` record whose header arrives late: the
`readonlyWhen` Qty cell is disabled, and the ruleless Product cell stays
editable. The test also checks that `findOne('inv', 'inv1')` reached the
header (its input shows `paid`).
- (b) The same form with a stored draft: flipping the status to `paid`
locks Qty at once, and flipping back unlocks it.
- (c) Controls: a stored non-paid record leaves Qty editable. A create
form with an empty parent leaves Qty editable, reads no record, and
still locks on a live flip.

### Ablation (one-shot, not kept)

At HEAD `4ad7a86a29`, the BASE `51269f0fdb` copy of
`MasterDetailForm.tsx` (the timer scrape) was written over the file.
Disk proof:

- Mutated blob `4bb3fa03e5` equals the BASE blob.
- `[120, 360, 800]` count went 0 to 1, and `observeHeaderRead` went 2 to
0.

Result: `(a)` went red (`AssertionError: expected false to be true`,
`Tests 1 failed | 3 passed (4)`). (b) and (c) stayed green, as expected:
the old live path and empty-parent behaviour pass them.

The restore ran from a trap with `git checkout HEAD --
REPO_ROOT/packages/plugin-form/src/MasterDetailForm.tsx`. Restored blob
`961c4b48dc` equals HEAD's blob, `git diff HEAD` is empty and `git
status` is clean.

## Verification (HEAD `4ad7a86a29`)

- Closure build `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-form^...' build`: exit 0.
- `pnpm --filter @object-ui/plugin-form run type-check` (`tsc --noEmit
&& tsc -p tsconfig.test.json`; the test project includes
`src/**/*.test.tsx`): exit 0.
- `pnpm exec vitest run` on all 20
`packages/plugin-form/src/MasterDetailForm*.test.tsx`, `--maxWorkers=1`:
`Test Files 20 passed (20)`, `Tests 121 passed (121)`.
- `pnpm exec vitest run` on the 29 other plugin-form test files that
name `MasterDetailForm` / `object-master-detail-form` (`git grep -l`):
`Test Files 29 passed (29)`, `Tests 443 passed (443)`.
- The full `packages/plugin-form/` directory and the full `pnpm test`
are **declared to CI**. The dispatch bars whole-package local runs on
the shared lock.
- Narrowed lint: `eslint --no-inline-config --format json` on the 2
changed `.tsx` files. Both are in the eslint config population
(`--print-config` resolves for each). The JSON shows 2 files, 0 errors
and 33 warnings. Of those, 29 are in `MasterDetailForm.tsx`, the same
count as the BASE copy of that file, and 4 are `no-explicit-any` in the
new test. `eslint.config.js` sets no `parserOptions.project` /
`projectService`, so linting is not type-aware and this diff cannot move
the verdict on any untouched file. Repo-wide `pnpm lint` is CI's.
- Pre-tsc battery: exit 0 for each of `check:phantom-deps`,
`check:self-import`, `check:unreferenced-sources`,
`check:doc-example-readers`, `check:handler-key-reads`,
`check:side-effects-array`, `check:element-data-source-declaration`,
`check:spec-symbols`, `check:action-forward-parity`,
`check:designer-field-key-parity`, `check:icon-record-names`,
`check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`,
`check:control-bytes`, `node
scripts/check-hand-rolled-comment-mask.mjs`, `node
scripts/check-changeset-fixed.mjs` and `node
scripts/check-changeset-presence.mjs`. `check:spec-floors` exits 1. The
dispatch expects that on main; not re-measured on main here. Recorded,
not touched.

## Acceptance notes

- **Create forms and object default values.** A create form's `parent`
now starts from `initialValues` / `initialData`. Object-level field
`defaultValue`s are not in it until the user's first header edit. The
old timer scrape picked them up only if the header rendered within 800
ms. A rule that reads a defaulted header field on a create form (for
example `parent.status != 'draft'`) now sees the field empty until that
first edit. No rule like that is known among the shipped examples; it is
not measured here.
- **Header fields that have no named control.** For example, a lookup.
These now reach `parent` from the loaded record, where the scrape never
saw them. This is a superset of the old context.

## Changeset

`.changeset/13062-master-detail-parent-context.md`:
`@object-ui/plugin-form` `patch`.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
… in the lint's words (objectui#13061) (#13080)

Fixes #13061

Clause-②: no

## What changed

Studio's RLS policy editor now rates a `USING` or `CHECK` predicate the
runtime cannot lower an **error**, carrying the server's verdict in the
server's words, so Save and the package-door autosave hold. Before,
`lintCelPredicate` rated a non-lowerable `USING` a warning worded
"...can be dropped and WIDEN access", left `CHECK` unjudged, and the
draft autosaved; publish and `os build` then refused it as
`rls-predicate-unenforceable`.

- **Verdict.** `isSupportedRlsExpression` from `@objectstack/formula`
(the function the RLS compiler and `@objectstack/lint`'s rule both
call), asked for both clauses: `rlsLowerabilityVerdict` in
`celAuthoring.ts`.
- **Words.** None written in objectui. The finding is the one
`@objectstack/lint`'s published `validateRlsPredicateEnforceability`
returns for a one-clause stack holding the source, severity and message
as written. For the card's predicate that reads: `RLS using is not
lowerable (unsupported operand "call"), so the policy is dropped and
grants no access` (CHECK: `... admits no write`). Loaded lazily and
feature-detected, like the existing `fieldRuleRootIssue` loader.
- **Save gate.** No code change needed: `PermissionAdvancedFacets`
already counts `severity === 'error'` per clause, and
`PermissionMatrixEditor` holds Save and `useDraftAutoSave` on that
count.
- **Retired.** The module-worded advisory, its
`engine.celLint.notPushdownable` key (the `CelLintMessageKey` member)
and its en / zh catalogue rows. The "fail open / WIDEN access" claims in
the module header, the `clause` hint doc, `CelPredicateField`'s docblock
and the app-shell README now describe the fail-closed runtime.
- **Changeset.** `@object-ui/app-shell` patch.

Landing point as claimed:
`packages/app-shell/src/views/metadata-admin/celAuthoring.ts`. Outside
the claim's file list, this PR also edits comments in
`CelPredicateField.tsx` and two paragraphs of
`packages/app-shell/README.md`. Both described the retired advisory and
would otherwise be false (AGENTS.md commandment 2, docs-driven). No
export, prop or published key moves; `celAuthoring.ts` stays internal to
`@object-ui/app-shell`.

## Mechanism hypotheses, measured

- **H1 holds.** The verdict was `severity: 'warning'` with `messageKey:
'engine.celLint.notPushdownable'`, for `hint.clause === 'using'` only;
CHECK took no path. The one caller that passes a `clause` is
`PermissionAdvancedFacets` (`clause="using"` and `clause="check"`); its
`reportClause` counts errors only, and `PermissionMatrixEditor` gates
Save and the autosave's `blocked` on `celErrorCount > 0`.
`clientValidation` never passes a clause.
- **H2 holds, and the lint export exists.** At the pin,
`@objectstack/lint` exports `validateRlsPredicateEnforceability` (with
the `RLS_PREDICATE_*` ids) and `@objectstack/formula` exports
`isSupportedRlsExpression`. The lint export carries the verdict's
message, so the message is attached, not re-worded.
- **H3 holds.** On `origin/main` the key's readers were
`celAuthoring.ts`, its two catalogue rows and two tests; all are moved.
`notTheRow` still uses `CelLintMessageKey`, so the type stays, with one
member.
- **H4 holds.** `record.asset_code == 'A1'` is clean on both clauses and
reports 0 blocking errors up (pinned).

Why the gate is the formula function and not "every lint finding": when
the gate refuses a clause, the rule reports through its shape pass
alone, so the carried finding is exactly the server's lowerability
verdict. The rule's other passes judge what a lowerable clause points at
and need the object's metadata; they stay the publish door's (see
Acceptance notes).

## Tests (head `ae0abf7`)

`pnpm exec vitest run --maxWorkers=2` from the worktree root over 14
files under `packages/app-shell/src/views/metadata-admin/`:
`celAuthoring.test.ts`, `celAuthoring.fieldRuleVerdict-9318.test.ts`,
the four `CelPredicateField.*` files, the five
`PermissionAdvancedFacets.*` files,
`ConditionalFormattingEditor.test.tsx`,
`PermissionMatrixEditor.autosave-11787.test.tsx`,
`PermissionMatrixEditor.packageDoorFacets.test.tsx`. Result: `Test Files
14 passed (14)`, `Tests 186 passed (186)`, lock verdict `command-exit
0`. The full app-shell suite is CI's.

New or changed pins:
- `celAuthoring.test.ts`: for USING and for CHECK, `upper(status) ==
"OPEN"` lints to exactly one `error` whose message equals the lint
rule's finding; with no clause named there is no such error.
- `PermissionAdvancedFacets.cel.test.tsx`: the card's predicate
`size(record.asset_code) > 3` in USING, in CHECK, and in both reports 1,
1 and 2 blocking errors up; the control `record.asset_code == 'A1'` on
both clauses reports 0 with both editors showing the valid affordance.
- `CelPredicateField.test.tsx`: the field renders the lint's message and
sets `aria-invalid`.
- `CelPredicateField.i18n-10862-s2.test.tsx`: the retired key's
per-locale case becomes a control that the lint's message ships as
written under en and zh, with no catalogue key.

## Ablation (one-off, not kept)

Mutation via `scripts/ablation-replace.mjs` (objectstack checkout): in
`rlsLowerabilityVerdict`, the line mapping the finding's severity was
replaced by one that always pushes `warning` (the pre-fix tier). Anchor
`x1 -> x0`, replacement `x0 -> x1`, blob `1c4e132a78f3 -> 7a02dbc069a0`.
Over the four changed test files: `Tests 6 failed | 70 passed (76)`.
Red: both `celAuthoring.test.ts` tier pins, the `CelPredicateField`
blocking pin, and the three `PermissionAdvancedFacets` blocking pins.
Green as expected: the two i18n controls (they pin words, not tier) and
the lowerable control. Restored by the tool: blob `1c4e132a78f3` equals
HEAD, `git diff HEAD` empty. No `dist/` is involved: the tests import
`./celAuthoring` from source.

## Gates (local, head `ae0abf7`)

| Command | Exit |
|---|---|
| `pnpm --filter @object-ui/app-shell type-check` (after the `^...`
closure build) | 0 |
| `pnpm --filter @object-ui/app-shell lint` | 0 (0 errors; the one
warning on a touched file, `CelPredicateField.tsx`, is on untouched
code) |
| `pnpm check:new-line-citations` | 0 (`0 new citation(s)`) |
| `pnpm check:control-bytes` | 0 |
| `pnpm check:i18n-keys` / `check:i18n-dead-keys` /
`check:i18n-designer-parity` / `check:i18n-drift` | 0 / 0 / 0 / 0 |
| `node scripts/check-changeset-presence.mjs` | 0 |
| `pnpm check:changeset-claims` / `check:pending-changeset-literals` | 0
/ 0 |
| `node scripts/check-governed-queue-guard.mjs --test` over the diff | 0
(`NOT GOVERNED`) |

## Acceptance notes

- **The rule's other passes stay at publish.** For a lowerable clause
the rule also judges references, for example `current_user.nope ==
owner_id` gets `rls-predicate-unknown-user-variable` at error tier. The
editor gives no finding for these. It is silent, not wrong, and publish
still refuses. This was read off the rule, not reproduced in Studio. Not
filed; carrier: none.
- **The lint's `hint` line is not shown.** The card asks for the
engine's sentence, and the finding's `hint` (rewrite guidance) is a
second field. The old advisory's `{detail}` also carried the compiler's
text after the dash, which the lint's verdict trims. Showing the hint
would be a wording decision for the lane. Carrier: none.
- **Legacy SQL-ish `=` predicates** (`owner_id = current_user.id`): the
editor's CEL parse error blocks Save, while the server's deprecated
bridge lowers them. This predates this PR, and the editor is the
stricter side. Canonical RLS is CEL (ADR-0058 D1), so not filed.
- **Lint missing at runtime.** If the lint export is absent or its chunk
fails to load, the editor gives no lowerability verdict, the same
degrade as every other entry point of the module, and publish still
refuses. The pin builds `@objectstack/formula` and `@objectstack/lint`
from one commit.
- The checklist re-point named in the card (acceptance[2] /
acceptance[4]) belongs to the checklist lane. It is recorded on
#24579, and this PR does not cover it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…isable / Enable and Clone (objectui#12982) (#13050)

Part of #12982
Clause-②: no — console UI over existing server doors (`GET
/meta/sharing_rule`, `POST /security/_activation/sharing_rule/:name`,
the `sys_sharing_rule` data door, `GET /sharing/rules/:name`); no spec
key, registry key or export changes

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

## Shape B, and why

Measured on a showcase boot of objectstack `main` (`5b14b7f`, which
carries #24428):

- `GET /api/v1/sharing/rules` serves no declared rule that the
activation ledger switches off. After `POST
/security/_activation/sharing_rule/share_new_inquiries_with_field_ops
{enabled:false}` the list dropped it, and `GET
/sharing/rules/share_new_inquiries_with_field_ops` answered `404
RULE_NOT_FOUND`. A page built on it (shape A) could never offer Enable.
It also takes only `object` and `activeOnly`: no search, sort or paging.
- `GET /api/v1/meta/sharing_rule` lists every declaration, switched off
or not, with `_packageId` / `_provenance`, and never a
`sys_sharing_rule` row. `GET /data/sys_sharing_rule` lists only
organization rules.
- So shape B needed the least new surface: the declared rules are the
environment scope of the `sharing_rule` registry type
(`/apps/setup/metadata/sharing_rule?scope=environment`), the page C9
(#12998) built for positions and permission sets. Its active switch is
the same ledger read and door. The organization rules stay on the
`sys_sharing_rule` data view, with the server's own search, sort and
paging. No page fetches and merges two lists.

## What changes (`@object-ui/app-shell`, metadata-admin)

- **The declared list.** `sharing_rule` joins `SETUP_CATALOG_TYPES`. The
list shows object, recipient, access level and the declaring package,
plus a ledger-backed Active switch that writes the activation door; a
refusal is shown in the server's words. It offers no New, even though
`/meta/types` serves `sharing_rule` with `allowRuntimeCreate: true`, and
it links to the organization rules page, where New lives.
- **Read-only editor.** A declared rule opened from the list is
read-only in the environment scope (`DECLARATION_ONLY_CATALOGS`,
`sharing-rule-catalog.ts`). Without that gate, a rule with no package
would open editable and its Save would `PUT /meta/sharing_rule` (see
server gaps).
- **Clone** (`SharingRuleCloneDialog`):
- It pre-fills a new organization rule from the server's compiled
projection of that one rule (`GET /sharing/rules/:name`; no client-side
compilation of the CEL), under a suggested name no declared rule holds.
- It writes through the `sys_sharing_rule` data door, which refuses a
duplicate; `POST /sharing/rules` is an upsert, so it is not used.
- The server's `409 RESOURCE_CONFLICT` is shown as sent. Once the copy
exists, the dialog offers "Switch <base> off" through the door.
- A base rule that is already off has no served projection, so its clone
opens the `sys_sharing_rule` New form pre-filled and asks for the
criteria there.
- **New** for organization rules is the generic `sys_sharing_rule` form.
It already shows the server's 409 for a declared name, measured below;
no change was needed.
- i18n: en and zh strings for the hint, the organization-rules link, the
column labels and the dialog. README section "The Setup catalog" and
`content/docs/guide/console-architecture.md` updated.

## Acceptance measurement (Playwright Chromium, console `vite :5190` →
`objectstack dev --fresh :4010`)

| Clause | Result |
|---|---|
| Two declared showcase rules listed read-only, with provenance | PASS:
both rows show `com.example.showcase`; there is no New; the detail page
shows the read-only banner and offers no Edit |
| Disable switches the vendor rule off through the ledger | PASS: `POST
/security/_activation/sharing_rule/share_new_inquiries_with_field_ops`;
the ledger row reads `active:false`; the rule stays listed with its
switch off |
| Clone creates a new-named rule | PASS:
`share_key_account_qualified_contacts_with_managers_copy` was created
with `criteria_json`
`{"$and":[{"stage":"qualified"},{"company":"Northwind"}]}`; "Switch …
off" then wrote the door |
| New with a declared name shows the server's refusal | PASS in the
Clone dialog and in the `sys_sharing_rule` New form: the 409 sentence
("…is declared by the application…") is shown verbatim |
| Organization-authored rule editable | PASS: `org_rule_one` is on the
`sys_sharing_rule` view with New and Edit |
| No request fetches and merges two lists | PASS: the declared page
reads `GET /meta/sharing_rule` and `GET /data/sys_metadata_activation`,
plus `GET /sharing/rules/:name` for one rule on Clone; no
`/sharing/rules` list read and no `sys_sharing_rule` list read |
| Reachable from Setup navigation | **Server**: the Setup nav's "Sharing
Rules" points only at `sys_sharing_rule` (`nav_sharing_rules` in
plugin-sharing) |

## Tests (repo-root vitest)

- New files: `ResourceListPage.declaredSharingRules-12982.test.tsx` (11
tests, fixtures recorded from the boot above),
`ResourceEditPage.declaredSharingRule-12982.test.tsx` (2),
`sharing-rule-catalog.test.ts` (8).
- The related metadata-admin set, plus
`product-neutral-renderer.ratchet`: 19 files, 160 tests, all passed.
- Reverse-verified (commit first, ablate, restore; `git status` clean
afterwards):
  - drop `!authoredObject` from `canCreate`: 1 test red;
  - force `declarationOnly = false`: 1 test red;
  - remove `sharing_rule` from `SETUP_CATALOG_TYPES`: 5 tests red.
- `pnpm turbo run type-check --filter=@object-ui/app-shell`: 30 tasks
successful. ESLint on the changed files: 0 errors (existing warnings
only).
- `check-changeset-presence`: passes.
`check-new-cross-file-line-citations`: 0 new.
- Full `pnpm test` and `pnpm lint` are CI's.

<sub>Opened by the objectstack epic seat
(`session_01Y5583bs2RDd53MH6kM1ca3`) through the fleet relay, verbatim
from dev session `session_01QceUKoangk5em2DNk1NGJG`, whose harness
refused the PR-create stroke.</sub>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01QceUKoangk5em2DNk1NGJG

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
… trigger object by label (objectui#13056) (#13079)

Fixes #13056
Clause-②: no

## What changed

Two Studio surfaces now draw the label the served metadata already
carries, the way the Principal card does since objectui#12693. The
machine name stays on the tooltip (`title`). While a label loads, when
the read fails, or when the item has no label, the machine name is the
text, as before.

1. **Explain access, "Contributed by" chips**
(`AccessExplainPanel.tsx`). Each layer's contributor chip now renders
through the Principal card's own `CitedItemChip`. A `position`
contributor takes its `meta/position` label and a `permission_set`
contributor takes its `meta/permission` label. Both come from the same
once-per-run, per-type list read (`lookUpItemLabels`), which now also
covers the names the layers cite. The chip's `title` is the name first,
then the kind and `via` it carried before (`org_member · position · via
everyone`). A `system` contributor is never labelled. The Principal
card's chips are unchanged: they get no `detail`, so their title is
still the bare name.
2. **Flow header, Trigger pill** (`previews/FlowPreview.tsx`,
`FlowMetaPills`). The trigger's object (a record trigger's `objectName`,
or the object a time-relative sweep reads) now shows its label, e.g.
"Record updated · Task". The label comes from `useObjectCatalog`, the
read the Start node's object picker already makes (`GET
/meta/object?preview=draft`), so the header and the inspector draw one
label. The object name moves to the pill's `title`.

No new package export, no new prop on an exported component, and no new
language-pack key.

## File surface: one line outside the dispatch list

`inspectors/ObjectPicker.tsx` gains the `export` keyword on
`useObjectCatalog`, plus a doc-comment line. The ruling says the header
must use "the same resolver the neighbouring surface already uses on
that page", and that resolver is this module-private hook. The module
stays out of every barrel, and the package entry is byte-unchanged. The
claim's file surface names "the label reads they need from served
metadata". The dispatch's own surface list does not name this file. This
conflict is reported, not resolved silently.

## Tests

New:

- `AccessExplainPanel.contributorLabels-13056.test.tsx` (5 tests):
  - the pin (a): labelled chips;
  - a contributor the Principal card does not cite;
  - CONTROL: unlabelled and system contributors;
  - CONTROL: Principal card unchanged, plus one read per type;
  - CONTROL: a failed list.
- `previews/FlowPreview.triggerObjectLabel-13056.test.tsx` (5 tests):
  - the pin (b): record trigger shows "Task";
  - the time-relative sweep;
  - CONTROL: an unlabelled object;
  - CONTROL: a failed catalog;
  - CONTROL: a flow with no trigger.

The no-catalog readings in `FlowPreview.headerPills-11779.test.tsx` and
`StudioDesignSurface.flowStatusTruth-11779.test.tsx` are unchanged and
green. Neither of them serves a catalog, so they still read `task`.

Each ablation was run at HEAD `9c04f63e5a` through
`ablation-replace.mjs`, which restores the file itself. Each restore is
proven by blob hash equal to HEAD and an empty `git diff HEAD`:

- **Row (a):** the contributor chip's `label=` prop was moved off
(anchor 1 to 0, blob `23beb1a5e954` to `460f26ce303a`). 4 failed, 1
passed. The passing test is the failed-list control. Two controls fail
too, because they wait on the label as their settle anchor. The restore
matches HEAD `23beb1a5e954`.
- **Row (b):** `objectLabel` was forced to `undefined` (blob
`ee897177aca4` to `89fd76fc1066`). The two label pins failed and the
three controls passed. The restore matches HEAD `ee897177aca4`.

## Gates

All run on `9c04f63e5a`:

- **Targeted vitest:** 15 files, 102 tests, all passed (the touched and
new files plus their neighbours).
- **Build and type-check:** the app-shell dependency closure build
exited 0. `@object-ui/app-shell` `type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`) exited 0.
- **ESLint, narrowed to the 5 changed files:** 0 errors (JSON formatter,
5 files counted). Type-aware linting is not configured in
`eslint.config.js`, so this narrowing cannot move any verdict on an
untouched file. The one new warning is
`react-refresh/only-export-components` on the new export.
- **The pre-tsc battery:**
- `phantom-deps`, `self-import`, `unreferenced-sources`,
`doc-example-readers`, `handler-key-reads`, `side-effects-array`,
`element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`
and `control-bytes` all exited 0.
- `check-hand-rolled-comment-mask`, `check-changeset-fixed`,
`check-changeset-presence` and `check:new-line-citations` all exited 0.
- `check:spec-floors` exited 1 with `no-artifact`, because it needs a
whole-workspace build. That is recorded per the dispatch and not fixed.
- **Declared to CI:** the full `packages/app-shell/` vitest directory.

## Acceptance notes

- When the explain answer names a contributor that the principal never
held, the list read covers it too (pinned).
- `FlowMetaPills` now makes one object-catalog read per mount, the same
request the Start node picker makes on the same page.

Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…s systemPermissions (objectui#13006) (#13081)

Fixes #13006
Clause-②: yes

`current_user.hasCapability('NAME')` now answers in the console's
predicate scope, from the `systemPermissions` list of the same
`/auth/me/permissions` answer the action gate reads. This is the
objectui half of #24414 (engine change
`ca426cb65d`, already in objectui's pin `f4755a68a7`).

## What changed

- **`@object-ui/core`**, `evaluator/subjectPermissions.ts`: a second
`Symbol.for` subject key, `SUBJECT_CAPABILITIES`, with
`bindSubjectCapabilities` / `subjectCapabilitiesOf` beside
`bindSubjectPermissions` / `subjectPermissionsOf` (the objectui#4421
carrier). `evaluator/fieldRules.ts`: `evalFieldPredicate` passes
`systemPermissions` to the engine beside `permissions`. The engine reads
`hasCapability` only from `EvalContext.systemPermissions`, and this is
the only place on the predicate path that builds that context. Measured
before the change: a list carried in the scope bag or on the subject as
a string key reached the engine only as `extra` variables, and the call
refused with "carries no capability list".
- **`@object-ui/app-shell`**, `providers/ExpressionProvider.tsx`:
`buildExpressionScope` takes `systemPermissions` and binds it onto the
one subject, under the symbol. It is not a CEL root and not a readable
member of `current_user`; `has(current_user.systemPermissions)` is
`false`. The new `useExpressionSystemPermissions` reads
`usePermissions().systemPermissions`, so there is no second fetch. It
hands the list on only when `useExpressionPermissions` binds the object
map from the same answer. Before load it hands on nothing, so the call
faults, as `can()` does; it is never defaulted to `[]`.
`expressionUser.ts` is unchanged, because the list belongs on the
evaluation context and not on `current_user`.
- **Docs**: `content/docs/layout/page-header.mdx` adds one line on
`current_user.hasCapability` next to the `can()` section. No Studio hint
in `views/metadata-admin` lists `can()`: `git grep` for
`current_user.can` outside tests finds only that doc and one error
string. So there was no hint to add a line beside.
- Actions' `visible`, the action capability gate (`useCapabilityGate`,
`ActionEngine`'s `requiredPermissions`) and the proxy pages are
unchanged.
- Changeset: `@object-ui/core` minor and `@object-ui/app-shell` minor.

## Pins

-
`packages/app-shell/src/providers/__tests__/hasCapability-13006.render.test.tsx`
uses a real `MePermissionsProvider`, `ExpressionProvider` and
`SchemaRenderer`, with a page node gated on
`current_user.hasCapability('manage_org_users')`:
- (a) Three principals. The **owner** sees the node. The **plain
member** and the **platform admin who is a plain member** do not. None
of the three arms reports anything.
- (b) **Not loaded**: the predicate faults and the diagnostic names it.
The node is not silently hidden; the fail-soft node gate shows it, which
is the existing node policy. A backend that never reported
`systemPermissions` gets the same result.
- (c) **Controls**: an empty reported list hides the node and reports
nothing. `can()` answers as before (edit granted and shown, delete
denied and hidden, nothing reported).
- **Watch item**: a real refetch, driven by an endpoint change, where
only the capability list differs. Across every render the list and the
map are bound together or not at all. Whenever the list changes, the map
changes with it, and the `ActionProvider` runner's `current_user` ends
on the new list. A second test covers an answer whose object map the
formula adapter refuses: neither half is bound, so the call faults
instead of answering from that answer's list.
-
`packages/core/src/evaluator/__tests__/subjectCapabilities-13006.test.ts`:
- bind and strip, and binding composes with the permissions map in
either order;
  - not a root or member;
- held answers true, not held answers false, an empty list answers
false;
  - absent faults with "carries no capability list";
  - all four subject aliases answer the same;
  - a list carried under a scope key is not read;
  - `can()` control.

## Ablation (one-off, not committed)

`node scripts/ablation-replace.mjs` from the objectstack checkout
deleted the `...(systemPermissions !== undefined ? { systemPermissions }
: {}),` line in `fieldRules.ts`. The tool reported the anchor count
going from 1 to 0 and the blob changing from `d2a1913c50b9` to
`5c640350e4d4`. It then ran both new files, with `@object-ui/core`
aliased to `src`, so no build was involved: `Tests 8 failed | 12 passed
(20)`.
- Red: the owner, member, platform-admin and empty-list arms of the
render test, plus core's held, not-held, empty-list and alias tests.
- Green: the not-loaded arms and the `can()` controls. That is the
predicted direction.

The tool restored the file to blob `d2a1913c50b9`, equal to the HEAD
blob, and `git diff HEAD` came back empty.

## Gates (all on HEAD `2c0b003d70`)

- Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit
0.
- `type-check` for `@object-ui/core` and `@object-ui/app-shell`: exit 0.
Both run `tsconfig.test.json`, which includes `src/**/*.test.ts(x)`.
- vitest with `--maxWorkers=1`, under the shared lock:
  - the two new files: 20 of 20 passed;
  - `packages/core/`: 207 files and 4034 tests passed;
  - `packages/app-shell/src/providers/`: 32 files and 294 tests passed.
- **Declared to CI:** the rest of `packages/app-shell/`, because the
whole directory does not fit in the foreground cap.
- The pre-tsc battery exited 0: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `check-hand-rolled-comment-mask.mjs`,
`check-changeset-fixed.mjs`, `check-changeset-presence.mjs`,
`check-changeset-no-major.mjs` and `check:new-line-citations`.
- `check:spec-floors` exited 1 with `no-artifact` because it needs a
whole-workspace build. Per the dispatch it was recorded and not
repaired: NOT MEASURED.
- ESLint, narrowed to the 5 changed source and test files: 0 errors, 19
warnings. The warnings are `no-explicit-any` and
`react-refresh/only-export-components`, in the same families as the
existing lines of `ExpressionProvider.tsx`. The JSON output counted 5
files. The config enables no type-aware linting (no
`parserOptions.project` / `projectService`), so this diff cannot change
the verdict on any file it does not touch.

## Acceptance notes

- `console/AppContent.tsx` and `views/RecordFormPage.tsx` build their
own object-field evaluators with `createExpressionEvaluator({ ...,
permissions })`. They are outside this card's surface, so they do not
pass `systemPermissions` yet. An object field's `visibleWhen` naming
`hasCapability` therefore faults there, and that gate fails open with a
diagnostic. Page nodes, nav entries and actions under
`ExpressionProvider` all answer. Wiring those two would be a one-line
change at each call site.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

Co-authored-by: Claude <noreply@anthropic.com>
… routes and on declared record pages (objectui#13046) (#13084)

Fixes #13046
Clause-②: no

## What changed

`@object-ui/app-shell`'s `readAppObjectScope`
(`packages/app-shell/src/console/appObjectScope.ts`) stops falling back
to the whole object registry (`GET /api/v1/meta/object`) on two route
shapes the card measured on cloud's control plane:

- **An object's route in an app whose package holds none of its
objects.** When the app's `meta/object?package=` read answers 0 objects,
an object route (`:objectName`, `:objectName/view/:viewId`) now goes
down the same by-name path a package app already uses for objects
outside its package: the route's object, its lookup and master-detail
targets, and the app's `requiresObject` entries, one by-name read each
(joined through the adapter cache since objectui#13044). Only the
route's object holds the route. The app's root (`/apps/APP`) keeps
today's path: package list, then the whole registry.
- **A record route whose record page is declared.**
`:objectName/record/:recordId` and its named-page sibling
(`RECORD_NAMED_PAGE_ROUTE`) join `APP_OBJECT_SCOPE_ROUTES`. Once the
page list has answered, the route picks the record page
`RecordDetailView` renders (the named page when it is a record page of
the object; else the object's default record page through
`defaultRecordPageObject`, else its first record page, as
`usePageAssignment` picks). For a **full** page it reads the objects
that page binds through `readPageObjectScope`, the way page routes do
(`pageObjectNames`), plus the route object and the app's
`requiresObject` entries. With no declared page, or a **slotted** one,
it reads the whole registry: the synthesized page derives its related
lists from every object that points at the record's object. Nothing is
read while the page list is out, so a record route that turns out to
have no declared page asks exactly the requests it asked before.

`AppContent.tsx` hands the page list to `readAppObjectScope` (one
callback, called only on a record route). `MetadataProvider.tsx` and
`ConsoleShell.tsx` are unchanged. No new export; no server change.

## Premise check: Setup has an app package, and it holds no objects

The dispatch's ruling 3 describes Setup as having no app package. On
objectstack `e4ec6ec`, `packages/apps/setup/src/index.ts` registers the
Setup app under package `com.objectstack.setup`
(`SETUP_APP_PACKAGE_ID`), whose manifest carries the app and no objects.
objectui#12811's PR measured the same thing live ("one empty `?package=`
answer, then the whole list"). So Setup has exactly the cloud app's
shape (cloud's `app-cloud` header says it mirrors `@objectstack/setup`),
and "a package-scoped answer of 0" does not tell them apart.

What keeps Setup's measured open unchanged is the route, not the
package: objectui#12811 measured Setup's **landing** (`/apps/setup`),
where by-name reads cost 11 calls and the landing loaded the whole
registry anyway. This PR keeps the app root on the whole-registry path,
so that open asks what it asked before (pinned below). **Setup's object
routes (`/apps/setup/sys_user` and similar) do change**: they now read
their object, its lookup targets and Setup's `requiresObject` entries by
name instead of the whole registry. On objectstack `e4ec6ec` the
plugins' Setup navigation contributions carry 12 `requiresObject`
entries (a one-time grep count, not checked entry by entry and not
re-derived by anything). The route waits only for its own object. That
is a request-count increase and a byte decrease on those routes. It is
raised for the seat in the report's `open_questions`.

## Tests

New pin
`packages/app-shell/src/console/__tests__/AppContent.emptyPackageScope-13046.test.tsx`
(real `MetadataProvider` under the real `AppContent`, client double
recording each call as its URL reads), 8 tests:

- (a) `/apps/cloud/sys_environment`, package answers 0: no
whole-registry read when the route first renders or after it settles;
calls are exactly the package list plus `object/sys_environment`,
`object/sys_organization`, `object/sys_user` (lookup targets) and
`object/sys_member` (`requiresObject`).
- (b) a record route with a declared full record page: in a package app,
the package list and `object/crm_quote` (bound outside the package)
only; in the empty-package app, the record object, the page's objects
and their lookup targets by name; the named-page route likewise.
- (c) CONTROLs, request lists counted exactly: Setup's open
`/apps/setup` asks `object?package=com.objectstack.setup` then `object`;
an app whose package holds its objects asks the package list and its
lookup target; a record route with no declared page asks `object`; a
slotted record page asks `object`.

Updated pin `AppContent.objectScope-12811.test.tsx`: its control "an app
whose package holds no objects of its own reads the whole registry"
asserted the old behaviour on an object route; it now asserts it on the
app's root, the shape this card keeps.

**Ablation** (one-time, no file kept): `appObjectScope.ts` replaced by
its blob at the base `bc98626ed5` (on-disk hash `e761114c` = base blob),
the new pin run: 4 failed (both (a) and all three (b)), 4 passed (the
four controls, so they assert today's requests). Restored with `git
checkout HEAD`: on-disk hash `5f143758` = HEAD blob, `git diff HEAD`
empty.

## Gates

At `87ee82176f` unless noted. Exit codes captured before any pipe.

- Closure build `turbo run build --filter=@object-ui/app-shell^...`:
28/28 tasks, exit 0.
- `pnpm --filter @object-ui/app-shell run type-check` (`tsc --noEmit`
and `tsc -p tsconfig.test.json`, which includes the tests): exit 0, at
`494be26b25` (same source; the next commit adds only the changeset).
- Targeted vitest, `--maxWorkers=1`, under the shared lock: the new and
updated pins, plus every test file naming `appObjectScope`,
`readPageObjectScope` or `AppContent`: 60 files, 496 tests, all passed
(app-shell 46 files / 336 tests; apps/console and i18n 14 files / 160
tests).
- `eslint --no-inline-config` on the 4 changed files: 0 errors; warnings
equal the base on the three existing files (10, 44, 3); the new test
file has 3 `no-explicit-any`, the sibling pin's idiom.
- Battery, all exit 0: `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:doc-example-readers`,
`check:handler-key-reads`, `check:side-effects-array`,
`check:element-data-source-declaration`, `check:spec-symbols`,
`check:action-forward-parity`, `check:designer-field-key-parity`,
`check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check:new-line-citations` (0 new), `check:test-path-roots`,
`scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`.
- `check:spec-floors`: exit 1, `no-artifact` (it needs the whole
workspace built); recorded, not fixed, as dispatched.

Declared to CI: the full `packages/app-shell/` and `apps/console/`
directories (not run locally: the container's test lock is shared).

## Acceptance notes

- NOT MEASURED: a live open of cloud's 环境 list and an environment
record, and live before/after opens of Setup and HotCRM. Reason: no
cloud or HotCRM stack was run in this container; the request lists above
are counted in the provider harness, not in a browser.
- HotCRM, read from its source (`objectstack-ai/hotcrm` `850f75c`):
`crm_opportunity`, `crm_lead` and `crm_case` declare full record pages,
so their record routes now read the package list plus the bound objects
outside it; `crm_account`'s record page is slotted and keeps the whole
registry.
- A record route without a declared page now issues its whole-registry
read after the page list answers rather than beside it. The request set
is unchanged (pinned); the two reads are serial on that route. NOT
MEASURED in time.
- The console root's prefetch (`useAppObjectScopePrefetch`) is not
handed the page list, so on a record route the scope starts from
`AppContent`, one permission round trip after the console root.
- Only the route stand-ins (`ObjectView`, `RecordDetailView`) are mocked
in the pin; a reader inside the real record view that reads
`useMetadata().objects` on its own is outside what this pin sees. The
census `openPathObjectReaders-12811` still passes.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…t over it (objectui#13068) (#13085)

Fixes #13068

Clause-②: no

## What changed

On a phone (`isMobile`, under 768 px), after a built app was previewed
and the preview closed, `ChatPane` offered the preview again with a pill
(`ai-chat-mobile-preview-open`, objectui#2481) drawn in a
`pointer-events-none absolute inset-x-0 bottom-24` wrapper beside the
chat column. At 390 px that box sat on the composer's first input line
(101 by 35 px).

The pill is now a row of the composer's own area. `ChatPane` hands it to
`ChatbotEnhanced` through the existing `promptHeaderSlot` (PR #13067,
objectui#12978) together with the pending-drafts bar: pill above bar,
both in flow above the input, so the conversation gives up the room
instead. The absolute wrapper is gone; the pill keeps its look, its
`data-testid` and its one tap to the full-screen preview.

Only `packages/app-shell/src/console/ai/AiChatPage.tsx` changes in
source. No prop, export or published face of `ChatbotEnhanced` changes
(`Clause-②: no`, as the claim declared).

### The shape of the slot content, and why

`promptHeaderSlot` renders its content in a `mb-2 flex empty:hidden`
row. Three facts decide what `ChatPane` puts in it:

- That row is a horizontal flex row with no wrap, so a bare fragment
would set the pill BESIDE the bar. Measured at 390 px the bar's sentence
already loses 8 px (`scrollWidth` 248 against `clientWidth` 240); beside
a 101 px pill it would keep about half. So the two are stacked in a
column.
- `empty:hidden` reads the DOM: an always-present column would keep the
row (and its 8 px margin) even when the bar renders nothing. So the
column exists only while a preview is available on a phone (`canvasApp
!== null && isMobile`). Otherwise the slot holds the bar alone, exactly
as PR #13067 left it, and stays empty when the bar renders nothing.
- The column does not depend on whether the overlay is open, so opening
and closing the preview never remounts the bar. A remount would reset
its count (`usePendingDrafts` holds no response cache) and the bar would
vanish until the ledger answered again. The bar is remounted once, when
a preview first becomes available on a phone; while the overlay is open
the column sits under the full-screen preview, unseen.

The PM's hypotheses, measured: H1 confirmed in source and in the browser
(pill only under `canvasApp && isMobile` with the overlay closed; the
card's box reproduced). H2 confirmed with the stacking above (the slot's
row would set a bare fragment side by side). H3 confirmed: `useIsMobile`
answers `innerWidth` under 768, and at desktop widths the canvas opens
beside the chat and no pill renders; the desktop slot content is
unchanged. H4 followed: the placement helpers were local to the
objectui#12978 test, so they move to a shared helper beside both tests
(the `__tests__` helper-module convention, e.g.
`console/__tests__/preferenceFramework.ts`), which both import.

## Measured boxes (Acceptance 2)

Chromium headless, zh-CN, the rig of PR #13067 reused: the worktree's
console Vite dev server serving the REAL full-page `AiChatPage`, or the
REAL `ChatDockMobileSheet` (the dock's phone presentation, the one
addition to that harness), with `fetch` stubbed in the page: one `build`
agent, two turns plus an `apply_blueprint` result that drafted an app,
and one draft (or none) in the drafts ledger. The driver taps the app
card's 预览, closes the full-screen preview, reads, then taps the pill.
"Before" writes `AiChatPage.tsx` of `main` `bc98626` over the fix
(restored afterwards, proved by blob hash against HEAD and an empty `git
diff HEAD`). Boxes are left top right bottom, CSS px, 390 by 844 unless
named.

**Control:** `before-fullpage-390-pill` reads the card's boxes exactly:
pill 144 712 246 748, textarea 17 713 373 777, 101 by 35.

| reading | pill | bar | textarea | pill on textarea | pill on bar |
first-line points on the input | placeholder / content width |
| --- | --- | --- | --- | --- | --- | --- | --- |
| full page, draft pending, before | 144 712 246 748 | 16 650 374 704 |
17 713 373 777 | 101 by 35 | 0 | 4 of 5 | 238 / 332 |
| full page, draft pending, after | 144 606 246 642 | 16 650 374 704 |
17 713 373 777 | **0** | 0 | 5 of 5 | 238 / 332 |
| full page, nothing pending, before | 144 712 246 748 | none | 17 713
373 777 | 101 by 35 | none | 4 of 5 | 238 / 332 |
| full page, nothing pending, after | 144 668 246 704 | none | 17 713
373 777 | **0** | none | 5 of 5 | 238 / 332 |
| dock sheet, draft pending, before | 144 712 246 748 | 16 650 374 704 |
17 713 373 777 | 101 by 35 | 0 | 4 of 5 | 238 / 332 |
| dock sheet, draft pending, after | 144 606 246 642 | 16 650 374 704 |
17 713 373 777 | **0** | 0 | 5 of 5 | 238 / 332 |
| full page 320 by 700, after | 109 462 211 498 | 16 506 304 560 | 17
569 303 633 | **0** | 0 | 5 of 5 | 238 / 262 |
| full page, no preview yet, after | none | 16 650 374 704 | 17 713 373
777 | none | none | 5 of 5 | 238 / 332 |
| full page 1280 by 900, after | none | 449 706 1121 760 | 450 769 1120
833 | none | none | 5 of 5 | 208 / 646 |

- **Before:** the pill covers the textarea's first line by 101 by 35
everywhere it shows; the middle first-line point reports the pill.
- **After:** the pill sits 8 px above the bar (642 to 650), or 9 px
above the input when nothing is pending (704 to 713). It touches
neither, all five first-line points reach the input, and the placeholder
「问问数据,或让我改改这个应用…」 fits whole. The textarea's box does not move: the
conversation's bottom moves from 650 to 606 with the bar and from 712 to
668 without it. Tapping the pill opened the full-screen preview in every
reading that has one.
- **Untouched states:** with no preview yet, the bar and textarea read
exactly as PR #13067's `after-fullpage-390`; at 1280 (a preview opened
beside the chat, then closed) they read exactly as its
`after-fullpage-1280`, and no pill is drawn.

Before and after, full page, 390 px, with a draft pending:

![before, full page,
390](https://github.com/objectstack-ai/objectui/raw/2338aed682493be66d792e9496f3a06e668345c2/evidence/13068/before-fullpage-390-pill.png)
![after, full page,
390](https://github.com/objectstack-ai/objectui/raw/2338aed682493be66d792e9496f3a06e668345c2/evidence/13068/after-fullpage-390-pill.png)

After, nothing pending, and in the dock sheet:

![after, full page, 390, nothing
pending](https://github.com/objectstack-ai/objectui/raw/2338aed682493be66d792e9496f3a06e668345c2/evidence/13068/after-fullpage-390-pill-nodraft.png)
![after, dock sheet,
390](https://github.com/objectstack-ai/objectui/raw/2338aed682493be66d792e9496f3a06e668345c2/evidence/13068/after-sheet-390-pill.png)

**Where the evidence lives:** commit `2338aed` of this branch, directory
`evidence/13068/`: every screenshot (before and after), each reading as
JSON, a README, the throwaway harness page and the Playwright driver.
The next commit, `4086704`, deletes the directory, so this pull
request's diff does not carry it (`git rev-parse 9c698ba^{tree}
4086704^{tree}` prints the same tree twice). No other branch was
created.

## Tests (Acceptance 1)

-
`packages/app-shell/src/console/ai/__tests__/AiChatPage.previewPillInFlow-13068.test.tsx`
(new). Real full-page `AiChatPage`, real `ChatDockMobileSheet` default
body, real `ChatPane`, `ChatbotEnhanced` and `PendingDraftsBar`, at
`innerWidth` 390. The stand-ins are those of the objectui#12978 test,
plus a handle beside the chat that calls the pane's `onPreviewDraftApp`
(as a draft card's Preview does) and a Live Canvas reduced to its close
button. happy-dom runs no layout, so the test pins the structure that
decides the boxes: for the pill against the input, the bar against the
input, and the pill against the bar, (1) the nearest element holding
both holds no conversation (`role="log"`), (2) nothing between either
and that element is out of flow (`absolute`, `fixed`, `sticky`, by class
or inline style), (3) the first one's row comes before the other's, (4)
neither sits inside the form. Cases: full page and dock sheet with a
draft pending; full page with nothing pending; the pill opens the
preview in one tap and closing it brings the pill back with the SAME bar
node (no remount); at 1280 px no pill renders and the slot holds the bar
alone.
- `packages/app-shell/src/console/ai/__tests__/inFlowPlacement.ts` (new,
shared helper): the readings of the objectui#12978 test, moved out
unchanged and generalized to any two elements.
- `AiChatPage.draftsBarInFlow-12978.test.tsx`: imports the helper
instead of carrying its own copy; same four readings, same two cases.
- **Ablation 1:** fix committed, then `AiChatPage.tsx` of `bc98626`
written over it (trap-guarded; anchor
`promptHeaderSlot={promptHeaderRows}` 1 to 0 on disk, the float wrapper
0 to 1). The new suite went red, `Tests 3 failed | 2 passed (5)`: the
three placement cases, each on exactly the old geometry
(`holdsConversation: true`, `outOfFlow: ["div absolute"]`,
`rowBeforeBelow: false`). The two that stayed green pin behaviour `main`
already had (one tap to the preview, nothing at desktop). Restore proved
by blob hash against HEAD and `git diff HEAD` reading 0 lines.
- **Mutation 2:** the column made to exist only while the pill shows
(`mobilePreviewAvailable && !mobileCanvasOpen`, anchor 1 to 0 on disk).
Only the one-tap case went red, `Tests 1 failed | 4 passed (5)`: after
closing the preview the bar was gone (`Unable to find an element by:
[data-testid="pending-drafts-bar"]`), the remount the column exists to
avoid. Restored and proved the same way.
- Targeted run on head `4086704` (tree `a6aeeb5`, the same as
`9c698ba`): `pnpm exec vitest run` over
`packages/app-shell/src/console/ai/` plus the 58 test files under
`packages/app-shell/src/layout/` and
`packages/app-shell/src/views/studio-design/` that mount `ChatPane`,
`AiChatPage`, `ChatDockPanel` or `ChatDockMobileSheet`: `Test Files 100
passed (100)`, `Tests 634 passed (634)`, exit 0. The full app-shell
suite is CI's (`Test Core` shards).

## Gates (local subset; the farm is CI's)

Each exit code was captured to a file before any pipe; all on tree
`a6aeeb5` (head `4086704`).

- `pnpm --filter @object-ui/app-shell lint`: exit 0 (`0 errors`,
warnings only; none on the changed lines or the new files).
- `pnpm --filter @object-ui/app-shell type-check`: exit 0, after
building the package's dependency closure (`turbo run build
--filter='@object-ui/app-shell^...'`, 28 tasks successful). A first run
before that build exited 2 on unbuilt dependencies only (`TS2307 Cannot
find module '@object-ui/fields'` and the like), a precondition, not a
reading. `tsc -p tsconfig.test.json --listFiles` lists the new test, the
shared helper and the edited objectui#12978 test.
- `pnpm check:new-line-citations` 0 (`0 new citation(s)`), `pnpm
check:control-bytes` 0, `pnpm check:vi-mock-specifiers` 0, `pnpm
check:vi-mock-inherit` 0, `pnpm check:vi-mock-override-shape` 0, `pnpm
check:test-path-roots` 0, `pnpm check:unreferenced-sources` 0, `pnpm
check:changeset-claims` 0, `pnpm check:pending-changeset-literals` 0,
`node scripts/check-changeset-presence.mjs` 0 (`4 source file(s) of 1
released package(s) changed, and this change declares 1 changeset(s)`),
`node scripts/check-changeset-no-major.mjs` 0.
- Changeset: `.changeset/13068-preview-pill-in-flow.md`,
`@object-ui/app-shell` patch.

## Acceptance notes

- At 390 px the pending-drafts bar's sentence 「有 1 项改动尚未发布——用户还看不到。」 is
cut by its own `truncate` (`scrollWidth` 248 against `clientWidth` 240;
at 320 px, 248 against 170), before and after this change alike: the
bar's box is the same in both readings. Reported to the seat; not
changed here.
- objectui#12725 is dispatched on the same file (the claim says so);
whichever lands second merges `main`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…nvelope accepted, blank string refused) (#13086)

Fixes #13075
Clause-②: yes

## What changes

`@object-ui/types`' navigation mirror typed an entry's `visible` as
`boolean | string`. The spec types every navigation branch's `visible`
as `EvaluatedExpressionInputSchema`, so the two disagreed both ways: the
mirror refused the `{ dialect, source }` envelope the spec accepts, and
accepted a blank string the spec refuses.

- `packages/types/src/zod/app.zod.ts`: `NavigationItemObject.visible` is
now `NavigationVisibleSchema`. Its accept set is `boolean`, a string, or
the spec's own `EvaluatedExpressionSchema` (by reference). Every
non-boolean value is handed to the spec's
`EvaluatedExpressionInputSchema` (imported from
`@objectstack/spec/shared`, not restated), and that schema's issues are
the refusal, with the spec's code, path and sentence. The spec's string
transform is not taken: this mirror writes nothing into an author's
document (decision batches #69 / #90), so a bare string stays a string.
This is the same spelling `visibleWhen`'s wire uses in `base.zod.ts`.
The judge runs only on a value an arm already took, so one fault is
reported once. The `doc` arm reads the same slot, so it changes the same
way.
- `packages/types/src/app.ts`: `NavigationItem['visible']` is `boolean |
EvaluatedExpressionInput`, the spec's input type by reference.
- `navigation-spec-parity.test.ts`: a new block pins parity both ways.
The envelope is accepted by both, a blank or whitespace string is
refused by both with the same code and sentence, and a blank `source`
inside the envelope is refused by both once at `visible.source`. The
`visible: boolean` row stays as the one recorded divergence, with its
existing reason.
- Consumer widenings, type-only, under seat ruling `6276914186` (A).
Three callback parameters change from `string | boolean | undefined` to
`NavigationItem['visible']`: layout's exported `VisibilityEvaluator`
(`NavigationRenderer.tsx`), and the `evalVis` callbacks in app-shell's
`hooks/useNavMemberGates.ts` and `views/nav-menu-renderer.tsx`. A fourth
site,
`packages/app-shell/src/layout/__tests__/navItemVisibleEvaluated.test.tsx`,
has two callback annotations widened the same way. app-shell's
`tsconfig.test.json` found it (3x TS2322 under `strictFunctionTypes`).
There is no runtime change: every reader already passes the value to
`defaultVisibility` / `evaluateNavigationVisibility`, which handle the
envelope.
- `.changeset/13075-nav-visible-envelope.md`: `@object-ui/types` `minor`
(widening by envelope, breaking narrowing on blank strings).
`@object-ui/layout` `minor`, because `VisibilityEvaluator` is on the
package entry through `export * from './NavigationRenderer'`, and
widening a callback parameter breaks a host evaluator annotated with the
old narrower type. `@object-ui/app-shell` `patch`, because nothing
widened there is on its entry.

## Reach (measured from source)

- **Producer:** `defineApp` in `@objectstack/spec`
(`packages/spec/src/ui/app.zod.ts`) returns `AppSchema.parse(config)`.
The spec's parse rewrites a `visible` string into `{ dialect: 'cel',
source }`, so any app authored through it carries the envelope from then
on. The spec also documents the envelope as the slot's normal form. Not
measured: an end-to-end read of a served app's navigation through the
console.
- **Renderer:** the console already evaluates an envelope `visible`.
`evaluateNavigationVisibility` (`@object-ui/app-shell`,
`ExpressionProvider.tsx`) takes `string | boolean | { dialect, source }`
and hands it to `ExpressionEvaluator.evaluateCondition`, which routes a
CEL envelope to the CEL engine.
`providers/__tests__/navigationVisibility-12700.test.ts` pins it with
envelopes. There is no renderer gap.

## Pins and ablation

- (a) The envelope parses on `NavigationItemSchema`, for a url entry and
a doc entry, and is kept as written.
- (b) `''` and `' '` are refused by both schemas: `invalid_union @
visible`, with the same sentence.
- (c) Controls: a bare non-blank string parses and is not rewritten;
`true` and `false` still parse on the mirror and are refused by the
spec.
- The envelope used in the pins reads no `data`. The card's own example
(`data.total == 1`) is now refused by the spec's data-source rule. That
rule is objectui#13076's chain, and that card's `superRefine` hook is
left alone here.

Ablation at `46731c3293`: `scripts/ablation-replace.mjs` (objectstack)
put back `visible: z.union([z.boolean(), z.string()]).optional()`
(anchor 1 to 0, blob `9e64d7e86420` to `cc23dad3b72b`).
`navigation-spec-parity.test.ts` then went `6 failed | 45 passed`. The
failures were the two envelope rows, the two blank-string rows on the
url entry, the blank-`source` row and the typed-envelope row. The
controls stayed green. The restore was proven by blob hash: after the
restore the blob was `9e64d7e86420`, equal to the HEAD blob, and `git
diff HEAD` was empty.

## Gates

At `bec1a8a688` (after the ruling's widenings):
- The closure build `pnpm --filter '@object-ui/app-shell^...' build`
(covers types and layout) exited 0.
- `pnpm --filter @object-ui/layout type-check` exited 0.
- `pnpm --filter @object-ui/app-shell type-check` exited 0. Its first
run was red: 3x TS2322 in `navItemVisibleEvaluated.test.tsx`, fixed as
above.
- Nav tests: every layout / app-shell test file naming
`useNavMemberGates`, `nav-menu-renderer` or `NavigationRenderer`, plus
`navigationVisibility-12700`. layout: 22 files / 260 tests passed.
app-shell: 26 files / 158 tests passed.
- The pre-tsc battery and the changeset scripts all exited 0.
`check:spec-floors` exited 1 with `no-artifact`, as before.
- Narrowed eslint over the 7 changed `.ts`/`.tsx` files: 0 errors and 37
warnings, all existing and none on touched lines.

At `46731c3293` (the types half, unchanged since):

- `pnpm --filter @object-ui/types type-check`: exit 0.
`tsconfig.test.json` lists `navigation-spec-parity.test.ts` (checked
with `--listFilesOnly`).
- Targeted vitest: `navigation-spec-parity` + `spec-derived-unions` gave
64/64 passed. The 27 test files in `packages/types` that name `app.zod`,
`../app.js` or `NavigationItemSchema`, plus `imported-defaults-8317`,
gave `887 passed (887)`. The full `packages/types/` directory is
declared to CI and was not run locally (per the dispatch's lock clause).
- `eslint --format json` over the three changed `.ts` files: 3 files, 0
errors, 1 warning. The warning is the existing `z.ZodType` any on
`NavigationItemSchema`, not new. `eslint.config.js` does not enable
type-aware linting, so this diff cannot change the verdict on any
untouched file.
- Pre-tsc battery, all exit 0: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `check-hand-rolled-comment-mask.mjs`,
`check-changeset-fixed.mjs`, `check-changeset-presence.mjs`,
`check:new-line-citations`. `check:spec-floors` exited 1 with
`no-artifact` (the workspace is not built); per the dispatch this is
recorded and not fixed.

## Acceptance notes

- The flat mirror's spelling follows the `visibleWhen` precedent
(`base.zod.ts`, `VisibleWhenInputSchema`), not a literal
`z.union([z.boolean(), EvaluatedExpressionInputSchema])`. The literal
union would rewrite every string `visible` into an envelope at parse,
against decision batches #69 / #90. It would also turn red the existing
`doc` row that asserts the mirror keeps a string `visible` as written.

The session for this change is
`https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Fixes #13074
Clause-②: yes

## What changed

A page `list-view` node now honours the spec's `requiredPermissions`,
and the `list-view` and `list` registrations publish it. Clause-② keys
published: **`requiredPermissions`** only. `addRecord` and `sharing`
stay unpublished.

This PR first published `addRecord` as well. The contract review
(`6277254453` on #13074) found that the console page host serves no
`onCreate`: `pageRelatedHandlers` answers a list with no parent with no
handlers. So that half was wrong on this base. The seat ruled
(`6277259990`) to land `requiredPermissions` alone and filed the page
host's create path as objectui#13089.

- **`requiredPermissions`** (`ListViewBlock`): the audience gate, read
the way the record blocks read it, through
`usePermissions().hasCapabilities(required)` from
`@object-ui/permissions`.
- A viewer missing any listed capability gets the record blocks'
insufficient-permissions notice (`role="status"`, "Insufficient
permissions to view list.") in place of the list.
- The check returns before `ElementDataSourceGate`, so no saved view is
resolved and no `find` is sent.
- A provider that reports no `systemPermissions` fails open, as
`hasCapabilities` does everywhere else.
- Only the page node reads the key. The object list page is gated by the
server.
- **`addRecord`, unpublished.** The block sends the Add button to the
`onCreate` that a `RelatedRecordActionsContext` provider serves for the
listed object, and withholds the button where none is served. That is
every console page today. So a node with `addRecord.enabled` draws no
button where it used to draw a dead one. A host `onAddRecord` prop
handed to the block still wins. The `UNPUBLISHED_EXEMPTIONS` row for
`list-view.addRecord` is reworded to this fact and cites objectui#13089,
which publishes the key once the page host serves `onCreate`.
- `index.tsx` publishes `requiredPermissions` (`array`, `of: 'string'`)
on `LIST_VIEW_INPUTS`.
- `registry-inputs-spec-parity.test.ts`:
- the by-measurement row `list-view.requiredPermissions` is struck, and
its member pin points at the new pin file;
  - the `list-view.addRecord` row stays, reworded.
- **Docs:** `packages/plugin-list/README.md` and
`content/docs/plugins/plugin-list.mdx` gain an "On a page —
`requiredPermissions`" section: the viewer must hold ALL listed
capabilities, otherwise the notice shows and no query is sent; an empty
or absent key is no gate; an unreported provider shows the list. Both
say `addRecord` and `sharing` are not published inputs.
- **CI scan fix.** `ListViewBlock` hands `ListView` a named local taken
from the document (`const listSchema = add.schema`), so
`check-handler-key-read-sites` keeps following the step into `ListView`.
The `view:list` alias census is 11 (as on `main`), and the test's floor
is unchanged. The first head passed `add.schema` inline, which the
scanner does not follow, and dropped the census to 7.
- `.changeset/13074-list-view-inert-keys.md`: `@object-ui/plugin-list`
`minor`.

## Pins


`packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx`.
Every node is authored in a `properties` bag that the spec's own
`list-view` row parses. It renders through the real `SchemaRenderer` and
this package's registration, under a real stock `MePermissionsProvider`.

- (a) `requiredPermissions`:
  - an unheld capability shows the notice, with no `find` and no grid;
  - a held capability shows the list and queries;
  - a partial grant is refused, and the full grant is its control;
  - an empty list is no gate;
  - with no key, the list is drawn for a viewer who holds nothing;
  - an unreported `systemPermissions` fails open.
- (b) The block's create routing, under a provider serving `onCreate`:
- a click calls `onCreate`, and `resolve` was asked for `{ objectName:
'crm_task' }`;
  - `position: 'both'` dispatches from each button;
- the button is withheld with no `onCreate`, with `createDisabled`, and
with no provider.
- (c) Controls:
- with no `addRecord`, no button is drawn and `resolve` is never called;
  - `sharing`'s Share button changes nothing;
- the registration publishes `requiredPermissions` and not `addRecord`
or `sharing`.

`ListViewBlock.specInputs-13047.test.tsx` has one restored row: a page
node draws no Add button with no provider, or under a provider whose
`resolve` answers the console host's frozen empty set. In that case
`resolve` was asked and no create is sent.

## Ablations (each restore proven by blob hash)

| Mutation in `ListViewBlock.tsx` | Result | Run at |
|---|---|---|
| gate condition prefixed `false &&` | 2 failed / 13 passed (unheld;
partial grant) | `ac5989279f`, restore blob `eba5fd8e3b56` |
| `onAddRecord: handlers.onCreate` replaced by `undefined` | 2 failed /
13 passed (click; position both) | `ac5989279f`, restore blob
`eba5fd8e3b56` |
| withholding return replaced by the unchanged node | 3 failed / 12
passed | `ac5989279f`, restore blob `eba5fd8e3b56` |
| withhold branch returns `{ schema: bound, onAddRecord: undefined }` |
4 failed / 59 passed (the four withheld rows, incl. the restored 13047
row) | `7156ba0657`, restore blob `3eff70d41efe` |

## Local gates

At `7156ba0657`:
- Targeted vitest on four files (inert-keys, specInputs-13047,
`registry-inputs-spec-parity`,
`scripts/__tests__/check-handler-key-read-sites.test.ts`): 373 passed.
- `pnpm --filter @object-ui/plugin-list type-check` exited 0, tests
included.
- These checks each exited 0: `check:handler-key-reads` ("11 read(s)
under 8 namespaced-only alias(es)"), `check:new-line-citations`,
`check:changeset-claims`, `check:pending-changeset-literals` and
`check:registry-bare-names`.

At `ac5989279f`:
- The closure build and the plugin-list type-check exited 0.
- Neighbour runs passed (12 files, 304 tests).
- Narrowed ESLint: 0 errors.
- The pre-tsc battery and the changeset scripts all exited 0.

Declared to CI and not run locally: the full `packages/plugin-list/` and
`apps/console/` directories, and the `@object-ui/console` type-check.

## Acceptance notes

- **`sharing`** stays unpublished with its recorded reason: the spec
declares no action for the Share button.
- **`addRecord`** stays unpublished. The page host's create path, the
objectui#12870 CONTROL row's re-examination, a pin through the real
`PageView`, and publishing the key all go to objectui#13089.
- **Two create paths** (`openChildForm` ignores `editMode: 'page'`):
source reading. Carrier: objectui#13089.
- **Possible dead Add button on the console object list**
(`renderListView` passes `addRecord` without `onAddRecord`): source
reading, not measured. Carrier: objectui#13089.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
… backfill a new field's literal default on publish (objectui#12929) (#13090)

Fixes #12929

Clause-②: yes

## What changes

The Studio 变更 (Changes) drawer now offers a backfill choice under each
field that a pending draft adds to an already-published object, when
that field declares a literal `defaultValue`. The choice reads 「补默认值(N
条已有记录)」 in zh and "Backfill default (N existing records)" in en, and it
starts unchecked. Publish all passes the checked fields to the
protocol's opt-in `backfill` (#24253, carried
by objectui's pin `f4755a68a797`). The publish toast then shows, for
each field, the rows that the server's `backfillApplied` receipt says it
wrote. When nothing is checked, the publish makes the same call it made
before, with the body `{}`.

- **`packages/data-objectstack/src/metadata-client.ts`**:
`MetadataClient.publishPackageDrafts(packageId, options?)`.
- `options.backfill` goes into the body as `backfill` only when it is
given. Otherwise the body is `{}`, byte for byte.
- The options type, `MetadataPublishPackageDraftsOptions`, is picked
from `@objectstack/client`'s `packages.publishDrafts` signature. The
request has no spec schema of its own, so the client signature is where
the protocol declares it.
- The receipt comes back on the existing
`MetadataPublishPackageDraftsResult`, which is the spec response by
reference, and the client does not examine it.
- **`packages/app-shell/src/preview/DraftChangesPanel.tsx`**:
`backfillCandidates` applies the batch door's own rule to the two bodies
the drill-in already reads.
- The rule: the object is published, the drill-in's diff lists the field
as added, the draft declares the field in its `fields` map, and the
field's `defaultValue` is present and is a LITERAL according to the
spec's `discriminateDefaultValueShape` (the same function the door
calls).
- N comes from the console's footer probe, `adapter.find(object, { $top:
0 })`, read as the signed-in caller, which is the same caller the door
writes as. N is shown only when the answer carries a numeric `total`.
Otherwise the choice shows no number.
- The choice is offered only where the sheet has a Publish and a
package, because the batch door is the only door that takes `backfill`.
- The footer calls `onPublish({ backfill })` with the checked fields, or
`onPublish()` with no argument when none is checked.
-
**`packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx`**:
`doPublish(options?)` passes the sheet's options to the batch door only
when the sheet gave some.
- On success with a receipt, the toast's description gives each field's
`updated` count.
- A field that stopped short gives the server's `error`, and the toast
becomes a warning. The publish itself still stands.
- **Strings**:
- All ten `@object-ui/i18n` packs: `preview.changes.backfillOption`, a
count family with every CLDR slot the pack's language selects, and
`preview.changes.backfillOptionNoCount`.
- The Studio table (en, zh): `engine.studio.backfillWritten` and
`engine.studio.backfillStopped`.
- **`.changeset/12929-publish-backfill.md`**: data-objectstack minor,
app-shell minor, i18n minor.

## Published faces this widens (`Clause-②: yes`, as the claim corrected
it)

- **`@object-ui/data-objectstack`**:
`MetadataClient.publishPackageDrafts` gains an optional second
parameter.
- The parameter's type is not re-exported from the package entry, the
same as `MetadataPublishPackageDraftsResult`.
- The built `dist/index.d.ts` now has a type-only reference to
`ObjectStackClient` from `@objectstack/client`, which is already a
dependency.
- **`@object-ui/app-shell`**: no exported name changes type.
`DraftChangesPanel` is not on the package entry, and
`StudioDesignSurface`'s props are unchanged. The widening is behaviour
in the drawer.
- **`@object-ui/i18n`**: two new key families in every pack.

## Mechanism hypotheses, measured

- **H1 holds.** `StudioDesignSurface` is the only host that mounts
`DraftChangesPanel` with `onPublish`. Its `doPublish` reaches
`shellClient.publishPackageDrafts`.
- `DraftPreviewBar` mounts the sheet read-only and publishes through
`usePublishAllDrafts`, the same as Home.
- `usePublishAllDrafts`, `PackagesPage`, `AiChatPage` and
`PendingDraftsBar` are unchanged. They call with the package id only, so
they send `{}`.
- The existing pins that assert the Studio call is exactly
`publishPackageDrafts(PKG)` stay green: `heldPublish-11786`,
`heldWidgetPublish-11910` and `itemDraftLine-12725`.
- **H2 holds.** The installed `@objectstack/spec` (pin build) declares
`PublishPackageDraftsResponse.backfillApplied`. The client pin parses
its receipt fixture against the installed
`PublishPackageDraftsResponseSchema`, so CI re-reads this.
- R, the first published release that carries `1e001c7df`, is
`18.0.0-next.5`.
- `git merge-base --is-ancestor 1e001c7` against the
`@objectstack/spec@18.0.0-next.5` tag commit exits 0. Against the
`next.3` tag commit it exits 1, with the control leg (`next.2` tag
commit inside `next.3`) exiting 0.
- `next.4` was never tagged, and the npm `next` dist-tag reads
`18.0.0-next.5`.
  - Floors: see Deviations.
- **H3 holds.** The drawer reads the draft body it already fetches for
the diff, and judges each field with the spec's
`discriminateDefaultValueShape`.
- A token default (`NOW()`, `current_user`) and an expression envelope
get no choice. Neither does a field with no `defaultValue` (or a `null`
one), nor a draft whose `fields` is not the map the door reads.
- The door's fourth condition is that the field is absent from the
object the engine serves. The sheet cannot see that, so it stays the
door's 400, which reaches the author through the existing error toast.
- **H4: an honest count source exists.** It is the console footer probe,
`adapter.find(object, { $top: 0 }).total`.
- At the pin, the protocol runs `engine.count` with the caller's context
for a zero-row page, so N is the records the caller can read. The
backfill reads and writes as that same caller.
- With no numeric `total`, the choice shows no number. The authoritative
count is the receipt's `updated`, and the toast reports it.

## Tests

Run from the repo root with `pnpm exec vitest run`. The final union ran
at head `ca83ad0`: `pnpm --filter @object-ui/data-objectstack test` exit
0 (97 files, 1179 tests passed); 26 app-shell files exit 0 (126 tests
passed): the 12 `DraftChangesPanel.*` sheet files and `DraftPreviewBar`,
12 Studio publish-path files (`publishCount-11807`,
`publishPosture-12517`, `publishAdvisoryReachesAuthor`,
`heldPublish-11786`, `heldWidgetPublish-11910`,
`packageLessFlows-11553`, `copilotDoors-12894`, `homeLoop-12513`,
`itemDraftLine-12725`, `manageSnapshotRefresh`, `changesCount-11787`,
`publishReadOnlyReason-12303`) and the new Studio pin; `packages/i18n/`
exit 0 (84 files, 1319 passed, 13 skipped). The full app-shell suite
belongs to CI.

-
**`packages/data-objectstack/src/metadata-client.publishBackfill-12929.test.ts`**:
  - no options sends `{}`, and so do `{}` and `undefined`;
  - `backfill` is sent as given and in order;
- the receipt is returned, and its fixture parses against the installed
spec.
-
**`packages/app-shell/src/preview/__tests__/DraftChangesPanel.backfill-12929.test.tsx`**:
- exactly one unchecked choice appears, for the added literal default on
the published object, naming 12 records and showing `= false`;
- token, expression, no-default and `null`-default fields get no choice,
and neither does an unpublished object;
  - no number is shown without a numeric total;
  - no choice appears without a package or without a Publish;
- unchecked, `onPublish()` is called with no arguments; checked, it is
called with `{ backfill: [{ object, field }] }`; checked then unchecked,
with no arguments again;
- `backfillCandidates` keeps falsy literals and refuses an array-shaped
`fields`.
-
**`packages/app-shell/src/views/studio-design/StudioDesignSurface.publishBackfill-12929.test.tsx`**
(the card's Done-when, end to end through the real surface, real sheet
and real client):
- checked: the request body is
`{"backfill":[{"object":"b2r4_ticket","field":"done"}]}` and the success
toast's description carries the receipt's count, N = 12;
- a field that stopped short: a warning toast with the rows and the
server's reason;
  - unchecked: the body is `{}` and the toast carries no backfill line.

### Ablation

Run at `6348eef`, with each fix committed first. Each mutation was made
with `ablation-replace.mjs`: the anchor hit once, landed on disk, and
the restore was proven as blob == HEAD with an empty `git diff HEAD`.
The suites import `@object-ui/data-objectstack` through the vitest alias
to `src`, so no build sits between the mutation and the run.

1. **The client pass-through.** `body: JSON.stringify(...)` was replaced
with `body: '{}'`. Turned red: 3 failed, 3 passed. The red tests were
the client's `sends the named fields as the body's backfill`, and
Studio's `checked` and `a field that stopped short`.
2. **The literal-shape check in `backfillCandidates`.** The check was
deleted. Turned red: 6 failed, 4 passed. A token and an expression
default each got a choice, so the five sheet cases that expect exactly
one choice found three, and `keeps only a declared literal default`
failed.
3. **The footer pass-through.** Replaced with `void onPublish()`. Turned
red: 3 failed, 10 passed. The red tests were the sheet's `checked:
Publish all passes { backfill: ... }`, and Studio's `checked` and
`stopped short`.

## Gates (local, captured before any pipe)

- `pnpm --filter @object-ui/data-objectstack lint`: exit 0, no errors.
`type-check`: exit 0, and `--listFilesOnly` includes the new test. Both
ran at `6348eef`; data-objectstack has not changed since.
- `pnpm --filter @object-ui/app-shell lint`: exit 0, no errors. The diff
adds one `react-refresh/only-export-components` warning, on the exported
`backfillCandidates`, the same kind the existing `computeChangeDetail`
export carries.
- `pnpm --filter @object-ui/app-shell type-check`: exit 0, both
programs. `--listFilesOnly` includes both new tests. Ran at `ca83ad0`.
- At `ca83ad0`, each exit 0:
- `check:i18n-keys`, `check:i18n-drift` (4 pack keys and 2 table keys
added, 0 en values changed), `check:i18n-designer-parity`;
- `check:new-line-citations` (0 new), `check:control-bytes`,
`check:spec-symbols`;
- `check:metadata-write-doors`, `check:phantom-deps`,
`check:esm-specifiers`;
- `node
scripts/check-changeset-{presence,no-major,fixed,claims,overwrite}.mjs`;
- `node scripts/check-vi-mock-{inherit,override-shape,specifiers}.mjs`;
- `node scripts/check-test-path-roots.mjs` and `node
scripts/check-type-check-coverage.mjs`.
- `check:i18n-dead-keys`: exit 0. It is a report, and it lists none of
the new keys. The packs have not changed since it ran.
- `node scripts/check-governed-queue-guard.mjs --test` over the 18
changed paths: NOT GOVERNED.
- `check:spec-floors`: not run, because no floor moves (see Deviations).

These belong to CI: the full app-shell suite, `pnpm lint` repo-wide,
`check:eager-closure` (it needs a console build), and
`check:published-dist`. This PR adds a runtime import of
`@objectstack/spec/data` to an eager-graph module. That subpath is
already a runtime import of `@object-ui/core`
(`discriminateDefaultValueShape`'s sibling `isRuntimeDefaultToken`), and
the file's own header records that the console's `vendor-objectstack`
chunk claims every `@objectstack/*` module, so no eager-closure movement
is expected. That is NOT MEASURED locally; `check:eager-closure` in CI
is the reading.

## Deviations

- **Floor clause.** I did not raise the `@objectstack/spec` floor of
`@object-ui/data-objectstack` (`^18.0.0-next.1`) or of
`@object-ui/app-shell` (`^18.0.0-next.3`) to R. objectui's AGENTS.md §9
「依赖与锁文件」 says these ranges matter only when objectui publishes, and says
not to edit them to align with the pin (「⛔ 别为「对齐 pin」去改它们」).
- At publish, `scripts/objectstack-floor.mjs` computes every floor from
`.objectstack-sha`: the earliest spec release whose tag contains the
pin.
- The pin `f4755a68a797` contains `1e001c7df`. The `next.5` tag commit
is an ancestor of the pin, and the pin is not in `next.5`. So the
computed floor is a release after `next.5`, and publishing waits for it
(that script's exit 2) until it exists. Either way the floor admits
nothing older than R.
- The dispatch's floor clause and the landing repo's rule disagree here.
I followed the landing repo and am naming the conflict rather than
picking a side quietly.

## Acceptance notes

These were observed but not filed. None was reproduced through a public
entry point, and none has a carrier.

- On the SQL driver, the column DEFAULT fills existing rows at `ALTER
TABLE … ADD COLUMN` (#24253's own premise
measurement). There the receipt's `updated` truthfully reads 0 while the
drawer named N existing records. The spec describes that 0 as a complete
answer, and the toast shows it as sent. The backfill matters on drivers
without a column DEFAULT (Turso remote, memory, MongoDB).
- `ObjectStackAdapter.normalizeQueryResult` answers `total` as `total ??
count ?? records.length`, and `find` returns `{ data: [], total: 0 }`
for a resource remembered as missing (404). So a `$top: 0` count read
could produce a confident 0 if a server omitted `total` (`$count=false`,
which the console does not send), or for a resource that 404ed earlier
in the session. This is a read-only inference about pre-existing adapter
code, not reproduced. The drawer shows the number only when it is
numeric, which does not guard against this case.

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

The pull request was created by the os-dev run of PM session
`https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ed view list that refresh and every console view write drop (objectui#13057) (#13092)

Fixes #13057
Clause-②: no

## What changes

One object-page open now reads `GET /api/v1/meta/view` **once** (was 3
before objectui#13044, 2 after PR #13053). This completes objectui#13044
ruling `6268839381` (a) to (c).

**The adapter keeps a settled list** (`@object-ui/data-objectstack`).
- `MetadataCache.share` still joins a request in flight and is never
answered from a settled entry. It now also **stores** its landing. A
request detached by `invalidate` / `clear` still stores nothing.
- The client wrap (`installViewListShare`) is unchanged otherwise. A
caller of `getClient().meta.getItems('view')` (the provider's eager
read) always asks the server, and its answer is stored under
`list:view`.
- `readViewList` (the source for `listViews` and `listViewOverrides`)
answers from the stored list while it holds one. Otherwise it reads
through the client wrap. Every caller gets its own `structuredClone`.

**Every path that should see a fresh list drops it first.**
- (a) **User refresh.** `ObjectView`'s `refreshData` calls
`dropPublishedViewList(dataSource)` before it bumps the counter. The
saved-views read it re-runs asks the server again. That covers realtime,
server actions, imports and the refresh action. Provider re-reads
(`refresh('view')`) go through the client, so they always ask.
- (b) **Every in-SPA view write.**
- The adapter seam (`invalidateViewKeys`) and the adapter's client write
wrap already dropped the key. Now that the key holds a settled list,
they drop it.
- **New: the console `MetadataClient`.** `useMetadataClient` (the hook
every app-shell write path takes its client from, as it does for the
advisory sink) wraps the client's write doors through
`installViewListDrop` (`views/metadata-admin/viewListDrop.ts`). Each
`save` / `publish` / `publishDraft` / `reset` / `rollback` whose type is
`view`, and each `publishPackageDrafts`, drops the list once the write
settles, whether it landed or was refused. It drops through the
adapter's existing public `invalidateCache(key)`.
- **Why an explicit drop and not the pulse.** The metadata-refresh pulse
does reach the adapter (the provider's `clearCache`). It also re-reads
every loaded provider type, so firing it on every Studio draft autosave
would be a refetch storm. Package-wide publishes get both.
- **`PackagesPage` `publishDrafts`** now calls `emitMetadataRefresh()`
after a 2xx batch, as Studio's publish-all, Home's and the chat bar's
do. It is sent before the `failed[]` verdict, as Studio sends it.
- (c) **Org or session switch.** Unchanged: `MetadataProvider`
`dropAdapterWorld` clears the whole adapter cache. It now has a
settled-list pin.

**No new export, no new public member.** `MetadataCache` is
package-internal. `viewListDrop.ts` is module-internal: no barrel
re-exports it, and `metadata-admin/index.ts` and the package `index.ts`
are untouched. The one cross-package spelling is the key literal
`list:view` in `viewListDrop.ts`. The provider and refresh pins drive a
real adapter, so if the key is renamed they go red.

## The writers, enumerated (and pinned so a new one cannot be silent)

The enumeration is pinned at the door, not by call-site list. Every
console `MetadataClient` is built by `useMetadataClient`: the provider's
draft-preview client is the read-only exception, and `new
MetadataClient(` was already ratcheted. Every door of `MetadataClient`
that sends a write is wrapped.

- `viewListDrop-13057.test.tsx` derives the write doors from
`MetadataClient.prototype`: every method whose comment-masked body calls
`this.sendWrite(`. It asserts that set equals `ITEM_WRITE_DOORS` plus
`PACKAGE_WRITE_DOORS`.
- The same file scans app-shell `src` (comment-masked with
`scripts/js-comment-mask.mjs`) for `createConsoleMetadataClient(` call
sites. It asserts they are exactly the hook and `MetadataProvider`.

Call sites under `views/metadata-admin/**` and `views/studio-design/**`
that can write a `view` row, from a grep of the write doors:
- `ResourceEditPage`: create (`save`, live or draft), the guarded draft
`save`, `publish`, `reset` (delete/reset and discard draft). The type is
generic, so `view` included.
- `ResourceHistoryPage`: `rollback`, generic type.
- `EmbeddedItemEditor`: `save(parentType, …)`.
- `DraftConflictDialog`: the guarded `save` used by `ResourceEditPage`
and Studio's `ObjectListViewInspector` (view drafts).
- `PackagesPage` and `StudioDesignSurface`: `publishPackageDrafts`.
Studio also has `publishDraft('flow', …)` and typed saves.

The other writes in those directories are typed `object` / `hook` /
`flow` / `permission`, and they keep the list (control pin). All of
these take their client from `useMetadataClient`.

## Pins

- (a) `MetadataProvider.oneMetadataRead-13044.test.tsx`: the console
order (eager read, then `listViews` plus `listViewOverrides`, plus a
second overrides read) is **1** request (was 2). The comment in the test
gives the reason.
- (a) The same file: within an open, later `listViews` reads are
settled. A provider `refresh('view')` asks again, and the next list read
sees what that refresh read (a row added server-side appears only after
it).
- (b) `ObjectView.refreshRereadsViews-13057.test.tsx` uses the real
page, a real adapter and a realtime refresh. The open reads once;
`refreshData` re-asks (1 then 2). Control: a re-render with no refresh
reads nothing.
- (c) `viewListDrop-13057.test.tsx` has one leg per writer class: save
(draft), save (live), publish, publishDraft, reset, rollback,
publishPackageDrafts. Each leg settles the list, writes, then the next
read re-asks exactly once. A refused view write drops it too. Controls:
a write of another type keeps the list, the write's answer passes
through untouched, and with no adapter in context the writes still land.
- (c) `oneMetadataRead`: `invalidateViewKeys`, and a `saveItem('view')`
straight through the adapter's client, each drop the settled list.
- (d) `PackagesPage.publishPulse-13057.test.tsx` uses the real sheet and
`useMetadataClient`. *Publish app* emits the pulse once, and the next
`listViews` re-asks. A batch answered with `failed[]` still emits it.
- (e) CONTROLS:
- `MetadataProvider.switchDropsAdapter-13044.test.tsx`: an org switch
and a session switch each re-ask after a settled read.
- `oneMetadataRead`: a draft-preview `listViews` is its own
`?preview=draft` request and never fills `list:view`
(`getCached('list:view')` stays undefined, and the published read still
asks once).
- `viewCacheInvalidation.pin.test.ts` stays truthful: `listViews`
consults exactly `list:view` (settled within an open), else the
transport, and no per-object key. Its header says the named writers drop
it.
- `MetadataCache.share-13044.test.ts`: `share` is never answered from a
settled entry and stores its landing. A request detached by `invalidate`
stores nothing.

## Ablations

Each was run through `ablation-replace.mjs` (anchor must hit; restore
proven by blob hash equal to HEAD and an empty `git diff HEAD`). The
subject resolves through the vitest alias to `src/`, so no dist build
was involved.

| # | mutation | went red | restore (blob = HEAD) |
|---|---|---|---|
| 1 | `readViewList`: drop the settled read (`if (settled !== undefined)
return …` replaced with `void settled;`) | (a) console order `expected 2
to be 1`, plus the two settled legs | `index.ts` 9ef6559 |
| 2 | `installViewListDrop`: the package door without `.finally(drop)` |
the `publishPackageDrafts` leg (`expected 1 to be 2`) and (d)'s re-read
leg; the other 6 writer legs stayed green | `viewListDrop.ts`
286c0f6 |
| 3 | `installViewListDrop`: the item doors without the view drop | all
6 item-writer legs plus the refused-write leg; the package leg and the
controls stayed green | `viewListDrop.ts` 286c0f6 |
| 4 | `refreshData` without `dropPublishedViewList` | (b) `expected 1 to
be 2` | `ObjectView.tsx` 6cc2e5c |
| 5 | `publishDrafts` without `emitMetadataRefresh()` | both (d) legs
`expected +0 to be 1` | `PackagesPage.tsx` 98ee995 |

## Gates

The numbers below are for head `ca09edc959`. Each exit code was captured
before any pipe.

- Type-check: closure built first (`turbo run build
--filter='@object-ui/app-shell^...'`, 28/28).
- `@object-ui/data-objectstack` `type-check`: 0. Its `--listFiles`
includes both edited tests.
- `@object-ui/app-shell` `type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`): 0. `--listFiles` includes all five new or edited
app-shell tests.
- Targeted vitest (`--maxWorkers=1`, under the verify lock): the 7 new
or edited files, then the families of the touched files, all green.
- The family set is every data-objectstack test that reads the view list
or `MetadataCache`, every `ObjectView*` test, every `PackagesPage*` and
`MetadataProvider*` test, and every app-shell test importing
`metadata-admin/useMetadata`, which takes in 129 studio-design files.
  - That is 265 files run in 7 chunks, 0 failures.
- `pnpm lint`, narrowed to the 13 changed `.ts`/`.tsx` files: 0 errors.
  - The 13 files come from eslint's own `--format json` output.
- `eslint.config.js` sets no `parserOptions.project` / `projectService`,
so linting is not type-aware, and this diff cannot move a verdict on an
untouched file.
- The new warnings are the two react-hooks ref warnings beside the
existing `tRef` ones in `useMetadataClient`.
- Pre-tsc battery, all 0: `check:phantom-deps`, `self-import`,
`unreferenced-sources`, `doc-example-readers`, `handler-key-reads`,
`side-effects-array`, `element-data-source-declaration`, `spec-symbols`,
`action-forward-parity`, `designer-field-key-parity`,
`icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`,
`control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs`. Also
`check:test-path-roots` and `check:new-line-citations`: 0.
- `node scripts/check-changeset-fixed.mjs`: 0.
`check-changeset-presence`: one changeset declared,
`.changeset/13057-settled-view-list.md` (`patch` for both packages).
- NOT MEASURED: `check:spec-floors`. It exits 1 with `no-artifact`,
because it needs the whole workspace built; that is not a verdict on
this diff.
- NOT MEASURED: the full `packages/app-shell/` and
`packages/data-objectstack/` directories. They are declared to CI per
the dispatch (no whole-package runs on the shared box).

## Acceptance notes (seen, not filed: static reading, no public-door
reproduction)

- The other `PackagesPage` lifecycle actions emit no metadata-refresh
pulse either: registry publish, revert, delete, enable/disable and
adopt-orphans all go through `apiJson`. Each can change live metadata,
views included. Ruling 4 named only `publishDrafts`. Carrier: none.
- A console `MetadataClient` view write drops `list:view`, but not the
adapter's per-object `view-overrides:OBJECT` / `view:OBJECT:NAME`
entries. Those need the object name, which `reset` / `rollback` /
`publish` do not carry. This was already the case before this PR, and
the stored override map can stay stale for the cache TTL after a
metadata-admin view publish. Carrier: none.

Session `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…jectui#13464) (#13495)

<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DDG54RD1DM5VMDEVmXdGkE6)_

Part of #13464. This PR does the card's first task item (declare
`sideEffects` on every published package). The longer-term item,
explicit `register*()` so every package can say `false`, stays on the
card for its own decision, so merging this should not close it.

Clause-②: no
Metadata-protocol: none

## What changes

29 published manifests that had no `sideEffects` now declare one.
`apps/console` is out of scope per the claim. Only the `sideEffects`
field moves in each manifest.

| Value | Packages | The read behind it |
|---|---|---|
| `false` | `react`, `auth`, `collaboration`, `data-objectstack`,
`mobile`, `permissions`, `providers` | Every non-test module under
`src/` scanned with the array gate's own `scanModule` (a superset of
what any import reaches, lazy targets included): no registration, no
bare import (CSS or otherwise), no global or `window` write. The only
top-level statements are `X.displayName = 'X'` on a binding the same
module declares: react 6, auth 1, mobile 1, permissions 1, the other
three none. `collaboration`'s one top-level call,
`createSafeTranslation(...)`, is a pure factory (read: it builds a
closure and touches nothing outside). |
| array | `plugin-calendar`, `plugin-charts`, `plugin-designer`,
`plugin-detail`, `plugin-editor`, `plugin-form`, `plugin-gantt`,
`plugin-kanban`, `plugin-list`, `plugin-map`, `plugin-markdown`,
`plugin-report`, `plugin-timeline`, `plugin-tree`, `plugin-view` | Each
array is exactly what `scripts/check-side-effects-array.mjs` derives:
the entry forms plus every module that registers at load time, in both
spellings. The gate passes on all 15 (plus the existing `app-shell` and
`layout`). |
| `true` | `plugin-ai`, `plugin-chatbot`, `plugin-dashboard`,
`plugin-grid` | Each registers on import, and for each no array
satisfies every guard: `plugin-ai` has no bundler alias to its source,
so the consistency test reads its `src/index.tsx` as a phantom path;
`plugin-chatbot`'s barrels are pure and register through `export *` from
`./renderer`, so the consistency test refuses them as named entries
while the array gate requires them; `plugin-dashboard` registers retired
node types in a loop, so `check-sdui-registration-pins` cannot read the
keys; `plugin-grid`'s `ObjectGrid` imports `hooks/useRecordCrudVerdicts`
(top-level `subscribeDataChanges`), and the gate's reachability rule
rejects that chain without naming `ObjectGrid`, which it would then call
STALE. |
| `true` | `cli`, `create-plugin`, `runner` | Executables: the `bin`
entry (`cli`, `create-plugin`) or the app entry (`runner`'s `main.tsx`,
`ReactDOM.createRoot(...)`) runs on load. Nothing bundles them as a
library, so `true` is the honest, free answer. |

### The consistency test

`scripts/__tests__/side-effects-declaration-consistency.test.ts` changes
in the two ways the amended claim allows:

1. Its census by name grows to the new population of `false` and array
packages, and the anti-vacuity floors move with it.
2. Its scan learns the carve-out the array gate already makes as
`local-binding-write`: `X.prop = ...` where `X` is declared in the same
module and the right-hand side calls or constructs nothing. A new
`describe` pins both directions: a function, class or const of the
module is pure; an imported binding, a namespace import, `window` and
`globalThis` writes, a right-hand side with a call or `new`, and an
element write stay findings.

Reverse check, on `c16787a` with the carve-out line removed: `no
sideEffects: false package has a load-time side effect` and the new
pure-direction pin go red (`Tests 2 failed / 113 passed`); restored from
`HEAD`, `git diff HEAD` empty.

## Measured

Console production build (`pnpm turbo run build --filter='./packages/*'`
then `pnpm --filter @object-ui/console build`, the same two steps as
`performance-budget.yml`), `apps/console/dist/eager-closure.json`:

| | head | eager closure, gzipped | eager / total chunks |
|---|---|---|---|
| before | `6f629a7` (main) | 3,106,386 B | 85 / 2291 |
| after | `fcfc372` | 3,106,204 B | 85 / 2291 |

A 182 B drop: no real saving for the console, which imports almost every
package and whose arrays name each barrel. The gain is for external
hosts that bundle only some `@object-ui/*` packages. No ceiling moves,
as the claim asked. The build's other plugin lines
(`declared-lazy-views`, `assert-types-zod-stays-lazy`) read the same
before and after.

`node scripts/check-sdui-registration-pins.mjs` on the `fcfc372` build:
all 68 registrations the arrays promise are present in the built
console.

## Tests and gates (local, head `fcfc372` unless noted)

- `pnpm exec vitest run
scripts/__tests__/side-effects-declaration-consistency.test.ts
scripts/__tests__/check-side-effects-array.test.ts
scripts/__tests__/check-sdui-registration-pins.test.ts`: `Test Files 3
passed (3)`, `Tests 172 passed (172)`.
- `node scripts/check-side-effects-array.mjs`: exit 0, 17 packages
green.
- `node scripts/check-eager-closure-budget.mjs`: exit 0 (3033.4 KB,
budget 3496.0 KB).
- On `c16787a` (before the `plugin-dashboard` switch): `pnpm exec vitest
run --maxWorkers=2 scripts/` gave 1 failed of 5857,
`check-sdui-registration-pins.test.ts` reading the dashboard loop as
unreadable, which `fcfc372` fixes and the run above re-checks;
`packages/vscode-extension` `export-to-react-preamble` and `app-shell`
`tenancyPostureWall.parity` tests (the other two that read
`sideEffects`): 9 passed.
- `node scripts/check-changeset-presence.mjs`, `node
scripts/check-changeset-no-major.mjs`, `node
scripts/check-changeset-fixed.mjs`, `pnpm check:new-line-citations`,
eslint on the changed test: exit 0 each.
- NOT MEASURED locally: the full `pnpm test` matrix and `pnpm lint`,
which CI runs.

## Acceptance notes

- The two `sideEffects` guards disagree about a pure barrel:
`check-side-effects-array.mjs` requires every entry form in an array,
and the consistency test refuses a named source entry with no load-time
effect. That is why `plugin-chatbot` is `true` instead of an array. Not
filed: it is a reading, with no consumer-visible defect today.
- `plugin-grid`'s and `plugin-detail`'s top-level
`subscribeDataChanges(...)` calls invalidate module-local caches. The
gate counts them as registrations, which is the conservative reading.

## Checklist

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

Session: `https://claude.ai/code/session_01Q4K9dtZAX6vw67etsxBeuP`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Q4K9dtZAX6vw67etsxBeuP

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Q4K9dtZAX6vw67etsxBeuP)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Run 5 read (ci 40153788034, lint 40153787956; head cfa1676afc = run-4 head + the #25726 lockfile splice as a verify commit). All 16 Test UI shards, Build Core, Test Core, Type Check UI and the lint Type Check lanes are read. Nothing new to file; every red has an owner.

Next: run 6 after the objectui main refresh (2c/2d/2e landed) plus the A2 patch; then the counter-probe and this PR closes.


Generated by Claude Code

objectstack-fleet Bot and others added 2 commits October 25, 2026 03:29
…tstack#25727) (#13496)

<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoL8sNsjGD2KCCE4QzojaRfw)_

Part of #25727
Part of #25677

Clause-②: no
Metadata-protocol: none

## What changes

Round 1 of objectui-readiness 3: objectui compiles against objectstack
`main` HEAD.

- `.objectstack-sha` moves from `47af3dde42` (where objectui#13485 left
it an hour ago) to `43edf90b56db8b47ad17684c145fb7fa8bf08583`,
objectstack `main` HEAD read with `git ls-remote` at 01:52Z. That is 9
objectstack commits, 3 of them touching `packages/spec`: 888d686
(approvals `excludeSubmitter` runtime), 769ac80 (ui/ wiring),
f6ba134 (refuse `allowDelete: false` beside `modifyAllRecords:
true`).
- Measured from the card's starting pin `e649c2d91e`, the round covers
19 spec commits. The one compile break they bring (TranslationPreview
TS1360, `positions` / `permissions`) and the i18n loader's group list
landed with objectui#13485, which landed while this branch was in
flight; this branch merged `main` and resolved every conflict to
`main`'s version, so its net diff is the pin line alone.
- The packed manifests in `.objectstack-pin/` are byte-identical to
`main`'s (the zod `^4.6.6` floor already moved with objectui#13485).
`pnpm-lock.yaml` is unchanged and `pnpm install --frozen-lockfile` exits
0.
- No changeset: `node scripts/check-changeset-presence.mjs` reports no
released source or published contract changed.

Path: `scripts/bump-objectstack.sh --no-commit
43edf90` (reported "on origin/main";
built and packed the 7 packages), then `pnpm install`.

## Verification (local, at the merged head)

| Gate | Exit |
|---|---|
| `pnpm type-check` (81/81 turbo tasks, includes
`@object-ui/app-shell#build`) | 0 |
| `pnpm --filter @object-ui/app-shell build` | 0 |
| `type-check:scripts`, `:e2e`, `:vitest-setup`, `:vitest-config`,
`:coverage` | 0 each |
| `check:spec-symbols`, `check:designer-field-key-parity`,
`check:action-forward-parity`, `check:component-surface-parity`,
`check:icon-record-names` | 0 each |
| `check:installed-pin-claims`, `check:lockfile-integrity`,
`check:lockfile-dedupe` | 0 each |
| `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`,
`check:i18n-designer-parity` | 0 each |
| vitest: every test file naming `modifyAllRecords` or
`excludeSubmitter`, `metadata-admin/previews/`, `packages/i18n/`,
`apps/console/src/loadLanguage.test.tsx` (239 files) | 0 |

The full `pnpm test` (about 23,000 s of measured per-file time) is left
to this PR's 16 CI shards; a 4-core container cannot run it inside one
foreground window.

Two readings that are not this PR's:

- `check:spec-floors` exits 1 with three findings (`@object-ui/auth`
floor vs `MEMBERSHIP_ROLE_EXTERNAL`, `@object-ui/types` floor vs
`navItemVisibleDataNeedsDataSource`, `@object-ui/cli` no artifact). The
gate judges manifest ranges against published npm spec versions; both
symbols already exist at `e649c2d91e`, and this PR moves no range. Its
workflow runs on push and release, not on pull requests.
- `check:eager-locale-catalogues` needs a built Console bundle
(prerequisite not met locally); CI's performance-budget job runs it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Session: `https://claude.ai/code/session_01K1ngdG6x8wAt8cjeVx2cSk`

Co-authored-by: Claude <noreply@anthropic.com>
… run (#13474)

Fixes #13442

Clause-②: yes
Metadata-protocol: none

A 100-row list bulk update took about 100 s in the console for about 19
s of server work. Two defects compounded, and this PR carries both
halves (the seat ruled one PR):

1. **Per-row results.** `ObjectStackAdapter.bulkUpdate` / `bulkDelete`
read only the server's `succeeded`, so a partial failure reached the
result dialog and the error CSV as one `batch_0: N records failed`
entry. They now resolve to a `BulkWriteResult` (`{ succeeded, errors: [{
id, error }] }`) read off the `results[].errors[]` that `updateMany` /
`deleteMany` already answer with `continueOnError: true`.
`executeBulkBatch` reports each named failure under its own id and
reason, so the dialog, the error CSV and Retry work per record whatever
the `batchSize`, and Undo now skips the failed rows. Only failures the
call does not name still fold into one `batch_N` entry. No objectstack
change was needed.
2. **One refresh per run.** New optional
`DataSource.batchMutationEvents(work)`: the adapter holds the
`onMutation` events of the writes `work` makes and delivers them when it
settles, one per (type, resource): the original event if only one was
held, else one resource-level event. Scopes nest, and a rejection still
delivers. `useBulkExecutor` runs every bulk run and every undo inside
it.

## Clause-② surface (what the built declarations add or change)

- `@object-ui/types`: `DataSource.bulkUpdate` / `bulkDelete` return
`Promise` of `number | BulkWriteResult` (was `number`). New exported
types `BulkWriteResult` and `BulkWriteRowError`. New optional member
`DataSource.batchMutationEvents`.
- `@object-ui/data-objectstack`: `ObjectStackAdapter.bulkUpdate` /
`bulkDelete` resolve to `BulkWriteResult` (was a number). This is
breaking for code that calls these two methods directly and reads a
count: it must read `.succeeded`. In-tree there is no such caller (`git
grep` finds only the executor). New method `batchMutationEvents`.
- `@object-ui/core`: `BulkBatchOps.bulkCall` may resolve to `number |
BulkWriteResult`.
- `@object-ui/plugin-grid`: the executor's `dataSource` face gains the
optional `batchMutationEvents`, derived from `DataSource` like the two
bulk members.

Backward compatibility is a union, not a replaced type. An adapter
returning a plain number still satisfies `DataSource`. Reverse check
against the REBUILT `packages/types/dist`: `const n: number = await
ds.bulkUpdate(...)` is refused with TS2322 (`Type 'number |
BulkWriteResult' is not assignable to type 'number'`), and in the same
program a `bulkUpdate: async () => 3` implementor compiles. The
changeset is `minor` on the four packages and states the breaking
adapter return.

## Mechanism checks (dispatch hypotheses)

- H1 holds: `bulkUpdate` returned `emitBulk((res as any).succeeded as
number)` and its per-id fallback swallowed each error. `bulkDelete` had
the same shape.
- H2 holds, and it is wider than the two views. Six paths subscribe to
`onMutation`: ListView, ObjectView (plus its `notifyDataChanged`),
ObjectKanban, ObjectCalendar, `useDataRefresh`, and the console's
`useMutationInvalidationBridge`, which puts every event on the
data-invalidation bus for every bus reader (ObjectGrid's own query,
related lists, and others).
- H3 holds. Inside one batch, `executeBulkBatch`'s per-row pass is
concurrent (`Promise.allSettled`). Batches run one after another.
- H4, decided by measurement: hold at the source, not coalesce in the
views. Holding at the source reaches all six subscriber paths.
Coalescing in ListView / ObjectView reaches two of them, and a
timer-based coalescer cannot bound a run whose rows are separated by
network round-trips. Measured end to end (real adapter, real client,
real `useBulkExecutor`, real `ObjectGrid` behind the real bridge, 25
rows, `batchSize: 1`): **1 list refetch per run with the scope, 22 with
it removed.** `ListView.tsx` and `ObjectView.tsx` are therefore not
changed.

## Tests

- `packages/data-objectstack/src/bulkPerRowResults-13442.test.ts`: real
adapter and `@objectstack/client`, only `fetch` stubbed. A 2-failure
`updateMany` answer gives 2 per-record errors with ids and messages. A
failed row is attributed by its request `index` when it has no `id` (the
spec's own example shape). Several reasons on one row are joined. For
`deleteMany`, a refused row keeps its reason, and a kept row with no
`errors[]` gets a generic one. Six holding cases: N updates give 0
events while the scope is open and then 1; the unscoped control gives N;
a single held event keeps its id and record; coalescing is per (type,
resource); the result passes through and a rejection still delivers;
nested scopes deliver once.
-
`packages/plugin-grid/src/__tests__/bulkRunPerRowResults-13442.test.tsx`:
a 100-row run with 2 server rejections sends one `updateMany` (no PATCH)
and ends with `errors` equal to the two records' ids and messages, with
no `batch_` entry. The refetch count is asserted exactly as `1`. ⚠️ That
run is outside `act()`, on React's real scheduler, with each PATCH
answering after a timer. Inside `act`, or with a microtask-only stub,
the pin stayed green with the scope removed (measured), because React
rendered once at the end.
- `useBulkExecutor.test.ts` (4 new cases) and `bulkFastPath.test.ts` (5
new cases). The existing `bulkUpdate.test.ts` / `bulkDelete.test.ts`
move to the new return shape.

One-off ablations (WRAP mode of `ablation-replace.mjs`, each restore
proven: blob equal to HEAD and `git diff HEAD` empty; no build is needed
because vitest aliases these specifiers to `src`):

- A. Executor scope off (`false &&` on the `batchMutationEvents` check):
refetch pin red, `expected 22 to be 1`; scope unit pin red.
- B. Adapter attribution off (`if (row || !row) continue;`): 5 red (4
adapter cases plus the end-to-end 2-failure pin).
- C. Core attribution off (iterate `[]` instead of `res.errors`): 7 red
(core, executor and end-to-end).
- D. Adapter holding off (`mutationHoldDepth < 0`): 4 holding cases red,
plus the refetch pin, `expected 22 to be 1`.

## Local verification at head `90d28e7`

- `pnpm exec vitest run packages/types/ packages/core/
packages/data-objectstack/ packages/plugin-grid/` under the lock:
VERDICT command-exit 0, `Test Files 946 passed (946)`, `Tests 18065
passed | 27 skipped (18092)`.
- `type-check` of types, core and data-objectstack: Done (at `677b254`;
none of their sources changed after, only the adapter README).
plugin-grid: `type-check` green at `7609f9d`, then `tsc -p
tsconfig.test.json` green at head for the reworked test. That last run
was a declared narrowing, unlocked, because a sibling's verify run held
the shared lock for about 20 minutes.
- `lint` of the four packages: exit 0 (0 errors; the repository sets no
warning ceiling).
- Exit 0: `check-changeset-presence`, `check-changeset-no-major`,
`check:new-line-citations` (0 new), `check:control-bytes`,
`check:test-path-roots`, `check:pending-changeset-literals`,
`check:changeset-claims`, `check:doc-fences`, `check:doc-json`.
`check-governed-queue-guard --test` over the 14 paths: NOT GOVERNED.
- NOT MEASURED locally: `check:readme-exports` and `check:doc-snippets`.
Both need the whole package set built (`check:readme-exports` stopped on
23 unbuilt packages, a precondition, not a verdict). In their place, the
two new guide snippets were compiled `--strict` against the built
`@object-ui/types` dist: exit 0. CI builds and runs both.
- `origin/main` was still at the branch point `a3c0da9` when this
opened, so the instructed merge was a no-op. objectui#13450 (same batch)
touches `WidgetRegistry.ts` / `widget.ts`, which are disjoint files.

## Acceptance notes

- Landing beyond the claimed file surface:
`content/docs/guide/data-source.md` and
`packages/data-objectstack/README.md`. These are docs only, and
AGENTS.md commandment #2 requires them for a public API change. No other
claim holds them.
- `ValueDataSource` (`packages/core/src/adapters`) implements
`onMutation` but not the new optional `batchMutationEvents`. A loop of
writes against it still notifies once per write. This is outside this
card's file surface. Successor: none.
- The `bulkDelete` fallback loop, used only when the client has no
`deleteMany`, counts a `{ success: false }` delete answer as deleted.
The pinned client has `deleteMany`, so this does not run today.
Successor: none.
- In the console a bulk run now costs at most two list refetches: the
held event's release, and the dialog's Done handler that ObjectGrid
already had.

Session: `https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
objectstack-fleet Bot and others added 5 commits October 25, 2026 04:00
…#13449) (#13475)

Fixes #13449

Clause-②: no (narrowing)
Metadata-protocol: narrowed

## What this does

Retires the raw-HTML `html` node type, as the triage grade routed it
(comment 6385669346: "Route: retire, as recommended"). Its renderer in
`@object-ui/components` wrote the authored `html` string into a `div`
through `dangerouslySetInnerHTML` with no sanitiser, and
`@objectstack/spec` declares no `html` component. It is removed, not
sanitised; no deprecation alias. Breaking for a page that still names
it, so the changeset is `minor` with a FROM / TO migration (this
repository's version policy forbids `major`).

- `@object-ui/components`: the `html.tsx` registration (`ui:html` plus
the bare key) and its import in `renderers/basic/index.ts` are gone.
- `@object-ui/core`: `html` leaves `PUBLIC_BLOCKS` (it sat there as the
curated tier's "escape hatch"), so `getPublicConfigs()` and the
published `sdui.manifest.json` no longer carry it.
- `@object-ui/types`: `HtmlSchema` leaves the package entry,
`DataDisplaySchema` and the `SchemaRegistry` map (so `ComponentType` no
longer offers `'html'`). The zod arm becomes `RetiredHtmlNodeSchema`, a
`retiredNodeType('html', …)` refusal inside `DataDisplaySchema`, not
exported: a `type: "html"` node is refused BY NAME at `type`, with the
remedy in the message. This is the shape the maintainer ruled for the
bare `kanban` key (objectui#8802) and that objectui#12954 reused for
`cloud:plan-status`.
- `@object-ui/cli`: `known-schema-types.ts` regenerated (`node
scripts/regenerate-known-schema-types.mjs`; its `--check` answers OK),
so `objectui check` no longer counts `html` / `ui:html`.
- Docs: `content/docs/components/basic/html.mdx` (it said "Render raw
HTML content safely", which was false), its `meta.json` row and its
index link are gone; `guide/react-pages.md` says there is no raw-HTML
block on either page tier. The schema-catalog example
`components-basic-html/basic-html.json` is gone and the catalog index
regenerated (`scripts/regenerate-catalog-index.py`; `--check` OK).
- `@object-ui/fields`: `html` stays in `FIELD_TYPES_SKIP_FALLBACK`,
moved from the "bare-name collision" group to a retired-key note. No
behaviour change: the field editor still stands down from the bare key,
so a stale node draws the unknown-component panel, never a rich-text
editor.

The `html` FIELD type (`HtmlFieldMetadata`, drawn by `field:html`, value
displayed through the rehype-sanitize path objectui#13431 landed) is a
different layer and is untouched.

## Reach, measured before and after (not a source-grep claim)

A temporary probe rendered both doors through the real `SchemaRenderer`
and the full console registration graph (`@object-ui/components` plus
`apps/console/src/register-plugins`), with a payload of an `img` element
carrying an `onerror` attribute plus a bold element:

| door | base `a3c0da9` | this branch |
|---|---|---|
| JSON node `{ "type": "html", "html": PAYLOAD }` | injected: the `img`
with its `onerror` attribute is in the DOM | unknown-component panel
(OBJUI-001), payload shown escaped as text |
| `kind: 'html'` page whose source writes an `html` tag carrying an
`html` attribute | compile reports ZERO diagnostics; the `img` with its
`onerror` attribute is in the DOM | compile refuses the tag
(`forbidden-tag`, `unknown-component`); the page draws its compile
report |
| `getPublicConfigs()` names `html` | yes | no |

The second row is worse than the card stated: the tier
`guide/react-pages.md` documents as "Untrusted OK", parsed and never
executed, could inject raw markup, because the page compiler whitelists
every registered type. The probe was replaced by a committed pin (below)
and deleted.

## Dispatch hypotheses, answered

- H1 holds: sink, import, `HtmlSchema` and the zod literal sat where the
claim read them at `a3c0da9`.
- H2: `content/docs/fields/rich-text.mdx` names `type: 'html'` twice,
and both are the FIELD type (`HtmlFieldMetadata` in the `ts` block;
"HTML Mode" of the rich-text widget in prose). Both stay.
`check:doc-types` had passed the code-block one only because the retired
node registered the same bare key, so it now carries a field-vocabulary
exemption in `scripts/check-doc-component-types.mjs`, worded like the
`richtext` row beside it.
- H3: `apps/console/dist` is not tracked, so nothing in-tree to
regenerate for the manifest; the two tracked generated files that named
the type are regenerated by their own generators (above). objectstack's
tracked `sdui.manifest.json` still names `html`: it is generated from
objectui's pinned build and its staleness gate forces a regenerate on
the next `.objectui-sha` bump (see Acceptance notes).
- H4 holds: no product producer. `git grep` for a quoted `html` over
every tracked file, read hit by hit: apart from what this PR removes
(the docs page, the catalog example, the `data-display-examples.json`
entry, tests), every hit is the field type, a `kind: 'html'` page, a
report export format, a code-editor language, or an HTML tag name.

## File surface beyond the claim's list

The claim named `renderers/basic/**`, the two types files, the docs and
tests naming the component, and the changeset. The dispatch's suggested
route asked for every other list naming `html` as a component type;
those are: `packages/core/src/registry/public-blocks.ts`,
`packages/types/src/{registry.ts,index.ts,zod/index.zod.ts,zod/README.md}`,
`packages/types/examples/data-display-examples.json`,
`packages/cli/src/utils/known-schema-types.ts` (generated),
`packages/fields/src/index.tsx` (regroup and comment only), the catalog
example and its generated index, `scripts/check-doc-component-types.mjs`
(one exemption), `content/docs/guide/react-pages.md`, and count / list
pins in `apps/console`, `packages/app-shell`, `packages/cli`,
`packages/fields`, `packages/types`. No governed surface: `node
scripts/check-governed-queue-guard.mjs --test` over all 31 paths answers
"NOT GOVERNED — 31 path(s) checked against 6 governed surface(s); none
matched."

## Pins (each red on the pre-fix implementation, see Ablation)

- `apps/console/src/__tests__/html-node-retired-13449.test.tsx` — the
`kind: 'html'` page refuses the tag and renders no authored markup; the
JSON node renders no authored markup; neither registry key survives
while `field:html` does; CONTROL: the same page without the tag renders
its `p` text.
- `packages/types/src/__tests__/html-node-retired-13449.test.ts` —
compile-time: no `html` member in `DataDisplaySchema`, no `'html'` in
`ComponentType`, with non-vacuity siblings; runtime: a `custom` issue at
`type` naming the retired type and both replacements; CONTROLS: a
sibling node still parses, an unknown literal takes the generic path
without the retirement text, and a form with an `html` FIELD still
validates.
-
`packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`
— the strict face (`objectui validate`) refuses `html` by name; the
namespaced refused-at-type pin falls 307 to 306 (`ui:html` left the
registry).
- `packages/components/src/__tests__/basic-renderers.test.tsx`,
`packages/fields/src/__tests__/registry-collision.test.ts` — the old
"renders HTML" / "bare html is the display widget" assertions became
absence pins with lit controls.
- Shifted counts and lists: `zod-mirror-parity` (pair moved to
`EXCLUSIONS`, 205 to 204), `content-channel-family-d-9256` (row left, 67
to 66), `arm-named-export-8784` (`ABSENT_BY_DECISION` row),
`public-contract` and `html-tier-manifest` in `apps/console`,
`widget-dom-leak-sweep` in `app-shell` (target and ledger counts
re-derived without `ui:html`).

## Ablation (one-shot, not committed)

With the fix committed at `bfd3722`, the nine implementation files were
put back from `a3c0da9` (`git checkout a3c0da9 -- …`, which restores
`html.tsx` too), landing proved on disk by count (`register('html'` 1,
the `import './html';` line 1, the `PUBLIC_BLOCKS` `'html',` row 1,
`export const HtmlSchema = BaseSchema.extend` 1, `RetiredHtmlNodeSchema`
0). Tests resolve `@object-ui/*` through the root vitest `resolve.alias`
to `src`, so no rebuild is in the path. The five pin files: `Test Files
5 failed (5)`, `Tests 11 failed | 65 passed (76)`: every new assertion
red, every control green. Restore leg from `HEAD` inside a trap: `git
diff HEAD` 0 bytes, `git status --porcelain` 0 lines, `html.tsx` absent,
every restored path's hash equals its `HEAD` blob.

## Local verification

Union run at `3c6faea` (the head before the last commit), each exit
captured to a file before reading, under the shared verify lock:

- `vitest run packages/types/ packages/core/ packages/sdui-parser/
packages/cli/ examples/schema-catalog/`: `Test Files 697 passed (697)`,
`Tests 17792 passed | 27 skipped (17819)`.
- `vitest run packages/components/ packages/fields/`: `Test Files 705
passed | 2 skipped (707)`, `Tests 8517 passed | 31 skipped (8548)`.
- `vitest run apps/console/`: `Test Files 243 passed (243)`, `Tests 2460
passed (2460)`.
- 48 reader files outside those packages (every test that names
`PUBLIC_BLOCKS`, `getPublicConfigs`, `KNOWN_SCHEMA_TYPES`,
`FIELD_TYPES_SKIP_FALLBACK`, the examples file, the catalog,
`react-pages.md`, `ui:html`, or the edited doc-types gate, plus
`unit-registry-absence-collision`): `Test Files 48 passed (48)`, `Tests
1543 passed | 16 skipped (1559)`.
- type-check: `core`, `components`, `fields`, `cli` exit 0; `types` exit
2 on one vacuous compile-time pin in the new test file (`'kbd'` is not a
`SchemaRegistry` key), fixed in `bfd3722`.

Re-run at the final head `bfd3722` (it changes only that one test file):
`type-check` of `types`, `core`, `components`, `fields`, `cli` all exit
0; the changed file `Test Files 1 passed (1)`, `Tests 6 passed (6)`.
Gates at `bfd3722`, all exit 0: `check:doc-types`,
`check:registry-bare-names`, `check:handler-key-reads`,
`check:doc-example-ids`, `check:docs-route-closure`,
`check:prompt-keys`, `check:entry-guard`, `check:action-forward-parity`,
`check:spec-symbols`, `check:new-line-citations` (VERDICT 0 new
citations), `check:control-bytes`, `check:doc-fences`,
`check:unreferenced-sources`, `check:side-effects-array`,
`check:doc-example-readers`, `check:icon-record-names`,
`check:changeset-claims`, `check:pending-changeset-literals`,
`check:test-path-roots`, `check:doc-json`, `check-changeset-presence`
("22 source file(s) of 7 released package(s) changed, and this change
declares 1 changeset(s)"), `check-changeset-no-major`,
`regenerate-catalog-index.py --check`,
`regenerate-known-schema-types.mjs --check`.

NOT MEASURED locally, left to CI: `check:sdui-registration-pins` (exit
2, needs a console build), `check:readme-exports`, `check:doc-snippets`,
`check:doc-examples` (exit 2 / collapsed population, need the wider
package build), and the `apps/console` and `packages/app-shell`
type-checks (need the whole build closure).

## Acceptance notes

- objectstack's repo-root `sdui.manifest.json` names `html`
(`"description": "Raw HTML content"`). It is generated from objectui's
pinned build by `scripts/gen-sdui-manifest-node.mjs`, and
`check-sdui-manifest`'s staleness check forces the regenerate on the
next `.objectui-sha` bump, which drops the entry. Carrier: that pin
bump. Until then objectstack's server-side `validateJsxPages` still
admits the tag, while the console's own compile already refuses it.
- objectstack's ADR-0080 table names `renderers/basic/html.tsx` as the
"today unsafe" escape hatch the constrained JSX tier "replaces"; this PR
completes that replacement. The ADR is a dated decision record and is
left as written.
- `docs/audits/2026-08-zod-to-json-schema-fidelity.md` still lists
`HtmlSchema`: a dated audit, left as written.

Session: `https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ`

---
_Generated by [Claude
Code](https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…objectui#13476) (#13482)

<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DYL5YEa1zRjoXpJH1JbeMbZ)_

Fixes #13476

Clause-②: no
Metadata-protocol: narrowed

## Summary

objectui#13367 kept one transitional reading in `ActionRunner`: a
`confirmText` or `successMessage` sentence with no `{{ }}` hole still
had its `${…}` tokens filled. This PR removes both branches, so a
holeless sentence is shown exactly as written, the same as any other
copy with no hole. It is the objectui half of
#25702; #25709
(warning to error, delete `ACTION_COPY_DOLLAR_TRANSITION_ROOTS`) waits
for objectstack's `.objectui-sha` to carry this merge.

## Changes

- `confirmMessage`: the `!hasTemplateHoles(text)` branch returns `text`
instead of `templates.evaluate(text)`. The method no longer takes the
evaluator; the param dialog's `interpolate` (still the `${…}` evaluator,
out of scope) now builds it right where it is used.
- `composeSuccessMessage`: a holeless `successMessage` returns
`successCopy` instead of running `substituteScopeTokens`.
- Docblocks updated: `ActionDef.confirmText`,
`ActionDef.successMessage`, the `RUNNER_TEXT` note,
`composeSuccessMessage`, `confirmMessage`, `templateEvaluatorFor`,
`interpolateTarget`'s note and `substituteScopeTokens` (the grammar's
one toast caller is now `outcomeMessages`), plus `hasTemplateHoles` in
`actionCopyTemplate.ts`.
- Docs: `content/docs/core/enhanced-actions.mdx` "Post-success
behaviour" now says `outcomeMessages` reads `${result.*}` and
`successMessage` reads `{{ result.* }}` / `{{ record.* }}`; the
complex-action example's `confirmText` uses a `{{ record.customer_name
}}` hole.
- Changeset `.changeset/13476-holeless-action-copy.md`:
`@object-ui/core` minor (a narrowing; objectui marks breaking changes
`minor`).

**Untouched, per the card:** `outcomeMessages` keeps its `${result.*}`
reading through `substituteScopeTokens`; `errorMessage` is still shown
as written.

The claim's file surface was amended by the seat (card comment
6391456644) for `actionCopyTemplate.ts`, the docs page, and the
app-shell test. Two more test files outside it also drove the old
reading end to end and are converted here:
`packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx`
and
`packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx`,
plus
`packages/react/src/hooks/__tests__/useActionTextLocalizer.outcomeMessages-11344.test.tsx`.

## Tests

Every fixture that fed the runner a holeless `${…}` `confirmText` /
`successMessage` now authors the `{{ }}` hole the runner reads, and
three rows pin the new behaviour:

- `ActionRunner.copyTemplate-13367`: "a `${…}` token in a sentence with
no hole is text, not an expression" (header door and row door), and "a
`${result.*}` token in a sentence with no hole is text, not filled".
- `ActionRunner.outcomeMessages-11344`: "rung 2 — a `${result.*}` token
in a successMessage with no hole is text". The "reads the `result` scope
only" row moves to an `outcomeMessages` entry, the one toast rung that
still uses the `${…}` grammar.
- `ActionRunner.rowRecordTemplates-12967`: the row-less control now
reads `Pause ?` (an unbound `{{ record.* }}` root prints empty) where
the evaluator left `${record.display_name}` raw.

Runs, at head `55dfcfd`, from the repo root:

- `pnpm exec vitest run packages/core/src/actions/`: 41 files, 625 tests
passed.
- Converted and neighbouring suites (app-shell invite feedback, toolbar
script selection, `onSuccess.navigate` hosts, param-dialog record holes,
`consoleServerAction`, components `renderers/action/`, element-button
i18n copy, plugin-grid row-action record hole, console registry parity,
react `hooks/`, app-shell `hooks/__tests__/`, record-alert, ListView).
The first sweep (44 files, 968 tests) failed 3 rows in
`rowActionRecordHole-12967` and `toolbarScriptSelection-12331`, which
still authored `${…}`; after converting them, those 2 files pass (11
tests). The second sweep: 120 files, 1233 tests passed.
- `pnpm --filter @object-ui/core type-check`: exit 0, after building the
core dependency closure.

**Ablation** (commit first, then mutate, trap-restored): with
`ActionRunner.ts` reverted to `origin/main`, the three new rows fail
(`Tests 3 failed | 45 passed (48)`); restored, `git diff HEAD` is empty
and the blob hash matches HEAD.

## Gates (local subset)

- `check:new-line-citations`, `check:control-bytes`, `check:doc-fences`,
`check:doc-types`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:action-forward-parity`: exit
0.
- `node scripts/check-changeset-presence.mjs`: passes, 1 changeset
declared.
- NOT MEASURED locally: `check:doc-snippets` and `check:doc-examples`
(exit 2, precondition: the doc-snippet build closure is not built here).
CI runs both; the docs edit changes one string literal inside a typed
snippet.
- Full `pnpm test` and `pnpm lint` are CI's.

## Acceptance notes

- No in-tree producer authors the holeless `${…}` shape: no non-test
`confirmText` / `successMessage` in this repo carries `${`, and
objectstack's measurement (card) found none in examples,
`platform-objects`, `plugin-approvals` or HotCRM.
- Fixtures left as `${…}` on purpose: `element-button-i18nCopy-12757`
and `action-outcomeMessages-forward-11344` (components) only pin that
copy reaches the runner unchanged, or never reach rung 2; the react
localizer's `outcomeMessages` fixtures are rung 1, which keeps
`${result.*}`.
- `ActionParamDialog.tsx`'s `interpolate` docblock still says it fills
`${...}` "in the template scope the runner reads `confirmText` in". The
record binding is still shared; the dialect differs now. Carrier: none,
noted only.

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

---
_Generated by [Claude
Code](https://claude.ai/code/session_01P5j5dvWsBeNZNp2qLwwRqH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
… spec derivations fail loudly (#13490)

<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DHcWWbiJqPPiDYgiSMxhTQo)_

Fixes #13459

Clause-②: no
Metadata-protocol: narrowed

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

## What changes

**1. `ObjectGantt` refuses a `gantt` block that fails
`GanttConfigSchema`** (`packages/plugin-gantt/src/ObjectGantt.tsx`).
Before: `safeParse` failure → `console.warn` → the unvalidated block was
returned and drawn, so a misspelt or missing key still rendered a
plausible chart. Now `getGanttConfig` returns `{ config: null,
invalidBlock }` on that branch and the component renders a refusal
(`role="alert"`, `data-testid="gantt-invalid-config"`) naming every
parse issue by its path in the block, e.g. `gantt.endDateField: …`,
`gantt: Unrecognized key: "startField"`. The console warning and the
shadowed-flat-keys warning still fire. Unchanged: a valid block, the
flat ObjectView / ListView spelling (still not parsed, as before), and
the "Gantt configuration required" screen for a view with no binding.

**2. The five designer spec derivations fail loudly**
(`packages/app-shell/src/views/metadata-admin/{dashboard,package,page,report,view}-schema.ts`).
The ten `try { … } catch { console.warn(…) }` sites (seven
`z.toJSONSchema` derivations, three form clones) and their `_…Failed`
memo flags are removed. Their only input is the bundled
`@objectstack/spec`, so a failed derivation is a build-time fact: it
fails the tests beside these files, and at runtime it propagates to the
route's `ErrorBoundary` (`console/AppContent.tsx`), which shows the
error and reports it, instead of rendering a form silently missing
fields. The getters' return types narrow from `JsonSchema | undefined`
to `JsonSchema`. No runtime input reached those catches: the dashboard
form's only runtime argument, `locale`, goes through
`localizeMetadataForm`, which does not throw on an unknown locale.

`tzShift.ts` (`timeZone` fallback) is out of scope per the claim (it
waits on a spec-row decision).

## Tests

On head `e26d1cd`:

- New
`packages/plugin-gantt/src/ObjectGantt.invalidBlockRefusal-13459.test.tsx`
(4): an undeclared key and a missing required field are each refused
with the path named and no chart; the console diagnostic stays; CONTROL:
a valid block renders its tasks.
- New
`packages/app-shell/src/views/metadata-admin/spec-derivation-13459.test.ts`
(14): each of the seven getters derives an object schema from the
bundled spec, and each THROWS (and does not warn) when `z.toJSONSchema`
throws. The failing `toJSONSchema` is a scoped `vi.doMock` + `doUnmock`:
a first version used a hoisted `vi.mock`, which leaked into
`view-schema.test.ts` in the same worker (3 failures, fixed in
`a3b60c1`).
- Ablation (each leg committed first, restored with `git checkout HEAD
--`, `git diff HEAD` empty after): `ObjectGantt.tsx` reverted to
`origin/main` → the three refusal cases fail, CONTROL passes;
`view-schema.ts` reverted → the three `view-schema.*` throw cases fail,
the rest pass.
- `pnpm exec vitest run packages/plugin-gantt/` → Test Files 100 passed
| 1 skipped (101), Tests 775 passed | 114 skipped.
- App-shell, narrowed: the 88 test files that import a changed module or
a component calling one (`git grep -lE` over the five module names and
their callers `PackageFormDialog`, `*DefaultInspector`,
`ViewColumnInspector`, `ViewVariantInspector`, `ReportConfigPanel`) →
Test Files 88 passed (88), Tests 721 passed. Blind spot: a test reaching
these modules through another import path is not in that set; CI's
sharded `pnpm test` runs the whole package.
- `type-check`: `@object-ui/plugin-gantt` exit 0, `@object-ui/app-shell`
exit 0 (turbo, both `tsc --noEmit` and `tsconfig.test.json`).
- Gates, local: `eslint --no-inline-config` on the touched files 0
errors; `check:new-line-citations`, `check:changeset-claims`,
`check-changeset-no-major`, `check-changeset-presence`,
`check:i18n-keys`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:control-bytes`,
`check:test-path-roots` all exit 0. `check:readme-exports` NOT MEASURED
(needs every package's dist; CI builds first).

## Acceptance notes

- The refusal copy is English, like the neighbouring "Gantt
configuration required" refusal: both address the view's author.
Localizing both would touch the ten locale packs, outside the claimed
file surface.
- `ObjectTimeline` still only warns on a failed
`TimelineExtensionSchema.safeParse` (its refusal covers a missing date
axis only). Same shape as this card's gantt half; not changed here
(outside the claimed surface).
- The `if (!specXForm || typeof specXForm !== 'object') return
undefined` guards in the form getters remain: they are not
catch-and-warn sites and the existing tests assert the forms are
defined.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AdaeVx4N1y9QsiBkJ31nfJ)_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AdaeVx4N1y9QsiBkJ31nfJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
…namic import (objectui#13450) (#13473)

Fixes #13450

Clause-②: no (narrowing)
Metadata-protocol: narrowed

Retires the `module` widget source. `WidgetRegistry.load()` resolved `{
type: 'module', url }` with `new Function('url', 'return import(url)')`.
That is the runtime dynamic import AGENTS.md commandment `#6` forbids
("No `eval()` / runtime dynamic imports to load components"). The claim
is the PM's comment `6386913269` on the card, for branch
`claude/issue-13450-retire-widget-module-source`, session
`https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ`.

## What changes

- **`@object-ui/types`**: the `module` member and its
`WidgetSourceModule` interface are removed. `RuntimeWidgetSource` is now
`WidgetSourceInline | WidgetSourceRegistry`, and the barrel no longer
exports `WidgetSourceModule`. The union's docblock records the
retirement and the migration. The manifest's JSDoc `@example` now uses
`{ type: 'inline', component }` with a locally defined `CustomChart`
component (the build-time import is named in a comment, so the block
compiles in isolation under `check-doc-example-types`) instead of a URL.
- **`@object-ui/core`**: the `case 'module'` arm and its
Function-constructor sink are removed from
`WidgetRegistry.resolveComponent()`. The `default:` arm is now
exhaustive (`const unhandled: never = source`), so a union member added
without an arm fails to compile.
- An untyped manifest that still reaches `load()` with `type: 'module'`
is refused by name before anything is fetched. Untyped means plain JS, a
cast, or an older `@object-ui/types`. The error names the widget, the
retired `"module"` source and both replacements.
  - Any other unknown type is refused with the supported types listed.
- In both cases `load()` emits `widget:error` and `loadAll()` reports
the error. The class docblock and its example no longer mention URL
loading.
- **Changeset** `.changeset/13450-retire-widget-module-source.md`:
`minor` for core and types. objectui never declares `major`
(`check-changeset-no-major`); the break is stated in the body as
**BREAKING**. The changeset gives the FROM → TO migration and the
one-line fix, and carries the `Clause-②:` / `Metadata-protocol:` lines.
- `page-nav-misc-spec-parity.test.ts`: the reason string in one
`it.each` row said "module/inline/registry loader union" and now says
"inline/registry". It is a test title, not an assertion.

## Migration (FROM → TO)

- FROM `source: { type: 'module', url: '/widgets/chart.js', exportName:
'Chart' }`
- TO `import { Chart } from './widgets/chart'` and `source: { type:
'inline', component: Chart }` (imported at build time, so the bundler
sees it)
- OR `ComponentRegistry.register('chart', Chart)` once, and `source: {
type: 'registry', registryKey: 'chart' }`

## Premise check: the card's "four emitting sites" are not widget
sources

The card and the triage route name four `type: 'module'` producers. Each
one was read in context at `a3c0da9`. All four are the generated
**`package.json`'s ESM marker** (`"type": "module"`), not a widget
manifest source. Deleting them would make the scaffolded packages
CommonJS and break their `import` syntax. **None was changed:**

| Site (by symbol) | What it builds | Verdict |
|---|---|---|
| `buildAppPackageJson` in `packages/cli/src/utils/app-generator.ts` |
temp app `package.json` (`name`, `private`, `type`, deps) | package.json
ESM marker; kept |
| `buildRoutedAppPackageJson` in the same file | routed temp app
`package.json` | package.json ESM marker; kept |
| `buildInitPackageJson` in `packages/cli/src/commands/init.ts` |
`objectui init` project `package.json` (`scripts.dev: 'vite'`) |
package.json ESM marker; kept |
| `buildPackageJson` in `packages/create-plugin/src/templates.ts` |
generated plugin `package.json` (`main`, `module`, `exports`) |
package.json ESM marker; kept |

So `@object-ui/cli` and `@object-ui/create-plugin` carry no change and
no changeset, which narrows the route. No widget-manifest producer of a
`module` source exists in this tree. A `git grep` for
`WidgetSourceModule` before this change hit only its declaration and the
barrel. `apps/console/src` and `packages/app-shell/src` construct no
`module` source (triage's reading). `objectstack` and `hotcrm` reference
neither `WidgetRegistry` nor `RuntimeWidgetSource` in code (the only
`objectstack` hit is a doc paragraph that does not name the `module`
source). Out-of-tree consumers are NOT MEASURED.

No user-facing doc names the `module` widget source. `content/docs`, the
package READMEs and `docs/` were checked with `git grep`; the control
`ComponentRegistry` hits in `content/docs`. The two JSDoc examples were
the only documentation of it, and both are rewritten.

## Tests (head `e15036c`)

- `be01ada` (patch round): `check-doc-example-types` exit 1 → 0 (34 → 35
compile; `WidgetRegistry #1` back on its TS2304 ledger row). JSDoc text
only.

New `describe('the retired \`module\` source (objectui#13450)')` in
`packages/core/src/registry/__tests__/WidgetRegistry.test.ts`:

1. A manifest with `source: { type: 'module', url }` is refused. The
rejection is an `Error` that names `"test-widget"`, `"module"`, `type:
'inline'` and `type: 'registry'`; these are the named subjects, not the
wording. The global `Function` constructor, proxied for construct and
apply, records zero calls. Nothing is marked loaded or synced into the
component registry. The literal carries `@ts-expect-error`, so the
type-level refusal is pinned too: core's `type-check` runs
`tsconfig.test.json`, which includes `src/**/*.test.ts`.
2. `loadAll()` reports the refusal for that widget, a sibling still
loads, and `widget:error` fires.
3. An unknown type (`remote`) is refused with `"inline"` and
`"registry"` named and no "retired" wording.

- `pnpm exec vitest run packages/core/ --maxWorkers=2`: `Test Files 211
passed (211)`, `Tests 4089 passed | 27 skipped (4116)`
- `pnpm exec vitest run packages/types/ --maxWorkers=2`: `Test Files 388
passed (388)`, `Tests 10568 passed (10568)`
- `pnpm --filter @object-ui/types --filter @object-ui/core run
type-check`: both `Done`, exit 0. This ran after both packages were
rebuilt, so core's test program read the new `dist/*.d.ts`.

**Ablation, runtime** (one-shot, through `ablation-replace.mjs`,
restored to the HEAD blob): a `case 'module'` arm with the
Function-constructor import was re-planted ahead of `default:`. On-disk
marker count was 1, sink count 1. Result: `Tests 2 failed | 24 passed
(26)`. Test 1 received `"A dynamic import callback was not specified."`
instead of the refusal; test 2 failed the same way. Test 3 stayed green,
which is the expected direction. After the restore, `git diff HEAD` was
empty and the blob equalled HEAD. A first attempt used an anchor that
the replacement re-contained; the tool refused it and nothing ran.

**Reverse verification, types** (cross-package): a `{ type: 'module';
url: string }` member was re-added to `RuntimeWidgetSource` and
`@object-ui/types` rebuilt; the dist marker count was 1. Core's `tsc -p
tsconfig.test.json` then reported `TS2578: Unused '@ts-expect-error'
directive` on the new test and `TS2322 ... is not assignable to type
'never'` on the exhaustive arm. After the restore and a rebuild, the
dist marker count was 0 and the same `tsc` was green.

## Gates (local, at `e15036c`)

Exit codes were captured to a file before reading:

- `lint` (core + types): exit 0. 0 errors; the warnings are all
pre-existing `no-explicit-any`, and none is on a changed line.
- `check:new-line-citations` 0 · `check:control-bytes` 0 ·
`check-changeset-presence` 0 · `changeset:check` (fixed + no-major) 0 ·
`check:changeset-claims` 0 · `check:pending-changeset-literals` 0 ·
`check:spec-symbols` 0 · `check:unreferenced-sources` 0 ·
`check:doc-example-readers` 0 · `check:shell-escape-residue` 0 ·
`check:test-path-roots` 0
- `check:readme-exports`: exit 1, NOT MEASURED for the repo as a whole.
The only unjudged population is `@object-ui/app-shell`, which was
unbuilt; every other package was judged with `0 fabricated, 0
wrong-path`. No README names `WidgetSourceModule`.
- `check:published-dist`: exit 1, NOT MEASURED. Its internal
whole-workspace `turbo run build` hit a heap OOM in
`@object-ui/app-shell`, so it judged nothing. That is environmental and
not this diff. CI owns this gate.

`origin/main` was merged before opening; it had not moved from the
branch base `a3c0da9`, so the merge was a no-op. objectui#13442 is in
flight in the same batch on disjoint files
(`packages/core/src/actions/**` and the `DataSource` type). The only
shared surface is the `@object-ui/types` barrel, where this PR removes a
single line in the widget block.

## Acceptance notes

- Remote widgets as a product capability (a signed manifest and an
allow-listed origin, decided in spec) are the maintainer's call and are
not part of this card, per the triage ruling.
- `register()` still accepts a manifest with a retired or unknown
source, and the refusal happens at `load()`. That matches the ruling's
"refuse an old manifest loudly in `default:`" and how the registry
already treats a missing `registryKey`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01432GBvPzfZ6CsLanp9xzkZ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…rkspace-root.mjs, so tests and the CLI generator read the same layout under the monorepo's ui/ (objectstack#25733) (#13498)

<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoKJXi9asZgeRJUKMTK6ywWF)_

Part of objectstack#25733 (child 2e of the epic). Part of
objectstack#25677.
Clause-②: no
Metadata-protocol: none

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

## Why

Standalone, the objectui tree is the pnpm workspace root. Under the
objectstack monorepo it is `ui/`, and the workspace root
(`pnpm-workspace.yaml`, `node_modules/.pnpm`) is one level up.
`ui/node_modules` then holds only the ui root package's own links. There
is no `ui/node_modules/.pnpm` and no `ui/pnpm-workspace.yaml`.

Measured on a scratch worktree of the objectstack verification head
`cfa1676afc`, with this tree applied under `ui/`: 12 tests went red
across 5 files, before this change.

- `check-doc-snippet-types.test.ts`, 2 reds. The UNDECLARED and
ROOT-DECLARED controls looked for `TREE/node_modules/.pnpm`, so both
read "NOT INSTALLED", and `check:doc-snippets` exited 2.
- `vite-objectstack-client-dist.test.ts`, 5 reds. The installed client
is a workspace link to `packages/client`, not a store symlink, and that
link carries its own `node_modules`.
- `vite-objectstack-spec-dist.test.ts`, 2 reds, and
`vite-objectstack-workspace.test.ts`, 2 reds. The checkout's own console
has the framework's `packages/` three levels up, so its tests are the
merged arm and not the baseline literals.
- `packages/cli` `app-generator.test.ts`, 1 red. The in-workspace case
used the checkout root as "a workspace root", and under `ui/` it is a
member.

## What changes

- **`scripts/workspace-root.mjs`** gains `virtualStorePath(root =
uiRoot())`, which is the workspace root's `node_modules/.pnpm`. It
throws, like `workspaceRoot()`, when no workspace governs the tree.
`findInstalledCopy` in `check-doc-snippet-types.mjs` reads it, and
returns null with no workspace. The relative path it reports reads
`../node_modules/.pnpm/…` under `ui/`.
- **`@object-ui/cli` `currentContext()`** returns `isMonorepo:
isWorkspaceRoot(cwd)`. That is the predicate `dev`, `serve` and `build`
already use (`utils/workspace-vite.ts`, objectui#3890), and it is the
same expression the function used to spell inline, `existsSync(join(cwd,
'pnpm-workspace.yaml'))`. See the proposal below.
- **Console vendor-chunk tests.** The standalone arm is read off a tmp
fixture (`TMP/objectui/apps/console`, nothing beside it), and the merged
arm off the existing monorepo-shaped fixture. The checkout itself is
asked only which arm its own layout names, with `isNestedWorkspace()` as
the independent witness. `vite-objectstack-spec-dist.test.ts` compares
the config's vendor test with the arm the layout selects, and pins the
baseline literal separately. **The regexes in
`scripts/vite-objectstack-workspace.ts` are unchanged.**
- **Client-dist test.** The dependency walk through a pnpm symlink is
held on a fixture store
(`node_modules/.pnpm/@objectstack+client@1.0.0/node_modules/{client,core}`
and a console symlink). The out-of-tree copy leaves the package's own
`node_modules` behind.
- **`check:ui-root-lookups`** gains the `workspace-file-at-ui-root`
shape. It fires on a `join`/`resolve` (or a template led by a
substitution) that names `pnpm-workspace.yaml` or `node_modules/.pnpm`
and whose base resolves to the objectui tree. The base can be
`uiRoot()`, `UI_ROOT`, `__dirname` / `import.meta.dirname` /
`fileURLToPath(import.meta.url)` climbed to the tree, or a `const` or
parameter default bound to one of those. It fires once, where the path
first names the file. It adds 14 self-test cases, 51 in total. The
allowlist entry for `app-generator.ts` is removed, because it would read
stale.

## Proposal: what "runs at a workspace root" means for
`currentContext()`

`currentContext()` is product code, so its meaning is written in its doc
comment and proposed here. "Runs at a workspace root" means **cwd itself
holds `pnpm-workspace.yaml`**, the same predicate `dev`, `serve` and
`build` use to skip install and to alias. A workspace **member** gets
the standalone map. That covers a user's package inside their own
monorepo, and objectui under `ui/`. The generator does not walk up,
because walking up would hand a member the empty in-workspace maps, and
no alias table backs those maps at a member's cwd.

**A user's project behaves exactly as before.** With the file in cwd it
gets the in-workspace map, and without it the standalone map, because
the expression is the same. Two tests pin it, both from fixtures: the
in-workspace case runs from a fixture workspace root, and a new case
runs from a fixture member and expects the standalone map.

## Reverse check

The new gate shape, run on the pristine #13491 head (`f8fa141`), fires
at exactly the two sites this PR changes, and nowhere else outside the
allowlist:

- `scripts/check-doc-snippet-types.mjs` `findInstalledCopy`,
`workspace-file-at-ui-root`, `join(root, 'node_modules', '.pnpm')`
- `packages/cli/src/utils/app-generator.ts` `currentContext`,
`marker-probe`, `existsSync(join(cwd, 'pnpm-workspace.yaml'))`

## Verification

**objectui standalone**, on this branch (main `6c3340c` plus this
commit, after `pnpm run setup`). Every command exited 0:

`pnpm check:ui-root-lookups` · `node scripts/check-ui-root-lookups.mjs
--self-test` (51 cases) · `pnpm check:new-line-citations` · `node
scripts/check-changeset-presence.mjs` · `node
scripts/check-changeset-overwrite.mjs` · `pnpm check:changeset-claims` ·
`pnpm type-check:scripts` · `pnpm check:test-path-roots` · `pnpm
check:pre-install-import-graph` · `pnpm check:control-bytes` · `pnpm
exec eslint` over the changed files (0 errors) · `pnpm --filter
@object-ui/cli test` (30 files, 416 tests).

`pnpm exec vitest run scripts/__tests__` was run whole in three shards
(`--shard=K/3`, under the verify lock): 196 files passed, 5787 tests.
Two files were skipped by their own `IS_CHILD` conditions.

**Merged layout.** I used a scratch worktree of the objectstack
verification head `cfa1676afc`. I installed it and ran the CI build of
the seven `@objectstack` packages, plus the `@object-ui` closure
`check:doc-snippets` builds. Then I applied #13491's diff and this diff
under `ui/` with `git apply --directory=ui`. This diff applies to the
landed main unchanged; it is byte-identical apart from blob ids.

- The 7 touched script test files: 293/293 passed.
- `vitest run --root .
packages/cli/src/__tests__/app-generator.test.ts`: 48/48 passed.
- `pnpm --dir ui run check:ui-root-lookups`: OK, 6 allowlist entries
live.
- `pnpm --dir ui run check:doc-snippets`: exit 0, 811 blocks compiled.
Both controls are live and installed at `../node_modules/.pnpm/…`.
- Control, before this diff: the same 7 files plus the CLI test showed
the 12 reds above, and `check:doc-snippets` exited 2 with both controls
"NOT INSTALLED".

## Bound of the new gate shape

Two of the three classes this card fixes are not syntactic joins: the
CLI test treating the checkout root as a workspace root, and the vite
sibling-directory probe. They are out of the gate's reach, and so are a
path assembled through `let` or across files. The gate header states
this bound.

## Out of scope

Pin scripts (objectstack#25711), spec drift (objectstack#25727),
QUICK_REFERENCE (objectstack#25697), dependency versions
(objectstack#25726), `.github/**`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018KHW3U8PmJue4aVvtXutRb

Co-authored-by: Claude <noreply@anthropic.com>
…objectstack#25697, DO NOT MERGE)

Merges objectui main (20 commits past cd70b78: objectui#13481 react
peer ^19, #13483, #13484 readers, #13485 spec catch-up, #13486 detail
panel edges, #13487, #13491 git paths, #13496 pin 43edf90, #13498
workspace-root files) into ui/. Conflicts: the six helper and gate files
this branch had carried as a pre-merge copy of #13484's patch take the
upstream version; ui/pnpm-lock.yaml stays deleted (the merged workspace
lock is recomputed and spliced in the next commit); plugin-list keeps
@objectstack/spec at workspace:^ and takes the ^19 react peers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
… merged lock (objectstack#25697, DO NOT MERGE)

pnpm install --lockfile-only on the refreshed ui/ tree: +9 rows, the
@object-ui/plugin-detail links objectui#13486 gave plugin-list, plugin-map
and plugin-timeline. Measured against objectui's own lock at bba96ce:
3 moved rows of 527 (happy-dom 21.0.4 and better-auth 1.7.12, the root
overrides kept on purpose), the same reading as the run-5 splice.
pnpm install --frozen-lockfile exits 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
… into the verify branch (objectstack#25697, DO NOT MERGE)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Run 6 pushed (head 7180ec63cc). The ui/ subtree is refreshed to objectui main bba96cee5e (20 commits past the import: 2b objectui#13484, 2c round 1 #13496 with the TranslationPreview / loadLanguage fixes from #13485, 2d #13491, 2e #13498, the react ^19 peer narrowing #13481, the plugin-detail edges #13486), and objectstack main c61f75d10f is merged in, which carries PR A2 (#25738). The merged lock was recomputed on top of run 5's spliced lock (+9 rows, the three plugin-detail workspace links) and measures 3 of 527 importer rows moved against objectui's own lock, the same two overrides kept on purpose as in run 5; pnpm install --frozen-lockfile exits 0.

Read before the push, in the merged layout from ui/: check:i18n-drift exit 0 (the run-4/5 Type Check UI red, 2d class), check:ui-root-lookups exit 0, check:installed-pin-claims exit 1 (pin class, #25711 / PR B). Two new readings for the cutover bill, not a card: check:lockfile-integrity reports 26 duplicated packages across the merged lock (two physical copies of ajv, chalk, commander, express, fumadocs-*, lucide-react, … — the gate "reports only"; dedupe or accept is a cutover decision), and check:lockfile-dedupe exits 2 because pnpm dedupe --check gives no reading from a workspace member directory (the gate must run at the workspace root once ui/ is a member).

Expected in this run: Test UI shards 7, 8, 10, 13 (2e) and the shard-16 loadLanguage red (2c) gone; Build Core 117/117; Test Core 6/6 without ui/ items and faster (A2); Type Check · consumer gates green (A2); Type Check · source gates and debt ledger red with a verdict (489 ui/ findings, child 5); the pin and QUICK_REFERENCE reds unchanged. After this read: the counter-probe, then this PR closes.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Run 6, new red class (one owner, no card): @object-ui/cli#type-check TS2321 in the merged layout. Type Check UI now gets past check:i18n-drift (the 2d class is gone on CI, as measured locally) and into the turbo type-check graph for the first time: 65 of 79 tasks green, then @object-ui/cli fails with

src/utils/workspace-vite.ts(216,12): error TS2321: Excessive stack depth comparing types 'Plugin & ExportedAPI' and 'Plugin'.

Reproduced on the worktree at 7180ec63cc. Cause: two vite type copies. The merged lock carries vite@8.3.5 four times (peer sets keyed on @types/node 26.2.0 / 26.6.3, esbuild 0.28.1 / 0.28.2, tsx 4.23.12 / 4.23.15) and @vitejs/plugin-react@6.0.5 twice; the copy linked under ui/packages/cli compares a Plugin from one vite instance with Plugin & ExportedAPI from the other, and tsc gives up. objectui standalone has one copy (its own lock pins esbuild 0.27.7 / tsx 4.23.12 for that peer set), so objectui's CI is green on the same source. This is the duplication check:lockfile-integrity reported before the push (26 packages, @vitejs/plugin-react among them), now with a red attached.

Measured fix: pnpm dedupe on the merged lock clears it (@object-ui/cli type-check exit 0, lock −1,937 / +206 lines). Cost against the 5a principle (objectui's resolutions kept): 50 of 527 ui/ importer rows move, all onto versions objectstack already carries — vitest 4.1.10 → 4.1.12 (18 rows, plus @vitest/ui, @vitest/coverage-v8), @types/node 26.2.0 → 26.6.3 (11), happy-dom 21.0.4 and better-auth 1.7.12 (the two overrides already kept), chalk 6.0.1, @testing-library/react 16.3.3, playwright / @playwright/test 1.63.0, eslint 10.11.0, turbo 2.11.5, ai 7.0.118, react-grid-layout 2.3.0, yaml, tsx, js-yaml, fs-extra, @changesets/cli. No major moves; the run-4 class (react-router 7 → 8) is not among them.

Owner: cutover bill item 6 (dedupe or accept the split; /mnt/project-files/d1-merge/out/5-cutover-bill-notes.md), carried by child 5's adaptation commit — no card, because the fix is a lock operation on the import, not code. Run 7 on this PR measures the dedupe option (Test UI 16 shards against the moved rows) once run 6 is read; the deduped lock is kept at hand. Unchanged plan: counter-probe after that, then this PR closes.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Run 6, same class, second site: Build Core 118/119, the one red is @object-ui/console#build on duplicated postcss. The TranslationPreview TS1360 of runs 2–5 is gone (@object-ui/app-shell#build is green: 2c round 1). The console's own tsc now fails in src/index-css-scan-excludes-tests.test.ts(105,24) with TS2321 comparing postcss@8.5.28 and postcss@8.5.31 Plugin types — the merged lock carries both (objectui's rows on 8.5.28, objectstack's on 8.5.31). Same owner as the vite copies above: the lock duplication, cutover bill item 6. The deduped lock collapses postcss to 8.5.31 alone, and the console's tsc --noEmit on the deduped install no longer reports the postcss error. Run 7 measures it.

Test UI 1/16 is also in: 360 of 362 files green; the only red is ci-cd-pipeline-doc.test.ts:1871 (no registry @objectstack/spec row in the spliced lock), the pin class (#25711 / PR B), which moved from shard 3 to shard 1 with the reshuffle. Nothing new there.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Verify run 6 read (head 7180ec6 = subtree at objectui bba96ce + objectstack main c61f75d, PR A2 in)

DO NOT MERGE. Reds are expected; this is the read.

What A2 bought (confirmed):

  • Type Check · consumer gates GREEN, Type Check · workspace GREEN (run 5: both red on check:published-readme-exports prerequisites).
  • Test Core shards carry no @object-ui/* items any more (2/6: 13/13 packages reported, all objectstack; 6/6 green where run 5 failed on @object-ui/types#build under OS_SKIP_DTS). Test Core 3/6, 5/6, 6/6 green.

Gone with 2c round 1 / 2e (objectui#13496, objectui#13498): Test UI 3–8, 10–12, 15, 16 green. Type Check UI passes check:i18n-drift (2d class gone).

Remaining reds, by class:

  1. Dependency-dedupe class (new in run 6, lock-recompute side effect): the merged lock carries vite@8.3.5 ×4 peer sets, @vitejs/plugin-react ×2, postcss 8.5.28/8.5.31 → TS2321 "Excessive stack depth" in @object-ui/cli#type-check (Type Check UI) and in the @object-ui/console build (Build Core 118/119). pnpm dedupe clears both locally and moves 50/527 ui importer rows onto objectstack's versions (vitest 4.1.12, @types/node 26.6.3, happy-dom 21.0.4, better-auth 1.7.12, …). Run 7 pushes that lock. Cutover bill item 16.
  2. Envelope caller census (new, cutover bill item 17): Test Core 2/6 red = @objectstack/client#test, src/envelope-caller-census.test.ts ×3 (client.analytics.query / meta / explain and automation.trigger hand callers the RAW dispatcher envelope while every sibling unwraps it — one SDK, two calling conventions #13079 §3). The census walks the whole workspace and now finds its first production SDK call site in-repo: ui/packages/data-objectstack/src/index.ts:6625 (this.client.analytics.query(payload); reads post-unwrap rows, not the envelope). Count 28 → 29, ledger mismatch, "none is production code" refused. The census already records this exact site as the OBJECTUI_CENSUS constant (productionCallSites 1, wouldBreak 0), so the re-cut is a conversion of that constant into a live LEDGER row. Reproduced locally (3 failed, 668 passed). Not a lock effect.
  3. Not this PR's — red on main: Test Core 1/6 = @objectstack/spec#test, scripts/liveness/check-liveness.test.ts (23 tests): the translation.json row /props/flows/children/screens cites packages/services/service-automation/src/engine.ts#renderFlowTextSlot, and engine.ts has no screens mention since 0189524 (refactor(service-automation): move AutomationEngine.resumeInternal's body into run-resume.ts (#25222 slice 8b) #25734) moved the only one (an incidental comment) into run-resume.ts. Confirmed at main f9a1e93; no fix on main yet; reported to the lane that owns the ledger. Nothing to port into this PR; it clears when main does.
  4. Known cutover classes, unchanged: Test UI 1/16, 2/16 (objectui pin / spec-drift class); 9/16, 13/16, 14/16 (QUICK_REFERENCE release-sync class); Type Check · source gates + debt ledger (check-type-check-coverage: ui/ packages without typecheck scripts, phantom pins — child 5 verdict); Lint lanes (ESLint, docs & code guards, repo & tooling, CI/release/engine, dispatch-gates, slot lookup & corpus) and Validate Package Dependencies — the ui/ workspace-glob classes read in runs 1–5.
  5. Test Core 4/6 red, leg not yet identified: the job's last leg (@objectstack/cli slice 2/3, 167 files / 2008 tests) is green and the step still exits 1, so an earlier leg among the shard's other 13 packages (service-automation, plugin-security, lint, driver-memory, plugin-email, plugin-hono-server, service-mailbox, metadata-fs, service-job, create-objectstack, client-react, knowledge-ragflow, observability) failed outside the reachable log window; 14/14 packages reported, no stall report. Reproducing locally now; the result goes into the run-7 read.

Next: run 7 = deduped lock on top of this head; then the counter-probe (a deliberate TS2339 in ui/packages/types must redden Type Check UI), revert, close.


Generated by Claude Code

… serve ui/ and packages (objectstack#25697 run 7, DO NOT MERGE)

`pnpm dedupe` over the spliced lock, nothing else: ui importer rows move onto
the versions objectstack already carries (vitest 4.1.12, @types/node 26.6.3,
happy-dom 21.0.4, better-auth 1.7.12, chalk 6.0.1, @testing-library/react
16.3.3, playwright 1.63.0, eslint 10.11.0, turbo 2.11.5, …), which collapses
vite@8.3.5 to one peer set and postcss to 8.5.31. Run 6 measured the
duplicates as TS2321 "Excessive stack depth" in @object-ui/cli type-check and
in the @object-ui/console build; this run measures whether one copy clears
both. The row count moved is in the run-7 read on the PR.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
… the workspace's ai 7.0.118 (objectstack#25697 run 8, DO NOT MERGE)

Run 7's dedupe left plugin-chatbot with two `ai` type copies: its direct
`ai` moved to 7.0.118 while `@ai-sdk/react@4.0.68` pins `ai` 7.0.65 exactly,
so `useObjectChat.ts` failed TS2322 in both Type Check UI and Build Core.
`@ai-sdk/react` 4.0.121 is the release that pins 7.0.118; this bumps the
pair (`@ai-sdk/react` pinned exactly to 4.0.121 — a caret resolves to
4.0.157, which pins ai 7.0.153 and recreates the two copies — and `ai` ^7.0.118) and refreshes the lock with
`pnpm install --lockfile-only`, which drops the 7.0.65 copy. The same bump is
the objectui-side item for the pre-freeze readiness round.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
… to prove the Console compiles against the workspace spec (objectstack#25697 run 9, DO NOT MERGE, revert follows)

Deliberately breaks a spec field the Console's action runner reads. The
merged tree is supposed to type-check ui/ against packages/spec at HEAD,
so Type Check UI must go red on this commit (TS2339 on `size`). If it
stays green the coupling the merge exists for is not wired. The next
commit reverts this one.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
…ize to ['xs','xl'] so the Console's size mapping stops compiling (objectstack#25697 run 10, DO NOT MERGE, revert follows)

Run 9's probe deleted the key outright and never reached the Console:
`pnpm --filter @objectstack/spec gen:schema` refused the bare deletion
against the committed authorable-key baseline (ADR-0104 / #4650), so
`@objectstack/spec#build` was red and every downstream job failed fast.
That is the spec's own retirement gate doing its job in the merged tree.

This probe keeps the key and changes its vocabulary instead, which the
baseline does not track (verified locally: gen:schema green). The ui
renderer compares `schema.size === 'md'` and derives its own type from
`SpecActionButtonProps['size']`, so Type Check UI must now go red at
`@object-ui/components#type-check` (TS2367) with the spec build green.
The next commit reverts both probes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Verify runs 7–10 read (heads fb8f570, 3b0be7c, 86f5a29, 84615e8)

Runs 7 and 8 answer the dependency-resolution class (run 6 comment): the lock was refreshed with pnpm dedupe (run 7) and @object-ui/plugin-chatbot moved onto the workspace's ai@7.0.118 with @ai-sdk/react pinned exactly to 4.0.121 (run 8). Run 7 was cancelled by fail-fast before most shards ran, so run 8 is the measurement.

Green for the first time on run 8: Type Check UI, Build Core (turbo 119/119 tasks), Build & E2E UI, Lint UI, Build Docs, Test Core 3/6 and 6/6, Test UI 4–8, 10–12, 15, 16.

Two lessons from the lock work, recorded for the cutover procedure (#25752):

  • pnpm dedupe moved ai to 7.0.118 while @ai-sdk/react@4.0.68 pins ai@7.0.65 exactly, so one dedupe produced a new TS2322 class in run 7. After a dedupe, re-check exact-pinned sibling pairs (ai / @ai-sdk/*).
  • A caret on @ai-sdk/react (^4.0.121) resolves to 4.0.157, which pins ai@7.0.153, and the tree is back to two ai copies. The pair has to be pinned exactly. The same pin lands objectui-side in the readiness card's round 2.

Red on run 8, by gate (every gate reproduced locally on the merged tree; items are the cutover bill numbers kept in #25752):

  • Build Core, after the green build: check:dts-closure refuses @object-ui/i18n for ./locales/*. The exports map uses a wildcard subpath and the gate tests the literal path. The 13 dist/locales/*.d.ts exist locally and the gate still refuses, so this is gate-side: expand * subpath patterns against dist (item 22). Can land before cutover.
  • Lint · repo & tooling: check:adr-anchors (ui/ cites ADR-0075 ×7 and an ADR-9999 fixture; objectui spells them ObjectUI ADR-…, CHANGELOG lines get an allowlist row, item 19); check:undeclared-dep-imports (@object-ui/console src imports @objectstack/spec and @objectstack/client declared as devDependencies; console ships a prebuilt bundle, so this is a ledger row with evidence, item 20); watch-hint and population declarations that enumerate workspace globs and miss ui/** (item 21a); ui/.objectstack-pin manifests, which the cutover deletes (21b); check:pnpm-flag-spellings on ui/AGENTS.md and an exact better-auth pin (21c, objectui-side).
  • Lint · scoped: check-comment-mask-corpus over-masks JSX text <code>src/docs/*.md</code> in ui/apps/console/src/pages/DocsIndex.tsx as a block-comment opener. Defect in scripts/js-comment-mask.mjs, fix with a pinned self-test case (item 23). 1 of 18,116 files, 258 s sweep.
  • Lint · spec: check:duration-unit-keys names 9 ui zod keys (autoPlay, autoResponseDelay, timeout, delay, duration, delayDuration, openDelay, closeDelay, refreshInterval). Decision E: scope the gate to packages/** or rename in ui/ (21d).
  • Lint · scoped: check:test-source-alias names 99 ui test items (function-body imports and clocked findBy* windows). Decision F: scope or adopt (21d). Run 8 itself argues for adopt, see Test UI 3/16 below.
  • ESLint (pnpm lint): 121 errors and 16,511 warnings over the merged tree; only the errors fail the gate, and every error is under ui/** (mostly react-hooks/static-components and react-hooks/purity, then no-restricted-imports, no-empty-object-type, no-console). The root flat config ignores only build output, so after the import ui/ is linted twice: by its own config (Lint UI, green) and by objectstack's rules. Decision G: ignore ui/** in the root config at cutover and converge the two configs in aftercare, or adopt objectstack's rules across ui/ now (item 25).
  • Test UI 3/16: app-shell/src/preview/__tests__/DraftChangesPanel.securityProblems.test.tsx, 2 of 3 tests, findByTestId('draft-security-problems') not found. Attributed: the panel feature-detects the rule with a function-body await import('@objectstack/lint'); in the merged tree that resolves to the workspace packages/lint/dist (385 exports) and its first transform inside vitest exceeds the 1000 ms findBy* window. With a 1.5 s pre-wait the test passes; it fails identically under the pre-dedupe lock. This is exactly the clocked-window class check:test-source-alias reports, so a warm module-scope import of @objectstack/lint in the test fixes it (item 24, objectui-side round 2).
  • Test Core 5/6: the whole-repo leg failed outside the log window; all 13 shard-5 packages pass locally and no stall fired. Unattributed; run 9 re-measures it.
  • Known classes, unchanged: Test UI 1/2 (.objectstack-pin liveness), Test UI 9/13/14 (QUICK_REFERENCE), Test Core 1/6 (main's liveness red), 2/6 (envelope census), 4/6 (skills catalog frontmatter), Type Check source gates (ui/ coverage verdict), Validate Package Dependencies (fixed group lacks the 40 @object-ui packages).

Counter-probe. Run 9 (86f5a29) deleted ActionButtonPropsSchema.size from packages/spec to prove the Console is compiled against the workspace spec. It never reached the Console: gen:schema refused the bare key deletion against the authorable-key baseline (ADR-0104 / #4650), @objectstack/spec#build went red and every lane failed fast within two minutes. That is the spec's own retirement gate working in the merged tree, and it means a spec PR cannot drop an authored key without the tombstone route, Console or not. Run 10 (84615e8) keeps the key and narrows its vocabulary to ['xs', 'xl'], which the baseline does not track (gen:schema green locally). Proven on run 10: the spec build is green and the Console side is red exactly where the probe points: Type Check UI fails at @object-ui/types#type-check (ui-action-size-11168.test.ts: 'default' / 'icon' not assignable to 'xl' | 'xs', TS2322) and Build Core fails at @object-ui/components#build (action-button.tsx:206 and action-group.tsx:136, TS2367 on the 'md' comparison), 88 of 90 build tasks otherwise green. A spec change that the Console does not follow is now a red PR, which is what the merge exists for. The next commit reverts both probes (run 11 = run 8 state on current main, which also re-reads Test Core on main's new 8-shard layout); this PR closes after that reading. Note: main now runs Test Core as 8 shards, so the shard numbers above (6 shards) do not map onto run 9/10.

维护者速读:run 8 首次 Type Check UI / Build Core 全绿;剩余红项逐门归因(账单 19–24),E/F 两项待 Tesla 决定;run 9 探针被 spec 自身的 key 退役闸门拦下(本身是好消息),run 10 改为收窄枚举再探一次,之后关闭本 PR。

… (objectstack#25697 run 11, DO NOT MERGE)

Restores packages/spec/src/ui/component.zod.ts to 3b0be7c. Runs 9
and 10 did their job: the spec's own retirement gate refused the key
deletion, and the vocabulary change turned Type Check UI and Build Core
red at the Console packages that read the field, with the spec build
green. Run 11 re-reads the clean tree on current main (8 Test Core
shards) before the verify PR closes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Closing: verify runs 1–11 are read (see the run comments above; the cutover bill lives in #25752, items 1–25). The branch claude/project-thread-dwhq6r-25697-verify stays as the measured tree (run 8 state) until the cutover PR lands. Never meant to merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation needs-user-decision protocol:ui size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants