Repository navigation
DO NOT MERGE — verification run for #25697: PR A + the ui/ subtree import of objectui main cd70b7818d - #25718
objectstack-fleet[bot] wants to merge 10000 commits into
Conversation
#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, console dock 1280 and 1010:   **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:   After, nothing pending, and in the dock sheet:   **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>
|
Run 5 read (ci 40153788034, lint 40153787956; head
Next: run 6 after the objectui Generated by Claude Code |
…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>
…#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
|
Run 6 pushed (head Read before the push, in the merged layout from Expected in this run: Test UI shards 7, 8, 10, 13 (2e) and the shard-16 Generated by Claude Code |
|
Run 6, new red class (one owner, no card): Reproduced on the worktree at Measured fix: Owner: cutover bill item 6 (dedupe or accept the split; Generated by Claude Code |
|
Run 6, same class, second site: Build Core 118/119, the one red is Test UI 1/16 is also in: 360 of 362 files green; the only red is Generated by Claude Code |
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):
Gone with 2c round 1 / 2e (objectui#13496, objectui#13498): Test UI 3–8, 10–12, 15, 16 green. Type Check UI passes Remaining reds, by class:
Next: run 7 = deduped lock on top of this head; then the counter-probe (a deliberate TS2339 in 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
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 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):
Red on run 8, by gate (every gate reproduced locally on the merged tree; items are the cutover bill numbers kept in #25752):
Counter-probe. Run 9 (86f5a29) deleted 维护者速读: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
|
Closing: verify runs 1–11 are read (see the run comments above; the cutover bill lives in #25752, items 1–25). The branch Generated by Claude Code |
⛔ 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 objectuimainatcd70b7818dwith the #25678 spike's adaptations, so that the four* UIjobs run withui/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)
b97bdad2a9subtree import of objectuimain@cd70b7818d(history kept)32d50453cb40@objectstack/*ranges in 32ui/manifests →workspace:^0dad5c2e11dropui/pnpm-lock.yaml,ui/pnpm-workspace.yaml, parkui/turbo.jsonasui/turbo.objectui.json, remove the preinstall pin gate4ddd900062rootpnpm.overrides(react 19.3.0 line, qs/shell-quote/tmp/uuid floors) +@radix-ui/react-focus-scope@1.1.16patch717defb0eelockfile recompute (one react/react-dom/@types line at 19.3.0, one zod 4.6.7)b2ba3a42dacherry-pick of PR A's7ca50a8a7bWhat this run must show (read on #25697)
Type Check UI,Test UI (n/16),Lint UI,Build & E2E UIrunning (not skipped) and their cold minutes; a second push reads turbo-warm minutes.size→buttonSizeonActionButtonPropsSchema(packages/spec/src/ui/component.zod.ts) must turnType Check UIred atui/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-graphandcheck:workspace-manifest-cyclesself-tests (DECLARED_WATCH_HINTS lackui/**/package.json),check:published-filesself-test (EXPORTS_CENSUS_FLOOR 50 vs 112 publishable),check:undeclared-dep-imports(9, Console devDependencies on spec/client),check:type-check-coverageandcheck:type-check-debt(ui/tsconfig.jsonis JSONC),check:cross-package-test-inputsself-test (the awaiting-import excuse ends at the import, by design),check:merge-driver(shadcn:updategenerator, ui manifests),check:agent-test-spelling(ui/AGENTS.md:210),check:declared-population-live(git ls-filesENOBUFS),check:adr-anchorsself-test (ADR-0075 cited by 7 Console files, no such record here),check:manifest-repository-directoryself-test (ui/.objectstack-pin/**),check:pnpm-flag-spellingsself-test,check:tier-file-adoption(47 packages),check:test-source-alias(dynamicimport()in app-shell tests),check:vendor-export-contract(@object-ui/authbetter-auth ^1.7.2 vs root 1.7.12),check:duration-unit-keys(9 ms-described keys without a unit in the name, all inui/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