Skip to content

merge(ui): objectui becomes the ui/ workspace of this repository — subtree import at the frozen tip 9e1fe03286 plus the adaptation series (objectstack#25752) - #25825

Merged
os-zhuang merged 10000 commits into
mainfrom
claude/project-thread-dwhq6r
Oct 25, 2026
Merged

os-zhuang merged 10000 commits into
mainfrom
claude/project-thread-dwhq6r

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Part of objectstack#25752 (child 5, the cutover procedure; steps 3–4). Part of objectstack#25677.
Clause-②: no
Metadata-protocol: none

⛔ Merged by the maintainer at the Tier H sitting, 2026-10-26 09:00Z (北京 17:00) — not before

This PR is Tier H by size (the whole objectui tree, ~2.6 M lines) and is the first of four merged in order at the sitting: this PR → 4a #25745 → PR B (objectstack#25697 item 6) → 4b PR 2 #25712, then the required-checks change (step 6). It stays draft until the seat says it is green; the seat never enqueues it. Reviewers: the GOVERNED_APPROVERS, requested when it leaves draft.

What this PR is

objectui (objectstack-ai/objectui) becomes the ui/ workspace of this repository, per the maintainer's D1 ruling (objectstack#25677: one repository, one fixed version group, the objectui repository archived as a migration notice).

  1. git subtree add --prefix=ui of objectui main at its frozen tip 9e1fe032866965c7cb0460cff2b6af09350236b7 (objectui#13545, the final .objectstack-sha pin, pointing at objectstack 5e7cf63e71). objectui main is frozen since 2026-10-25 15:40Z (freeze notice: objectstack#25752); nothing lands there after that commit. History is kept (no squash).
  2. Root workspace file. pnpm v10 reads overrides and patchedDependencies from pnpm-workspace.yaml only, so objectui's pnpm field moves there: the qs / shell-quote / tmp / uuid floors, the single react line (19.3.0), the @radix-ui/react-focus-scope@1.1.16 patch; the react-router override narrows to the 8.x line it fixes (objectui's react-router-dom 7.18 pins a 7.x react-router with no fixed version to force).
  3. Adaptation series (objectstack#25803, card J) — one commit per gate or concern, each named in its message. Drop 1: every @objectstack/* range in the 32 ui manifests becomes workspace:^; ui/ stops being a workspace root of its own (nested pnpm-workspace.yaml deleted, pnpm field and preinstall pin check removed, turbo.json parked as turbo.objectui.json); the ui root manifest drops its turbo-recursive scripts; scripts/ui-import/splice-lockfile.mjs carries objectui's lockfile resolutions into the merged lock. Drop 2 (root gates, ledgers, eslint ignore, skills move, the K1 scoping of check:published-files) follows in this PR before it leaves draft; the list is in /mnt/project-files/d1-merge/out/cutover-adaptation/SERIES.md and will be mirrored here.
  4. One lockfile. ui/pnpm-lock.yaml is removed; the root lock is objectui's resolutions spliced in (moved 0 against objectui's own lock at the splice), reconciled twice with pnpm install --lockfile-only, then pnpm dedupe; pnpm install --frozen-lockfile and pnpm dedupe --check exit 0. ai 7.0.118 and @ai-sdk/react 4.0.121 hold one copy each.

What this PR does not do

Accepted red classes (procedure #25752, "Accepted red classes") and the bill

Anything red on this PR is read against /mnt/project-files/d1-merge/out/5-cutover-bill-notes.md (items 1–36). A red that is not on the bill stops the sitting until it is ruled.

Verification

(filled by the seat before the PR leaves draft: the CI run, the local gate sweep, the lock verdicts and sha, the git am acceptance of the series.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU

objectstack-fleet Bot and others added 30 commits October 20, 2026 02:39
…ecord card (objectui#13102) (#13109)

Fixes #13102
Clause-②: no

## What changes

`record:details` no longer draws a record pointer pair's type half as
its own row. An object can declare a polymorphic pointer as a pair: the
id half (`record_id`) names its sibling with `referenceVia`
(`object_name`). `DetailSection` already draws the id half as the record
card, headed by the target object's label. The type half also drew as a
row of stored machine name beside that card.

`foldPointerTypeHalves` in
`packages/plugin-detail/src/renderers/record-details.tsx` runs after
every other fold (field security, the strip dedupe, the H1 dedupe). It
reads the object's declared fields: every field with a non-empty
`referenceVia` that names a declared sibling forms a pair. When the id
half is drawn anywhere in this body (the direct `fields` list or any
section, `{ group }` references included, since they resolve before the
fold), the type half leaves every list of the body.

- **Declared pair only.** No field name is read. A text field called
`object_name` that no field names in `referenceVia` still draws.
- **Only the row goes.** The record (`ctx.data`) is untouched, so the
card still resolves the pair from the type half's value, and every other
reader still reads it.
- **Unchanged:** an object with no pair, and the id half's card.

One choice beyond the card's wording: the type half is folded only when
its id half is drawn in the same body. If the body does not draw the id
half (for example, the author listed `object_name` but not `record_id`),
no card on this body names the target object, so the type half keeps its
row. Without this condition, the object type would be shown nowhere on
the body. Pinned by the third case below.

No export, prop, key or spec surface moves. The helper is
module-private.

## Tests

New:
`packages/plugin-detail/src/renderers/__tests__/record-details.pointerTypeHalf-13102.test.tsx`,
through the real `RecordDetailsRenderer` in a `RecordContextProvider`,
with a probe face registered under `RECORD_POINTER_CARD_TYPE`:

1. (a) a pair, `sections` body: one card, and it receives `{ objectName:
'invoice', recordId: 'inv_1' }` (so the type half still feeds the card).
No `Target Object Type` label and no `invoice` text in the body.
2. (a) a pair, direct `fields` body: the same fold.
3. a pair whose id half the body does not draw: the type half keeps its
row.
4. (b) control: the same field names with no `referenceVia` draw every
listed field, `object_name` included.

Both fixtures declare `nameField: 'name'`, so the page-H1 dedupe takes
`name` and none of the fields under test. A first run without it showed
the H1 dedupe hiding `object_name` on the control object.

**Ablation** (via `scripts/ablation-replace.mjs` in the objectstack
checkout, anchor `const body = foldPointerTypeHalves(filteredFields,
filteredSections, objSchemaFields);` replaced with the unfolded
pass-through), on HEAD `5a5fd3415e`: anchor 1 to 0, blob `90106dfff7e2`
to `570f6724e181`. Cases 1 and 2 went red (`Tests 2 failed | 2 passed
(4)`), and the controls stayed green. Restore: blob after restore
`90106dfff7e2` equals the HEAD blob, and `git diff HEAD` is empty. The
test imports the renderer by relative path from `src`, so no `dist`
build sits between the mutation and the run. The same ablation also ran
on the first commit `83fdc295c0`, with the same result.

## Gates (worktree HEAD `5a5fd3415e`, base `df943a2a1a`)

- Closure build `pnpm --filter '@object-ui/plugin-detail^...' build`,
then `pnpm --filter @object-ui/plugin-detail type-check` (`tsc --noEmit
&& tsc -p tsconfig.test.json`): exit 0. `--listFiles` on
`tsconfig.test.json` counts the new test file once.
- vitest, `--maxWorkers=1`, under the shared lock: the new file `4
passed (4)`. The family that reads the touched file (all 22
`renderers/__tests__/record-details.*` files plus
`__tests__/DetailSection.recordPointer-12045.test.tsx`): `Test Files 23
passed (23)`, `Tests 173 passed (173)`.
- **Declared to CI:** the full `packages/plugin-detail/` directory. The
dispatch order bars whole-package-directory runs in this container.
- `eslint --no-inline-config` on the two changed source files: exit 0, 0
errors. `record-details.tsx` carries 29 warnings, the same count as its
`origin/main` version, and the new test file carries 0.
- 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`,
`scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`.
- `check:spec-floors`: exit 1, `no-artifact` (no full workspace build in
this worktree). Recorded, not fixed, as the order says.

## Changeset

`.changeset/13102-details-pointer-type-half.md`:
`@object-ui/plugin-detail` `patch`.

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…lared-pointer rung (#13110)

Fixes #13103
Clause-②: no

## What

`page:header`'s declared-pointer rung (`declaredTitle`) now reads the
record through the same `withOptionLabels` copy the `titleFormat` rung
already reads. A select `nameField` (or `displayNameField`) heads the
record page with its option label, for example `Config change`, not its
stored value `config_change`. The copy is computed once (`labelledData`)
and both rungs read it, so the option lookup is not restated:
`optionLabelFor` stays the one place this file maps an enum value to its
label, and its doc comment now names both rungs.

Unchanged: a text `nameField` (it has no `options`, so
`withOptionLabels` leaves its value as served), the rung order (explicit
title, declared pointer, `titleFormat`, resolver, record-key net,
placeholder floor), and the field-level-security reads (a masked field
still arrives absent; the labelled copy only rewrites a top-level scalar
the record carries).

## Tests

New
`packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx`
renders the real `page:header`:

- (a) an object with `nameField: 'action'`, a select whose
`config_change` option is labelled `Config change`: the H1 is `Config
change`; the same with a `titleFormat` beside it (the pointer still
answers first, with the label); the array form of `fields`.
- (b) CONTROLS: a value no option declares renders raw; a text
`nameField` whose value equals another select field's option value is
unchanged; the `titleFormat` rung still labels a select token.

Ablation (one-off, no permanent file):
`objectstack/scripts/ablation-replace.mjs` swapped
`recordDisplayValueAt(labelledData, declaredNameField(objSchema))` back
to read `data` (anchor 1 to 0, blob `c4acc45de256` to `b4fadf1fe8ee`).
The new file went `3 failed | 3 passed (6)`: the three label pins red
(`Expected: "Config change"`, `Received: "config_change"`), the three
controls green. Restore: blob after restore `c4acc45de256` equals the
HEAD blob, `git diff HEAD` empty.

## Gates (on `74fdaee25e`)

- Closure build `pnpm --filter '@object-ui/components^...' build`: exit
0.
- `@object-ui/components` `type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`): exit 0; the test config's file list includes the
new test.
- Targeted vitest: the four `page-header-title*` files, `4 passed (4)`,
`42 passed (42)`; then every `packages/components` test file that names
`page:header` or `layout/containers`, `36 passed (36)`, `493 passed
(493)`.
- `eslint` on the two changed source files: exit 0, 0 errors (warnings
are the file's existing `no-explicit-any` set; the new test's three
match its sibling's helper).
- Pre-tsc battery, each 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`, `scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`, `check:new-line-citations`.
- Declared to CI, not run locally: the full `packages/components`
directory, and `packages/app-shell` tests that render `page:header`
(record detail views).

## Acceptance notes

- `unifiedTitle` (the resolver rung, `getRecordDisplayName`) still reads
the served record. It is reached only after the declared-pointer and
`titleFormat` rungs both decline, so it is not the path this card
measured; whether its type-aware derivation can ever pick a select field
was not measured here. Noted, not changed (outside this card's ruling);
carrier: none.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…, never a derived field (#13112)

Fixes #13107
Clause-②: no

## What this changes

The bulk-action confirm step lists each affected record by the object's
declared `nameField`. Before this change, when no column of the view
showed that field, the step listed another text field in its place.
Measured on HotCRM's All Contacts list, it showed job titles instead of
contact names.

Two halves, both in `@object-ui/plugin-grid`:

- **Harvest (`ObjectGrid` `getSelectFields`).** The grid's own `$select`
now asks for the object's declared `nameField`. It is read through
`declaredNameField`, the resolver's single spelling of that pointer. A
formula field is requested by name, because the server serves formulas.
It is fetched, not drawn: it joins the projection, not the columns. It
goes through the same two gates as the grid's other extra fields:
`isProjectableField` (the object must declare it) and
`passesProjectionGate` (the viewer may read it). It joins the existing
union, so a `nameField` that is already a column is sent once. This is
the projection a standalone `object-grid` sends, and the one every group
row page of a server-grouped grid sends. The measured All Contacts view
is grouped by account (`grouping.fields: crm_account`), so that is the
path its rows take.
- **Floor (`BulkActionDialog` confirm step).** A new module-local
`affectedRecordName` decides the name. An object with no declared
`nameField` keeps `getRecordDisplayName`'s full ladder, unchanged. For
an object that declares one, the step uses the declared value, or the
resolver's own `Record #ID` floor when the row lacks it. The derive
steps (4 and 4b) are never reached for such an object. Two cases leave
the row without the value: the server did not answer it, or the viewer
may not read it.

No change to `getRecordDisplayName`'s global ladder in `@object-ui/core`
(triage ruling). Nothing is added to the package entry: no export, prop
or type member.

## Pins
(`packages/plugin-grid/src/__tests__/bulkConfirmNameField-13107.test.tsx`)

Each pin drives the real ObjectGrid and BulkActionDialog. The data
source honours `$select` the way a server does: a row carries the
selected keys only.

- (a) Formula `full_name` as `nameField`, with columns `first_name` /
`last_name` / `title`. The `$select` carries `full_name`, the confirm
step lists `John Smith` and `Marcus Reed`, and no column header is drawn
for it.
- (a) The same object on a server-grouped grid: every group row page's
`$select` carries `full_name`.
- (b) The server leaves `full_name` off the answer: the step lists
`Record #c1` and `Record #c2`, never the job titles.
- (b) The viewer may not read `full_name`: it is not requested, and the
step lists `Record #ID`.
- CONTROL (c): a `nameField` that is a column (the lead list shape) is
sent once and listed as before.
- CONTROL (c): an object with no `nameField` asks for nothing more and
keeps today's derive ladder. It lists the titles, as before.

## Ablations (one-off, via `scripts/ablation-replace.mjs` in the
objectstack checkout; no permanent test)

Each mutation landed on disk with the anchor count going 1 to 0. Each
was restored and checked: the blob equals HEAD and `git diff HEAD` is
empty.

1. Drop the harvest (`...nameFields` removed from `extraFields`). Blob
`c025fe82f5a1` became `49fa09861016`. 3 of 6 went red: both (a) pins and
the first (b) pin, because that pin asserts `full_name` in `$select`.
Restored to `c025fe82f5a1`, the HEAD blob.
2. Drop the floor (the `affectedRecordName` condition forced to the full
ladder). Blob `15c5e833e1ec` became `62e734d67541`. Both (b) pins went
red, and each received `VP of Engineering` and `Director of Commercial
Systems`. That is the card's symptom, reproduced. Restored to
`15c5e833e1ec`, the HEAD blob.

## Gates (final head `0d08662299`; source last changed at `85d07587eb`,
later commits are the test file and the changeset)

- Closure build `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-grid^...' build`: exit 0.
- `pnpm --filter @object-ui/plugin-grid run type-check` (`tsc --noEmit`
plus `tsc -p tsconfig.test.json`; the new test is in that program by
`--listFilesOnly`): exit 0 on `0d08662299`.
- Targeted vitest, `--maxWorkers=1`, run under the shared lock: the new
file passes 6/6 on `0d08662299`. The families that touch these files ran
on `85d07587eb`: 69 files, 1771 tests passed. That set is every
plugin-grid test mentioning `$select` or the resolver, plus every test
anywhere naming `ObjectGrid.tsx` or `BulkActionDialog`. The full
`packages/plugin-grid/` directory is declared to CI.
- `eslint` on the three changed source and test files: 0 errors. The
warnings are `no-explicit-any` in the test file, the same style as the
precedent `bulkConfirmRecordTitles-12332` test.
- 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` and `check-changeset-presence` all exit 0.
`check:spec-floors` exits 1 with `no-artifact`, because the workspace is
not fully built. This is a known state of main and is recorded, not
acted on.

## Acceptance notes

- **The other projection, outside this card's file surface.** An
UNGROUPED list view is fetched by `plugin-list`'s `ListView`, which
hands the rows to the grid. Its `$select` harvest has no arm for the
declared `nameField`. On such a list, a `nameField` that is not a column
now reads as `Record #ID` in the confirm step, not as a wrong name, but
it is still not the name. The repair is a single `addSpeculative` call
for the declared `nameField` in that harvest. `ListView` already has the
matching arms for `rowColor` and `referenceVia`. That is plugin-list,
outside the claim, so it is reported to the PM seat and not done here.
- `changeset`: `@object-ui/plugin-grid` patch.

The session that wrote this:
`https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…when declared (#13117)

Fixes #13099
Clause-②: no

## What

The create entry uses the object's `create` form view when the object
declares one, else today's `form` / `formViews.default`. An edit is
unchanged. This is ruling B′ on #24692 (ruling
comment 6281834942), scoped by the director's pointer 6281857581 on the
card. Its first bullet, quoted verbatim as the director asked:

> **One selection line, pinned by a test.** The create entry
(`packages/app-shell/src/utils/recordFormNavigation.ts:218`) uses
`objectDef.formViews?.create` when the object declares it, else today's
`objectDef.form ?? objectDef.formViews?.default`. This is the documented
create-vs-edit model (`content/docs/ui/create-vs-edit-form.mdx`) made
true by convention; it does not wait on the spec PR.

- `recordFormNavigation.ts`: new `selectRecordFormView(objectDef, flow)`
holds the one selection. `resolveFormViewLayout` and
`resolveRecordFormModalLayout` take an optional `flow` (`'create' |
'edit'`). A caller that names no flow gets the default form view, the
same answer as before.
- `AppContent` (the console's New/Edit modal) passes `editingRecord ?
'edit' : 'create'`. That is the same test that sets the modal's own
`mode`, which comes from the `?form=` URL param (`new` or a record id).
So the selected view always matches the mode the form opens in, and an
edit cannot reach the create selection.
- `useActionModal` (action-opened object forms) passes the flow from the
same `mode` the `ModalForm` opens in. `view` and `edit` keep the default
form view.

## The served key: `OBJECT.create`, not a bare `create` (mechanism,
measured)

The pointer's literal spelling `objectDef.formViews?.create` does not
match the runtime shape. A container's `formViews.create` expands to the
form item named `OBJECT.create` (`expandViewContainer` in
`@objectstack/spec/ui`). `MetadataProvider.mergeViewsIntoObjects` keys
`objectDef.formViews` by that served name. The existing pin
"`showcase_contact` keeps its default whichever order its two forms
arrive in" (`MetadataProvider.merge.test.ts`) asserts the keys
`showcase_contact.create` / `showcase_contact.form`.

A literal `formViews.create` read would therefore never match the
documented showcase example, and a pin on a hand-built fixture would
pass anyway. The selection reads `formViews[OBJECT.create]`, and the new
pin builds its fixtures from served rows run through the real merge. No
second spelling is read; there is no `??` alias between the two.

## Not moved (per the ruling)

There is no `mode` / `formView` plumbing and no inline add. The Add
button keeps its position and its `enabled: true` gate. The
`p1-spec-alignment` parity row is untouched.

## Pins
(`packages/app-shell/src/utils/__tests__/createFormView-13099.test.ts`)

- (a) `showcase_contact` (a `create` form view whose sections and size
differ from the default `form`): a create selects the `create` view's
sections and `modalSize`, through `resolveFormViewLayout` and
`resolveRecordFormModalLayout`.
- (b) An edit of the same object keeps the default `form`, which is
tabbed with different sections. A call with no flow gives the same
answer as an edit.
- (c) CONTROL: `crm_lead` has a named Web-to-Lead form and no `create`
form. Its create uses `form`, and the named form is never picked. The
legacy `formViews.default` is still the create fallback. A named-only
object creates with no layout.

**Ablation**, run through `ablation-replace.mjs` at HEAD `2e759c1083`.
The mutation replaces `if (createView) return createView;` with a no-op,
and the anchor goes from 1 hit to 0. Result: (a) red with `expected [
'identity', 'notes' ] to deeply equal [ 'quick' ]`, (b) and (c) green
(`1 failed | 2 passed`). After the restore, the blob is
`df4f0b151ec4b5ebaef09a6aef65424f38e6c873`, equal to HEAD, and `git diff
HEAD` is empty.

## Verification (HEAD `2e759c1083`)

- `pnpm --filter '@object-ui/app-shell^...' build` (the closure build):
exit 0. Then `pnpm --filter @object-ui/app-shell type-check`: exit 0.
That is `tsc --noEmit` plus `tsc -p tsconfig.test.json`, and
`--listFiles` counts the new test file in the test project.
- Targeted vitest (`--maxWorkers=1`, under the lock): the new pin plus
every test file naming `recordFormNavigation` / `useActionModal` /
`AppContent`, 99 files in three batches. Results: 34 files / 260 tests,
31 / 235 and 35 / 338, all passed.
- **Declared to CI:** the full `packages/app-shell/` directory. The
dispatch ruled out whole-package-directory runs locally.
- Lint, narrowed to the 4 changed `.ts`/`.tsx` files with `eslint
--no-inline-config --format json`: 4 files, 0 errors. The warnings are
43 / 15 / 5 on the three edited files, equal to their base-commit
counts, and 0 on the new test. The root `eslint.config.js` names no
`parserOptions` / `projectService` / type-checked preset, so linting is
not type-aware. This diff cannot move any untouched file's verdict.
- 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` and `new-line-citations` all exited 0. So did
`check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs` and
`check-changeset-presence.mjs`.
- `check:spec-floors`: exit 1, `no-artifact`. The workspace is not fully
built, so this is NOT MEASURED, recorded and not fixed, as the dispatch
says.

## Acceptance notes

- When the `create` form view is selected, it is the whole form: its own
sections, layout, size and subforms. Model-derived inline subforms
(`attachInlineSubforms`) attach to the default form, so they do not
appear on such a create.
- A list view that also uses the key `create` in the same container
takes the name `OBJECT.create` first. The form is then renamed
`OBJECT.create_2` by the expansion, which stamps its own collision
warning, and the create falls back to the default form.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
… shipped showcase or retired (objectui#13104) (#13132)

Fixes #13104

Clause-②: no

Every row has its conclusion: the card's eleven, plus `studio-editor`,
which the claim's amendment (comment `6283451551`) added as row 12.
Eleven specs are repaired against the current surface and the shipped
showcase. One case is deliberately retired (`user-filters`' lookup quick
filter). `screen-flow` needs no change because its red does not
reproduce. ⛔ No product source is touched; the diff is eleven files
under `e2e/live/`. No `test.skip` / `test.fixme` is added, and
`record-history-display`'s skip guard is gone.

This is a resume dispatch. The first dev's draft (`2f09525`) was
unverified when its container restarted. Every row below was
re-measured, red on BASE `df943a2` and green on this head. Three commits
on top of the draft change what did not hold (R1, R4, R5 below). Patch
round 1 adds `8493767` for row 12. Session:
`https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`.

## How it was measured

- **Backend (the one the card names, "the shipped showcase"):**
objectstack `main` `d20bf9b09`, whose `.objectui-sha` is the shipped
console pin `12dc7b47c340`. Its `examples/app-showcase` ran from a built
objectstack worktree with `objectstack dev --seed-admin --fresh -p
4113`, on a fresh database for each whole-suite run.
- **Console:** this worktree's console Vite dev server (`cd apps/console
&& DEV_PROXY_TARGET=http://localhost:4113 pnpm exec vite --port 5213
--strictPort`). The diff touches only `e2e/live/`, so the console source
under test is BASE's.
- **Runner:** `pnpm exec playwright test` with
`playwright.live.config.ts` wrapped only to add `executablePath:
/opt/pw-browsers/chromium`. This container's Playwright 1.62.1 wants
chromium revision 1234, and 1194 is the one installed. The BASE specs
ran from a `git archive df943a2 e2e/live` copy.
- **Whole-suite runs:** every spec under `e2e/live/`, in one run,
alphabetical, one worker, as the Tier 1 run
(#24598) ran them.
  - BASE: 46 passed, 15 failed or timed out, 1 skipped.
  - Head `3afb8d1` (round 1): 59 passed, 2 failed, both `studio-editor`.
- Head `8493767` (patch round 1, row 12 repaired): 61 passed (11.0m),
exit 0. Every spec is green, `studio-editor` included.
- **Control backend (H2):** objectui's own CI-lane backend:
`e2e/live/ci/start-backend.sh`, published `@objectstack/*@18.0.0-next.5`
plus the showcase at its release tag (`820fd5ae`), on `:4114`, with a
second console dev server on `:5214`. The head `3afb8d1` whole-suite run
there gave the same reading as the primary: 59 passed, 2 failed, both
`studio-editor`. In patch round 1 the repaired `studio-editor` also ran
there at `8493767`: 2 passed. `backend.env` is not edited.

## Per-row conclusions

Before = BASE `df943a2`, after = the final head, both read from the
whole-suite runs above (tests passed / tests in the spec).

| spec | cause, re-measured at BASE | conclusion | before → after |
|:--|:--|:--|:--|
| `studio-object-designer` | F1 timed out: it opens the Studio app,
which a stock boot does not serve. F4: the banner is "A `select` field
needs its choices", for a picklist with no option source | repaired: F1
on the Studio surface's package switcher (R4); F4 gives the picklist one
option before adding the empty row | 2/4 → 4/4 |
| `studio-record-page` | `getByLabel(/^Object/i)` resolves first to the
app bar's "ObjectOS home" link, and the Object control is a
`role=combobox`; Page type now defaults to list | repaired: anchored
label, combobox and option, Page type set to record | 0/1 → 1/1 |
| `user-filters` | tabs: no `view-tab-urgent` (ADR-0053 made tabs an
interface-page element); switcher: not in the toolbar; hollow view:
`cell` where the grid exposes `gridcell`; lookup filter: no filter bar
on `showcase_task.tabular` | repaired: tabs on the Task Triage page, the
switcher through the Display popover with the exact shipped whitelist,
`gridcell`. The lookup case is retired (R2) | 2/6 → 5/5 |
| `inline-edit-polish-2572` | the lock poll read 8 edit affordances
where 0 were expected: the request opens on `manager_review`
(`lockRecord: false`); the spec also spent the seeded project |
repaired: its own project, and the lock asserted at `exec_review` (R1) |
0/1 → 1/1 |
| `field-conditional-rules` | no `tax_rate` input where `toBeDisabled`
waited: a `readonlyWhen` field renders the read-only field group |
repaired: the input is gone and the read-only group is shown under
`paid`, and back to an editable input under `sent` / `draft` | 0/1 → 1/1
|
| `grid-file-upload` | `getByText('Line Items')` matches the sr-only
DialogDescription and the section heading (strict mode). The second
cause (ADR-0104 stored form) sits behind it | repaired: the section
found by its heading role; the receipt is a file id, read back byte for
byte through `GET /api/v1/storage/files/ID` | 0/1 → 1/1 |
| `form-view-subforms` | the same `Line Items` collision | repaired:
heading role | 0/1 → 1/1 |
| `cascading-options` | no `md-form-submit` on `showcase_cascade`'s flat
ModalForm | repaired: waits for the ModalForm's `modal-form-footer` |
1/2 → 2/2 |
| `detail-related-list` | `waitForURL(/[?&]recordId=/)` timed out: a
row's name link opens `/record/ID` | repaired: the id read from the
route, on Northwind; the primary Projects list in its own tab (R3) | 0/1
→ 1/1 |
| `record-history-display` | skipped by its app-todo guard | repaired:
targets `showcase_account`, with no skip guard (R5) | skipped → 1/1 |
| `screen-flow` | passes at BASE, both alone and in the whole-suite
order | no change: the card's red does not reproduce (below) | 1/1 → 1/1
|
| `studio-editor` (row 12) | the shipped `showcase_project_workspace`
opens read-only (lock `no-overlay`), so there is no "Add block"; and the
page form has no "Layout" section heading. Both are intended (below) |
repaired: each test authors an editable copy of the page; the Layout
check reads where the layout renders, on the canvas | 0/2 → 2/2 |

**`screen-flow` (read first, as triage asked).** No record names a cause
for the Tier 1 red. #24598 says only "FAILED
(known-stale, not consulted)". On the shipped showcase it passes at BASE
alone and in the whole-suite order. objectui's own Live E2E lane
(published `18.0.0-next.5`) also reads it green, judged by the step `Run
live E2E allowlist` = success with no `continue-on-error`:
- `main`'s scheduled run `39213623070`, at `4484a11`. That is three
commits after the Tier 1 console pin `4016370a`, and the three touch
record-page reads and publish tooling.
- PR run `39359398003`, today.

The job log itself answered 403 through this container's proxy, so the
reading rests on the step conclusion.

## Review points

**R1 · `inline-edit-polish-2572`: the lock assertion is kept.**
- The draft swapped the lock for an "In approval · editable" reading.
The lock is back, at a step that locks.
- Before (BASE): poll until `page.locator('[title="Double-click to
edit"]').count()` is `0` after the request opens. Then
`lockedBudgetGroup.dblclick()` and `expect(saveBtn).toBeHidden()`.
- Now:
- (2a, an addition) at `manager_review`: "In approval · editable" is
shown, and a double-click opens a session.
- (2b) The test approves `manager_review` with `POST
/api/v1/approvals/requests/ID/approve`. The seeded admin holds the
`manager` position (`ADMIN_APPROVAL_POSITIONS` in the showcase's
`seed-approval-demo.ts`). It waits for the pending `exec_review` request
with `lock_record: true`.
- Then it asserts the original lock unchanged: zero affordances after a
fresh mount, and a double-click that opens nothing. It adds the "Locked
for approval" band as a positive signal.
- The budget edit moves from 160000 to 600000, because `exec_review` is
reached only over the flow's decision edge for a budget above 500000
(`showcase_budget_approval` in the showcase's
`automation/flows/index.ts`).
- Measured: BASE red (8 vs 0); `46625db` green alone (24.3 s); green in
the head whole-suite run.

**R2 · `user-filters`' lookup quick filter: retired, with the
measurement.**
- `git grep -n userFilters d20bf9b -- examples/app-showcase/src` lists
every declaration in the shipped showcase (positive control:
`allowedVisualizations` is found in `task.view.ts`).
- The views and pages read:
  - page `showcase_active_projects` (`health`, `status`);
  - page `showcase_task_triage` (tabs);
  - page `showcase_task_workbench` (`status`, `priority`);
  - `project.view.ts` (`status`, `health`).
- `task.view.ts` declares none by design; its own ADR-0053 note says so.
- Every one of those fields is `Field.select`. The task object's one
lookup, `project` (master-detail), is a user filter nowhere. So no
shipped fixture can drive the case, and it is deleted.
- Unit reading (not a diff): four plugin-list tests render the lookup
filter picker (`filter-lookup-FIELD`):
  - `UserFilters.declaredDisplayField-10545`
  - `UserFilters.lateTypeCoercion-12008`
  - `UserFilters.pendingLabel-12890`
  - `UserFilters.retiredFieldType`
- 10545 pins the picker's display-field read. None of the four picks a
record through the picker: the selection is seeded with
`initialSelections`, and 12008's emitted-filter assertions are on
boolean and numeric fields.
- **Coverage lost:** picking a record in a lookup quick filter narrows
the list, persists as a `uf_FIELD` URL param and badges the count.
Nothing pins that end to end now.

**R3 · `detail-related-list`: Northwind and its related list are in the
shipped seed.**
- `examples/app-showcase/src/data/seed/index.ts` seeds the account
`Northwind` and its projects "Website Relaunch" (active) and "Legacy
Sunset" (completed).
- `examples/app-showcase/src/data/objects/project.object.ts` declares
the `account` lookup with `relatedList: 'primary'` and
`relatedListTitle: 'Projects'`.
- "Rule Z" is objectui's own default in `buildDefaultPageSchema.ts`: an
`isPrimary` related list gets its own tab. It is pinned by the
plugin-detail test "promotes an isPrimary related list to its OWN tab
(rule Z default)".

**R4 · `studio-object-designer` F1: the title's promise holds, and the
mechanism coverage is narrower.**
- Before (BASE): create the package on the Studio app's
`developer/packages` page, then
`getByTestId('package-switcher').click()` and
`expect(getByRole('option', { name: pkgName })).toBeVisible()`.
- After: on `/studio/com.example.showcase/data`, create the package
through the switcher's "New package" dialog, with no reload. Then:
  - `expect(chip).toContainText(pkgName)`;
- the open switcher lists a button named by the package's name followed
by its id. This commit tightened the draft's id-only match to the name
the refreshed list carries.
- Why it moved: the sidebar `package-switcher` (`ContextSelectors.tsx`)
renders only for an app that declares `contextSelectors`. A stock
showcase boot serves `showcase_app`, `setup` and `account` (`GET
/api/v1/meta/app`), and none declares one.
- What it no longer pins: the original watched a switcher that stays
mounted while a package is created elsewhere, so it pinned the
`objectui:packages-changed` refetch. On the Studio surface the create
dialog navigates into the new package, and the switcher's list effect is
keyed on the package id, so the route change alone refetches the list.
- No unit test pins either switcher's `objectui:packages-changed`
refetch. The only test that names the event is
`packages-io.inflightShare-11797`, and it pins the in-flight read drop.
- The draft cited ADR-0048 for "no stock boot loads the Studio app".
objectstack's ADR-0048 is about cross-package metadata collision, so
both comments now state the reading and its instrument instead.
- F4's BASE banner was the "needs its choices" error, not the "System
identifier must be" error F4 guards. Giving the picklist an option first
keeps F4 on its own bug.

**R5 · `record-history-display`: `showcase_account` declares `enable: {
trackHistory: true }`** in
`examples/app-showcase/src/data/objects/account.object.ts`.
- It is the only showcase object that does.
- Its fields, with the extension's, carry no boolean and no datetime. So
the original's booleans-as-words and datetime legs cannot be driven on
the showcase. Both stay pinned in app-shell's
`auditHistoryDisplay.test.ts` ("localizes booleans through t with Yes/No
fallback", "localizes datetime values instead of raw ISO strings"), and
the spec header now says so.
- This head also asserts the "Customer Since" diff line before asserting
no raw `YYYY-MM-DD`, so that check cannot pass on an absent line. Read
once: "Customer Since: 7/26/2026 → 8/4/2026".

## H4

`grid-file-upload` and `form-view-subforms` share the `Line Items`
cause. Each spec now finds the section by its heading role.
`e2e/live/helpers.ts` is unchanged, so no other spec's reading moves.

## Row 12 · `studio-editor` (patch round 1)

**Correction first.** Round 1 said "Tier 1 read it green at `4016370a`",
and that was wrong: it was inferred, not measured. Tier 1
(#24598) never ran this spec. For
`studio-authoring.record-page-roundtrip`, its pin set is "files 0/1 …
(studio-record-page.spec.ts …)", and `studio-editor` appears nowhere in
that record. Nothing moved between Tier 1 and now.
#7753 item 6 (closed 2026-08-11) had already
recorded both causes against QA run #7695:
"targets a 'Layout' heading that no longer exists, plus a **shipped**
page the editor correctly locks". objectstack's checklist carries the
same record as `automated.stale` on that item.

**The producers, and whether each state is intended.** Both states are
intended.
- **The lock.** `page` is registered create-only: `supportsOverlay:
true, allowEnvironmentOverlay: false, allowRuntimeCreate: true`
(`metadata-plugin.zod.ts`). ADR-0005's override table lists `page` as ❌
("Conservative default — these bind to routes and side-effects"). Its
addendum "Two-tier authorization (PR-10d.7, 2026-05)" says "users may
author brand-new items of this type, but artifact-shipped items remain
immutable".
- objectui's `ResourceEditPage` applies that rule in its own two-tier
gate ("artifact-backed items need allowEnvironmentOverlay").
- Since #21670 (closed 2026-10-04), the
layered read's protection envelope reports the same refusal:
`servedLockState` joins `packagedBaseRefusal`. Read on the shipped
showcase: `lock: no-overlay`, `editable: false`, `provenance: package`.
That envelope is what draws the "This item is locked" banner.
- The exact commit that introduced the console gate is NOT MEASURED.
Both clones here are shallow (objectui from 2026-10-13, objectstack from
2026-09-16), and the commit-search endpoint answered 403 here, its
control query included. The ADR names the rule, and
#7753 dates the state to 2026-08-11 or
earlier.
- **The missing "Layout" heading.** objectui `29bc4bf00f`
(objectui#2308, 2026-07-06) prunes `name` / `regions` / `children` from
the page form (`PAGE_FIELDS_OWNED_ELSEWHERE`, `page-schema.ts`): the
block tree is "edited on the canvas", and no bare section header is left
behind. The spec form's "Layout" section holds only `regions`, so it
does not render.

**The repair keeps every assertion and re-points the two stale ones.**
- Each test authors an editable copy of the shipped page in the
environment: GET `showcase_project_workspace`, PUT it under
`e2e_studio_editor_STAMP`, and DELETE it in `finally`. The copy reads
`lock: none`, `editable: true`, and after a run the page list holds no
`e2e_` leftovers.
- "Config sections": the Basics heading, as before, plus the Data
Context heading, plus a guard that no lock banner stands in front of the
editor. The guard is a precondition, not a replacement.
- "Layout": read where the layout renders now, on the canvas. There is
one Add block per region (the count is derived from the copied page's
`regions`), and the canvas offers Add region. On the shipped page none
of these render.
- Add block, and the picker's Card / Section / Record details, are
unchanged. Each match is unique among the picker's 33 entries.
- Measured: BASE 0/2 (no "Layout" heading; no Add block, timeout);
`8493767` 2/2 alone, and green in the final whole-suite run.

## Gates (head `8493767`, exit codes captured before any pipe)

- `pnpm type-check:e2e`: 0
- `pnpm exec eslint` over the eleven changed specs: 0 errors, 11
warnings (BASE bytes of the same files: 12)
- `pnpm check:new-line-citations`: 0 (0 new citations)
- `pnpm check:control-bytes`: 0
- `pnpm check:test-path-roots`: 0
- `node scripts/check-changeset-presence.mjs`: 0. No source or published
contract of a released package changed, so no changeset is owed.
- The live suite table above is the acceptance reading.

## Acceptance notes

- The objectstack checklist's `automated.stale` markers for these specs
are the objectstack `domain:devx` seat's to drop. ⛔ They are not edited
here.
- Coverage this PR does not restore, named once more:
  - the lookup quick-filter pick (R2);
  - the mounted-switcher `objectui:packages-changed` refetch (R4);
- booleans and datetimes in History on the live stack (R5, unit-pinned).
- Diff: 11 files, 353 insertions, 212 deletions.

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

---------

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

Fixes #13116
Clause-②: no

## What this changes

An ungrouped list view fetches its rows through `plugin-list`
`ListView`. Its `$select` harvest (`addSpeculative`) had arms for view
bindings, grouping, `rowColor`, `settledWhen` and `referenceVia`, but
none for the object's declared `nameField`. So when no column showed
that field, the rows arrived without it, and the bulk confirm step
(`BulkActionDialog`, objectui#13107) fell to the `Record #ID` floor.

This adds one arm, `addSpeculative(declaredNameField(objectDef))`,
beside the `referenceVia` arm. `declaredNameField` is the same
`@object-ui/core` helper `ObjectGrid` `getSelectFields` uses for this
field (PR #13112), and plugin-list already imports `@object-ui/core`.
The arm goes through the gates every other arm uses: the known-field
gate (the object must declare it) and the FLS gate (the viewer may read
it). It is fetched, not drawn: it joins the projection, not the columns.
`required` is a set, so a `nameField` that is already a column is sent
once. Nothing else moves; no export, prop or type member is added.

## Pins
(`packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx`)

The data source honours `$select` the way a server does: a row carries
the selected keys only. plugin-list does not depend on plugin-grid, so a
stub grid stands in, and the confirm step's name is asked of the rule
that step applies (`recordDisplayValueAt(row, declaredNameField(def))`,
then `getRecordDisplayName`) against the rows the grid is handed. The
real dialog is pinned on plugin-grid's side by
`bulkConfirmNameField-13107.test.tsx`.

- (a) Formula `full_name` as `nameField`, columns `first_name` /
`last_name` / `title`: the `$select` carries `full_name`, no grid column
is drawn for it, and the rows name `John Smith` and `Marcus Reed`.
- CONTROL (b): an object with no `nameField` sends the same `$select` as
before (`body`, `id`, `subject`).
- CONTROL (b): a `nameField` that is a column is sent once.

## Ablation (one-off, via objectstack `scripts/ablation-replace.mjs`; no
permanent test)

Dropped the arm (anchor `addSpeculative(declaredNameField(objectDef));`,
count 1 to 0). Blob `ecffd5bc77e4` became `978e1d188f1f`. Pin (a) went
red: `expected [ 'first_name', 'id', …(2) ] to deeply equal [
'first_name', 'full_name', …(3) ]`; the two controls stayed green (1
failed, 2 passed). Restored: blob equals HEAD (`ecffd5bc77e4`), `git
diff HEAD` empty, anchor count back to 1.

## Gates (head `00aa971518`)

- Closure build `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-list^...' build`: exit 0.
- `pnpm --filter @object-ui/plugin-list run type-check` (`tsc --noEmit
&& tsc -p tsconfig.test.json`): exit 0. `--listFiles` on the test config
lists the new test file.
- New test file: 3 passed. The `$select` family (the 26 plugin-list test
files that name `$select`, the new one included): 26 files, 430 tests
passed.
- Full `packages/plugin-list/` directory: NOT MEASURED locally. It ran
past the foreground ceiling on the shared test lock and was stopped; it
is declared to CI.
- ESLint on the two changed source files (`--no-inline-config`,
`--format json`): 2 files, 0 errors. `ListView.tsx` carries 205
warnings, the same count as on `origin/main`. The config is not
type-aware, so this diff moves no verdict on an untouched file.
- Pre-tsc 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`, `scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`, `check:new-line-citations`.
- `check:spec-floors`: exit 1, `no-artifact` (the workspace is not fully
built here). Recorded, not fixed, per the dispatch.

## Acceptance notes

- One `@object-ui/plugin-list` `patch` changeset:
`.changeset/13116-listview-namefield.md`.

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

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

Co-authored-by: Claude <noreply@anthropic.com>
…and word a refused policy name (#13167)

Fixes #13113
Clause-②: no

## What changed

Studio's row-level security editor no longer autosaves the blank row
that "Add policy" inserts.

- **Hold, don't send (ruling 1).** `PermissionMatrixEditPage` counts RLS
rows whose `name` is empty or blank in the draft itself
(`rlsUnnamedCount`). Any such row holds the shared `useDraftAutoSave`
(`blocked`), the same way a CEL lint error does. It also disables the
environment-door Save button. The row says beside itself "Give the
policy a name to save it." (`rls-name-needed-N`), and the status line
says the same in a neutral tone. The test is emptiness only: the spec's
`^[a-z_][a-z0-9_]*$` is not restated in the client.
- **Name the field (ruling 2).** When a save is refused,
`rlsNameRefusal` reads the refusal's own `MetadataError.issues`. Each
issue at `rowLevelSecurity.N.name` renders as "Not saved. Policy name,
on row N+1: PROBLEM", where PROBLEM is the server's message passed
through `translateValidationMessage`. Any other issue path keeps today's
text, appended after the mapped lines. With no mapped issue, the strip
text is unchanged. This only changes how the door's answer is displayed.
It is not a second validator.
- **The draft keeps the row (ruling 3).** The held row stays in the
in-memory draft. A reload discards the unsaved draft, because nothing
was persisted, so the row is gone after a reload. This is the same for
any held edit, such as a CEL error. Ruling 3 explicitly allows this.
- Copy: `perm.rls.nameNeeded` and `perm.rls.nameRefused` in the
metadata-admin designer table, en and zh.
- No package export, prop, type member or language-pack key is added.
Changeset: `@object-ui/app-shell` patch.

## Verification (at `9a6cdafe39`)

- New pins `PermissionMatrixEditor.rlsAddPolicyHold-13113.test.tsx`,
driving the real `PermissionMatrixEditPage` against a fake client that
refuses the way the door does: (a) an unnamed row sends no PUT within
the autosave window, shows the hint, and shows no banner. (b) Naming it
sends the save. (c) A 422 with `rowLevelSecurity.0.name
[invalid_format]` reads as the policy-name message, with no raw path or
zod code. (d) Controls: a CEL error still holds, and a refusal on
another path keeps the transport's text. 5/5 passed.
- Targeted family: `vitest run --maxWorkers=1` over
`metadata-admin/PermissionMatrixEditor*` and
`PermissionAdvancedFacets*`: 28 files, 196 tests passed. The
metadata-admin i18n family (`CelPredicateField.i18n*`,
`studio-locale.i18n`, `metadata-form-i18n`): 3 files, 22 tests passed.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc
-p tsconfig.test.json`): exit 0. The test project's `--listFilesOnly`
includes the new test file. The closure build `--filter
'@object-ui/app-shell^...' build` ran first and exited 0.
- Ablation 1 (drop the hold: `saveHeld = celErrorCount > 0`): (a) and
(b) went red, 2 failed and 3 passed. Ablation 2 (drop the mapping:
`setError(err?.message ?? String(err))`): (c) went red, 1 failed and 4
passed. Each mutation was landed and proven on disk by
`ablation-replace.mjs` (anchor count 1 to 0, blob changed). Each restore
was proven by blob hash: after restore, blob `864d2df7b5b3` equals the
HEAD blob, and `git diff HEAD` is empty.
- Lint, narrowed to the 4 changed `.ts`/`.tsx` files (`eslint
--no-inline-config --format json`): 4 files, 0 errors, 23 warnings. All
23 sit on lines this PR does not add (`no-explicit-any` and others). The
config enables no type-aware linting, so this diff cannot change a
verdict on an untouched file. The whole-repo `pnpm lint` is left to CI.
- 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:spec-floors` exited 1 as a
prerequisite failure ("no-artifact: build the workspace first"), so it
is NOT MEASURED, as the dispatch expected on main.
- Declared to CI: the full `packages/app-shell` directory test run. The
shared lock was in use, so no whole-package run was done locally.

## Acceptance notes

- `studio-design/metadataError.ts` already has a general
`issueRefusal(e, locale, locate)` with an `IssueLocator`. It needs a
`MetadataSelection` target and a `StudioRefusal` strip, which this
permission editor's plain-string error strip does not have. So the
mapping here is local to the editor. Moving the editor onto the
`StudioRefusal` strip would be a larger change than this card covers.
- Today, a nameless row the server refuses also carries a second issue
(`rowLevelSecurity.0 [custom]`). With the hold in place, such a row is
no longer sent. If a non-empty name is refused together with a row-level
issue, the row-level issue keeps the raw text, appended after the mapped
line.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ked publish case (objectui#13120) (#13168)

Fixes #13120

Clause-②: no

## What changed

Two objectui#12929 test files read the backfill checkbox by its counted
name with a synchronous `getByRole`. Both reads now wait with
`findByRole`. Each keeps exactly the same name and the same assertion,
so a checkbox without the count still fails.

1. **`StudioDesignSurface.publishBackfill-12929.test.tsx`**, case
"unchecked: the publish names no field, so nothing is written into
existing rows". The read right after `openSheet()` is now `await
within(sheet).findByRole('checkbox', { name: new RegExp(...N...) })`:
the same `\bN\b` name (N = 12) and the same `not.toBeChecked()`. The
other two cases already read it this way. `openSheet()` gets a doc
comment saying it resolves once the option is drawn, not once its count
is.
2. **`DraftChangesPanel.backfill-12929.test.tsx`**, case "one choice —
the added literal-default field of the published object — unchecked,
naming the record count". The read after `settled()` and
`findAllByTestId('draft-backfill-option')` is now `await
screen.findByRole('checkbox', { name: 'Backfill default (12 existing
records)' })`, with the exact same string. The claim was amended to add
this file in patch round 1.

The PR also adds `.changeset/13120-backfill-checkbox-wait.md` with an
empty frontmatter, so it releases nothing; its text names both files.
`node scripts/check-changeset-presence.mjs` asked for it because it
counts every file under a released package's `src/` as published source,
test files included. No product source changed.

## Why it flaked

In `DraftChangesPanel.tsx`, `EntryDetail` draws the backfill option in
the same render that sets `detail` and `candidates`. The record count
comes from an effect that runs after that render
(`source.find(entry.name, { $top: 0 })`). Until `setRecordCount` lands,
the label reads "Backfill default" with no number. Both tests waited for
the option, not for its count, so on a slow runner they could read
between those two renders. That read finds a checkbox named "Backfill
default" and no checkbox whose name contains 12.

## Deterministic reproduction (one-shot, not left in the tree)

For each leg, a script rewrote the file's count-read mock
(`ADAPTER.find` in file 1, the `find` set in `beforeEach` in file 2) and
checked the rewrite on disk: the anchor count went from 1 to 0 and the
injected marker from 0 to 1. After each leg it restored the file from
`HEAD` (`git checkout HEAD --` with an absolute path, under an
EXIT/INT/TERM trap). The restore was confirmed by comparing blob hashes
and by an empty `git diff HEAD`. "+200 ms" means the count resolves 200
ms after it is asked for (`setTimeout`). "Never" means it returns a
promise that never settles.

| File | Tree and count read | Result |
| --- | --- | --- |
| 1 publishBackfill | BASE `ed28057`, unmutated | exit 0, 3 passed: the
flake's usual green |
| 1 publishBackfill | BASE, +200 ms | exit 1, 1 failed and 2 passed: the
unchecked case, `Unable to find an accessible element with the role
"checkbox" and name /\b12\b/` (the same message as merge-group run
39367006901); the role dump shows `Name "Backfill default"` |
| 1 publishBackfill | this branch, +200 ms | exit 0, 3 passed |
| 1 publishBackfill | this branch, never | exit 1, 3 failed: each
`Unable to find role="checkbox" and name /\b12\b/` once the `findByRole`
window runs out (1164 to 1396 ms) |
| 2 DraftChangesPanel | BASE `ed28057`, unmutated | exit 0, 10 passed |
| 2 DraftChangesPanel | BASE, +200 ms | exit 1, 1 failed and 9 passed:
the one-choice case, `Unable to find an accessible element with the role
"checkbox" and name "Backfill default (12 existing records)"`; the role
dump shows `Name "Backfill default"` |
| 2 DraftChangesPanel | this branch, +200 ms | exit 0, 10 passed |
| 2 DraftChangesPanel | this branch, never | exit 1, 1 failed and 9
passed: the one-choice case, `Unable to find role="checkbox" and name
"Backfill default (12 existing records)"` after 1306 ms |

In the "never" legs, every read of the counted name goes red, so none of
them was weakened. In file 2, the other nine cases do not read the
counted name and stay green.

## Every read in both files

**File 1 (publishBackfill)**
- **Changed:** the unchecked case's counted-checkbox read, which was the
file's only synchronous read of the counted name.
- **Left alone:**
  - The checked and stopped-short cases already used `findByRole`.
  - `openSheet()` reads only through waits.
- `getByTestId('draft-changes-publish')` is the footer button, drawn
with the sheet and not count-gated.
  - The request and toast assertions sit behind `waitFor`.

**File 2 (DraftChangesPanel)**
- **Changed:** the one-choice case's counted-checkbox read, the file's
only synchronous read of the counted name.
- **Left alone:**
- In the same case, `options[0]` and its `= false` text come from the
option and the literal span, which are drawn with the option, not with
the count. The `find` call assertions sit behind `waitFor`.
- "names no number when the count read carries no numeric total" uses a
waiting `findByRole` on the exact name 'Backfill default'. That name is
also the pre-count name, so a slow runner can only push it toward green,
never red. A one-shot probe showed the case still discriminates: given a
numeric total (7), it goes red, `Unable to find role="checkbox" and name
"Backfill default"`, 1 failed and 9 passed.
- The no-package and no-Publish cases make synchronous absence reads
after `settled()`. No option is drawn and no count is read, and an
absence read cannot turn red because of a slow runner.
- The footer cases read `/^Backfill default/`, a prefix that matches the
label with or without the count. That includes the synchronous
`getByRole` after the click in "checked: Publish all passes…". With the
count delayed 200 ms, all 10 cases pass on this branch, so these reads
were measured as not count-gated. `getByTestId('draft-changes-publish')`
is the footer button.
  - The `backfillCandidates` cases are pure function tests.

## Gates

Head `05dc463`. Exit codes were captured before any pipe.

- File 2 unmutated, three runs: exit 0 each, `Tests 10 passed (10)`.
File 1 unmutated at this head: exit 0, `Tests 3 passed (3)`.
- `pnpm --filter @object-ui/app-shell type-check` and `lint`: exit 0
each. They ran after `turbo run build --filter=@object-ui/app-shell^...`
(exit 0, 28 of 28 tasks from cache). Lint reports `0 errors`, plus the
package's existing warnings, none on either file. `eslint --max-warnings
0` on the two files: exit 0.
- `pnpm check:new-line-citations`: exit 0 (`0 new citation(s)`).
- `pnpm check:control-bytes`: exit 0.
- `node scripts/check-changeset-presence.mjs`: exit 0 (2 source files, 1
changeset, empty frontmatter).
- These also exit 0: `check:changeset-claims`,
`check:pending-changeset-literals`, `changeset:check`,
`check-changeset-overwrite.mjs`.
- `check-governed-queue-guard.mjs --test` on all three paths: NOT
GOVERNED.
- The full CI farm runs in CI.

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

Implemented in session
`https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7` (round 0 and
patch round 1).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…N rows (objectui#13156) (#13170)

Fixes #13156

Clause-②: no

## What changes

The Import Wizard now sends a saved mapping its rows in the format the
mapping declares. A mapping that declares `sourceFormat: 'csv'` is sent
`format: 'csv'`, the rows as CSV text and `mappingName`. That is the
REST door's proven shape from the card:
`{"format":"csv","mappingName":"crm_account_import","csv":…}` answered
200, upsert, 2/2. A mapping that declares `json`, or declares no format,
still gets JSON rows. Hand-mapped imports are unchanged.

Before this change, the wizard parsed every file in the browser and
always sent `{"format":"json","rows":[…]}` with `mappingName`. The
import door judges the declared `sourceFormat` against the payload, as
it should. It answered `400 MAPPING_FORMAT_MISMATCH`, so HotCRM's
shipped lead, account and contact CSV mappings imported 0 rows
(objectstack-ai/hotcrm#2188, F-07). The fix follows the declaration, and
nothing server-side moves.

## How

All changes are in `packages/plugin-grid/src/ImportWizard.tsx`:

- **`assembleImportRequest`** (the request builder; the one place every
import request is built) takes a new option, `mappingSourceFormat`. On
the named-mapping branch, `csv` gives `{ format: 'csv', csv, mappingName
}` with no `rows`. Any other value gives `{ format: 'json', rows,
mappingName }`, as before.
- **`sourceRowsToCsv`** (new, pure) writes the source-keyed rows from
`buildSourceRows` as RFC 4180 text: a header row, then one line per row,
joined by CRLF. A cell is quoted when it holds a comma, a double quote,
a CR or an LF. The door's CSV parser starts a quoted run at any double
quote and drops a bare CR, so those two cases have to travel quoted. The
columns come from the rows themselves, so the CSV text and the JSON rows
cannot disagree.
- **`buildImportRequest`** (the submit path) passes
`activeMapping?.sourceFormat`, read from the mapping the server serves
(`listImportMappings`, or the `savedMappings` prop). It is kept as a
string, so the callback keys on the declaration and not on a memoised
object. The real import, the dry-run pre-check and the background import
job all go through this one builder, so all three send the CSV.

The wizard's file read is unchanged. No uploaded text is kept, for the
reason under H2 below.

## The PM's hypotheses, measured

- **H1, single producer: confirmed.** `assembleImportRequest` was the
only place with a `format` literal. Its named-mapping branch hard-coded
`format: 'json'`. `SavedMapping.sourceFormat` (`'csv' | 'json' | 'xml' |
'sql'`) was already declared, and `asSavedMapping` passes it through.
The `listImportMappings` wire fixture (`listImportMappings.test.ts`)
carries it. The format is never inferred from the file name.
- **H2, which text goes out: the preview's rows, written as CSV.** On
the mapping path, the preview step renders an editable `Input` for every
mapped column (`StepPreview`, through `handleCorrect`), and
`buildSourceRows(headers, rows, corrections)` applies those corrections.
Sending the file as uploaded would silently drop them. It would also be
wrong for three other inputs: a `.tsv` file (the door's CSV parser
splits on commas only), a pasted table and an `.xlsx` sheet (neither has
CSV text at all). The CSV is therefore written from the preview's rows,
with corrections applied. A pin corrects a cell and asserts that the CSV
carries the correction.
- **H3, `.xlsx` against a csv mapping.** Before the fix, the wizard
parsed the sheet in the browser and sent JSON rows with `mappingName`.
The ablation leg reads this as `format: "json"` plus `rows`, which the
door refuses as above. Now the sheet's cells go out as CSV text, exactly
as the preview shows them. A pin builds a real workbook with ExcelJS,
passes it as `initialFile`, and asserts `format: 'csv'` with the sheet's
text and no `rows`. The door's own comment backs this outcome: "xlsx
rows are tabular like csv; a csv-declared mapping applies to both".
- **H4, preselection and the manual path's insert: measured, not
fixed.** They are a different producer from the request builder, so they
stay out of this PR. See Acceptance notes.
- **H5, request-shape pins plus ablation: done.** See Verification.

## Verification (final head `9ff6405`)

Every heavy run went through the shared verify lock.

- **Targeted wizard suites.** `pnpm exec vitest run --maxWorkers=2` over
all 26 import test files in `packages/plugin-grid/src`
(`*mport*.test.ts(x)`, `__tests__/*mport*.test.tsx`,
`savedMapping.test.ts`) gave `Test Files 26 passed (26)` and `Tests 245
passed (245)`, exit 0.
- **New pins.**
- `ImportWizard.mappingSourceFormat-13156.test.tsx` drives the real
wizard to `importRecords`. A csv mapping with a corrected cell gets
`format: 'csv'`, CSV text and `mappingName`, and the dry-run pre-check
sends the same payload plus `dryRun: true`. An `.xlsx` sheet against the
csv mapping is sent as CSV text. CONTROL: a json mapping still gets JSON
rows, corrections applied.
- `importSavedMapping.test.ts` gains the builder's branch table (csv,
json, undeclared, and the manual path ignoring a declared format) and
`sourceRowsToCsv`'s quoting cases.
- **Ablation, run at `9341d6d` and again at `9ff6405`.** `node
../objectstack/scripts/ablation-replace.mjs` replaced the decision
`opts.mappingSourceFormat === 'csv'` with `false`, so every named
mapping got JSON rows again. The anchor went from 1 hit to 0 and the
blob changed, both checked on disk.
- The two pin files then read `Tests 3 failed | 10 passed (13)`. The
failures were exactly the three csv-mapping cases: the builder branch,
the corrected-cell CSV and the `.xlsx` sheet. The JSON control and the
quoting cases stayed green. That is the expected direction: red.
- The restore was proven: the blob equals `HEAD`, and `git diff HEAD`
and `git status --porcelain` are both empty.
- **The door's own parser.** I ran objectstack's `parseCsvToRows`, taken
verbatim from `packages/rest/src/import-prepare.ts` at `580fd4ed3` and
transpiled, over the five CSV texts the pins fix as `sourceRowsToCsv`
output. All five parse into exactly the JSON rows the old request
carried. CONTROL: the same values sent unquoted, an inner double quote
and a bare CR, both parse differently. This is a source-level
measurement, not a live door. No backend was booted.
- **Static checks.**
- `pnpm --filter @object-ui/plugin-grid lint`: exit 0, 0 errors. The new
test file has no entries.
- `pnpm --filter @object-ui/plugin-grid type-check`: exit 0, 0 errors.
It ran after building the dependency closure with `turbo run build
--filter='@object-ui/plugin-grid^...'` (13 of 13 tasks). `tsc -p
tsconfig.test.json --listFilesOnly` lists the wizard and both test
files.
- **The other consumer of the request.** The request shape is what
`importRecords` receives, and app-shell's identity-import wrapper is the
other host that reads it. Its four suites pass (4 files, 29 tests); see
Acceptance notes.
- **Gates.** `pnpm check:new-line-citations` (0 new), `pnpm
check:control-bytes`, `pnpm check:changeset-claims`, `node
scripts/check-changeset-presence.mjs` and `node
scripts/check-changeset-no-major.mjs` all exit 0. No user-facing string
changed, so the i18n gates were not run.

## Acceptance notes

- **Identity import (`sys_user`) with a csv-declared saved mapping still
imports nothing. The refusal now happens in the browser.** app-shell's
`createIdentityImportDataSource` wraps this wizard for `sys_user`, and
it offers saved mappings (objectui#7740). Its `importRecords` reads
`request.rows` only, splits them into batches, and throws "Identity
import needs mapped rows (rows[])" when there are none.
- Measured with a probe over the real wrapper, with `authFetch` stubbed:
- JSON rows plus `mappingName` reach the door, 1 request. Before this
fix, the door refused that request for a csv mapping, either with `400
MAPPING_FORMAT_MISMATCH` or, without a meta reader, with
`MAPPING_NOT_FOUND`. Both come from `prepareImportRequest` in the
identity door. This half is a source reading of `plugin-auth`'s
`admin-import-users.ts` at objectstack `580fd4ed3`, not a measurement.
- CSV text plus `mappingName` makes the wrapper throw, with 0 requests
sent.
- So the outcome is unchanged (0 rows imported on either side of this
PR), but the refusal is now client-side.
- The real fix is to make the wrapper forward `format: 'csv'` text. That
change is in `packages/app-shell/src/views/identityImport.ts`, outside
this claim's file surface, so it is not made here. It is raised to the
seat.
- I found no shipped mapping that targets `sys_user`. objectstack's
examples ship one mapping, the showcase's `showcase_inquiry_feed`, which
declares `csv` and targets `showcase_inquiry`; that mapping is on the
generic door this PR fixes. HotCRM was not checked.
- The four app-shell identity-import suites
(`packages/app-shell/src/views/__tests__/identityImport*`) pass at
`9ff6405`: `Test Files 4 passed (4)`, `Tests 29 passed (29)`. None of
them submits through a mapping, so none of them reaches the changed
request.
- **The shipped mapping is offered but not preselected (H4).** It is
measured. A saved mapping becomes active only through an explicit pick
(`handleSelectSavedMapping`). Nothing selects one when a file loads, and
the reset clears it. This is a product choice, not a contract breach: no
declaration says a mapping is preselected, and choosing one among
several is a design question. Its producer is the mapping step's
selection state, not the request builder. Not changed. Carrier: none.
- **Manual mapping inserts and rejects the synonym values (H4).** It is
measured. The manual path starts in `insert` (`defaultWriteMode`), and
the user can choose upsert in the options panel. A mapping's value
synonyms (`transform: 'map'` with `valueMap`) are applied only by the
server's named-mapping path, by design. With this fix, the named-mapping
path is the one that works for a csv mapping. Not changed. Carrier:
none.
- **Mappings declaring `xml` or `sql`** are still offered in the
selector, and the door refuses them with `400
MAPPING_FORMAT_UNSUPPORTED` and a clear message. Nothing measured
produces such a mapping for the console. Unchanged. Carrier: none.
- **A mapping that declares no format** keeps JSON rows. Served mapping
documents carry the spec's default (`sourceFormat` defaults to `csv`,
and the `listImportMappings` wire fixture shows it filled), and the door
treats an undeclared format as compatible with either payload. So this
branch only keeps today's behaviour for a document that lacks the key.
- **Edge case of the CSV form.** If the user blanks every cell of the
LAST row, that row is not sent. The door's CSV parser drops trailing
all-empty rows, while the old JSON rows sent it as an empty record.
Parsed files never contain all-empty rows (every wizard parser drops
them), so only that hand-edit reaches this.
- objectui#13124 (the finished step's subtitle) shares this file and
runs after this card, as the claim records.
- `test:repo` and the full package suite were not run locally; both
belong to CI.

## Files

- `packages/plugin-grid/src/ImportWizard.tsx`: +45 / −7
-
`packages/plugin-grid/src/ImportWizard.mappingSourceFormat-13156.test.tsx`:
new, 206 lines
- `packages/plugin-grid/src/importSavedMapping.test.ts`: +73
- `.changeset/13156-import-mapping-csv.md`: `@object-ui/plugin-grid`
patch

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…Fields Filter-panel whitelist reader (objectui#13118) (#13169)

Fixes #13118

Clause-②: no (narrowing)

## What

`@objectstack/spec` 18 retires the list view's `filterableFields`
(#24640, maintainer ruling 「C」, record
6278975049; pulled into 18.0 by 「提前」, record 6279033298). No per-view
Filter-panel whitelist replaces it: the Filter panel offers every field
the user may read, minus the fields the object marks `hidden: true`.
This PR removes objectui's readers of the key.

- **Renderer** (`@object-ui/plugin-list`). The `filterFields` memo in
`ListView` drops the whitelist branch and the `!!whitelist` exemption
for hidden fields, and the memo's deps drop `schema.filterableFields`.
The no-whitelist branch already offered every readable field minus
`hidden` ones, so a view without the key sees no change. A hidden field
still appears when a condition the panel holds filters on it
(objectui#11810's rule, unchanged).
- **Relays**. The `object-view` delegation in `@object-ui/plugin-view`
stops forwarding the key from all three sources (named view, host
`views` entry, node), and `OBJECT_VIEW_DECLARED_FORWARDED_KEYS` drops it
(31 node reads become 30: 27 host-composition plus 3 declared). The
`@object-ui/app-shell` object page stops relaying it off the active
view. The `object-view` registration stops publishing it as an input.
- **Types** (`@object-ui/types`). `NamedListView.filterableFields` is a
`?: never` tombstone. `ObjectViewSchema.filterableFields` is deprecated
and typed by reference to the list view's spec-derived slot, the slot
its zod twin already carries; see H2 for why it is not a tombstone yet.
- **Zod mirror**. The by-reference line `filterableFields:
stripImportedDefaults(SpecListViewSchema).shape.filterableFields` stays,
as the card prescribes; only its comments changed (H3).
- **Docs**. The `ObjectViewSchema` example in
`content/docs/api/schema-reference.md` stops teaching the key;
`content/docs/plugins/plugin-view.mdx` and
`packages/plugin-view/README.md` stop listing it.
- **Changeset**. `.changeset/13118-retire-filterable-fields.md`: `minor`
for `plugin-list`, `plugin-view`, `types`, `app-shell`, with the
BREAKING paragraph and its remedy (delete the key; for a curated
end-user filter bar, author `userFilters`). The `Clause-②` line above is
the claim's, and the delivered diff still matches it: it accepts nothing
new and narrows the published surface (the registration input, the
`NamedListView` member, the renderer behaviour).

## The PM's hypotheses, measured

**H1 — confirmed.** The renderer change is the card's own. Tests that
asserted the whitelist now assert the spec's prescription; none was
deleted.
- No-key behaviour does not move: the objectui#11810 row "offers no
hidden field, and orders columns, other business fields, system fields"
is unchanged and green at the head, and green with the whitelist branch
put back (the ablation below runs BASE's memo logic).
- The removal is pinned by four rows that a stored view still carrying
the key exercises: `ListView.filterFieldList-11810` (the spec's list, no
hidden field brought back), `ListView.filterFieldRead-11925` (the
read-checked list, no narrower and no wider),
`ListView.sortFieldFallback` (the inverted control: the filter builder
offers every field) and `ListViewBlock.specInputs-13047` (through the
real `SchemaRenderer` page node: "Add filter" starts on `Name` with or
without the key).
- Ablation, one shot, committed fix first: `ablation-replace.mjs` put
the whitelist branch back into the memo's filter by a literal anchor
(anchor 1 to 0, marker 0 to 1 on disk, blob changed), ran the four
files, and restored (blob equals HEAD, `git diff HEAD` empty). Result:
exactly the four rows red, 66 green. The failures read: `expected [
'title', 'estimate_hours', …(1) ] to not include
'owning_business_unit_id'`; `expected [ 'title', 'priority' ] to deeply
equal [ 'title', 'status', 'priority' ]`; `Unable to find an element
with the text: Name`; `expected 'Amount' to be 'Name'`. No permanent
ablation file was left.

**H2 — measured per parity test, at objectui's pin `f4755a68`.** Every
one stays, none is skipped.
- `object-view-unmirrored-keys-7779`: models the key with the file's own
mechanism, a named bucket pinned exactly, after objectui#12030's PENDING
precedent. A sixth bucket, `BUCKET_SPEC_RETIRED_AHEAD_OF_PIN =
['filterableFields']`, holds a key the installed protocol still calls
live and objectstack `main` retires. It is self-expiring: its row
asserts the installed spec does not carry the tombstone yet, so the pin
bump reddens it with the instruction to move the key into
`PROTOCOL_RETIRED_KEYS` and `BUCKET_PROTOCOL_RETIRED_TOMBSTONED`. The
node-level read pins for the key are inverted, not deleted; the
named-view census moves from 43 read plus 22 unread to 42 plus 23 (65
declared, unchanged).
- `registry-inputs-spec-parity` (console): the existing
`UNPUBLISHED_EXEMPTIONS` entry for `list-view.filterableFields` keeps
its slot with a true reason; it goes stale on its own at the pin that
carries the tombstone.
- `object-view-spec-parity`: green unchanged in mechanics (TS and zod
both still declare the key); its `SPEC_COUNTERPART` note is updated.
- `zod-mirror-parity`: green with no change, because of the next point.
- ⚠️ **Deviation, reported:** a local `?: never` tombstone on
`ObjectViewSchema` was tried first and measured red.
`node-recursion-point-8344` (types `type-check`, TS2344) reads every zod
arm's output against the TS node union, and the `object-view` arm became
a new drifted arm against the by-reference zod slot, which still admits
`string[]` at the pin. That ratchet says not to add a name to make a red
go away, so the node's member is typed by reference to the same spec
slot instead: both faces flip to the spec's tombstone at the pin bump,
with nothing to edit there. `NamedListView` has no zod twin, so its
tombstone holds now.

**H3 — confirmed.** The zod line stays. Under objectstack `main`'s spec
(the H4 green leg below), `@object-ui/types` builds, so leaving the
by-reference line compiles there.

**H4 — reproduced, red then green.** In a scratch objectstack worktree
at `origin/main` `580fd4ed3c98` (`git merge-base --is-ancestor
4bc3bec HEAD` answered 0), after `pnpm run setup`, with
`.objectui-sha` edited locally and never committed,
`OBJECTUI_ROOT=/home/user/objectui-issue-13118 bash
scripts/build-console.sh` under the verify lock:
- at BASE `ed2805768b32`: exit 1, `@object-ui/plugin-list#build` failed
with exactly two errors in `src/ListView.tsx`, TS2339 (`Property
'length' does not exist on type` the spec's `[REMOVED]` brand) and
TS2769 (no `Set` constructor overload matches it);
- at this head `05d46fed0a2b`: exit 0. The bundle canary, the single-zod
canary and the spec-injection assertion all pass; the dist is ready. The
script's notice that the tracked `sdui.manifest.json` records a
different objectui SHA is the objectstack pin bump's to regenerate.
- The scratch worktree and the two objectui build worktrees the script
registered were removed afterwards (node_modules first, `git worktree
remove` without `--force`, both clean).

## Gates, at head `05d46fe`

- `type-check` for `types`, `plugin-list`, `plugin-view`, `app-shell`
and `console`: exit 0 at `416ec6c`, after building the console's
dependency closure at objectui's pin (`turbo run build
--filter=@object-ui/console^...`, 34 tasks). A per-package `type-check`
without that closure answers TS2307 for every sibling package: not
measured, not red. The one later commit, `05d46fe`, restores two blank
lines in `objectql.ts`; `types` and `plugin-list` `type-check` were
re-run on it, exit 0.
- `lint` for the same five packages: exit 0, 0 errors, at `416ec6c`. Per
changed file, the eslint findings at the head equal BASE's count or fall
below it.
- Targeted tests, 18 files (the touched test files, the four parity
tests, `node-recursion-point-8344`), re-run on `05d46fe`: 18 files, 937
tests passed.
- `pnpm check:new-line-citations` (0 new), `pnpm check:control-bytes`,
`pnpm check:changeset-claims`, `node
scripts/check-changeset-presence.mjs`, `node
scripts/check-changeset-no-major.mjs`: exit 0 each.
- Full package suites and the rest of the farm are CI's.

## Acceptance notes

- **Shared files.** `domain:ui#2` holds objectui#13116 in `ListView.tsx`
(the `$select` harvest) and objectui#13114 in the app-shell
`ObjectView.tsx` (the tab bar and the chart branch). This diff stays in
the `filterFields` memo and its docblocks, and in the one relay line of
the app-shell file. Whichever lands second merges `main`.
- **Test files beyond the claim's census.** Two files that never spelled
the key had to move with the reader: `ObjectView.relayRungCensus-7559`
(a new `unread` answer, the `striped` precedent) and, per its own
instruction to delete a stale entry in the same change,
`InterfaceListPage.relayCensus-11572`. `node-recursion-point-8344` is
unchanged and was measured.
- **Left alone on purpose.** `SchemaForm.topLevelUnion-11251` (the
spec's own view form at the pin still offers the key),
`objectView-config-keys-retired-4730` (a locale-key record),
`ObjectView.specShapedNamedView-8254` (history), `ROADMAP.md` and
`docs/audits/**` (dated records). All green or untouched.
- **What the pin bump that takes `4bc3becd5f` will meet.** Each
announces itself by name: the self-expiring bucket in
`object-view-unmirrored-keys-7779`; the `SPEC_COUNTERPART` anchor in
`object-view-spec-parity`; the console's `list-view.filterableFields`
exemption going stale; and the `ObjectViewSchema` member, which narrows
with no edit because it is typed by reference.
- **Observation, not filed (no carrier).** objectstack's
`scripts/build-console.sh` checks `OBJECTUI_ROOT` with a directory test
on `.git`. A linked worktree's `.git` is a file, so the override is
skipped and the script falls back to the sibling `../objectui` checkout
("Using objectui source at /home/user/objectui" in both legs above).
Here the two share one object store, so the SHA resolved and both
readings stand.

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

---------

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

Fixes #13122
Clause-②: no

## What

The console's app-missing branch in
`packages/app-shell/src/console/AppContent.tsx` (`requestedAppMissing`)
draws three verdict screens — `app-not-found` ("This app can't be
opened"), `app-unreachable` and `app-not-available`. All three return
above the `ConsoleLayout` mount, so they carry no app bar, and they
offered only Retry: a stale bookmark or an uninstalled app's link was a
full-screen dead end.

Each of the three now draws "Back to home" beside Retry. The button is
the access-denied screen's own, factored into one render helper
(`renderBackHomeButton`) that both screens call: same copy key
(`empty.appAccessDeniedHome`), same navigation (`navigate(homePath)`,
the declared home). Test ids follow the existing pattern:
`app-not-found-home`, `app-unreachable-home`, `app-not-available-home`.
The access-denied screen keeps its `app-access-denied-home` test id and
renders the identical button. No new i18n key, no export, no chrome
change.

## Pins (in `AppContent.deniedVsUnpublished.test.tsx`)

- (a) `the $testId screen offers the way home beside Retry, and it goes
home` — one case per verdict: the home button is drawn with the text
"Back to home", Retry is drawn beside it, clicking home lands on
`/home`.
- (b) CONTROL `Retry on the $testId screen is still drawn and still
re-checks` — one case per verdict: clicking Retry calls
`refreshMetadata` again and the verdict screen comes back. Written
independently of the home button on purpose.
- (b) the access-denied screen is unchanged: the existing cases `the
denial screen offers a way back to /home` and the declared-landing case
stay green.

## Ablation (one-off, no permanent file)

Fix committed first; mutation through `ablation-replace.mjs` deleting
the `renderBackHomeButton(...-home)` line from the missing branch only
(anchor 1 to 0, blob 9343ae5 to bcd2e851e811), trap-guarded
restore:

- mutated: `Tests 3 failed | 22 passed (25)` — exactly the three (a)
cases red; the three Retry CONTROLs and the denied-screen home case
green.
- restored: blob after restore == HEAD blob
`9343ae560f748d669af602aed68ce6e9649104b2`, `git diff HEAD` empty.
- green on HEAD `ada1ba4e01`: `Test Files 1 passed (1)`, `Tests 25
passed (25)`.

## Gates (local, at `ada1ba4e01`)

- `@object-ui/app-shell` closure build (`--filter
'@object-ui/app-shell^...'`, 29 projects) exit 0, then `pnpm --filter
@object-ui/app-shell type-check` (echoes `tsc --noEmit && tsc -p
tsconfig.test.json`; the test config includes `src/**/*.test.tsx`) exit
0 — under the shared verify lock.
- Targeted vitest on the edited test file, `--maxWorkers=1`: 25/25.
Declared narrowing: this one-file, one-worker run (and the ablation) ran
outside the shared lock after about 30 minutes queued behind a sibling
full-directory run.
- eslint narrowed to the two changed files: 0 errors (warnings are
pre-existing lines). Population read from the root `eslint.config.js`;
`--format json` counted 2 files; that config enables no type-aware
linting (no `projectService` / `parserOptions.project`), so this diff
cannot move a 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:spec-floors` exit 1 — recorded as known on main per dispatch,
not touched.
- Declared to CI: the full `packages/app-shell/` directory run (a
sibling's full-directory run held the shared lock for over 35 minutes at
one worker; it does not fit the foreground cap).

## Acceptance notes

- The reused copy key is named for the access-denied screen
(`empty.appAccessDeniedHome`). Reused as ruled rather than renamed; a
rename would touch all ten packs for no user-visible change.
- Changeset: `.changeset/13122-app-missing-way-home.md`,
`@object-ui/app-shell` patch.

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

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

---------

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

## What

Opening a `type: chart` saved view on the console list page removed the
view tab bar. Since objectui#12440 the strip is handed to `ListView` as
`toolbarLeading`, and `renderListView`'s chart branch (`viewDef.type ===
'chart'`) returns before `ListView` is reached, so a chart view drew no
tabs and the only way back to the grid was the browser's Back button.

Ruling 1 route taken: the chart branch draws the strip itself, above the
chart. The row that a server-refused view already used is lifted into
one element, `viewTabRow` (same classes and
`data-testid="view-tab-row"`, gated on `viewTabBar`, so on `viewTabsFit`
too: below `sm` it is absent and the app bar's switcher stays the
phone's control). Both chart returns (dataset-bound and unbound) wrap
their `Suspense` face with it. List faces keep the strip in `ListView`'s
toolbar row, unchanged; no face draws two.

Ruling 2: the page-level refused-view fallback now reads
`refusedViewFace && viewTabRow`, which is the same markup as before.

Files: `packages/app-shell/src/views/ObjectView.tsx`, a new pin
`packages/app-shell/src/views/ObjectView.chartViewTabs-13114.test.tsx`,
and `.changeset/13114-chart-view-tabs.md` (`@object-ui/app-shell`
patch). No export, prop or type member is added.

## Pins

The pins drive the real `ObjectView` → plugin `ObjectView` →
`renderListView` → `ListView` / `ObjectChart` chain.

- (a) A chart view draws exactly one `view-tab-bar`, inside
`view-tab-row`, with no `list-toolbar-leading`. Clicking the grid tab
switches back to the grid, where the one strip sits in the list toolbar
slot.
- (b) CONTROL: a grid view has exactly one strip, in
`list-toolbar-leading`, and no standalone row.
- (b) CONTROL: below `sm` a chart view draws no strip.
- Refused-face CONTROL: the existing
`ObjectView.viewRefusal-12112.test.tsx` passes unchanged. Its
`getByTestId('view-tab-bar')` throws on more than one match.

## Verification (head `e0ea6dbd93`)

- Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit
0.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit` plus
`tsc -p tsconfig.test.json`): exit 0. `--listFilesOnly` on the test
project lists the new pin.
- Targeted vitest (13114 pin, 12440 toolbar pin, 12112 refusal pin): 3
files, 12 tests passed.
- Family run: 17 files, 766 tests passed. It covers the ObjectView chart
and tab tests, and every test that reads `ObjectView.tsx` source.
- Ablation via `ablation-replace.mjs`. The anchor `{viewTabRow}` in the
chart wrapper (hits 1 to 0) was replaced with `{null}`, and the blob
went from `bb7a2825d46c` to `f79650798867`. Pin (a) went red with
`Unable to find an element by: [data-testid="view-tab-row"]`, 1 failed
and 2 passed. The two controls stayed green. Restore: the blob equals
the HEAD blob `bb7a2825d46c` and `git diff HEAD` is empty.
- Lint narrowed to the two changed files with `eslint --format json`: 2
files, 0 errors. The 183 warnings are identical to `origin/main`'s copy
of `ObjectView.tsx`, where the same 183 are pre-existing. The config has
no `parserOptions.project` or `projectService` (not type-aware), so this
diff cannot move any untouched file's verdict.
- Gate battery: exit 0 for 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` and
`check-changeset-no-major`.
- NOT MEASURED: `check:spec-floors`, reason: prerequisite not met
(no-artifact, it wants a full workspace build). The order said to record
it, not fix it.
- NOT MEASURED: the whole `packages/app-shell/` directory, reason: a
`--maxWorkers=1` run under the shared lock printed no file result in 45
minutes. I stopped it to release the lock to queued siblings. It is
declared to CI.

## Acceptance notes

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>
…lish (objectui#13160) (#13174)

Fixes #13160

Clause-②: no

## What changed

The package sheet's **Publish** (`PackageDetailSheet`'s `publish` in
`packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`) now
shows the reasons the server returned, under the existing count
sentence.

`POST /packages/:id/publish` refuses on a 200: the dispatcher's `{
success: true, data }` envelope around the door's
`PackagePublishResult`, whose own `success` is false and whose
`validationErrors` carry one `message` per reason, each against the item
(`type`, `name`) it judged. The sheet threw only
`engine.packages.detail.publishBlocked` with the count, so the author
read "Publish blocked by 1 validation error(s)." and never why (the
card's measurement: a package with no items, refused with "No metadata
items found for package …").

- The count sentence stays as the heading. Each
`validationErrors[].message` is a list line under it, in the server's
words, led by the item it names when the server names one (`object/lead:
…`; `api: …` where the producer names only the type; the bare message
where it names neither). Nothing restates the protocol's rule.
- The answer is typed with the spec's `PackagePublishResult`
(`@objectstack/spec/system`) instead of `any`.
- `success: false` with an empty `validationErrors` still says
`engine.packages.detail.nothingToPublish`. The old fallback there read
`validationErrors[0].message` only when the list was empty, so it could
never fire; it is gone.
- The message block (`msg`) carries an optional `details` list; only
this refusal sets it. Every other action on the sheet renders exactly as
before.
- No i18n key is added or changed; no export, prop or accept set moves.

## Mechanism readings (the dispatch's H1 to H4)

- **H1, the shape: confirmed, measured on objectstack `origin/main`
`46bb5e3e5`.** The route (`packages/runtime/src/domains/packages.ts`,
the `POST /packages/:id/publish` branch) answers `deps.success(result)`
with `MetadataManager.publishPackage`'s result. Its two refusals both
carry a non-empty `validationErrors` of `{ type, name, message }`
(`PackagePublishResultSchema` in
`packages/spec/src/system/metadata-persistence.zod.ts`): no items
(`type` and `name` both empty), and the gate or validation failures (the
namespace gate leaves `name` empty with `type: 'api'`). No other field
or path is in the shape.
- **H2, many errors: no cap.** `SheetContent` is `overflow-y-auto`, so a
long list extends the sheet's scroll height, as the uncapped
pending-drafts list on the same sheet already does; `break-words` keeps
a long item id inside the sheet's width. NOT MEASURED in a browser: the
reading is the sheet's own class list, not a rendered layout.
- **H3, controls: pinned.** A published package keeps
`engine.packages.detail.published`; an empty `validationErrors` keeps
`nothingToPublish`; a request that throws (`TypeError: Failed to fetch`)
shows its own message. None of the three renders a list.
- **H4, ablation: red with the fix reverted** (below).

## Tests

New:
`packages/app-shell/src/views/metadata-admin/PackagesPage.publishRefusalReasons-13160.test.tsx`
(real sheet and `apiJson`; `fetch` stubbed). Two refusal cases (one
item-less reason; two reasons, one with `type/name`, one with `type`
only) and the three H3 controls.

Ablation, after committing the fix (`bedd292`): `PackagesPage.tsx`
written back to the base blob (`98ee995`, `PackagePublishRefusal` count
0, the old count-only branch count 1), new file run, then restored with
`git checkout HEAD --` and checked by blob hash (`431a3fa` equals the
HEAD blob, `git diff HEAD` empty):

```text
× one reason that names no item: the measured refusal of a package with no items
× two reasons: each is a line, led by the item it names, in the order the server sent them
AssertionError: expected [] to deeply equal [ Array(1) ]   (the heading rendered, no lines under it)
Tests  2 failed | 3 passed (5)
```

The three controls stay green on both sides, as they should.

## Local gates, on head `5178919`

Each exit code captured to a file before any pipe; the heavy three ran
under the shared verify lock after the final commit.

| Command | Exit | Its own verdict line |
|---|---|---|
| `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit` and
`tsconfig.test.json`, which includes `src/**/*.test.tsx`) | 0 | no
`error TS` |
| `pnpm --filter @object-ui/app-shell lint` | 0 | `0 errors` (warnings
are the package's existing ones) |
| `pnpm exec vitest run --maxWorkers=2
packages/app-shell/src/views/metadata-admin/PackagesPage` | 0 | `Test
Files 10 passed (10)`, `Tests 81 passed (81)` |
| `pnpm check:new-line-citations` | 0 | `VERDICT
new-cross-file-line-citations: 0 new citation(s)` |
| `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` |
| `pnpm check:changeset-claims` | 0 | `No pending changeset names a file
this change touches.` |
| `node scripts/check-changeset-presence.mjs` | 0 | `2 source file(s) of
1 released package(s) changed, and this change declares 1 changeset(s)`
|
| `node scripts/check-changeset-no-major.mjs` | 0 | `Every major is the
synchronized one` |
| `pnpm check:i18n-keys` | 0 | every in-scope call-site key resolves
(run although no i18n text changed; `check:i18n-drift` not run, no
string-table row moved) |

The dependency closure was built first (`pnpm turbo run build
--filter='@object-ui/app-shell^...' --concurrency=2`, exit 0), so the
type-check read current `dist/*.d.ts`.

Declared to CI: the full `pnpm test` shards, `Build & E2E`, and every
other workflow family.

## Acceptance notes

- `/publish-drafts` (`publishDrafts`, the other door on this sheet) was
read for the same defect and does not have it: since objectui#11922 it
names each refused item with its reason through `refusedItemsText`, and
its empty-`failed[]` refusal reads the envelope's own text. No finding.
- "Adopt loose items" is not touched: the card leaves that half unfiled
and unverified.
- objectui#13157 (queued, same file) is not folded, per the claim.

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ectui#13128) (#13176)

Fixes #13128
Clause-②: no

## What changed

`CreateItemDialog`, the shared form behind every Studio create dialog,
rendered its display-name and identifier labels with no `htmlFor`, and
its two inputs had no `id`. A screen reader therefore heard two
unlabeled edit fields, and `getByLabelText('Object name')` found
nothing. Two `React.useId()` ids now bind each label to its input,
unique per mount. There is no visual, copy, export or prop change.

The component is shared by five call sites in `StudioDesignSurface.tsx`,
so all five dialogs get the binding: Create app, the Interfaces create
dialog (report, page or dashboard), New object, New automation and New
permission set. The seat ruled the file surface onto
`CreateItemDialog.tsx` in comment 6285011413. The New object dialog's
record-sharing picker is already named by the label element that wraps
it, so it is unchanged.

## Pin

`CreateItemDialog.labels-13128.test.tsx` drives two real openers:
- New object, through the real `DataPillar` empty state;
- Create app, a sibling dialog, through the real `StudioDesignSurface`
toolbar.

In each dialog, `getByLabelText` for the name label and for "Identifier"
must find an `INPUT` with that accessible name. Label strings are read
by catalog key.

## Ablation (one-shot, run at 1efc5df41a)

Run with objectstack's `scripts/ablation-replace.mjs`, which replaces
the `Label htmlFor={labelInputId}` opening tag with a bare `Label`
opening tag in `CreateItemDialog.tsx`, then reruns the pin:
- anchor x1 to x0; blob 8e7320e to a9b1abd818f1 (the mutation
landed);
- `Tests 2 failed (2)`. Both legs go red because both dialogs share the
component: "Found a label with the text of: Object name, however no form
control was found associated to that label";
- restore: the blob after restore,
8e7320e, equals the HEAD blob of the
file, and `git status` is clean.

## Verification (at af6b19e935)

- Closure build `pnpm --workspace-concurrency=2 --filter
'@object-ui/app-shell^...' build`: exit 0.
- `pnpm --filter @object-ui/app-shell type-check`: exit 0. Its
`tsconfig.test.json` includes `src/**/*.test.tsx`, so the new test is
type-checked too.
- Targeted vitest, `--maxWorkers=1`, over the new pin plus every test
file that renders `CreateItemDialog`: oneLabel-11787, storedName-11792,
createAppAccessibleName, createObjectOwd, flowPresets-11861,
newFlowTrigger-11788. Result: `Test Files 7 passed (7)`, `Tests 34
passed (34)`.
- The full `packages/app-shell/` directory is declared to CI. The order
forbids whole-package runs on this shared box.
- Lint narrowed to the two changed `.tsx` files. Running `eslint
--no-inline-config --format json` counted 2 files, 0 errors and 6
warnings. Five warnings are `no-explicit-any` in the test's mocks, the
same shape its sibling suites use. One is the existing
`react-hooks/set-state-in-effect`, which origin/main's copy of the file
also reports. Both files resolve in the root `eslint.config.js`. The
config sets no `parserOptions.project` or `projectService`, so
type-aware linting is off and this diff cannot change the lint verdict
on any untouched file.
- 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` each exited 0. So did
`check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs` and
`check-changeset-presence.mjs`.
- `check:spec-floors`: NOT MEASURED. It exited 1 with no-artifact
because it needs a full workspace build, so its gate body never ran.

## Acceptance notes

- The changeset is `.changeset/13128-new-object-labels.md`, an
`@object-ui/app-shell` patch that names the five dialogs.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…en the server refuses the run (#13177)

Fixes #13161

Clause-②: yes

An action's param dialog now keeps what the admin typed when the server
refuses the run. It stays open and busy until the run settles, closes on
success, and on a refusal keeps every value and shows the refusal above
the buttons, so the admin can fix the input and confirm again, or
cancel.

## What changed

- **`@object-ui/core`**: `ParamCollectionHandler` gains an optional
fourth argument, `run`. Called with the collected values, it dispatches
the action and resolves with that run's `ActionResult` (a dispatch that
throws resolves as a refused run, as `execute` reports one).
- A refused attempt gets its post-execution (error toast, `onFailure`)
at once.
- A successful attempt's post-execution (success toast, result dialog,
chain, `onSuccess`) waits until the handler settles, so the dialog is
closed before any of it shows.
- A `run` call while one is in flight returns that run and dispatches
nothing new.
- Once `run` was called, the handler's own answer only ends the session.
`null` after a refusal is a cancel.
- A handler that never calls `run` keeps the old contract: one dispatch
with the values it resolves.
- `execute`'s dispatch tail (update operation, registered handler,
builtin, legacy fallbacks) moved unchanged into the private
`dispatchCollected`, so the lone run and the session share it.
- **`@object-ui/app-shell`**: `ActionParamDialog` runs the action from
inside the dialog when its state carries `run`.
- While the run is in flight, Confirm (spinner, `aria-busy`) and Cancel
are disabled and the dialog refuses to close.
  - On success it settles with the values that ran, then closes.
- On a refusal it keeps the values and shows the run's own `error` in a
`role="alert"` block above the footer, outside the scrolling region.
- Both runner-fed hosts forward `run`: `useConsoleActionRuntime` and
`RecordDetailView`.

## The PM hypotheses, as measured

- **H1, holds.** `handleSubmit` called `state.resolve` and then
`onOpenChange(false)`. The runner's `execute` awaited the handler, then
dispatched and post-executed, so the refusal toast came after the dialog
had closed. The runner is the only caller of the param handler (one call
site, in `execute`). Every console param dialog renders this one
component, mounted three times:
- `useConsoleActionRuntime`, feeding the runner for ConsoleShell,
ObjectView, PageView and `DeclaredActionsBar`;
  - `RecordDetailView`, which feeds the runner from its own runtime;
  - `MetadataTypeActions`, which has no runner and runs its own fetch.
- **H2, shape (a), through the runner.** A fix inside
`@object-ui/app-shell` alone was measured unsound, for two reasons:
- The glue could correlate its own `api`, `flow`, `script` and `modal`
handlers with the dialog through the dispatch object. But an `operation:
'update'` action (`executeUpdateOperation`) and the builtin executors
never reach a glue handler, so their dialog would hang open.
- After a refusal the glue cannot re-dispatch, because `execute` has
already returned.

Shape (b), a runner loop that re-opens the dialog, would have to be
opt-in: the in-repo handler doubles that resolve fixed values
(`ActionRunner.declaredPatch-13022`, the `action-*` suites in
components) would loop forever on a persistent refusal. That opt-in
would itself be a type change, and the dialog would flicker closed and
back open. So the narrowest widening is the one optional `run` argument.
- **H3, measured in the producers' source: none of the three refusals
names a field.**
- "Either email or phoneNumber is required": the objectstack plugin-auth
`badRequest` sends `{ success: false, error: { code: 'INVALID_REQUEST',
message } }`.
- "User already exists. Use another email.": better-auth's
`APIError.from` sends `{ message, code }`.
- The redirect-URL refusal: better-call's endpoint rethrows its
`ValidationError` as `APIError(400, { message, code: 'VALIDATION_ERROR'
})` and drops the zod `issues`, so the path survives only inside the
sentence.

Every console handler also flattens a refusal to `ActionResult.error`
(text). So the dialog marks no field. The refusal is the form's, and a
field named inside the sentence is not read (pinned with the
redirect-URL sentence). The wire was not re-measured in a browser.
- **H4, holds.** Cancel after a refusal settles `null`, closes and
dispatches nothing more (the dialog pin, and the runner pin: one
dispatch, one toast). Success closes, then toasts, exactly as before
(the runner pin orders the toasts `error`, `success`, and the console
pin ends on the success toast).
- **H5.** The ablation is below.

## Published face

Clause-② is `yes`, measured. The only face that moves is
`@object-ui/core`'s `dist/actions/ActionRunner.d.ts`, read after the
closure build:

- the `ParamCollectionHandler` line gains `run?`, a function from the
values (a Record of string to any) to a Promise of `ActionResult`
(generics spelled out in words here because GitHub strips them from PR
text);
- the `ActionRunner` class gains `private dispatchCollected;`.

`@object-ui/react`'s `ActionProviderProps.onParamCollection` imports the
type by name, so its bytes do not change. `ParamDialogState` gains an
optional `run`, but that type is not in `@object-ui/app-shell`'s barrel.
The changeset declares `@object-ui/core` minor and
`@object-ui/app-shell` patch, the precedent objectui#12967 set for the
third argument.

## Files beyond the claimed surface (in-place, same defect class)

- `packages/app-shell/src/views/RecordDetailView.tsx`: the second
runner-fed host. Its handler forwards `run` the same way, or record-page
dialogs would keep closing.
`RecordDetailView.paramRuntimeParity-6431.test.tsx`'s `DIALOG_READS`
gains `run`; that pin goes red when only one runtime carries the key.
- `packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx`
(a comment) and `MetadataTypeActions.paramDialogClose-6473.test.tsx` (a
header line): both quoted the old submit expression, which this change
replaces. The census pin in that suite (two settle-then-close sites in
the dialog) stays green unchanged, because `handleSubmit` keeps one
close site.
- No open pull request touches any of these files (REST read of the open
PRs' files at the time of writing).

## Evidence, at head `84d0fb1`

- Targeted suite: every test under `packages/core/src/actions/`, plus
every `ActionParamDialog*`, `RecordDetailView.param*`,
`RecordDetailView.confirmRuntimeParity*`, `useConsoleActionRuntime*` and
`MetadataTypeActions*` suite in app-shell, `page-header-actions` and the
`renderers/action` suites in components, and `packages/react/src/`.
Result: `Test Files 222 passed (222)`, `Tests 2947 passed (2947)`.
- New pins:
- `ActionRunner.paramRun-13161.test.ts`: 7 tests on the runner session;
- `ActionParamDialog.refusalKeepsInput-13161.test.tsx`: 9 tests on the
dialog;
- `useConsoleActionRuntime.paramRefusal-13161.test.tsx`: one end-to-end
test with a real `ActionRunner`, the console runtime's own handler and
the real dialog.
- Ablation 1, the dialog. Committed fix, then `ActionParamDialog.tsx`
written back to the base blob.
- Mutation on disk: `git hash-object` `89f103a` equals the base blob,
with `state.run` and `action-param-refusal` counted at 0.
- Run: `Tests 9 failed | 1 passed (10)` across the dialog suite and the
end-to-end suite. The ablation target fails at `Unable to find an
element by: [data-testid="action-param-refusal"]`. The one green test is
the no-`run` control, by design.
- Restore: `git checkout HEAD --` the absolute path, then `git
hash-object` `fac7fb7` equals the HEAD blob, and `git diff HEAD` is
empty.
- Ablation 2, the runner. Same procedure on `ActionRunner.ts`.
- Mutation on disk: `5778e79` equals the base blob, with
`dispatchCollected` counted at 0.
- Run: `Tests 8 failed | 6 passed (14)`. All 7 session pins and the
end-to-end test fail. The 6 legacy `ActionRunner.params.test.ts` tests
stay green both ways, which is the old-contract control.
- Restore: `074b624` equals the HEAD blob, and `git diff HEAD` is empty.
- `type-check` for `@object-ui/core` and `@object-ui/app-shell`: exit 0
(both programs, `tsc --noEmit` and `tsconfig.test.json`; app-shell's
test program includes every `*.test.tsx`).
- `lint` for both packages: exit 0, 0 errors.
- Each gate printed its own verdict and exited 0:
- `pnpm check:new-line-citations`: `VERDICT
new-cross-file-line-citations: 0 new citation(s)`;
  - `pnpm check:control-bytes`: OK;
  - `pnpm check:changeset-claims`: no findings;
- `node scripts/check-changeset-presence.mjs`: 10 source files of 2
released packages, 1 changeset;
  - `node scripts/check-changeset-no-major.mjs`: OK;
- `node scripts/check-governed-queue-guard.mjs --test` over the 11
changed paths: NOT GOVERNED.
- No user-facing string was added (the refusal is the run's own text and
the busy state is a spinner), so the i18n gates were not run.

## Acceptance notes

- **Field marking has no channel.** `ActionResult` carries a refusal as
text, and every console handler (`apiHandler`, core's server-action
handler, the flow handler, the runner's update path) flattens the
envelope's `fields` to it. A refusal whose envelope does name a field is
therefore also shown as the form's. Whether to widen `ActionResult` is a
question in the report, not a change made here. Carrier: none.
- **`MetadataTypeActions` still closes on Confirm.** It runs without the
runner, and no metadata type's type-level action declares params today:
the datasource type's `test_connection` and `migrate_credential` declare
none. The path is dormant. Carrier: none.
- **A run that never settles leaves the dialog busy with Cancel
disabled.** The run cannot be called back, and closing would hide its
outcome, which is the defect this card fixes. The cost is accepted and
stated here rather than handled.
- **The runner still toasts a refusal** while the dialog shows it, which
is the object form's precedent (banner plus toast). A success toast is
unchanged.
- **New `Record of string to any` spellings** in the runner and its test
add `no-explicit-any` warnings. They mirror the handler's own return
type. The repo sets no `--max-warnings`.
- **Out of scope, for its owner** (reported, not fixed): Users, then
Create User left blank, answers "Either email or phoneNumber is
required" on a form with no phone field. The `sys_user` `create_user`
action declares `email` with `required: false`, because the endpoint
takes email OR phone and the phone param is feature-gated. So the
dialog's own "is required" check cannot fire, and the server's copy
names a field the form does not show.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…n it does not name (#13173)

Fixes #13138
Clause-②: no

Pin (c) is in this PR, under seat ruling `6285028835` (claim surface
amended to `FlowObjectListField.tsx` and `FlowNodeConfigField.tsx`):
- `unpairedDecisionEdges` in `flow-decision-edges.ts` returns the
Decision's plain out-edges that no branch is paired with.
- `FlowNodeConfigField` passes the edge branches filtered to them.
- `FlowObjectListField` draws those read-only connection cards beside
the branch rows, not only while the list is empty.
- A paired connection is never listed twice.

Pin: `FlowObjectListField.addBranchKeepsEdges-13138.test.tsx` (2 tests).
Ablation: restoring the rows-empty gate turns it red, with the control
green.


## What changed


`packages/app-shell/src/views/metadata-admin/inspectors/flow-decision-edges.ts`
— the Decision branch to out-edge reconciliation:

- Every fallback pairing (the objectui#1927 by-order mirror, the derived
Target column, and the "same target node" tier when a target is picked)
now pairs a branch only with an out-edge that carries no routing of its
own (no `condition`, `label` or `isDefault`), or with an edge the stored
branch list owned before this commit. An edge no branch owns — a
connection whose routing was written in the connection inspector — is
driven only by a branch that binds it.
- `applyDecisionBranches` and `syncDecisionEdgesByOrder` take a required
`prior` argument: the node's stored `config.conditions` that the commit
replaces. It tells "this branch's own edge, edited one cell at a time"
apart from "a connection no branch has routed". Without it, a label-less
branch whose expression is edited would lose its own edge and a
duplicate with the stale guard would be created (measured on the first
pass of this branch).
- The detach path (a target-less branch removes the edge bound to it)
now only removes an edge the branch list owned: a new, empty branch
binds the default edge by the objectui#1930 matcher, and on base that
deleted a connection-inspector default edge outright.

`FlowNodeInspector.tsx` passes `getFieldValue(node, field)` as `prior`
at both call sites. No export, key or prop moves.

## Measured on base first (ruling 4)

The new pin file run against the `origin/main` blob of
`flow-decision-edges.ts` (96cc52d), through `applyDecisionBranches`
replaying the editor's one-cell commits:

- card case (a) — one out-edge `{condition: {dialect: cel, source: "1 ==
1"}}`, Add branch Label=Otherwise, Expression=true, no target ⇒ `[{id:
edge_1, target: end, label: Otherwise, isDefault: true}]`: the condition
gone.
- card case (b) — Target=End picked first ⇒ the same single overwritten
edge (the same-target tier claimed the conditioned edge on the first
commit).
- `Tests 5 failed | 6 passed (11)`: all five 13138 pins red, the six
controls green.
- A further base probe: an existing branch plus a connection-inspector
`isDefault` edge; adding an empty branch and committing its label
deleted that edge (`[e1]` out of `[e1, e2]`).

## Tests

- New `flow-decision-edges.addBranch-13138.test.ts` (15 tests): pins
(a), (b) incl. spec-parse of the produced edges, the
new-branch-never-detaches pin, and controls (d): by-order pairing of
unrouted edges, expression/default/rename edits of an existing branch,
label-less expression edits (targeted and by-order), clearing an owned
branch's target still detaching, claiming an unrouted drawn edge.
- `flow-decision-edges.test.ts`: every call passes `prior`; the "updates
the existing edge in place" fixture is back to its original label-less
edge, now with the stored branch that owned it.
- Family (every test file that imports or names `flow-decision-edges` /
`FlowNodeInspector`, 51 files) at 307cb8fbbd: `Test Files 51 passed
(51)`, `Tests 776 passed | 1 skipped (777)`.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc
-p tsconfig.test.json`; the test config lists all 3 touched
reconciliation files) after the closure build: exit 0.
- The whole `packages/app-shell/` directory is declared to CI (shared
test lock).

## Ablation (one-off, no permanent file)

At 307cb8fbbd, `scripts/ablation-replace.mjs` replaced the pairing guard
`(owned.has(i) || !carriesRouting(edges[i]))` with `true` (the
unconditional positional mirror): anchor x1 to x0, blob 42e99b4a54 to
aef90adcd9. The pin file went `Tests 4 failed | 11 passed (15)` — all
three (a) pins and the (b) Target=End pin red. Restore: blob after
restore 42e99b4a54 equals the HEAD blob, `git diff HEAD` empty, rerun
`15 passed (15)`.

## Gates (at 307cb8fbbd; re-run at `e532118089` with pin (c): family of
63 files / 961 tests, type-check and the battery all exit 0)

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`,
`scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`. `check:spec-floors` exit 1,
`no-artifact` for packages not built in this worktree (recorded, as the
order expects on main).

eslint narrowed to the 4 changed source files (`--no-inline-config
--format json`): 4 files, 0 errors, 4 warnings, all on pre-existing
lines of `FlowNodeInspector.tsx` (412, 436, 455). `eslint.config.js`
configures no type-aware linting (no `parserOptions.project` /
`projectService`), so this diff cannot move any untouched file's
verdict.

## Acceptance notes

- Resumed from the pushed sha 634ec94323 after a container restart;
every verification above was re-run in this session.
- The engine-published form path (no Target column) uses the same
`prior` rule through `syncDecisionEdgesByOrder`.

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

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…trigger shows the picked record (objectui#13162) (#13180)

Fixes #13162

Clause-②: no

## What was wrong, as measured

Both halves were measured in Chromium (1440×900) against HotCRM `main`
`f22dfa52`, booted locally with its npm `@objectstack/*` 18.0.0-next.5.
That release's prebuilt console bundles objectui `4016370`, whose
`LookupField.tsx`, `useRecordQuery.ts` and popover wrapper are
byte-identical to `main`. Two hosts were driven: the Delegations create
form (an object form in a dialog) and the Set Manager action dialog. The
server answers the searched and the unsearched `GET
/api/v1/data/sys_user` with the same row shape, `id` included, so the
answer is not the cause.

**(a) After typing `mar` (one match), on the Delegator lookup:**

| how the pick was made | published console (before) | this branch |
|---|---|---|
| click on the row once the answer landed | set | set |
| click at once, before the debounce fired | set | set |
| Enter, no arrow key | **nothing set**, popover stays open | set |
| ArrowDown before the answer landed, Enter after | **nothing set** |
set |
| ArrowDown then Enter, after the answer | set | set |
| mouse click at coordinates read before the answer landed | **nothing
set**, popover dismissed | nothing set (see Acceptance notes) |

The Set Manager dialog gives the same readings, so the cause is the
field, not `ActionParamDialog.tsx` (objectui#13161 holds that file; it
is untouched here).

**(b)** A full-size single-select lookup drew its value as a chip above
a trigger that kept reading "Select…". This held after a pick with or
without a search, and for a value the record already held. The chip did
carry the name; the trigger never did.

## What changed

- **Enter after a search (half a).** Once the rows on screen answer the
typed term, the first row is the active row (named by
`aria-activedescendant`), and Enter takes it. A keystroke clears the
active row. While the answer is still pending (debounced or in flight),
no row is made active, so Enter can never take a row from the previous
term's list. With no term typed, nothing changes.
- **The signal behind it.** `useRecordQuery` (internal to
`@object-ui/fields`, not on the package entry) reports `answeredSearch`,
the term its `records` answer. It is set in the same commit as
`records`, and a superseded answer commits nothing (objectui#10712), so
it cannot set it either. The field compares it with the typed term. A
guess inside the field from loading transitions was rejected: it has a
window, a keystroke processed between the debounce firing and React's
render, in which an old term's rows read as answering the new one.
- **The trigger shows the record (half b).** A single-select field draws
the record's label in the trigger in every size, as compact mode already
did, with the whole label on hover when it is cut short. A search-first
(people) field also draws the avatar there, through the same read gate
the chip used. The single-select chip goes, so the name is drawn once;
its ✕ moves beside the trigger with the same accessible name ("Remove
LABEL") and the same disabled rule. Multi-select keeps its chips and its
"N selected" trigger. The placeholder reads muted in both sizes. The
picked row already carries the label, so nothing is read again to draw
it.

**File surface, declared.** The claim named `LookupField.tsx`. The exact
"rows answer the term" signal belongs to the query kernel beside it,
`useRecordQuery.ts`, so this PR also touches that file: one additive
member on an internal hook result. No open PR touches either file (all 9
open PRs read, 123 files). No export, prop or accept set moves: the
built `dist/index.d.ts` has 0 hits for `UseRecordQueryResult` and
`answeredSearch` (control: `LookupField`, 3 hits). The hook's own
declaration file ships under `dist/widgets/` as every widget's does, and
is reachable from no `exports` key.

## Evidence

- **Pin:** `LookupField.pickSticks-13162.test.tsx`, 13 tests, driving
the real `LookupField` through a controlled host.
- **Ablation 1, both halves.** The fix's two source files were set back
to their `d05f33b` bytes (both hashes checked against the base blobs),
then the pin was run: 7 failed, 6 passed. The 7 are the behaviour tests:
three for (a) and four for (b). For (b), "Priya Raman" was expected and
"Select…" received. The 6 are the controls and the pending-Enter guard.
Restored with `git checkout HEAD`, `git diff HEAD` empty, both hashes
equal to HEAD's: 13/13.
- **Ablation 2, the guard.** The "rows answer the term" condition was
dropped from the default row (anchor hit once, blob changed, restored
blob equal to HEAD). Only the pending-Enter guard went red: an active
row appeared while the answer was still held.
- **Live, after, on this branch's console** (dev server proxied to the
same backend). Set Manager: type `mar`, and "Marta Quinn" is the active
row. Enter puts it in the trigger, and Confirm sends `POST
/api/v1/auth/admin/set-user-manager` with `managerId`, which answers 200
and closes the dialog. Business Units, New, parent: one click with no
search, and the trigger reads "QA EMEA Sales". The Delegations table
above, right-hand column.
- **Local gates at `0498442`** (exit codes captured before any pipe):
- `pnpm --filter @object-ui/fields lint`: 0, with 0 errors. The touched
source files carry no new warnings: `LookupField.tsx` went from 53 to
52, `useRecordQuery.ts` stayed at 6. The pin has none.
  - `pnpm --filter @object-ui/fields type-check`: 0.
- Vitest over 111 files that render or test the lookup pickers, across
fields, app-shell, plugin-form, plugin-grid, plugin-detail, components
and types: 1543 passed.
- `check:new-line-citations` 0 (0 new citations), `check:control-bytes`
0, `check:changeset-claims` 0, `check-changeset-presence` 0,
`check-changeset-no-major` 0.
- i18n gates are not owed: no string or key changes, and the clear
control reuses `lookup.remove`.
- **Changeset:** `.changeset/13162-lookup-pick-sticks.md`,
`@object-ui/fields` patch.

## Acceptance notes

- **A click aimed at the rows shown before the answer lands can still
reach no row.** Typing narrows the list only when the debounced answer
lands. A click aimed at "Marta Quinn" in the three-row list, but pressed
after the list shrank to one row, lands below the shrunken popover,
which then dismisses. Nothing is set, and nothing could be: the press
never reached a row. Measured on both consoles. A click that reaches a
row always commits it, before and after this change. The open question
in the dev report covers whether anything should change here. The
candidate, narrowing the loaded rows on each keystroke, would add a
second, client-side search beside the server-resolved one (ADR-0061), so
it is not done here.
- **The card's "text click" failure did not reproduce.** A Playwright
locator click on the row committed, both once the answer had landed and
at once after typing. A locator click measures the row, then presses its
centre, so a list that shrinks between the two is the pointer race
above. That is the likely reading, not a measured one.
- **Observation, not filed:** a single-select value whose record no
longer resolves (deleted, unreadable) draws the placeholder once
hydration settles. This is unchanged by this PR, and was not measured in
a browser.
- **The live harness.** HotCRM at `f22dfa52`, booted `--fresh`. Two
throwaway users and one business unit were created through the admin
API. Everything ran on private ports and was stopped by PID after the
readings. The Delegations create request answered 403 for a delegator
other than self: that is the card's sibling finding F-14, not this one.

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

Implemented by a dispatched `os-dev` run; 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>
…side Overrides (#13179)

Fixes #13145
Clause-②: no

## What changed

Settings pages (Setup, Configuration) can now take a value back to
unset, and an override back to the inherited value. No server change:
the settings door already reads `{"key": null}` as "clear this layer's
value" (measured on the card).

- `apps/console/src/pages/settings/SettingsField.tsx`
- An emptied text-like box (text, email, url, phone, password, textarea,
and the free-text box of a `valueDomain` select) now puts `null` in the
draft, never `""` (helper `textOrUnset`). The number control already did
this and is unchanged. The closed Radix select cannot be emptied, so it
is unchanged.
- A row whose source badge reads "Overrides"
(`console.settingsView.sourceBadgeOverrides`) shows one small "Reset to
default" button beside the badge. It calls the row's `onChange(null)`:
the key goes into the draft as `null`, the page turns dirty, and the
existing Save sends it. After the save the badge is whatever the
server's read-back for that row says. The client sets no source state of
its own. The badge choice moved from `InheritanceBadges` into
`sourceBadgeOf` (pure, same rules) so the header can tell an Overrides
row from the rest. The button hides once the draft already holds `null`
for that key, and it is disabled while saving.
- `packages/i18n/src/locales/*.ts`:
`console.settingsView.resetToDefault` in all ten packs.
- `.changeset/13145-settings-unset-and-reset.md`: patch for
`@object-ui/console` and `@object-ui/i18n`.

The API client was re-read: `saveSettingsNamespace` sends
`JSON.stringify(patch)`, which keeps `null` values (only `undefined` is
dropped). The new pins read the PUT body as it leaves `fetch`, so a
dropped `null` would turn them red.

## Pins

New
`apps/console/src/pages/settings/__tests__/SettingsView.unsetAndReset-13145.test.tsx`.
It drives the whole page over a stubbed `fetch` through the real
`../api`:
- (a) clearing a text field and saving sends exactly
`{"smtp_host":null}`. A cleared password box sends
`{"smtp_password":null}`.
- (b) "Reset to default" on an Overrides row sends
`{"default_country":null}`. The row then shows the server answer's
"Inherited from" badge, with no Overrides badge and no reset button.
- (c) CONTROLS: a typed value still sends the string. The number control
sends `2525` as a number. An Inherited row and an unbadged row offer no
reset, and the page has exactly one reset button.

`SettingsField.sourceBadgeLocale-12694.test.tsx`: the badge matcher now
skips text inside a `button`. The zh copy of the new action ("恢复默认")
contains the default layer's word, so the matcher would otherwise count
it as a second badge. The badge assertions are unchanged.

## Ablations (one-shot, through `ablation-replace.mjs`, wrapped and
restored)

1. `return raw === '' ? null : raw;` replaced with `return raw;`.
Result: the (a) cases go red: "a cleared text field is sent as null" and
"a cleared password box is sent as null". `Tests 2 failed | 4 passed
(6)`. Restore: blob f98737e == HEAD, and `git diff HEAD` is empty.
2. The reset render guarded with `false &&`. Result: (b) goes red, and
so does the CONTROL's "exactly one reset" count. `Tests 2 failed | 4
passed (6)`. Restore: blob f98737e == HEAD, and `git diff HEAD` is
empty.

## Verification (head 70c30d495f)

- Closure build `pnpm --filter '@object-ui/console^...' build`: exit 0.
- `pnpm --filter @object-ui/console --filter @object-ui/i18n
type-check`: exit 0. The console's tsconfig includes `src`, so it covers
the tests.
- vitest `apps/console/src/pages/settings/`: 22 files, 180 tests passed.
Ran at the final head.
- vitest `packages/i18n/src/__tests__/` plus the app-shell
product-neutral ratchet: passed, at ed7baec5cc. The later commit touched
only the badge-pin test.
- eslint on the 12 changed `.ts`/`.tsx` files, as the package `lint`
scripts run it: 0 errors. Type-aware linting is not configured, so these
files cannot move the verdict on files they do not touch. With
`--no-inline-config`, one `react-hooks/static-components` error shows on
an existing, already-disabled line that this PR does not touch. The base
version shows the same error.
- Gates, each 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`,
`scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`.
- Declared to CI, not run in full locally: the whole `apps/console` and
`packages/i18n` test directories, and the repo-wide `pnpm lint`.

## Acceptance notes

- After a reset, the badge follows the server's `cascadeChain`. If only
the manifest default is left, the effective scope is `default`. Today's
badge rules draw no badge for that case, not an "Inherited" one. This PR
does not change those rules.
- Password: the box never shows the stored secret. Typing into it and
then emptying it now sends `null`, which clears the stored secret.
Before this PR it sent `""`. Per the ruling, emptied means unset.
- The `color` row's hex text box and the `json` textarea still send what
is typed. They are outside the ruling's text-like list.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…d, not the first column (objectui#13150) (#13182)

Fixes #13150
Clause-②: no

## What this changes

On phone-width screens, `ObjectGrid` draws each record as a stacked
card. The card branch took the first displayed column as the card title
(`const titleCol = displayColumns[0]; // First column is always the
title`). A view whose first column was Owner, a lookup, titled every
card "Dev Admin", and the record's own name ("Plant A check") sat in a
body row at the bottom of the card.

The card is now titled by the object's declared `nameField`:

- **Read through `declaredNameField`.** This is the resolver's single
spelling of the pointer, and the same one the bulk confirm step names
records by (objectui#13107). `ObjectGrid` already imports it and already
holds the object definition (`objectSchema`, the same value it hands
`BulkActionDialog` as `objectDef`). No second way to find the name field
is added, and nothing guesses a field by its name.
- **The name field is a shown column:** that column titles the card. The
card body is every other shown column, so the name is not shown twice,
and the first column it displaced (Owner) reads as an ordinary body row.
- **The name field is not a shown column:** the title reads the row's
value of it. Measured first: both fetches that feed this grid already
ask for the declared `nameField` whether or not a column shows it
(`ObjectGrid`'s `getSelectFields`, objectui#13107, and `ListView`'s
`$select` harvest, objectui#13116). A masked name field with no column
has no `cell`, so the title draws `MaskedCellRenderer` for it, which
keeps the fail-closed rule.
- **The row has no value for the declared name field** (for example, the
viewer may not read it, so it is not fetched): the title shows the
card's existing empty marker (an em dash). No other field stands in for
the declared name.
- **The object declares no `nameField`:** the first shown column stays
the title, and the body is unchanged.

The card's title element now carries `data-testid="grid-card-title"`.
That is the selector for the browser reading at 390x844. Before this
change the title was an untagged
`div.font-semibold.text-sm.truncate.mb-1`, and its classes are
unchanged.

Nothing is added to the package entry: no export, prop, schema key or
type member. The grid's empty state in the same file is untouched
(objectui#13142 is not addressed here).

## Pins

New file
`packages/plugin-grid/src/__tests__/mobileCardTitleNameField-13150.test.tsx`.
It drives the real `ObjectGrid` at `innerWidth` 390. The data source
honours `$select` the way a server does: a row carries the selected keys
only.

- The measured shape: columns Owner (lookup), Notes, Name, with
`nameField: 'name'`. Each card is titled by its name, its body shows
"Dev Admin", and the name does not appear again in the body.
- `nameField` not a column (columns Owner, Notes): the cards are still
titled by the name, and the last `find` call's `$select` carries `name`.
- The server leaves `name` off every row: both titles read as an em
dash, never "Dev Admin".
- CONTROL: an object that declares no `nameField` keeps Owner ("Dev
Admin") as the title, with the name in the body.

## Ablations (one-off, through `scripts/ablation-replace.mjs` in the
objectstack checkout; no permanent test)

The test imports `../ObjectGrid` from source, so no build sits between
the mutation and the run. Each mutation landed on disk (anchor count 1
to 0). Each was restored and checked: the blob equals the HEAD blob
`790065a2608d` and `git diff HEAD` is empty.

1. Title back to the first column (`titleCol = displayColumns[0]`): blob
`790065a2608d` became `21f43c2a79d8`. 3 of 4 went red, each receiving `[
'Dev Admin', 'Dev Admin' ]`. That is the card's symptom, reproduced. The
control stayed green.
2. Body no longer drops the title column (`bodyColumns =
displayColumns`): blob `790065a2608d` became `cc455adef36f`. The
measured-shape pin went red (`expected 'OwnerDev AdminNotesBoiler
roomNamePla…' not to contain 'check'`); the other 3 stayed green.

## Gates (head `17838dd`)

- `pnpm exec vitest run` on the new file: 4/4 passed.
- Every `plugin-grid` test that sets `innerWidth` (the tests that reach
the card branch), plus `bulkConfirmNameField-13107.test.tsx`: 18 files,
98 tests passed.
- Every `app-shell` test that sets `innerWidth`: 26 files, 129 tests
passed.
- The full `packages/plugin-grid/` directory and `pnpm lint` are left to
CI.
- Dependency closure build `pnpm --workspace-concurrency=2 --filter
'@object-ui/plugin-grid^...' build`: exit 0.
- `pnpm --filter @object-ui/plugin-grid type-check` (`tsc --noEmit &&
tsc -p tsconfig.test.json`): exit 0. The new test is in the test program
(`--listFilesOnly`).
- `node scripts/check-changeset-presence.mjs`: exit 0. It found 1
changeset for 1 released package.
- `pnpm check:new-line-citations`: exit 0, 0 new citations.
- `pnpm check:control-bytes`: exit 0.
- `eslint` on the two changed source and test files: 0 errors. The
warnings are `no-explicit-any` in the test file, in the same style as
the sibling `mobileCardLookupDisplay` test.
- No i18n keys and no README or `content/docs` fences were touched.

## Acceptance notes

- **The displaced first column keeps its desktop cell face in the
body.** `generateColumns` makes column one the record link
(`primary-field-link`). When the name field takes the title, that column
moves into the card body and is drawn through its own `cell`, so its
value is styled as a link. The link opens the same record that tapping
the card opens, and it stops the event from reaching the card's own
click handler. A lookup column elsewhere in the body is already drawn as
a link, to the referenced record. This is noted only. It needs a
card-only face from the column builder, which is outside this claim's
file surface.
- **A viewer who may not read the name field** now sees an em dash as
every card's title, where before the title was the first column's value.
This is the "never another field" rule the bulk confirm step follows,
and the body still shows the other columns.
- No README or guide text describes how the card picks its title, so no
docs changed.
- `changeset`: `@object-ui/plugin-grid` patch.

The session that wrote this:
`https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG`.

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

Co-authored-by: Claude <noreply@anthropic.com>
… engine left unarmed (objectui#13159) (#13185)

Fixes #13159
Clause-②: yes

Setup › Packaged automation drew the activation FLAG as if it were the
EFFECTIVE state: every packaged schedule flow the boot reports "NOT
bound — disabled by deployment policy" showed a checked switch and an
"On" badge, and only the small Details chip said otherwise. The
Activation cell now keeps the two facts apart.

## What changed

- `packages/app-shell/src/views/setup/packagedFlows.ts` — the page's
reading-rules module (split out of the page file for testability).
`joinPackagedFlows` now carries the runtime row's `unboundCause` (only
as a non-empty string, absent otherwise, the way `reason` is carried).
New pure function `activationReading(row)` returns `off` (flag off),
`not-running` (flag on and the engine sent an `unboundCause`), or `on`.
- `packages/app-shell/src/views/setup/PackagedAutomationPage.tsx` — the
badge beside the switch follows `activationReading`: "On" (default
variant), "Not running here" (outline variant, never destructive: a
deployment policy is not a defect), "Off" (secondary). The switch is
untouched: it still shows and writes the flag, so an author can keep the
flag set for a deployment that does arm the flow. After a successful
toggle the flows half re-reads `GET /automation/_status` (a flows-only
counter, so the actions section is not reloaded): the toggle's answer is
`{ name, enabled }` only, and the flip can change what the engine armed
(switching a schedule flow on under the policy leaves it unarmed;
enabling a subflow re-arms its callers through `rejudgeSubflowCallers`).
A refused toggle does not re-read.
- New i18n key `packagedAutomation.notRunning` in all ten packs. English
"Not running here" and Chinese "未在此运行" are the words Studio's
Automations rail already uses for the same state
(`engine.studio.auto.notRunning`), so one state reads one way across
Setup and Studio. This widens `@object-ui/i18n`'s `TranslationKeys`, so
the claim's `Clause-②: yes` stands.
- `.changeset/13159-automation-effective-state.md` —
`@object-ui/app-shell` patch, `@object-ui/i18n` patch.

The Details chip, its sentence and its no-classification rule are
unchanged; its doc comment is updated because the structured field it
waited for now rides the wire. No cause is named anywhere on the page:
`unboundCause` is read for PRESENCE only.

## Premise check, measured against objectstack pin `f4755a68a797`

- **H1 (source of the truth): holds.** At the pin, `FlowRuntimeState` in
`packages/spec/src/contracts/automation-service.ts` declares
`unboundCause?: FlowUnboundCause` ("Present exactly on the rows that
carry `reason`"), and the engine's `getFlowRuntimeStates` spreads `{
reason, unboundCause }` together from `describeUnboundReason`, which
answers for an enabled, trigger-declaring flow that is not bound and
returns undefined otherwise. The `_status` route passes those rows
through as `{ flows, total }`. Ancestry of the objectstack#23842
implementation (PR objectstack#23854, merge commit `6422da58e83a`) under
the pin: local `git merge-base --is-ancestor` answered exit 1, a false
negative, because the local objectstack checkout is shallow (13 commits
of history under the pin). The REST compare reads both ways:
`6422da58...f4755a6` answers `ahead`, ahead_by 321, behind_by 0; the
reverse answers `behind`, behind_by 321. So the field is served at the
pin and the card is not pin-blocked.
- **H2 (two facts apart): holds**, implemented as above. The switch
stays live and checked for an unarmed row.
- **H3 (controls): pinned.** A bound flow reads "On", a flow with no
trigger (`bound: false`, no cause) reads "On", a switched-off flow reads
"Off", and clicking the switch on an unarmed row still sends `{ enabled:
false }`.
- **H4 (acceptance with ablation): see below.**

## Tests

New, in `PackagedAutomationPage.test.tsx` (describe "the Activation cell
says whether the flow runs"): the acceptance case, the controls, every
cause read the same way and none styled as an error, the flag still
written from an unarmed row, the post-toggle re-read, and no re-read
after a refused toggle. New in `packagedFlows.test.ts`: `unboundCause`
carried as a non-empty string and absent otherwise, and
`activationReading` for each cause, on, and off.

Run at `d126afb`, through the shared verify lock: `pnpm exec vitest run
--maxWorkers=2 packages/app-shell/src/views/setup/ packages/i18n/` gave
`Test Files 89 passed (89)`, `Tests 1395 passed | 13 skipped (1408)`.

**Ablation, at `d126afb`.** The fix was committed first. The two source
files (`PackagedAutomationPage.tsx`, `packagedFlows.ts`) were checked
out from the base `4c36eab`; an on-disk count confirmed the mutation
landed (`packagedAutomation.notRunning` in the page 1 → 0, `export
function activationReading` 1 → 0). The page test file then gave `Tests
4 failed | 17 passed (21)`. Red: the acceptance case ("Unable to find an
element with the text: Not running here"), the controls case and the
every-cause case (`expected 'On' to be 'Not running here'`), and the
post-toggle re-read (`expected 'On' to be 'Not running here'`). Green by
design: "still writes the flag" and "no re-read after a refused toggle",
which guard behaviour the fix must keep. The restore used `git checkout
HEAD --` under an EXIT/INT/TERM trap. It was proven by blob hash equal
to HEAD for both files and an empty `git diff HEAD`.

## Gates (local, at `d126afb`; exit codes captured before any pipe)

- `pnpm --filter @object-ui/app-shell type-check` → 0; `pnpm --filter
@object-ui/i18n type-check` → 0. The dependency closure was built first
with `turbo run build --filter=@object-ui/app-shell^... --concurrency=2`
(28/28 tasks).
- `pnpm --filter @object-ui/app-shell lint` → 0 (0 errors, no warning on
a changed file); `pnpm --filter @object-ui/i18n lint` → 0.
- `pnpm check:new-line-citations` → 0 (0 new citations); `pnpm
check:control-bytes` → 0; `pnpm check:changeset-claims` → 0; `node
scripts/check-changeset-presence.mjs` → 0; `node
scripts/check-changeset-no-major.mjs` → 0; `pnpm check:i18n-keys` → 0;
`pnpm check:i18n-drift` → 0 (1 key added, 0 en values changed); `pnpm
check:i18n-dead-keys` → 0 (the new key is not listed); `pnpm
check:pending-changeset-literals` → 0; `pnpm check:spec-symbols` → 0;
`pnpm check:test-path-roots` → 0.
- NOT MEASURED locally: `pnpm check:eager-locale-catalogues`. It
answered PREREQUISITE NOT MET (it weighs
`apps/console/dist/eager-closure.json`, which needs a console build), so
the result is left to CI. The packs grew by one short key each.
- Repo-wide runs (full `pnpm test` shards, `Build & E2E`, docs) are
CI's.

## Acceptance notes

- **File surface.** The claim names the page file. The row model and the
join live in `packagedFlows.ts`, the page's own reading-rules module
that was split out of the page file "because these are not components".
Both it and its test are edited here. No other module is touched:
`PackagedActionsSection.tsx` is untouched because packaged actions have
no armed-or-not state beside their flag.
- **Two derivations of one fact.** Studio's `deriveFlowRunStatus`
derives "not running" from `bound` and `triggerType`. This page reads
the contract's classifier `unboundCause`, the field objectstack#23842
added for exactly this page. At the pin the two agree by construction:
`unboundCause` is present iff the row is enabled, unbound and declares a
trigger. Not a defect, and no card is filed.
- **ADR-0126 §7.4 reading.** §7.4 scopes the page to "on/off for this
scope". The switch remains exactly that. The badge's "Not running here"
is the same class of exception as the platform `reason` disclosure that
objectui#9217 already admitted, because it is sourced from the engine,
never inferred client-side.
- A toggle-on can show "On" for one round trip before the re-read lands
"Not running here". The switch is disabled only while the toggle request
itself is in flight.
- The dispatch named `origin/main` `91b658a`. By worktree creation it
had moved, and the branch is cut from `4c36eab`.
- No browser pass was run: the defect and its controls are pinned at the
renderer with the fake server, and the ablation shows the acceptance
case going red.

Implemented by an os-dev agent in session
`https://claude.ai/code/session_014MXhk1yjWh37d634hLy3p7`.

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

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

Fixes #13133
Clause-②: no

## What

The inbox header's breakdown line read "4 total · 1 notifications + 3
pending approvals". `notifications.badgeNotifications` was interpolated
with the named placeholder `{{unread}}`, so i18next never
plural-selected and every pack held one string for every count.

- `InboxPopover` now passes `count` to
`notifications.badgeNotifications`, as `notifications.approvalsPending`
beside it already does. The strings switch from `{{unread}}` to
`{{count}}`.
- Each of the ten packs carries the family its language needs, in the
shape the gates already accept for `approvalsPending`: a base key plus
`_one` / `_other`, with `_many` in es, fr and pt, `_few` / `_many` in
ru, and all six CLDR categories in ar. The wording follows each pack's
existing string and its `approvalsPending` forms. en reads "1
notification" / "3 notifications".
- ja, ko and zh carry the base key plus `_one` / `_other` with one
repeated string. They do not carry a single form, because
`all-locales-key-parity.test.ts` requires every pack to define every
`en` key; `approvalsPending` does the same in those three packs.

### Bounded in-place fix: `notifications.badgeApprovals`

The span right next to it had the same defect: `{{approvals}}`, one
form, so one pending approval read "1 pending approvals". It is fixed
the same way under the role file's bounded in-place-fix exemption,
because all four conditions hold:
1. It is the same defect class.
2. The fix is mechanical and its shape is pinned by `approvalsPending`.
3. It lives in the same two claimed files.
4. It runs through the same i18n gate family.

The render pin below covers it as well.

## Tests

- New: `InboxPopover.badgePlural-13133.test.tsx`. It renders the popover
against a real i18next instance and the shipped `en` pack. The sibling
`InboxPopover.badgeBreakdown.test.tsx` stubs `t()` with an interpolator
that cannot plural-select, so it cannot see this defect.
  - With 1: "1 notification" and "1 pending approval".
  - With 3: "3 notifications" and "3 pending approvals".
- CONTROL: the Approvals tab body (`notifications.approvalsPending`)
still reads "1 pending approval".
- Updated: `inboxBadgeBreakdown-i18n-7233.test.ts`.
  - `badgeTotal` keeps its named `{{total}}`.
- The two addends are now asserted as count families: base, `_one` and
`_other` exist, and every slot carries `{{count}}` and not the retired
placeholder.
- Targeted run at `59793beaa2`, under the shared lock with
`--maxWorkers=1`: 8 files, 143 passed. The 8 files are:
- the new pin, `InboxPopover.badgeBreakdown`, `AppHeader.inboxVariant`;
- `inboxBadgeBreakdown-i18n-7233`, `all-locales-key-parity`,
`count-families-11445`, `plural-categories-11432`, `en-zh-key-parity`.
- Ablation, via `ablation-replace.mjs`: the anchor `count:
unreadTopics,` was deleted from the call (1 hit to 0, blob
`00c41f45f732` to `a6dd207ab8cf`).
- The singular leg went red: it expected `/^1 notification$/` and
received `{{count}} notifications`. The plural leg went red too.
  - The CONTROL stayed green.
- The restore was proven by blob hash: after restore `00c41f45f732`
equals HEAD's `00c41f45f732`, and `git diff HEAD` is empty.

## Gates

- **Type-check:** `@object-ui/i18n` and `@object-ui/app-shell`
`type-check` exit 0 (both `tsc --noEmit` and `tsconfig.test.json`). The
`@object-ui/app-shell^...` closure was built first. The app-shell run
was repeated at `59793beaa2`.
- **Pre-tsc battery at `59793beaa2`:** all exit 0. The battery is:
- `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:test-path-roots`,
`check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`,
`check-changeset-presence.mjs`.
- **`check:i18n-drift`:** "2 en value(s) changed (4 key(s) added …), 18
pack value(s) followed".
- **`check:spec-floors`:** exit 1. This is a known state on main, so it
is recorded here and not touched.
- **Lint, narrowed:**
- The population is the root `eslint.config.js` that each package's
`lint` (`eslint .`) reads.
- `eslint --no-inline-config --format json` over the 14 changed
`.ts`/`.tsx` files at `59793beaa2` reports 14 files, 0 errors and 8
warnings. All 8 are pre-existing `no-explicit-any` warnings on unchanged
lines of `InboxPopover.badgeBreakdown.test.tsx`.
- The config enables no type-aware linting (no `parserOptions` /
`projectService`), so this diff cannot change the verdict on any
untouched file.
- **Declared to CI:** whole-directory runs of `packages/app-shell/` and
`packages/i18n/`. The dispatch order forbids whole-package-directory
runs on the shared lock.

## Acceptance notes

- A host that overrides these two keys in its own resources must now
spell `{{count}}`. The changeset says so.
- Changeset: `@object-ui/i18n` and `@object-ui/app-shell`, patch.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ackages sheet (#13181)

Fixes #13157
Clause-②: no

## What changed

`PackageDetailSheet`
(`packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`) gated
its actions on `manifest.scope` being `system` or `cloud` (`isKernel`).
That is a second rule beside the Scope badge's. The badge reads the
row's served `writable` (objectstack#14375, objectui#12723). A
project-scoped or scope-less code package, or a marketplace install,
passed the scope rule, so HotCRM's package was badged "Read-only · code"
and still offered every write action.

The sheet now reads the badge's verdict, `pkg.writable === true`
(anything else is never writable, the way the badge draws it). The write
actions are offered only on a writable package:

| Action | Read-only package | Server's answer on a read-only package
(objectstack `origin/main` `a43405d596`,
`packages/runtime/src/domains/packages.ts`) |
|---|---|---|
| Edit | hidden | `PATCH /packages/ID` calls `requireWritablePackage`
(objectstack#24705): 422 `WRITABLE_PACKAGE_REQUIRED` |
| Publish | hidden | `requireWritablePackage`: 422
`WRITABLE_PACKAGE_REQUIRED` |
| Revert | hidden | `requireWritablePackage`: 422
`WRITABLE_PACKAGE_REQUIRED` |
| Disable | hidden | `requireWritablePackage`: 422
`WRITABLE_PACKAGE_REQUIRED` |
| Delete app | hidden | `requireWritablePackage`: 422
`WRITABLE_PACKAGE_REQUIRED` |
| Duplicate | hidden | `requireDuplicableSource`: 422
`DUPLICATE_SOURCE_NOT_A_BASE` (the source must be a writable base) |
| Adopt loose items | hidden | not refused (see Acceptance notes); it is
a write into this package |
| Enable (shown only when the package is disabled) | offered | not
refused: the enable arm has no writability gate |
| View info, Export | offered | reads |

**Duplicate (ruling 2, measured from the server source):** it does not
create a new writable package from a read-only one. The server refuses a
read-only source before it mints the target, so it is a refused action
and is hidden like the others.

**Hidden, not disabled with a tooltip:** the badge's wording ("Read-only
· code", "System", "Read-only") is already on the sheet, one row above
the actions, and a tooltip on a disabled button does not show in most
browsers. Adding a new sentence would need new strings in `i18n.ts`,
which is outside this card's file surface. So every refused action is
hidden, the same way for all of them. The existing kernel sentence
("This is a platform kernel package. Authoring actions are disabled.")
still shows on system and cloud packages, which now also get View info
and Export. Those are reads.

A writable package's sheet is unchanged: the same buttons, in the same
order.

## Tests

New `PackagesPage.readOnlyActions-13157.test.tsx`, through the real
`PackageDetailSheet`:
- (a) read-only packages (project-scoped code package, scope-less,
system) offer none of Edit, Publish, Revert, Disable, Duplicate, Adopt
loose items, Delete app. View info and Export stay.
- (b) CONTROL: writable packages (scope-less, and project-scoped) offer
all of them.
- (c) Duplicate and its form are absent on a read-only package.
- A row with no boolean verdict offers no write action. A disabled
read-only package offers Enable.

Run at `8804d718b0`:
- New test file: `Tests 8 passed (8)`.
- Every test file naming `PackagesPage` (21 files, the new one
included): `Test Files 21 passed (21)`, `Tests 306 passed (306)`.
- `pnpm --filter '@object-ui/app-shell^...' build` exit 0, then `pnpm
--filter @object-ui/app-shell type-check` exit 0. Its
`tsconfig.test.json` includes `src/**/*.test.tsx`.
- `eslint --no-inline-config --format json` on the 2 changed source
files: 2 files, 0 errors. The 15 warnings are all on lines this diff
does not touch. The root `eslint.config.js` sets no
`parserOptions.project` or `projectService`: no type-aware linting, so
this diff cannot change a verdict on an untouched file.
- Gates, each 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`,
`scripts/check-hand-rolled-comment-mask.mjs`,
`scripts/check-changeset-fixed.mjs`,
`scripts/check-changeset-presence.mjs`.
- Declared to CI: the whole `packages/app-shell/` test directory (not
run locally, by the dispatch's lock rule).

**Ablation** (with `scripts/ablation-replace.mjs`, on the committed
fix): anchor `const writable = pkg.writable === true;` replaced by
`const writable = true;`. The anchor count went 1 to 0, and the blob
went `d374aafcc4bd` to `fad646251e9d`. The new file went red: `Tests 6
failed | 2 passed (8)`. The two that stayed green are the two writable
controls. Restore: the blob after restore is
`d374aafcc4bda1d5730e6aa9f1bd09739300ecc3`, which equals the HEAD blob,
and `git diff HEAD` is empty.

## Acceptance notes

- **The dispatch named the wrong source for the verdict.** It said the
badge's verdict lives in `package-scope.ts`, but the badge reads
`pkg.writable`. `package-scope.ts` (`buildPackageScopeOptions`) filters
Studio's authoring destinations by `scope` alone. The sheet now gates on
the verdict the badge actually reads. `package-scope.ts` is not touched.
- **The pending-drafts block still uses `isKernel`.** Its Publish (N)
and Discard changes (N) buttons are not in this card's action list.
Overlay drafts can be bound to a code package, and the server does not
gate either draft door on writability. They are left as they were.
- **Not measured, not filed:** (1) the server's `POST
/packages/ID/adopt-orphans` arm calls no writability gate, so from the
source alone it could bind loose items into a read-only package. The
sheet no longer offers it there. (2) `buildPackageScopeOptions` keeps
any package whose `scope` is not `system` or `cloud`, read-only
scope-less ones included. Neither was exercised through a public door.
Carrier: none.

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

Co-authored-by: Claude <noreply@anthropic.com>
…e importing." (objectui#13124) (#13186)

Fixes #13124

Clause-②: no

That line is copied verbatim from the claim. The delivered diff adds no
string-table key and no export: no `grid.import.*` key in any language
pack, so `@object-ui/i18n`'s `TranslationKeys` is untouched. Per the
claim, the seat corrects the line to `no` at ACCEPT.

## What changed

`ImportWizard`'s dialog subtitle chose its text from `step` alone
(`upload`, `mapping`, else `grid.import.previewDescription`), and a
finished import keeps `step === 'preview'`. So the result screen read
"Import Complete / 1 created / 1 skipped" under "Review data before
importing.".

- On the result screen the header renders no `DialogDescription`.
- The result screen's own headline (`grid.import.importComplete`, or
`grid.import.importCancelled`) is rendered as the dialog's
`DialogDescription`. `RESULT_HEADLINE_CLASS` overrides the primitive's
`text-sm text-muted-foreground`, so the headline looks as it did. The
dialog's `aria-describedby` therefore names the finished state, and
exactly one description is mounted in every state.
- The upload, mapping, preview and history subtitles are unchanged.
- No new key, export, prop or type member. One changeset:
`@object-ui/plugin-grid` patch.

## How the shape was chosen (the dispatch's H1 to H4, measured)

**H1, every arm of the subtitle.** Read from the ternary and from every
`setResult` site; no path that sets `result` moves `step`.

| state | step | result | subtitle before | after |
|---|---|---|---|---|
| upload | upload | none | uploadDescription | unchanged |
| mapping | mapping | none | mappingDescription | unchanged |
| preview | preview | none | previewDescription | unchanged |
| importing: sync, or a background job queued or processing | preview |
none | previewDescription | unchanged (see Acceptance notes) |
| result, not cancelled: sync server import, legacy per-row, job
succeeded or failed, 405 refusal, thrown failure | preview | set |
previewDescription | header: none; description: the importComplete
headline |
| result, cancelled | preview | set, cancelled | previewDescription |
header: none; description: the importCancelled headline |
| history | upload | none | historyDescription | unchanged |

**H2, reuse vs add vs none.**

- Radix, measured: the only `@radix-ui/react-dialog` in `pnpm-lock.yaml`
is 1.1.23. Its `DialogContent` sets `aria-describedby` only while a
`Description` is mounted (`descriptionPresent`), and its dist has no
`console.*` call, so no warning fires either way. The repo already
treats a dialog without a description as an accessibility gap (the
fullscreen editor's sr-only description, pinned by objectui#3404's
test). So "none" alone would trade the stale sentence for an undescribed
dialog; ablation leg B below shows exactly that.
- Reusing `importComplete` visibly in the header would print "Import
Complete" twice, one line above the other.
- The chosen shape reuses the existing done-state keys as the
description, prints nothing twice, and adds no key.

**H3, controls.** The upload, mapping and preview subtitles are pinned
unchanged, each as the dialog's accessible description.

**H4, ablation.** Below.

## Tests

New:
`packages/plugin-grid/src/ImportWizard.doneSubtitle-13124.test.tsx`,
through the real wizard and a stubbed data source:

- controls: the upload, mapping and preview subtitles, each what
`aria-describedby` names;
- a completed import (the card's repro, "1 created" and "1 skipped"):
"Review data before importing." appears nowhere, and the description is
the "Import Complete" headline, carried by exactly one element;
- a cancelled background import: the same, with "Import cancelled".

Expected copy is read by key from `IMPORT_DEFAULT_TRANSLATIONS`, so a
rewording moves the test with it.

Ablation: the fix was committed first; each restore is proven by the
file's blob hash equal to HEAD's and an empty `git diff HEAD`. The test
imports `./ImportWizard` relatively, so it reads `src/`; no `dist/` is
involved.

- Leg A (H4): the whole `ImportWizard.tsx` reverted to the base commit
`4c36eab`. 2 failed, 1 passed: the completed and cancelled cases find
the header description "Review data before importing."; the controls
pass.
- Leg B, the "none" shape (header subtitle dropped, the completed
headline a plain paragraph again), through `ablation-replace.mjs`
(anchor 1 to 0). 1 failed, 2 passed: the completed case finds no
`aria-describedby` at all (`{ text: null, carriers: 0 }`).

Local runs, all at `041498e` (this PR's head):

- `pnpm exec vitest run` over the 40 test files that name `ImportWizard`
or its result copy (derived with `git grep -l`): exit 0, Test Files 40
passed (40), Tests 356 passed (356).
- `pnpm --filter @object-ui/plugin-grid lint`: exit 0, 0 errors; none of
the file's existing warnings sits on a changed line.
- `pnpm --filter @object-ui/plugin-grid type-check`: exit 0, after
building the dependency closure; `tsc -p tsconfig.test.json --listFiles`
lists the new test.
- Exit 0 each, verdict lines green: `pnpm check:new-line-citations` (0
new), `pnpm check:control-bytes`, `pnpm check:changeset-claims`, `node
scripts/check-changeset-presence.mjs`, `node
scripts/check-changeset-no-major.mjs`, `pnpm
check:pending-changeset-literals`, `pnpm check:test-path-roots`, `pnpm
check:i18n-keys`, `pnpm check:shell-escape-residue`, `pnpm
check:vi-mock-specifiers`.
- `node scripts/check-governed-queue-guard.mjs --test` over the three
paths: NOT GOVERNED.
- Not run locally: `pnpm check:i18n-drift` (no i18n key or value
changes); the repo-wide `pnpm test` and `pnpm lint` are CI's.

## Acceptance notes

Observed, not changed here, not filed:

- While an import runs (sync, or a background job queued or processing),
the subtitle still reads "Review data before importing." above the
progress bar. Same subtitle-follows-step class, but the dispatch keeps
the other steps' subtitles unchanged, and no importing-state sentence
exists to reuse without a copy decision. Carrier: none.
- The step indicator still highlights "3. Preview" on the result screen.
Carrier: none.
- An import that fails outright (405 refusal, thrown server failure,
background job `failed`) shows the green check and the "Import Complete"
heading. With this change that heading is also the accessible
description, exactly as visible. Read from source, not measured through
the console. Carrier: none.

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

Co-authored-by: Claude <noreply@anthropic.com>
…r of the create button (objectui#13134) (#13187)

Fixes #13134

Clause-②: no

## What was wrong

At 390x844 the object-list page's floating create button
(`mobile-fab-create`: `fixed`, `bottom-36`, `h-12`) sits over the end of
the list, and nothing can scroll the last card clear of it.

Measured before writing code (the PM's mechanism assumption 2, which box
owns the scroll): **no box scrolls the phone card list at all.** Below
768px `ObjectGrid` draws stacked cards instead of the table, straight
into `ListView`'s view area, which is `overflow-hidden`. So is every box
between it and this page (the `ListView` root, `plugin-view`'s
`ObjectView` root and content). The table scrolls in its own body; the
card list had no body of its own. So:

- a short list (the card's Projects, 5 records) fits, does not scroll,
and its last card stays under the button;
- padding the list, the card's "Expected", cannot fix it alone: padding
inside a clipping box grows the clipped box and makes no scroll room
(measured below);
- a list taller than the screen is clipped, with nothing to scroll.

The clearance needs a scroller first. Assumption 1 holds for the button.
Assumption 2 resolves to the card list's producer,
`@object-ui/plugin-grid`'s card view (not `plugin-list`, not the console
shell). Assumption 3 holds: the button renders on `showCreate`.

## The fix

- **`@object-ui/plugin-grid`, `ObjectGrid` card view:** the cards and
the grid description scroll in a box of their own, `h-full
overflow-y-auto overflow-x-hidden`,
`data-testid="object-grid-card-scroller"`, marked `data-fab-clearance`.
With no host padding it, it adds no room. Its host still bounds it, so
`h-full` resolves. Under a host with no bounded height it grows with its
content, as before.
- **`@object-ui/app-shell`, `ObjectView`:** while the phone FAB is
drawn, the list host carries `max-sm:[&_[data-fab-clearance]]:pb-48`,
which is 12rem at the end of the marked scroller: the button's 9rem
offset plus its 3rem height, so the last card scrolls above the button's
top edge. `max-sm` is the button's own `sm:hidden`. The FAB and the room
both read the existing `showCreate`, so they cannot disagree, and there
is no room where the button is not drawn.

Nothing moves in any package entry: no export, prop or schema key.
`data-fab-clearance` is a DOM attribute, the same posture as
`data-fab-yield` (`ConsoleChatbotFab`).

## Geometry reading (replica in Chromium, not the live console)

The page is a replica built from the class strings of every box in the
chain, read from this tree's source. That covers the layout `AppShell`
with shadcn `SidebarProvider` and `SidebarInset`, the console `main`,
`console-route`, `RouteFader`, this page's root, FAB and content chain,
`plugin-view`'s `ObjectView` root and content, the `ListView` root,
toolbar, view area and record-count bar, and `ObjectGrid`'s card list
and card class. Tailwind was compiled from the installed `tailwindcss`
4.3.3 for exactly those classes, and card contents are fixtures.
Browser: Chromium at `/opt/pw-browsers/chromium`, viewport 390x844.
Input: 20 `mouse.wheel` steps (deltaY 400) over the list. Positions are
px from the top.

| tree | cards | box that scrolls | last card before | last card after
wheel | FAB top |
|---|---|---|---|---|---|
| base | 5 | none | 573 to 683 (under FAB) | 573 to 683 (under) | 652 |
| base | 20 | none (`ListView` view area clips 2368px into 718px) | 2343
to 2453 | unreachable | 652 |
| base, padding on the card list only | 5 | none | 573 to 683 | 573 to
683 (under) | 652 |
| this PR, FAB drawn | 5 | `object-grid-card-scroller` (padding-bottom
192px) | 573 to 683 | 501 to 611 (clear) | 652 |
| this PR, FAB drawn | 20 | `object-grid-card-scroller` | 2343 to 2453 |
501 to 611 (clear) | 652 |
| this PR, FAB not drawn | 5 | none (fits, padding-bottom 0) | 573 to
683 | 573 to 683 | none |
| this PR, FAB not drawn | 20 | `object-grid-card-scroller`
(padding-bottom 0) | 2343 to 2453 | 693 to 803 (flush end) | none |

For the seat's live reading at 390x844 with real touch or wheel input:
the scroll container is `[data-testid="object-grid-card-scroller"]`, the
last card is `[data-testid="object-grid-card-scroller"] > .space-y-2 >
:last-child`, and the button is `[data-testid="mobile-fab-create"]`.

## Tests

-
`packages/plugin-grid/src/__tests__/ObjectGrid.cardScroller-13134.test.tsx`:
at 390px every card sits inside exactly one `data-fab-clearance` box,
which is the scroller (`h-full`, `overflow-y-auto`) and carries no
padding of its own. At 1280px (the table) nothing carries the marker.
-
`packages/app-shell/src/views/ObjectView.mobileFabListClearance-13134.test.tsx`:
with create offered (plain, and greyed by `disabledWhen`), an ancestor
of the list's marked scroller pads it by at least the `bottom-N` plus
`h-N` read off the FAB's own classes, under `max-sm`, matching the FAB's
`sm:hidden`. With create hidden by `visibleWhen`, or closed by the
principal, there is no button and no room.

Reverse verification. The fix was committed first (58c8790). Each
mutation went through objectstack's `ablation-replace.mjs`: the anchor
went from 1 hit to 0, the blob changed, and after the run the restored
blob equals HEAD and `git diff HEAD` is empty.

- app-shell leg, `listFabClearance` forced to `undefined`: predicted the
two room cases RED and the two no-room cases GREEN. Observed `Tests 2
failed | 2 passed (4)`, both failures `expected null not to be null`.
- plugin-grid leg, the scroller's classes and marker removed: predicted
the 390px case RED and the 1280px case GREEN. Observed `Tests 1 failed |
1 passed (2)`, failing with `expected to have a length of 1 but got +0`.

## Gates (local, at HEAD 58c8790)

- `pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/
packages/app-shell/src/views/ObjectView` (through `os-verify-lock.sh`):
`Test Files 305 passed (305)`, `Tests 2732 passed (2732)`, VERDICT
command-exit 0.
- `pnpm turbo run build --filter='@object-ui/app-shell^...'
--concurrency=2` (`Tasks: 28 successful, 28 total`), then `pnpm --filter
@object-ui/plugin-grid type-check` and `pnpm --filter
@object-ui/app-shell type-check`: both scripts echoed `tsc --noEmit &&
tsc -p tsconfig.test.json`, VERDICT command-exit 0. Each new test file
is in its package's `tsconfig.test.json` program (`--listFilesOnly`, 1
hit each).
- `node scripts/check-changeset-presence.mjs`: exit 0, "4 source file(s)
of 2 released package(s) changed, and this change declares 1
changeset(s)".
- `pnpm check:new-line-citations`: exit 0, "0 new citation(s)".
- `pnpm check:control-bytes`: exit 0, OK.
- Not run locally (CI's): repo-wide lint and the full test farm. No i18n
key or doc fence was touched.

## Acceptance notes

- Overlap: objectui#13150's in-flight branch also edits `ObjectGrid`'s
card view (the `secondaryCols` line, a few lines below this PR's
wrapper). The hunks are not adjacent. Whoever lands second merges
`main`.
- Not measured and not changed here: the console's AI launcher
(`ConsoleChatbotFab`, `fixed bottom-20 h-14` below `sm`) also floats
over the list's bottom-right. While the create button is drawn this room
clears it too, since it sits lower. Where create is not offered and the
launcher is drawn, the list gets no room for it. Not reproduced in a
browser. Carrier: none.
- Not measured and not changed here: other phone surfaces under the
create button carry no `data-fab-clearance` box: the grouped grid (which
keeps the table at phone width), kanban, gallery and calendar. Carrier:
none.
- The FAB's comment says it sits above "the bottom mobile-nav (h-12 +
safe-area)". The console draws no bottom nav (the button sits above the
AI launcher). Left as is, outside this card.

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

Co-authored-by: Claude <noreply@anthropic.com>
…ects (objectui#13165) (#13189)

Fixes #13165

Clause-②: no

## What changed

The Audit Log's Object filter
(`apps/console/src/pages/system/AuditLogPage.tsx`) is now a searchable
picker of the objects the console's metadata serves. It replaces the
free-text box that sat under the placeholder "e.g. account".

- Each option reads `Label (name)`, the spelling the object-ref picker
gives an object (`objectRefOptionLabel` from `@object-ui/fields`).
Options are sorted by that label in the display locale.
- Choosing one commits the object's API name, as the Actor filter's
`sys_user` lookup commits a user id (objectui#11701).
- "Any object" is the unfiltered state. Choosing the chosen object
again, or "Clear filters", removes the filter.
- The visible "Object" label now names the control through `htmlFor` /
`id`, because a `combobox` takes no accessible name from its content. No
literal `aria-label` is added.
- The query is unchanged. The list fetch still sends `object_name` as a
plain-value equality in `$filter` to `GET /api/v1/data/sys_audit_log`.
- No object is named in the page. The options come from
`useMetadata().objects`, the served registry the presets already read,
so the picker costs no new request.

## The hypotheses, measured on `origin/main` `186b78d`

- **H1, the producer: confirmed, with one correction.** `load()` put the
box's trimmed text into `filter.object_name`, serialized it into
`$filter` and sent it to the data door `/api/v1/data/sys_audit_log`.
That is an exact equality. The correction: the placeholder was not drawn
from any app. It was the hard-coded literal "e.g. account" in the page.
- **H2, pick rather than type: confirmed.** The page already reads
`useMetadata().objects` for its presets. That is the whole served
registry, the `object` type list behind `MetadataProvider`'s `objects`
getter. The ledger's writers in objectstack name either the audited
object itself (`ctx.object` in `audit-writers.ts`) or one of four
platform objects:
  - `sys_session`, for login and logout;
  - `sys_setting` and `sys_platform_setting`, for config changes;
  - `sys_user`, for user administration and import.

All four are declared in `packages/platform-objects`, so they are
registered objects. The contains-match alternative was rejected on a
measurement as well as on taste. objectstack's ledger read gate
(`namedParentObjects` in `parent-record-read-gate.ts`) reads only a
plain value, `$eq` or `$in` on `object_name` as a confinement. A
contains operator would lose that narrowing, and this card is not about
the door's filter semantics.
- **H3, controls: pinned.**
- Clearing, by re-picking or by "Clear filters", sends no `$filter` and
lists every row.
  - Action and Actor each join the object filter in one `$filter`.
- Paging keeps the object filter, and picking a new object starts again
at page 1.
- **H4, acceptance: pinned, and it goes red with the fix reverted.**
Choosing Accounts sends `object_name` equal to `crm_account` and lists
only its rows. Typing `account`, the card's own example, into the
picker's search finds Accounts.

## Tests

- New: `AuditLogPage.objectFilter-13165.test.tsx`, 8 tests.
- Updated: `AuditLogPage.presets-12667.test.tsx`. Its
preset-joins-the-bar pin drove the removed text box. It now picks
`account` through the picker and asserts the same filter as before.

Run at head `a47caf8`, through the shared verify lock: `pnpm exec vitest
run --maxWorkers=2` over the four `AuditLogPage.*` suites,
`consoleFaces.displayLocale-9909.test.tsx` and
`orphanedPageComponentRefs-10520.test.tsx`. Result: `Test Files 6 passed
(6)`, `Tests 51 passed (51)`, `VERDICT command-exit 0`.

## Ablation (one-off, no permanent file)

The fix was committed first (`2583e95`). Then only `AuditLogPage.tsx`
was checked out from base `186b78d` (blob `a240710`), with a trap that
restores from `HEAD`.

- **The mutation landed on disk.** `objectFilterOptionsOf` went from 3
occurrences to 0. `placeholder="e.g. account"` went from 0 to 1 (counted
in the two blobs).
- **The new file went red: `Tests 8 failed (8)`.** One test failed
because the free-text input was present. The others failed on `Unable to
find an accessible element with the role "combobox" and name "Object"`.
That is the predicted direction.
- **The restore is proven.** `git checkout HEAD -- path`; the disk blob
`4aa9e0d` equals the HEAD blob, and `git diff HEAD` is 0 bytes.

## Gates (local subset, at `a47caf8`; exit codes captured before any
pipe)

- `pnpm --filter @object-ui/console lint`: exit 0, `0 errors, 220
warnings`. The two warnings on `AuditLogPage.tsx`, `catch (err: any)`
and the setState-in-effect, were already on main.
- `pnpm --filter @object-ui/console type-check`: exit 0, after building
the closure with `pnpm --workspace-concurrency=2 --filter
"@object-ui/console^..." build` (exit 0). `tsc --listFiles` lists all
four `AuditLogPage.*` test files, so the check covers them.
- Each of these exited 0:
  - `pnpm check:new-line-citations` (0 new)
  - `pnpm check:control-bytes`
  - `node scripts/check-changeset-presence.mjs` (1 changeset declared)
  - `node scripts/check-changeset-no-major.mjs`
  - `pnpm check:changeset-claims`
  - `pnpm check:pending-changeset-literals`
  - `node scripts/check-vi-mock-specifiers.mjs`
  - `node scripts/check-vi-mock-inherit.mjs`
  - `node scripts/check-vi-mock-override-shape.mjs`
  - `node scripts/check-test-path-roots.mjs`
- `node scripts/check-governed-queue-guard.mjs --test` over the four
paths answers NOT GOVERNED.
- Not run locally, because they belong to CI: the full test shards and
the repo-wide lint. No i18n gate applies, since no string moved into a
translation table.

## Changeset

`.changeset/13165-audit-log-object-picker.md` is a `patch` for
`@object-ui/console`. The changeset-presence gate asks for it because
`apps/console` is in the release's fixed group, although no export, prop
or accept set moves.

## Acceptance notes (not changed here)

- **The shared `Combobox` searches option values only.** `CommandItem`
gets `value={option.value}` and no `keywords`, so typing an object's
visible label finds nothing unless its letters also occur, in order, in
the API name. A happy-dom probe, not committed, typed into a `Combobox`
holding `Accounts (crm_account)` and `Opportunities (crm_opportunity)`:
  - `Accounts` and `Opportunities` showed no option;
  - `account` and `crm_opp` showed the matching one.

The limitation lives in `packages/components/src/custom/combobox.tsx`
and reaches every consumer whose labels differ from its values, the
object-ref field included. Fixing it moves a published package, so it is
outside this card's surface. No real-browser reading was taken, and
there is no carrier.
- **Other controls still lack a name tied to their label.** The Action
select, the From/To date inputs and the Actor lookup trigger keep their
visible labels unassociated. The test DOM dump shows the Action select
as a `combobox` with an empty name. Pre-existing and left as it was.
- **The table and drawer still print the raw API name.** The Object
column and the drawer title show `object_name`, not the label. Polish,
left as it was.
- **Rows of an unserved object cannot be reached by this filter.** An
object the metadata no longer serves (a deleted one, say) is not
offered, so the filter cannot narrow the ledger to its old rows. The
changeset says so too.

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

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

---------

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

Fixes #13126
Clause-②: no

## What

Studio's New package dialog described the package as "project-scoped"
(zh 创建项目范围的软件包), a scope word ObjectStack retired when it renamed
project to environment (ObjectStack ADR-0006). The dialog is not
environment-scoped either (create sends no `scope`; the server defaults
it, objectui#12869), so per the dispatch ruling the sentence now carries
no scope word:

- en: "Create a writable package to author and publish your own
metadata. The id should be reverse-domain (e.g. {example})."
- zh: "创建可写的软件包,用来编写和发布你自己的元数据。ID 建议使用反向域名格式(例如 {example})。"

Only the `engine.packages.create.description` row changes (the table
carries en and zh only). No existing pin asserted the old sentence, so
no pin was updated.

## Files

- `packages/app-shell/src/views/metadata-admin/i18n.ts` (the one row, en
+ zh)
-
`packages/app-shell/src/views/metadata-admin/PackageFormDialog.createCopy-13126.test.tsx`
(new pin)
- `.changeset/13126-package-dialog-copy.md` (`@object-ui/app-shell`
patch)

## Pin and ablation

The new test renders the dialog in create mode in en-US and zh-CN, reads
the description through the dialog's `aria-describedby` link (positive
control: the interpolated `com.acme.crm` is present), and asserts it
does not match `/project/i` (zh: also not `项目`).

Ablation via `objectstack/scripts/ablation-replace.mjs` (anchor hit 1 to
0, landed on disk), one leg per locale, at commit 0d9f30f2e1:

- en leg: old en sentence put back: `× carries no "project" scope word
in English`, `Tests 1 failed | 1 passed (2)`, exit 1.
- zh leg: old zh sentence put back: `× carries no 项目 scope word in
Chinese`, `Tests 1 failed | 1 passed (2)`, exit 1.
- Restore proven by blob hash after each leg: `i18n.ts` =
`c151bc60479f1e3ad5f4256aa2cb763e220de545` = the HEAD blob; `git diff
HEAD` empty.

## Local verification (at 0d9f30f2e1)

- Closure build `pnpm --filter '@object-ui/app-shell^...' build`: exit
0.
- `pnpm --filter @object-ui/app-shell type-check`: exit 0
(`tsconfig.test.json` lists the new test file, counted with
`--listFiles`).
- New test: 2/2 passed. Touched-file family (all `PackageFormDialog.*`,
`PackagesPage*` and metadata-admin `*i18n*` tests, 26 files): 223/223
passed.
- eslint narrowed to the 2 changed source files (root
`eslint.config.js`, no type-aware linting, so the diff cannot move any
untouched file's verdict): 2 files, 0 errors, 0 warnings.
- 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`.
- Declared to CI: the full `packages/app-shell/` vitest directory (not
run locally, per the dispatch's shared-lock rule).

## Acceptance notes

Other rows in the same table that contain "project", left as they are:

- `engine.packages.scope.project` (en "Read-only · code", zh 只读 · 代码包):
only the key names the `project` wire value of `ManifestSchema.scope`;
the shown text has no scope word.
- zh `engine.packages.form.help.scope`
(部署范围:cloud(云端)、system(系统)、project(项目)): names the literal `scope` enum
value the spec still carries; it is drawn only in edit/view mode (create
has no scope row), so it is not this sentence. Whether the gloss 项目
should change follows whatever the spec does with the enum value.
- zh `engine.packages.form.help.namespace`: `todo_project` is an example
object name, not the retired term.

---

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

Co-authored-by: Claude <noreply@anthropic.com>
…abel where no card covers it (#13191)

Fixes #13144

Clause-②: no

## What was wrong

On the flow canvas every forward edge was the same vertical curve from
the source card's bottom to the target card's top (`edgePath`), with its
branch pill at the curve's midpoint (`edgeMidpoint`). Only back edges
took a detour (`backEdgePath`). So a Decision branch that skips a rank
(the card's Start → Decision → Notify → End, plus a Decision → End
branch) ran straight under the Notify card. The line was hidden under
the card, the pill sat under it, and a click there selected Notify.

## What changed

- **`flow-canvas-layout.ts`: new `forwardEdgeRoute(from, to, obstacles,
labelSize?)`.** It samples the old curve against every node box except
the edge's own two.
- **No box crossed:** the edge comes back as before, byte for byte:
`edgePath` for the path and `edgeMidpoint` for the pill.
- **A box crossed:** the edge turns aside onto a vertical lane, runs
down it and turns back into the target's top. The lane sits
`DETOUR_LANE_GAP` (24px, never more than `PADDING`) outside the boxes
the edge would cross, and moves further out past any neighbouring box it
would run through.
- **Choosing a side:** a left lane and a right lane are both built and
checked against every box. The one that crosses fewer boxes wins, then
the one with the shorter way across. A tie goes left, away from the
right-hand lane that revise loops use. The pill sits on the lane, below
the first turn, in the open band above the first box.
- **The same "label under a node" symptom on an edge between two
cards.** An edge can skip a rank by running in the 44px gap between two
cards. It crosses neither card, so its path stays the same. But its pill
sat at the midpoint, in that rank, under both cards. When the caller
passes the pill's size, the router moves the pill along the same curve
to the open band below the source, next to the sibling branch pills.
With no size (the region view draws no pills), nothing moves.
- **`FlowCanvas.tsx`** draws everything on the route: the stroke, the
wide transparent hit path, the pill, the insert handle and the problem
badge. It passes each branch pill's estimated size. The Problems-panel
reveal pans to the routed pill point.
- **`flow-region-view.tsx`** routes nested-region edges through the same
function. It passes no pill size.
- **No public surface moves:** no export from the package entry, prop,
schema key or accept set changes. `flow-canvas-layout` is not reachable
from the package entry.

## Browser reading (console preview gallery, no backend)

Recipe: objectui's `verify` skill. Run `cd apps/console && pnpm exec
vite --port 5291 --strictPort`, open `/preview-gallery.html?only=flow`,
and drive it with the repo's Playwright (`executablePath:
'/opt/pw-browsers/chromium'`).

The gallery's flow sample has no Decision → End branch that skips a
node. So the readings used three local-only edits to
`apps/console/src/preview-samples.ts`. None of them is in this PR:

- **A, the card's flow:** the sample's nodes and edges are replaced with
start → decide → notify → end. decide → notify is `isDefault`; decide →
end has `condition: "record.status != 'done'"`.
- **B, the stock sample plus one edge:** `{ id: 'e_check_end', source:
'check', target: 'end', condition: "record.status != 'done'" }`.
- **C, between siblings:** start → decide; decide → a and decide → b
(with conditions); a → end and b → end; plus decide → end with the same
condition.

Each reading does four things:

1. Pans the canvas so the pill is in view.
2. Checks the drawn path at 201 points against every card's rect, and
compares the pill's rect with every card.
3. Asks the browser which element is topmost at the pill's centre.
4. Clicks that point with a real mouse, then reads which edge the canvas
marks as selected.

The "after" rows were read on this branch. A was read again at the final
head.

| reading | path points inside a card | pill rect against the cards |
topmost at pill centre | real click on the pill selects |
|---|---|---|---|---|
| A, main | notify 74 | overlaps notify | node notify | node notify, no
edge |
| A, branch | none | overlaps none | the pill | edge decide → end |
| B, main | wait 16, task 16, review 16, notify 16, enrich 16 | in the
task / review gap, under task → review's insert handle | the svg layer,
not the pill | nothing; task → review's node palette opened |
| B, branch | none | overlaps none | the pill | edge check → end |
| C, main | none (the line runs in the gap) | overlaps a and b | the
pill (its middle shows in the gap) | edge decide → end |
| C, branch | none (path unchanged) | overlaps none | the pill | edge
decide → end |

Path against the skipped node in A, in canvas coordinates. Notify's box
is x 28 to 268, y 272 to 338.

- **main:** `M 148,216 C 148,305 148,305 148,394`. This is a vertical
line at x 148, through Notify's box, and the pill point (148, 305) is
inside it.
- **branch:** `M 148,216 C 148,230 4,230 4,244 L 4,338 C 4,366 148,366
148,394`. The lane at x 4 stays 24px left of Notify. The pill point (4,
258) is in the band between the Decision's bottom (216) and Notify's top
(272).

The stock sample's own `e_skip_end` (Mark as not due → End, which has no
pill) was already drawn through four cards on main. 401 points on the
old curve fall inside task 32, review 53, notify 44 and enrich 32. The
branch draws `M 432,826 C 432,840 434,840 434,854 L 434,1436 C 434,1464
290,1464 290,1492`: it runs down the right-hand side, and no point falls
inside a card.

## Tests

The new tests are `forwardEdgeRoute — an edge that skips a node is drawn
around it (objectui#13144)` (10 cases, in the existing
`flow-canvas-layout.test.ts`) and
`FlowCanvas.skipEdgeRoute-13144.test.tsx` (6 cases, DOM). The DOM cases
read each card's box from its `left`/`top`/`width`/`height` styles and
the pill's box from its `foreignObject`. They pin four things:

- the path and the hit path stay clear of every card;
- the pill box overlaps no card;
- the between-siblings pill leaves the rank;
- region-view edges stay clear of region nodes.

One DOM case is labelled as a control. Happy-dom does no hit-testing, so
the pill's click wiring passes on main too. The real click is in the
browser table above.

Runs (repo root, through the shared verify lock):

- **New tests:** `pnpm exec vitest run --maxWorkers=2 --reporter=verbose
packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts
packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.skipEdgeRoute-13144.test.tsx`
gave `Test Files 2 passed (2)`, `Tests 49 passed (49)` at `84e17ad`.
`b596a0d` changes only the changeset.
- **Flow suites:** `pnpm exec vitest run --maxWorkers=2` over the
`previews/FlowCanvas`, `previews/FlowPreview`, `previews/flow-`,
`previews/flowFieldChrome`, `previews/__tests__` and
`inspectors/FlowNodeInspector` path filters gave `Test Files 72 passed
(72)`, `Tests 842 passed (842)` on the `84e17ad` tree.
- **Type-check:** `pnpm exec turbo run build
--filter='@object-ui/app-shell^...' --concurrency=2` exited 0. Then
`pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`) exited 0. The second project compiles the package's
tests.

Ablations, each through `ablation-replace.mjs` with an anchor that must
hit, and restored to a blob equal to HEAD:

1. **The router neutralised**, at `298ce70`: `if (obstacles.length ===
0) return straight;` was replaced by `return straight;`. Result: 7
failed, 39 passed. The failures were the four routing cases in
`flow-canvas-layout.test.ts`, the two canvas path/pill cases and the
region-view case. The cases that stayed green are the unchanged-path
controls and the click-wiring control.
2. **The pill move neutralised**, at `84e17ad`: the midpoint early
return in `straightLabel` became unconditional. Result: 2 failed, 47
passed. The failures were `moves the pill of an edge running between two
cards out of their rank, onto the same path` and `draws the straight
line and puts the pill where no card covers it`.

## Gates (local, at `b596a0d`)

- `node scripts/check-changeset-presence.mjs`: exit 0. 5 source files of
1 released package; 1 changeset declared.
- `pnpm check:new-line-citations`: exit 0. `VERDICT
new-cross-file-line-citations: 0 new citation(s)`.
- `pnpm check:control-bytes`: exit 0.
- `pnpm check:changeset-claims`, `pnpm
check:pending-changeset-literals`, `node
scripts/check-changeset-no-major.mjs`: exit 0 each.

Repo-wide lint and the full test farm are CI's.

## Acceptance notes

- **Pill past the frame.** A lane beside a card in the leftmost column
runs inside the diagram's 28px padding (x 4 when the column starts at
28). The pill is centred on the lane, though, so a long pill reaches up
to half its width left of the frame that `size` describes. Fit-to-view
frames `size` only, so when the fit is limited by width the pill's left
part can sit off-canvas. Back-edge pills already reach past the right
side the same way. Not changed here.
- **Crowded pill band.** A pill moved below its source shares that band
with its sibling branches' pills. `layoutBranchLabels` narrows each pill
to the distance to its neighbour minus 8px. In reading C that is 63px,
so the pill reads "if recor…" and the full wording stays in its tooltip.
In edit mode an insert handle, which always sits to the right of its own
pill, can overlap the next pill. That handle rule is the existing one: a
three-way decision with long labels hits it today.
- **No upward detour.** A forward edge whose target is not below its
source (possible only with manual positions) is never detoured, because
there is no room going down. It keeps `edgePath`.
- **Short first turn.** The first turn takes half the open band below
the source (at most 28px), and the pill gets the other half. With the
56px auto-layout gap, a 144px sideways turn happens over 28px, which
reads as a short hook.
- **README left as is.** The `Visual flow canvas` example in
`packages/app-shell/README.md` has exactly this shape: decide → end,
`isDefault`, label "Skip", past the email node. Its prose ("rendered as
labels on the connectors") is still true, so the README is not edited;
it is outside this claim's file surface.
- **Findings filed:** none.

## Files (lines added / removed against `5dbc6a5`)

-
`packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts`:
+256 / -0
- `packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx`:
+45 / -7
-
`packages/app-shell/src/views/metadata-admin/previews/flow-region-view.tsx`:
+10 / -3
-
`packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.test.ts`:
+257 / -0
-
`packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.skipEdgeRoute-13144.test.tsx`:
+252 / -0
- `.changeset/13144-decision-skip-edge-route.md` (`@object-ui/app-shell`
patch)

Session: `https://claude.ai/code/session_01GL347th7aWQUnY2k8diSLG`
(dispatched by seat `domain:ui#3`, claim comment 6285215359).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
#13193)

Fixes #13129
Clause-②: no

## What

`AdvancedChartImpl` passed no `tick` to any axis it draws, so recharts
painted every tick label in the axis `stroke` default, `#666` (about 3:1
on a dark card). Every axis the advanced chart draws now takes one
shared tick style, `AXIS_TICK_STYLE = { fill:
'hsl(var(--muted-foreground))' }`, the same fill `ChartImpl.tsx` uses:

- the category x axis, through `xAxisCommonProps` (used by the combo
branch and the vertical cartesian branch);
- the combo's two value `YAxis`;
- the horizontal-bar branch's value `XAxis` (single and dual) and its
category `YAxis`;
- the vertical cartesian branch's value `YAxis` (single and dual);
- scatter's `XAxis` and `YAxis`;
- radar's `PolarAngleAxis` and `PolarRadiusAxis`.

Only the fill is set; the font size stays the container's `text-xs`, and
every other tick prop is unchanged.

Mechanism, measured in the rendered DOM: `ChartContainer` already
carries `[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground`,
but recharts 3 draws tick text under
`g.recharts-cartesian-axis-tick-label` (in a separate z-index layer),
while `g.recharts-cartesian-axis-tick` now holds only the tick `line`s.
That selector matches no text, which is why the computed fill was
`#666`. Polar axes match neither.

## Tests (at HEAD 6ab7bd8720)

- New pin
`packages/plugin-charts/src/AdvancedChartImpl.axisTickFill-13129.test.tsx`:
for bar, line, area, bar with two value axes, horizontal-bar (single and
dual), combo, scatter and radar, it counts the drawn axes and asserts
every tick `text` of each axis has `fill="hsl(var(--muted-foreground))"`
and not `#666`. Result: 9 passed (9).
- Ablation (via `ablation-replace.mjs`, wrap mode): anchor `const
AXIS_TICK_STYLE = { fill: 'hsl(var(--muted-foreground))' };` replaced
with `const AXIS_TICK_STYLE = undefined;` (anchor x1 to x0, blob
e5b9d2f to 9f9f650ede13). Result: 9 failed (9), for example
`recharts-xAxis-tick-labels#0 tick fill: expected '#666' not to be
'#666'`, and `angleAxis#0 tick fill: expected '#808080' ...` on radar.
Restore: blob after restore e5b9d2f equals the blob at HEAD, and
`git diff HEAD` is empty.
- All 30 `AdvancedChartImpl.*` test files: 544 passed. The 29 other
plugin-charts test files that name `AdvancedChartImpl`: 497 passed. The
4 plugin-dashboard tests that read tick DOM: 68 passed.
- `pnpm --filter @object-ui/plugin-charts type-check` (`tsc --noEmit &&
tsc -p tsconfig.test.json`; the test config includes
`src/**/*.test.tsx`), after building the `@object-ui/plugin-charts^...`
closure: exit 0.
- eslint, narrowed to the 2 changed source files: 0 errors (the json
output counts 2 files). The config sets no `parserOptions.project`, so
type-aware linting is off and this diff cannot change the result for any
file it does not touch.
- `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.mjs`, `check-changeset-fixed.mjs`,
`check-changeset-presence.mjs`: all exit 0.
- The full `packages/plugin-charts/` directory is left to CI.

## Changeset

`.changeset/13129-chart-axis-ticks.md`: `@object-ui/plugin-charts`
patch.

## Acceptance notes

- The `[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground`
class on `ChartContainer` (`ChartContainerImpl.tsx`) matches nothing
under recharts 3, for the reason above. It is now redundant for the
advanced chart and was already dead. Not filed: it is dead code with no
user-visible effect once the tick prop is set. Whoever next edits
`ChartContainerImpl.tsx` can drop it or retarget it to
`.recharts-cartesian-axis-tick-label`. Nobody has picked it up.
- `ChartImpl.tsx` restates the same fill object on each of its axes.
Left as is; it is outside this card's file surface.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…bove it (#13195)

Fixes #13127

Clause-②: no

## What changed

In a package with no objects, Studio's Data rail said "No objects yet —
create one below to start". The rail draws nothing below that sentence:
`StudioRail` renders its heading row (with the rail's only create
control, the "+"), then the search box, then the list where the sentence
sits. The sentence now names that "+", in the wording the Automations
and Access rails already use for their own empty sentences
(`engine.studio.auto.none`, `engine.studio.access.none`):

| locale | before | after |
|---|---|---|
| en | No objects yet — create one below to start | No objects yet —
click “+” above to create one |
| zh | 还没有对象 — 在下方新建一个开始 | 还没有对象 — 点上方的「+」新建 |

Only the value of `engine.studio.data.noObjects` changes. No key is
added or removed, and nothing on the package entry moves. Changeset:
`.changeset/13127-data-rail-empty-copy.md` (`@object-ui/app-shell`
patch).

## Premise check against `main` at `961b69f`

1. `engine.studio.data.noObjects` renders inside the Data rail
(`DataPillar` mounts `StudioRail` with `testId="studio-rail-data"`), in
the list area under the heading row that carries the "+"
(`addTestId="studio-rail-add"`). Confirmed: the "+" is above the
sentence and nothing that creates is below it.
2. The sibling string that also says "below",
`engine.studio.data.firstObjectHint` ("… Create one below; then design
its fields …"), does not render in the rail. It renders in the content
column's empty block, directly above that block's "New object" button
(`data-testid="empty-state-new-object"`). Its pointer is right for where
it renders, so it is unchanged (its zh value, "点击下方按钮创建一个", likewise).
3. The ten locale packs under `packages/i18n/src/locales/` do not carry
this key. The designer's own table in `metadata-admin/i18n.ts` holds en
and zh only (the `engine.*` carve-out its header describes), so those
two rows are all that move; `check:i18n-drift`'s designer leg reports
the zh row followed the en change.

No other key of that table is touched.

## Test

`StudioDesignSurface.emptyPackage.test.tsx` gains one case on the real
`DataPillar` over an empty package: the rail's empty sentence is inside
`studio-rail-data`, it names the "+" (en `“+”`, zh `「+」`), and the
rail's `studio-rail-add` precedes the sentence in document order.

Ablation (one-off, not kept), run with the fix committed at `b316b28`:
the en value was put back to the old text through
`objectstack:scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob
`73bdf93cdf49` → `42f7baac5071`). The new case went red, `expected 'No
objects yet — create one below to …' to contain '“+”'`, and the file's
other three cases stayed green (`Tests 1 failed | 3 passed (4)`).
Restore proven by blob == HEAD (`73bdf93cdf49`) and an empty `git diff
HEAD`.

## Local gates, head `dfa0da6`

- `pnpm exec vitest run` over the touched test file, the
Automations/Access empty-state file, `studio-locale.i18n`,
`zhPillarNames-11801`, `select-placeholder-literal-11252`,
`block-config-i18n`, and the designer-parity and en-drift gate tests →
`Test Files 8 passed (8)`, `Tests 121 passed (121)`, exit 0.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc
-p tsconfig.test.json`; the test config lists the touched test file) →
exit 0, after the dependency closure build.
- `node scripts/check-changeset-presence.mjs`, `pnpm
check:new-line-citations` (0 new), `pnpm check:control-bytes`, `pnpm
check:i18n-keys`, `pnpm check:i18n-drift`, `pnpm
check:i18n-designer-parity`, `pnpm check:i18n-dead-keys` (report), `pnpm
check:changeset-claims`, `pnpm check:pending-changeset-literals`, `pnpm
changeset:check` → each exit 0.

Repo-wide lint and the full test farm are CI's.

## Selectors for a browser read

- The Data rail: `[data-testid="studio-rail-data"]`.
- Its "+": `[data-testid="studio-rail-data"]
[data-testid="studio-rail-add"]`.
- The empty sentence: `[data-testid="studio-rail-data"] p` (the only
paragraph in the rail while the package has no objects; it has no test
id of its own).

## Acceptance notes

- Read-only packages (not changed here, and not made worse). When the
package is read-only, the Data pillar passes no `onAdd` to `StudioRail`,
so the rail draws no "+", yet the rail still renders
`engine.studio.data.noObjects`; and the content column's block still
shows `firstObjectHint` ("Create one below") while its button is hidden.
The old text pointed below at nothing; the new text points above at
nothing. objectui#12514 gave the Automations and Access rails the
read-only posture "no rail pointer" (pinned in
`StudioDesignSurface.emptyStates-12514.test.tsx`); the Data pillar did
not get it. This is a source reading, not reproduced in a browser (it
needs a non-writable package with no objects), and the render condition
lives in `StudioDesignSurface.tsx`, outside this claim's file surface.
Raised to the seat in the report; no carrier yet.
- `check:i18n-dead-keys` lists `engine.studio.data.readOnlyPackage`
("This package is read-only — switch to or create a writable package to
add objects") as a confirmed dead-key candidate: no render site in
`packages/app-shell/src`. Not touched: it is another key of the same
table.

Session: `session_01GL347th7aWQUnY2k8diSLG` (dispatching seat; this run
is its `mode:subagent` dev).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ted hidden columns are not listed (objectui#13125) (#13196)

Fixes #13125

Clause-②: no

## What changes

The Studio **Pending changes** sheet (`DraftChangesPanel`), in the
drill-in under an item row:

- **Field rows lead with the field's label.** An added field now reads
`+ Visit Date` with the machine name `visit_date` beside it in
monospace, the way the item row already reads (#11862). Changed and
removed rows read the same way. Added and changed rows show the label of
the draft definition (what publishing installs); a removed row shows the
published one's. A field with no label, or with a label equal to its
name, reads as its name alone in monospace, exactly as before. The label
is read the way the spec declares a field label, as a string: anything
else, or a blank one, is no label.
- **`__search` is no longer listed as an author change.** A field row is
dropped when every definition of that field in the two bodies carries
`system: true` and `hidden: true`. That is the Data pillar's existing
rule, `isStudioHiddenSystemField` (#11780), imported rather than
restated: no second list of internal names.

Why `__search` appeared at all: the `/meta` read exits serve an object
with the platform's materialization seam replayed onto it (objectstack
metadata-protocol `materializeFromRegistry`, which calls the registry's
`materializeServedObjectOnto`: injected system columns, the search
companion, the title designation). The draft read is one of those exits.
A NEW object has no published body, so every stamped column read as
added.

Where the rows' data comes from (measured before choosing): the drill-in
(`EntryDetail`) already reads both item bodies on expand, and
`computeChangeDetail` diffs them. The labels and the two marks are read
off those same two bodies, so there is no new fetch.
`computeChangeDetail` now also returns `fields.labels`, a Map from each
listed field to its label (a Map because `__proto__` is a legal field
name).

### Row selector for the browser reading

Each field row is now `[data-testid="draft-entry-detail"]
[data-testid="draft-field-row"]`: a `p` whose own text is the mark and
the label, with the machine name in the `span.font-mono` inside it. The
"Also changed:" key rows and the item row are unchanged.

## Scope

- The changed and removed field rows get the same label-first row and
the same filter as the added rows. The card's Expected says "Field rows
lead with the field label" while the claim names the added-fields rows;
it is the same component, the same defect class and one row helper, so
the drill-in stays consistent. Declared here so the seat can judge it.
- Not touched: the summary line (#13147), the field pickers (#13152),
the "Also changed:" key rows, the item row. No package export, prop or
i18n key is added.

## Behaviour change worth naming

An UPDATE whose two bodies differ only in a column the rule hides now
reads "No differences detected" once expanded, and drops its Update
badge. The no-change mark (#11807) reads the same filtered diff, as it
already reads the envelope-free pair (#12594).

## Acceptance notes

- **Audit columns and `owner_id` are still listed on a NEW object**
(`created_at`, `created_by`, `updated_at`, `updated_by`, `owner_id`).
The platform injects them with `system: true` but without `hidden: true`
(`AUDIT_FIELD_DEFS`, `OWNER_FIELD_DEF`), so the existing rule keeps
them, and this PR does not widen that rule (dispatch instruction). They
now read by label, for example `+ Created At` beside `created_at`.
Whether "injected but not hidden" counts as an author change in this
sheet is a product decision, not this card's. Carrier: the dispatching
seat (`domain:ui` seat 3).
- Seen while testing, not fixed here: a field literally named
`__proto__` never reaches the field diff, because `diffFields`
(metadata-admin `object-fields-io`) collects its `byName` in a plain
object. The spec's field-name grammar admits that name. No public-door
reproduction was taken, so no card. Carrier: none.

## Tests

New:
`packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx`
(11 tests). Its fixtures copy the platform literals the #11780 pin
copies (`provisionSearchCompanion`, `OWNING_BUSINESS_UNIT_FIELD_DEF`,
the `created_at` row of `AUDIT_FIELD_DEFS`), with that pin's three
controls: `internal_note` (hidden, not system: listed), `owner_id`
(system, not hidden: listed), `created_at` (audit column: listed).

Ablations, one per changed behaviour. The fix was committed first
(`7ed0889`); each mutation went through objectstack's
`scripts/ablation-replace.mjs` in wrap mode, which proved the anchor hit
once and the blob changed, and proved the restore (blob equal to HEAD,
`git diff HEAD` empty):

1. The hidden-column filter replaced by `return true;`: 4 failed, 7
passed. The four hidden-column assertions went red; the "marks one body
lacks" control stayed green, as it should.
2. The label lead replaced by `const lead = undefined`: 3 failed, 8
passed. The three render assertions went red; the pure label tests
stayed green (they read `computeChangeDetail`, not the row).

Local runs on head `e1ac777`, exit codes captured before any pipe:

- `pnpm exec vitest run packages/app-shell/src/preview/`: exit 0, 29
files, 211 tests passed.
- `pnpm exec vitest run` on four Studio files that render the real panel
or pin the rule (`StudioDesignSurface.publishPosture-12517`,
`StudioDesignSurface.publishCount-11807`,
`DataPillar.hiddenSystemFields-11780`, `DraftPreviewBar`): exit 0, 4
files, 25 tests passed.
- `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc
-p tsconfig.test.json`; the test project includes `src/**/*.test.tsx`):
exit 0.
- `node scripts/check-changeset-presence.mjs`: exit 0.
- `pnpm check:new-line-citations`: exit 0, 0 new citations.
- `pnpm check:control-bytes`: exit 0.

No test outside `packages/app-shell/src/preview/` expands the drill-in:
a `git grep` for `draft-entry-detail`, `draft-entry-toggle` or
`computeChangeDetail` over every package's tests hits 10 files, all in
that directory. Repo-wide lint and the full test farm are CI's.

Changeset: `.changeset/13125-pending-field-rows.md`,
`@object-ui/app-shell` patch.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-tesla pushed a commit that referenced this pull request Oct 25, 2026
…711)

With ui/ on main (#25825), the objectui side of the release lane lands as tree
edits instead of a patch file:

- README step 2: the pin, the dist-injection probes, objectui's release lane,
  its Changesets config, the QUICK_REFERENCE release sync (17c) and the nested
  pull_request_template.md are deleted; objectui's 65 pending changesets stay
  under ui/ for #25831;
- 4a-ui-side.patch applied under ui/ (git apply --check exit 0 on this tree),
  then deleted; scripts/ui-import/README.md now records what was applied;
- the SDUI manifest is regenerated from ui/ for the merged tree (107 -> 106
  components; objectui#13449 retired html), from the patch the 4b PR 2a
  thread measured.

check-changeset-fixed, check:lockstep-package-count, check:sdui-lockstep and
check-sdui-manifest (--self-test and the run) exit 0.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe
os-zhuang pushed a commit that referenced this pull request Oct 25, 2026
…s the fixed group, @objectstack/console builds from ui/apps/console, the objectui pin is deleted (#25711) (#25745)

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

Part of objectstack#25677
Fixes #25711
Blocked-by: sub-issue 5 of #25677 (the subtree PR that adds `ui/`; not
filed yet)

Clause-②: no
Metadata-protocol: none

**Maintainer rulings (Tesla, 2026-10-25 09:06Z, director thread):** 17a
— the release npm token has publish rights on `@object-ui`. 17b, ruling
B — the private VS Code extension `object-ui` stays in the one fixed
group. 17c, ruling B — the QUICK_REFERENCE release-sync rows retire;
decision B (17c) carried here.

> ⛔ **Tier H, cutover sitting.** The cutover #25825 is on `main`
(`fd40831416`, with #25833). This PR merged `main` and applies its `ui/`
half as tree edits on that tree. Local verdict on `fb110c2323`: comment
6404553650, every step and gate exit 0. It merges per ruling 30 = B once
that sweep is clean and the four fast lanes are green.

> Body refreshed 2026-10-25T22:55Z on head `fb110c2323` (the merge of
`main` `fd40831416` is `e2db258a24`). Comments read through 6404553650.

## What changes

**1. One fixed group** (`.changeset/config.json`). The 38 public
`@object-ui/*` packages and the private VS Code extension `object-ui`
(17b) join the `@objectstack/*` fixed group; `@object-ui/example-*`,
`@object-ui/site`, `@object-ui/runner` and `@object-ui/test-support` are
ignored, as in objectui. (objectui#13506 made `@object-ui/runner`
private before the freeze at `9e1fe03286`.) `ui/.changeset/config.json`
is deleted by the ui-side patch (changesets reads one config per
repository), and objectui's 65 pending `ui/.changeset/*.md` stay under
`ui/` for aftercare #25831. Measured on cutover head `c04b36dcb9`,
moving them as they stand reds `check-empty-changeset` (3 empty
frontmatters) and `check-adr-0087-registration` (41 breaking changesets
with no disposition). Root `check-changeset-fixed` accepts a private
name in the group only when `PRIVATE_FIXED_MEMBERS` declares it
(`object-ui` alone, with its reason), the package is private, and
`privatePackages.version` is true; it reds if that member leaves the
group. `check:lockstep-package-count` subtracts the declared private
members, so the published count is 111. `check-changeset-no-major` now
reads `packages/console/build-dist.sh` as the Console's packed-copy
build script, and `check-empty-changeset`'s frontmatter-parser family
loses the deleted objectui digest.

**2. `@objectstack/console` builds in this workspace.**
`packages/console/build-dist.sh` copies `ui/apps/console/dist` (built by
turbo through `@object-ui/console#build`) into `packages/console/dist`,
ships the tracked `sdui.manifest.json` (ADR-0080), and runs the
single-zod canary over the bytes it ships. Root task
`@objectstack/console#build:dist` (`turbo.json`) depends on
`@object-ui/console#build`; `pnpm console:build` runs it, and `pnpm
release` / `cut-rc.yml` run it after `pnpm run build`. `files` and
`prepublishOnly` are unchanged. `scripts/build-console.sh` is deleted.
The CLI's remedy for a missing Console dist names `pnpm console:build`.

**3. The objectstack-side pin is deleted.** `.objectui-sha`,
`scripts/bump-objectui.sh` (+ selftest),
`scripts/objectui-changeset-digest.mjs`, `scripts/objectui-range.mjs`,
`scripts/check-prerelease-pin-watch.mjs`,
`scripts/check-console-sha.mjs`, `scripts/check-console-injection.mjs`,
`scripts/assert-console-spec-injection.mjs`,
`scripts/console-spec-probes.mjs`,
`packages/spec/scripts/build-console-objectstack-root.test.ts`; the pin
reads in `cut-rc.yml` ("THE objectui PIN IS AN INPUT HERE" and `Read the
committed objectui pin`), `release.yml`, `lint.yml`,
`showcase-smoke.yml` and the root `package.json` scripts (`objectui:*`,
`check:console-sha`, `check:console-injection`, `check:objectui-*`,
`check:prerelease-pins`; `pnpm dev*` no longer runs
`check:console-sha`). `scripts/check-console-intercept-disarm.mjs`
stays. Release notes (`collect-release-notes.sh`) say "no objectui pin"
when the new ref has none. Gate self-tests and rosters that named the
deleted scripts follow (`dispatch-gates`, `pnpm-filter-targets`,
`check-pnpm-filter-targets` (tree-aware: foreign `@object-ui/` targets
must be 0 without `ui/`, >0 with it), `bare-root-worklist`,
`check-type-check-coverage`). The lockstep count in
`content/docs/protocol/backward-compatibility.mdx` and
`scripts/publish-smoke-pack.mjs` reads 112, both scopes
(`check:lockstep-package-count --fix`), and
`packages/spec/vitest.repo-tests.json` drops the deleted test.

**4. The objectui-side pin is deleted, in `ui/`.** Until `ui/` was on
`main`, this half was carried as `scripts/ui-import/4a-ui-side.patch`.
After the cutover landed, `fb110c2323` applied it under `ui/` with
README step 2's deletions, then deleted the patch file.
`scripts/ui-import/README.md` now records what was applied. It deletes
`.objectstack-sha`, `.objectstack-pin/`, `bump-objectstack.sh`,
`objectstack-pin.mjs`, `objectstack-floor.mjs`,
`check-spec-range-floors.mjs`, `check-installed-spec-pin-claims.mjs`,
the dist-pin Vite probes `vite-objectstack-spec-dist.ts` /
`vite-objectstack-client-dist.ts`, `changeset-release.yml`,
`spec-range-floors.yml`, `release-github-releases.mjs` and their tests,
plus (17c) `sync-quick-reference-release.mjs`, its test and the
`quick-reference-current-release-4143` pin, with `QUICK_REFERENCE.md`'s
version rows. The single-zod guard moves to
`ui/scripts/vite-single-zod.ts` (it runs in every console build);
`apps/console/vite.config.ts` drops both injections. Every reader of the
Changesets config goes through a new `changesetConfigPath()` (own root
standalone, monorepo root when nested); objectui's
`check-changeset-fixed` and `check-unused-dependencies` otherwise keep
their extension rules (17b). The patch was written against verify head
`9e37acd126` and checked with `git apply --check` on cutover heads
`1c0d02eda4` and `c04b36dcb9` and on `main` at `fd40831416`. It also
deletes the nested `ui/.github/pull_request_template.md`, which never
renders and whose only markdown-test-inputs reader was the claims test.

**6. SDUI manifest.** Carries the sdui manifest regeneration for the
merged tree (107 → 106 components; objectui#13449 retired html):
`sdui.manifest.json` and `scripts/sdui-manifest.record.json`,
regenerated from `ui/` now that `.objectui-sha` is gone and the
dual-mode gates (#25833) read the in-tree oracle.

**5. One release lane.** objectui's `changeset-release.yml` is gone;
root `release.yml` publishes both scopes in one `pnpm -r publish`, and
the root `scripts/release-github-releases.mjs` cuts one GitHub Release
per package tag across both scopes.

## The four deleted Console checks, and what proves the same thing now

| Deleted | What it proved | Now |
|---|---|---|
| `assert-console-spec-injection.mjs` | the built Console bundles THIS
tree's `@objectstack/spec`, not the copy objectui's lockfile installed |
Can no longer be false. Every `@objectstack/*` dependency under `ui/` is
`workspace:^` (33 spec, 2 client, 2 lint, 2 formula rows measured),
which pnpm links and never resolves from the registry; the merged
lockfile has 0 registry entries for `@objectstack/spec` or
`@objectstack/client` and 37 `link:` rows to `packages/spec`. The
single-zod guard (Vite, `ui/scripts/vite-single-zod.ts`) and canary
(`build-dist.sh`, on the shipped bytes) still run in every build. |
| `check-console-injection.mjs` | a Console dist restored from the
pin-keyed cache was built against the current spec | Can no longer be
false. The cache keyed on `.objectui-sha` is gone; turbo's hash for
`@object-ui/console#build` includes `@objectstack/spec#build`,
`@objectstack/client#build` and `@objectstack/lint#build` (measured with
`turbo run build:dist --filter=@objectstack/console --dry=json`), so a
spec change misses the cache. |
| `check-console-sha.mjs` | the local `packages/console/dist` was built
from the pinned objectui commit | The pin it compared against is gone,
so that drift cannot occur. A local dist can still be older than `ui/`
after a pull; `pnpm console:build` rebuilds it (a turbo cache hit when
nothing changed). Nothing checks this on `pnpm dev` any more: reported
to the dispatcher. |
| `console-spec-probes.mjs` | the shared probe derivation for the two
injection checks above | Left with them; nothing else imported it. |

## Verification (merged tree at objectui@cd70b7818; the 17b/17c items
re-measured on verify head `9e37acd126`)

- `pnpm changeset version` on the cutover head `1c0d02eda4`
(objectstack#25825, objectui frozen at `9e1fe03286`) + this PR + the ui
patch: exit 0, and all 112 members move 18.1.0 → 18.2.0 together (111
public: 73 `@objectstack/*` and 38 `@object-ui/*`, plus `object-ui`).
The private, ignored `@object-ui/runner` stays at 18.1.0. On that tree
root and objectui `check-changeset-fixed` and
`check:lockstep-package-count` exit 0.
- Earlier, on verify head `9e37acd126` (when `@object-ui/runner` was
still public): all 113 members move 18.1.0 → 18.2.0 in one bump: 112
public (73 `@objectstack/*`, 39 `@object-ui/*`) and the private
extension `object-ui`. The other private `@objectstack/*` packages move
as they do on `main`. `pnpm -r publish` and `release-verify-npm` skip
`object-ui` (private), so nothing it owes the registry changes.
- `pnpm -r publish --dry-run` after the version (at `9e37acd126`): 112
packages, 39 `@object-ui/*` + 72 `@objectstack/*` +
`create-objectstack`, all 18.2.0. `pnpm pack` of `@object-ui/core`:
`@objectstack/spec` `workspace:^` → `^18.2.0`, `@object-ui/types`
`workspace:*` → `18.2.0`.
- `pnpm console:build` (CI=true): exit 0, single-zod canary green. The
packed `@objectstack/console` has the same top-level `dist/` entries as
the published 18.1.0 tarball, minus `dist/.objectstack-injection.json`
and `dist/.objectui-sha`; assets 2,268 → 2,381 (newer objectui), tarball
9,260K → 9,140K.
- Console eager-closure budget
(`ui/scripts/check-eager-closure-budget.mjs`, ceiling 3,496.0 KB): this
build 3,054.2 KB gzipped, `vendor-objectstack` 1,605.2 KB; the published
18.1.0 dist measured by the same ceilings: 3,522.3 KB,
`vendor-objectstack` 1,851.5 KB. The membership half is red identically
on both (`packages/core`: 1 of 89 modules in `vendor-objectstack`), so
it predates this PR.
- The ui-side patch on verify head `9e37acd126`: `git apply --check`
passes after README steps 1–2 and reproduces the edited `ui/` tree byte
for byte (60 files). With this PR's root half, install and `pnpm run
build`: `type-check:scripts`, `type-check:vitest-config`, `lint:root`
exit 0; root and objectui `check-changeset-fixed` pass; `vitest run
scripts/__tests__` 17 failed / 5,573 passed, where 2 are 15 s load
timeouts that pass alone, 2 fail the same way without the patch
(`check-lockfile-integrity`; `quick-reference-commands-4149` on the root
scripts the adaptation drops, cutover bill), and 14 read objectui's
nested `ui/.github/`, which 4b PR 2 deletes.
- actionlint 1.7.7 over `release.yml`, `cut-rc.yml`, `lint.yml`,
`showcase-smoke.yml`: exit 0. No step reads a pin file.

## Gates (this branch, no `ui/`)

`dispatch-gates --commands` derives 166 families for this diff; all 166
ran, and `dispatch-gates --ran` reconciles: 164 run, 2 NOT-MEASURED. All
were run on head `4ac569dc00` (after merging `main` at `f0cf338330`,
which had moved `.objectui-sha` and the root `build` script), after a
full `pnpm run build`. 156 exit 0, including `check:required-contexts`,
`check:workflow-status-functions`, `check:workflow-step-name-quoting`,
`check-changeset-no-major`, `check-empty-changeset`,
`check-adr-0087-registration`, `check:lockstep-package-count` (112) and
`check:nul-bytes`. One is red on `main` itself and not this PR's:
`check:liveness` (`translation/flows.screens` cites
`service-automation/src/engine.ts`, which #25734 refactored; this PR
touches neither). The other 9 are red or not measured because `ui/`, PR
B or 4b has not landed yet, as designed:

| Gate | Exit | Why red here | Clears when |
|---|---|---|---|
| `check-changeset-fixed` | 1 | the 38 `@object-ui/*` names and
`object-ui` in `fixed` have no package without `ui/` (on cutover head
`1c0d02eda4` + this PR + the ui patch: exit 0, 111 public + 1 declared
private) | sub-issue 5 |
| `check-prerelease-pin-watch --self-test`, `--verbose` | 1, 1 | named
only by `prerelease-pin-watch.yml`; this PR deletes the script | PR B of
#25697 |
| `check-self-test-wired --self-test` | 1 | `ci.yml` `console-pin` and
`prerelease-pin-watch.yml` still invoke deleted scripts | PR B of #25697
|
| `check:pm-dispatch-gates` | 1 | 3 of 1,913 fast-tier cases, all on the
same live invocations of deleted scripts | PR B of #25697 |
| `check:console-sha`, `check:console-injection` | NOT-MEASURED (254) |
root scripts deleted here; only the `console-pin` job names them | PR B
of #25697 |
| `check-sdui-manifest`, `check:sdui-lockstep` | 1, 1 | they read
`.objectui-sha` | 4b PR 2 (#25712) |

## Depends on, outside this PR

- **Sub-issue 5** (subtree PR): adds `ui/`; this PR merges right after
it.
- **PR B of #25697** (retire the pin gates in the cutover window):
removes the `console-pin` job and `console` filter in `ci.yml`,
`prerelease-pin-watch.yml`, and the live specimens in the
`dispatch-gates` self-test that still name
`check-prerelease-pin-watch.mjs`. Until it lands,
`prerelease-pin-watch.yml` runs a script this PR deletes.
- **4b PR 2 (#25712)**: `check-sdui-lockstep`, `check-sdui-manifest` and
`gen-sdui-manifest-node` read `.objectui-sha` and go red without it;
regenerating `sdui.manifest.json` from `ui/` is theirs (the root copy
lags the old pin, and `build-dist.sh` ships the tracked one). Also the
nested `ui/.github/` and the tests that read it, and both AGENTS.md
files.
- **Maintainer**: the npm token's `@object-ui` publish rights (card risk
1) — confirmed by Tesla, ruling 17a.

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

https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-tesla pushed a commit that referenced this pull request Oct 25, 2026
This was referenced Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants