Skip to content

Implement visual designer for Object UI schemas - #1

Merged
huangyiirene merged 10 commits into
mainfrom
copilot/add-designer-feature
Jan 13, 2026
Merged

huangyiirene merged 10 commits into
mainfrom
copilot/add-designer-feature

Conversation

Copilot AI commented Jan 13, 2026 •

Copy link
Copy Markdown
Contributor

Adds @object-ui/designer package - a visual editor for creating and modifying Object UI schemas through a GUI.

Components

  • DesignerContext: State management for schema, selection, and CRUD operations
  • Canvas: Live preview with click-to-select and visual highlighting
  • ComponentPalette: Categorized browser for 50+ components (Form, Layout, Overlay, etc.)
  • PropertyPanel: Dynamic forms generated from component metadata with type-specific inputs
  • Toolbar: JSON import/export with modal editor
  • Designer: Main component integrating three-column layout

Usage

import { Designer } from '@object-ui/designer';

function App() {
  const [schema, setSchema] = useState({ type: 'div', body: [] });
  return <Designer initialSchema={schema} onSchemaChange={setSchema} />;
}

Implementation Details

  • Real-time preview updates on property changes
  • Automatic ID generation for all nodes
  • Recursive tree operations for nested schemas
  • Support for both single-node and array body types
  • Click-to-add workflow (drag-and-drop deferred)

Documentation

  • English README with API reference
  • Chinese implementation guide
  • Visual layout documentation
  • Working demo application

Node operations handle schema mutation immutably. Selection state syncs across all panels via Context. Component metadata drives property form generation dynamically.

Original prompt

帮我开发设计器


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 4 commits January 13, 2026 11:31
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Copilot AI changed the title [WIP] Add designer feature to application Implement visual designer for Object UI schemas Jan 13, 2026
Copilot AI requested a review from huangyiirene January 13, 2026 11:38
@huangyiirene
huangyiirene requested review from Copilot and removed request for huangyiirene January 13, 2026 11:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive visual designer package for creating and modifying Object UI schemas through a graphical interface. The designer provides real-time preview, component palette, property editing, and JSON import/export capabilities.

Changes:

  • Added @object-ui/designer package with 6 core React components
  • Implemented context-based state management for schema operations
  • Created demo application with Vite + React 19
  • Added comprehensive documentation (English README, Chinese implementation guide, visual guide)

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/designer/tsconfig.json Configured TypeScript with ES2020 target, module paths, and build settings
packages/designer/src/index.ts Main entry point exporting Designer components, context, hooks, and types
packages/designer/src/context/DesignerContext.tsx State management context with schema CRUD operations and helper functions
packages/designer/src/components/Toolbar.tsx Top toolbar with JSON import/export and modal editor
packages/designer/src/components/PropertyPanel.tsx Dynamic property editor with type-specific inputs
packages/designer/src/components/Designer.tsx Main component integrating three-column layout
packages/designer/src/components/ComponentPalette.tsx Categorized component browser with click-to-add functionality
packages/designer/src/components/Canvas.tsx Live preview canvas with selection and hover highlighting
packages/designer/package.json Package configuration with dependencies and scripts
packages/designer/VISUAL_GUIDE.md Visual documentation of interface layout and features
packages/designer/README.md Comprehensive usage guide with API reference
packages/designer/IMPLEMENTATION.zh-CN.md Chinese implementation guide
examples/designer-demo/* Complete demo application setup
DESIGNER_SUMMARY.md Bilingual implementation summary
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/designer/tsconfig.json Outdated
},
"include": ["src"]
"include": ["src"],
"exclude": ["node_modules", "dist", "../renderer", "../ui"]

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclude pattern '../renderer' and '../ui' use relative paths that may not work as intended from the tsconfig location. Consider using absolute patterns or verifying these paths exclude the intended directories.

Suggested change
"exclude": ["node_modules", "dist", "../renderer", "../ui"]
"exclude": ["node_modules", "dist"]

Copilot uses AI. Check for mistakes.

// Helper function to ensure all nodes have unique IDs
const ensureNodeIds = (node: SchemaNode, idPrefix = ''): SchemaNode => {
const nodeId = node.id || `${idPrefix}${node.type}-${Math.random().toString(36).substr(2, 9)}`;

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Math.random() for ID generation can result in collisions. Consider using a more robust ID generation method like crypto.randomUUID() or a library like uuid for guaranteed uniqueness.

Copilot uses AI. Check for mistakes.

const handleCopyJson = () => {
navigator.clipboard.writeText(JSON.stringify(schema, null, 2));
alert('Schema copied to clipboard!');

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using alert() is not user-friendly and blocks the UI. Consider using a toast notification or non-blocking feedback mechanism instead.

Copilot uses AI. Check for mistakes.
};

const handleDelete = () => {
if (confirm('Are you sure you want to delete this component?')) {

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using confirm() is not consistent with modern UI patterns. Consider using a custom dialog component or alert-dialog from @object-ui/ui for better user experience.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +8
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
"../../packages/renderer/src/**/*.{js,ts,jsx,tsx}",
"../../packages/designer/src/**/*.{js,ts,jsx,tsx}",
],

Copilot AI Jan 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'content' field is for Tailwind CSS configuration, not PostCSS. This configuration should be in tailwind.config.js. PostCSS config should only contain plugins like 'tailwindcss' and 'autoprefixer'.

Suggested change
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
"../../packages/renderer/src/**/*.{js,ts,jsx,tsx}",
"../../packages/designer/src/**/*.{js,ts,jsx,tsx}",
],
plugins: {
tailwindcss: {},
autoprefixer: {},
},

Copilot uses AI. Check for mistakes.
@huangyiirene
huangyiirene marked this pull request as ready for review January 13, 2026 12:53
@huangyiirene
huangyiirene merged commit 9c5497b into main Jan 13, 2026
2 of 5 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

✅ All checks passed!

  • ✅ Type check passed
  • ✅ Tests passed
  • ✅ Lint check completed

Copilot AI added a commit that referenced this pull request Jan 24, 2026
- Fix handleExportCSV to guard on gridRef.current?.api (issue #1)
- Add dedicated onContextMenuAction callback instead of overloading onCellClicked (issue #2)
- Remove icon property from customItems to prevent HTML injection (issue #3)
- Remove validation claim from README - only basic AG Grid editing (issue #4)
- Add test assertions for all new inputs (editable, exportConfig, etc.) (issue #5)
- Fix onExport type to only support 'csv' format (issue #6)
- Remove unused ColumnConfig properties (autoSize, groupable) (issue #9)
- Type schema props with proper interfaces instead of 'any' (issue #10)
- Update export description to only mention CSV (issue #11)
- Add AG Grid Community vs Enterprise section to docs (issue #8)
- Update README and docs with new callback and clarifications

All tests pass (8/8), lint clean (0 errors)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI added a commit that referenced this pull request Feb 20, 2026
…warning, i18n fallback

- Issue #1: Normalize `in`/`not in` operators to backend-compatible `or`/`and` of `=`/`!=`
- Issue #2: Filter merging now validates and filters empty conditions
- Issue #3: CSV export safely serializes arrays (semicolon-separated) and objects (JSON)
- Issue #5: Request counter prevents stale data from overwriting latest results
- Issue #6: PullToRefresh resets pull distance immediately to prevent UI lock
- Issue #7: $top configurable via schema.pagination, data limit warning shown
- Issue #8: Extended i18n fallback translations for all ListView labels
- Issue #9: Defensive null checks in effectiveFields for mismatched objectDef
- Issue #10: Added FilterNormalization, Export, and DataFetch test suites

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
This was referenced Oct 1, 2026
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…rom the package entry (objectui#10913) (objectstack-ai#10954)

Fixes objectstack-ai#10913
Clause-②: no

Supersedes PR objectui#10949, which was re-cut to drop a model-named
commit trailer: this head `d28ba66c9` (base `5c94589f0`) carries the
same diff (stable patch-id `2e6c60c1` for both commits), so the
measurements below, taken at `1df262efb`, carry over, and the changeset
gates and `check-control-bytes` were re-run on this head with exit 0.

## What this does

The pending changeset `.changeset/9954-read-rate-banner.md` says that
`useReadRateReading` and `ReadRateBanner` are exported from
`@object-ui/app-shell`. The package entry exports neither of them. A
pending changeset is published verbatim into the CHANGELOG at the next
release, so this sentence would ship false. An author who read it and
imported either name would get a module-not-exported error.

The fix edits that one sentence, as triage `5865850392` execution note 1
directs:

- **Before:** "Both are exported from `@object-ui/app-shell`."
- **After:** "Neither is exported from the package entry; a host reaches
the banner only through `ConsoleShell`, which mounts it."

The sentence before it already says the banner is "mounted in
`ConsoleShell`", so the paragraph now says both things triage asked for.
The wording follows the sibling entry
`.changeset/10439-storage-usage-banner.md` from PR objectui#10910, which
says "Neither banner is exported from the package entry." The
frontmatter is byte-identical: the md5 of the frontmatter block is
`968447af` at base `3b469c8ea` and at head. `git diff --numstat` gives 2
insertions and 1 deletion in this one file. The only other change is the
line wrap of the replaced sentence.

## Premise, re-measured at base `3b469c8ea` (still true at `origin/main`
`5c94589f0`, which changed none of these files)

- `packages/app-shell/src/index.ts`: `grep -c ReadRate` gives **0**. As
a control, `grep -c ConsoleShell` on the same file gives 2, so the
instrument does read the entry. The entry imports named members from
`./hooks/index.js` and `./layout/index.js`. It has no `export *` from
either, and neither named list includes the read-rate hook, its types,
`classifyReadRate`, or the banner. The two sub-barrels do list the pair,
but nothing outside the package can address them.
- `packages/app-shell/package.json` `exports` maps only `.` and
`./styles.css`.
- `ConsoleShell` renders the private `ConsoleShellProviders`, and that
component mounts `ReadRateBanner` beside `ImpersonationBanner` and
`StorageUsageBanner`. Outside tests, `ConsoleShell.tsx` is the only
module that imports `ReadRateBanner`. `ConsoleShell` itself is exported
from the entry.
- The changeset is still pending. It is on `main`, and release PR
objectui#5400 is still open.

## No new changeset, and why the edit is in place

- `check-changeset-presence` says none is owed: the diff touches
`.changeset/` only.
- `check-changeset-overwrite` is report-only. It lists this file as a
pre-existing changeset that was modified, with the declared packages
identical at base and head (`@object-ui/app-shell: minor`,
`@object-ui/i18n: minor`). That is its case 2, a deliberate prose
correction. The precedents PR objectui#10828 and PR objectui#10891 got
the same report-only reading and landed with no new changeset.
- Those two precedents appended dated supersession notes and deleted
nothing. Their sentences were true when written and a later PR made them
false. This sentence was false when it was written: the entry has never
exported the pair. Triage's binding note says to edit the sentence
itself, and `changeset-polarity-census` says the same in its own output:
"ROTTED and BORN FALSE take opposite repairs".

## Gates (at head `1df262efb`, hand-derived from `package.json` and
`.github/workflows/`, each exit captured by redirect-then-status)

| Command | Verdict line | Exit |
| --- | --- | --- |
| `node scripts/check-changeset-presence.mjs` | ✅ No source or published
contract of a released package changed in this range, so no changeset is
owed. | 0 |
| `node scripts/check-changeset-claims.mjs` | ✅ No pending changeset
names a file this change touches. | 0 |
| `node scripts/check-changeset-overwrite.mjs` | report-only: 1
modified, declared packages identical at base and head | 0 |
| `node scripts/check-changeset-no-major.mjs` | ✅ No changeset declares
a `major` bump. | 0 |
| `node scripts/check-changeset-fixed.mjs` | ✅ All workspace packages
are in the changeset fixed group. | 0 |
| `node scripts/check-pending-changeset-literals.mjs` | ✅ No test source
names a pending changeset. | 0 |
| `node scripts/check-control-bytes.mjs` | ✅ check-control-bytes: OK | 0
|
| `node scripts/check-new-cross-file-line-citations.mjs` | VERDICT
new-cross-file-line-citations: 0 new citation(s) | 0 |
| `node scripts/check-spec-symbol-derivation.mjs` | ✅ spec member
citations: nothing cites a key its spec symbol does not declare. | 0 |
| `node scripts/check-installed-spec-pin-claims.mjs` | OK (it excludes
`.changeset/`, so it was run for completeness only) | 0 |
| `node scripts/check-governed-queue-guard.mjs --test` (the one path) |
✅ NOT GOVERNED | 0 |

**Tests.** These ran from the repo root, under the shared verify lock,
at head `1df262efb`. The set is every `scripts/__tests__` test file that
names `.changeset`, derived with `git grep -l` (24 files), plus the
suites of the other scripts that read `.changeset/` (9 files). Result:
**Test Files 33 passed (33), Tests 1451 passed (1451)**, lock `VERDICT
command-exit 0`.

No package test or type-check was run, and none is owed: the diff
touches no package, and no gate above reads `dist/`. The `Spec Main
Shape Gate` and the rest of CI run on the PR.

Session of this run:
`https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk` (the
`domain:ui` seat objectstack-ai#1 dispatch, dev subagent).

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
… the 2026-09-28 cloud E2E read the session's language (objectui#10900) (objectstack-ai#10953)

Fixes objectstack-ai#10900
Clause-②: yes

The four surfaces the 2026-09-28 cloud E2E read in English under zh-CN
now read the session's language. English is unchanged and stays the
default.

⚠️ **Clause-② differs from the claim.** The claim (comment 5866735286)
declared `Clause-②: no` on the premise "display strings and locale
entries only; no published type". The implementation widens the public
surface by four optional members, so this body declares `yes`:
`ActionRunner.setTranslator` (`@object-ui/core`),
`SocialSignInButtonsProps.buttonText`, `LoginFormLabels.socialButton`
and `RegisterFormLabels.socialButton` (`@object-ui/auth`). Nothing is
removed, renamed or narrowed, and no accept set changes. The changeset
declares `minor` for those two packages (the repository's precedent for
a widening) and `patch` for `@object-ui/react`, `@object-ui/i18n`,
`@object-ui/app-shell` and `@object-ui/console`. The seat may amend
either.

## Strings

| Surface | Key | en (unchanged) | zh before → after |
|---|---|---|---|
| Generic action success toast | `actions.completedSuccessfully` |
Action completed successfully | English literal → 操作已成功完成 |
| Login provider button | `auth.login.socialButton` | Continue with
{provider} | English literal → 使用 {provider} 继续 |
| Login divider (CSS uppercases it) | `auth.login.orText` | or continue
with email | English literal → 或使用邮箱继续 |
| Sign-up provider button | `auth.register.socialButton` | Sign up with
{provider} | English literal → 使用 {provider} 注册 |
| Sign-up divider | `auth.register.orText` | or continue with email |
English literal → 或使用邮箱继续 |
| Build Doctor button name + drawer title | `console.ai.buildDoctor` |
Build Doctor | English literal → 构建诊断 |
| Build Doctor tooltip | `console.ai.buildDoctorTitle` | Build Doctor —
what actually landed? | English literal → 构建诊断 — 实际生效了哪些变更? |
| Build Doctor tooltip before the first message |
`console.ai.buildDoctorDisabledTitle` | Send a message first | English
literal → 请先发送一条消息 |
| Setup → marketplace breadcrumb | `console.breadcrumb.marketplace` |
Marketplace | humanized URL slug → 应用市场 |
| Marketplace search placeholder | `marketplace.searchPlaceholder` |
Search apps by name or manifest ID… | 按名称或 manifest ID 搜索应用… →
按名称或标识搜索应用… |

`{provider}` is the component's own single-brace hole (the `{seconds}`
convention in `auth.login`), filled with the provider's display name:
the component's own label for the branded providers it knows, otherwise
the name the server reports. The nine new keys are in all ten packs; the
eight other packs carry their own translations (see the diff).

## What was measured before each choice

1. **The toast.** `@object-ui/core` cannot reach a translator
(`@object-ui/i18n` depends on core). A toast handler receives only the
final string, so it cannot tell the runner's fallback from an author's
identical text. The only two sites in this repository that install a
toast handler on a runner are `ActionProvider` and `useActionRunner` in
`@object-ui/react`, which already depends on `@object-ui/i18n`. Three
other constructors (the provider-less `useAction()` fallback,
`useActionEngine`'s standalone engine, and core's one-off
`executeAction()`) never receive a toast handler from this repository's
code. So the runner takes an injected translator (the same seam
`recordDelete` takes its `t` through) and those two owners install the
session's `t`, read at call time so a language switch reaches an
existing runner. It is asked only for the fallback: an author's
`successMessage` and a server message stay verbatim. No new dependency
edge. The provider-less `useAction()` fallback runner shows no toast
unless a consumer installs a handler by hand, and then it is English.
2. **Login.** `@object-ui/auth` takes text only through `labels` with
English defaults. `LoginForm` / `RegisterForm` passed no text to
`SocialSignInButtons`, and their `orText` label was documented as the
divider but rendered nowhere. `orText` now feeds the divider and a new
`socialButton` template feeds the button. All four callers pass the
keys: the console's `LoginPage` / `RegisterPage` and
`@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage`. The
capitals in the E2E are the divider's `uppercase` class.
3. **Build Doctor.** The Share button beside it already read
`console.ai.*` through `ChatPane`'s `t`; the Build Doctor button and
`BuildDebugDrawer`'s title now use the same translator.
4. **Marketplace.** The breadcrumb is not under `console/marketplace/**`
as the claim assumed. `AppHeader`'s `system` branch drew the segment
after System from `humanizeSlug` of the URL. Only the
`system/marketplace` routes are mounted by this package, so that segment
now reads the pack; host-mounted `system/*` pages keep their slug. The
search matches the localized display name, `manifest_id` and the
localized description. The zh placeholder names the name and 标识, the
identifier each card prints under its name, which is the zh pack's
existing word for identifiers. It promises nothing the filter does not
match, and a pin measures both halves through the page's own filter. The
en value is unchanged.
5. **Parity.** `all-locales-key-parity.test.ts` requires every `en` key
in all nine other packs and nothing extra, so each key is in all ten
packs. `check:i18n-drift` reads no `en` value change.
6. **Serial constraints.** PRs 10910, 10911 and 10914 had landed before
the first edit (ancestry exit 0); the branch fast-forwarded onto them.
PR 10924 landed during the work and is merged in (a merge commit). Its
locale edits add `commentFailed` and the `widgetForbidden*` keys in
blocks this change does not touch. Its `AppHeader.tsx` hunks (the
imports, the admin-status block and the `studioDesignPath` block) are
more than thirty lines from this change's `system` branch edit.
`MarketplacePackagePage.tsx` is untouched here.

## Tests

- **New pins**, each rendering the real component or running the real
runner under a real `I18nProvider` in zh and en:
  - `ActionRunner.defaultSuccessToast-10900` (`@object-ui/core`);
- `ActionProvider.defaultSuccessToast-10900`, for `ActionProvider` and
`useActionRunner`, including a language switch on an existing runner
(`@object-ui/react`);
  - `socialButtonLabels-10900` (`@object-ui/auth`);
  - `socialButtonsLocale-10900` (the console's login and sign-up pages);
- `authPages.socialLabels-10900` (`DefaultLoginPage` /
`DefaultRegisterPage`);
  - `buildDoctor.locale-10900` (button and drawer);
  - `AppHeader.marketplaceBreadcrumb-10900`;
- `MarketplacePage.searchPlaceholder-10900` (placeholder plus the filter
it describes).
- **Red on base, green on head.** With the fix committed, every non-test
source file was put back to base `1dae95a41`. The fix's anchors were
counted at 0 on disk, then the eight suites ran: `Test Files 8 failed
(8)`, `Tests 19 failed | 17 passed (36)`. The passing 17 are the English
controls and the checks that already held. The restore from HEAD was
proved by blob hashes and an empty `git diff HEAD`. On head: `Test Files
8 passed (8)`.
- **Reverse type check.** A label typo in `DefaultLoginPage` gives
exactly `TS2353 … 'orTextTypo' does not exist in type 'LoginFormLabels'`
against the rebuilt `@object-ui/auth` declarations, so the type-check
reads the new `.d.ts`, not a stale one. It was restored by blob check.
- **At head `f6582636f`** (after merging `main` at `5c94589f0`):
- the dependency closure of `@object-ui/console` was rebuilt (34 of 34
tasks);
- `type-check` exits 0 for `@object-ui/core`, `i18n`, `react`, `auth`,
`app-shell` and `console`;
- `vitest` over `packages/core/`, `packages/react/`, `packages/auth/`
and `packages/i18n/`, plus the four touched `app-shell` directories
(`layout`, `console/ai`, `console/auth`, `console/marketplace`) and the
console's `pages/auth`: `Test Files 479 passed (479)`, `Tests 7063
passed | 13 skipped`;
- `check:control-bytes`, `check:i18n-keys`, `check:i18n-drift`,
`check-changeset-presence` and `check:new-line-citations` pass.
- **At `9ab3b7207`**, before the merge:
- the full `packages/app-shell/` and `apps/console/` suites: `Test Files
978 passed | 1 skipped (979)`, `Tests 10202 passed | 9 skipped`;
  - every `scripts/` suite: `Test Files 177 passed | 2 skipped (179)`;
- ESLint over the changed `.ts`/`.tsx` files: 0 errors, and each changed
source file's warning count is equal on base and head.
- **Declared narrowing.** After the merge, the full `app-shell`,
`console` and `scripts/` suites were not re-run locally; CI runs the
full farm on `f6582636f`.
- **NOT MEASURED: Bundle Analysis** (the eager-closure budget, including
the `i18n-locale-en` chunk). Reason: it needs a console `vite build`,
which CI runs.

## Acceptance notes (observed, not changed here, not filed)

- The other `system/*` breadcrumb segments the console host mounts
(settings, apps, profile, approvals, ai-approvals, audit-log) still show
their humanized English slug under zh.
- The Build Doctor drawer's body (description, verdict lines, section
titles) is still English; only its title was in scope.
- The runner's other English fallbacks remain: its error-toast fallback
for a non-string error, the parallel-chain failure text, and the Undo
label on an undoable success toast. `setTranslator` is the seam any of
them would use.
- The eight non-zh packs translate "manifest ID" literally in the
marketplace placeholder, and the English source says "manifest ID" too.
- `LoginForm` keeps a `hasSocialProviders` state that nothing reads (an
existing lint warning).

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

_Body wording amended by the `domain:ui` seat objectstack-ai#1 after contract review
`5868740252` (its D1 to D3); no code claim moved._

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…y on that day in every zone, and the formula date functions do a calendar day's arithmetic on the UTC calendar (objectui#10866, slice 3) (objectstack-ai#10957)

Part of objectstack-ai#10866
Clause-②: no

Slice 3 of the date-only zone family's carrier card: the timeline
renderer's gantt variant and the D1 day arithmetic of core
`FormulaFunctions`, per release note `5866905836`, ruling `5864664470`
(objectui#10903, its last execution parameter) and claim `5867569822`.
The card stays open for slice 1's owed items, listed under Acceptance
notes.

Why `Clause-②: no`: stored date-only values read and compute as the
stored day. No schema, published type or accepted set moves, and ruling
`5864664470` states the arithmetic needs no statement.

## What changed

### Timeline, gantt variant (`plugin-timeline` `renderer.tsx`)

One read, `readGanttDate`: a date string goes through core
`toDisplayDate` (the objectui#10183 convention, which the bar tooltip's
`formatDate` has taken since slice 2), so a date-only `2026-10-05` is
local midnight of the day it names and a value with a time part keeps
its instant. A number or a `Date` is an instant and is read by `new
Date(value)` as before; for a `Date` that is a fresh copy, so no method
the authored object carries is called afterwards (objectui#7027). Every
stop on the gantt branch now reads through it:

- `generateTimeScaleHeaders` (exported): both ends. The walk moves and
prints with local setters and getters, so a date-only end starts it on
that day in every zone.
- `calculateDateRange`: reduces with the same read and prints each end
as the viewer's calendar day (`toDateInputValue` of a `Date`, local
getters). `toISOString()`'s date part named the day before east of UTC
once the read is local.
- `calculateBarDimensions`: all four ends.
- `findUnusableGanttDate`: its "does it parse" half asks the same read,
so `2026-02-30` is refused with the existing
`timeline.gantt.unusableRange.malformedDate` message (no new key), where
the engine rolled it into March and the bar was drawn there. Kept on the
engine parse, the gate would pass a value the reduce below it reads as
an Invalid Date.
- The min-over-max guard: both ends, as the headers read them.
- `emptyGanttDateRange`: the viewer's today (`toDateInputValue(new
Date())`), where it was the UTC day.

No import line of the renderer changed: `toDisplayDate` and
`toDateInputValue` were already imported from `@object-ui/core`.

### Core formula date functions (`core` `evaluator/FormulaFunctions.ts`)

- `readFormulaDate`: the engine parse (a date-only value is UTC midnight
of its day, as before), the same named `Invalid date` error, and a `day`
flag from `isRealCalendarDate`, the shared judgement, imported inside
core from `utils/date-display` (already on core's barrel).
- `DATEADD`: a calendar day goes through `addToDay`, which uses UTC
setters. Its months go through `addMonthsUtc`: step to the 1st, move the
month, clamp the day to the target month's last day. That is the rule of
objectstack `@objectstack/formula`'s stdlib `addMonthsUtc` behind CEL
`addMonths(d, n)`. A year is twelve months on the same rule. A day moved
by days, months or years comes back as `YYYY-MM-DD`; hours and minutes
give an instant (UTC midnight of the day, moved). Anything else (an
instant, or a date-only string naming a day its month does not have)
keeps the base code path verbatim: local setters, `setMonth` with no
clamp, an instant out.
- `DATEDIFF`: the month and year units read a day's fields with UTC
getters and an instant's with local getters (`calendarFields`), so two
instants count exactly as on the base. Day, hour and minute units are
millisecond differences, unchanged.
- `DATEFORMAT`: a day's fields with UTC getters, an instant's with local
getters (`calendarFields`), as on the base.
- `TODAY()` and `NOW()` are untouched. The objectui#10915 docblock on
`TODAY()` stands, and a new docblock above `DATEADD` states the three
functions' rule.

### Pins, docs, changeset

- Two pins registered in `PINS` of
`scripts/__tests__/date-only-zone-pins-10183.test.ts`, plus one header
sentence: `TimelineGantt.dateOnlyZone-10866` (plugin-timeline) and
`FormulaFunctions.dateOnlyZone-10866` (core). Each runs under
`America/Los_Angeles` and `Asia/Shanghai`, each zone opens with a rig
case (the gantt pin adds a fixture-validity case per zone), and each
carries instant controls. The gantt pin reads the named producer,
`examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json`,
from disk. The formula pin's every-zone cases also run in the suite
zone.
- Docs: one paragraph in `content/docs/plugins/plugin-timeline.mdx`,
"Time Scales (Gantt)".
- Changeset `.changeset/10866-gantt-axis-formula-day-arithmetic.md`:
`patch` for `@object-ui/plugin-timeline` and `@object-ui/core`.

## Readings, base `3b469c8ea` against head, in both zones

Base readings: the base sources with the two new pins, in a separate
comparison worktree, forks pool with the driver's flag (`Tests 27 failed
| 24 passed (51)`, every red a row below), plus a direct probe of core
`FormulaFunctions` under `TZ`. Head readings: the pins, green.

Gantt, `en-US`:

| reading | LA base | LA head | Shanghai base | Shanghai head |
|:--|:--|:--|:--|:--|
| producer `gantt-style-timeline.json`, month axis | Dec 2023, Jan 2024,
Mar 2024, Apr 2024 | Jan 2024, Feb 2024, Mar 2024, Apr 2024 | Jan 2024 …
Apr 2024 | same |
| computed day axis over bars `2026-10-05`…`2026-10-07` | Oct 4, Oct 5,
Oct 6 | Oct 5, Oct 6, Oct 7 | Oct 5, Oct 6, Oct 7 | same |
| pinned `2026-10-05`…`2026-10-07`, instant bar from
`2026-10-06T04:00Z`, its `left` | axis Oct 4, Oct 5, Oct 6 (red before
the bars) | 43.75% (21:00 on the 5th) | 58.333…% (the UTC hour) | 75%
(12:00 on the 6th) |
| pinned start `2026-10-06`, pinned end `2026-10-06T03:00Z` | draws |
refused as inverted (the end is 20:00 on the 5th here) | draws | draws,
axis Oct 6 |
| empty plan at `2026-10-05T20:00Z`, day scale | Oct 4 | Oct 5 | Oct 5 |
Oct 6 |
| bar starting `2026-02-30` (suite zone, UTC) | no refusal: the gate let
it through | refused, naming `items[0].items[0].startDate` | | |

Formula functions:

| call | base | head, every zone (UTC, LA, Shanghai) |
|:--|:--|:--|
| `DATEADD('2026-01-31', 1, 'month')` | `2026-03-03T00:00:00.000Z` in
every zone | `2026-02-28` |
| `DATEADD('2026-03-31', -1, 'months')` | `2026-03-03T00:00:00.000Z`
(UTC, Shanghai), `2026-03-03T01:00:00.000Z` (LA) | `2026-02-28` |
| `DATEADD('2024-02-29', 1, 'year')` | `2025-03-01T00:00:00.000Z` in
every zone | `2025-02-28` |
| `DATEADD('2026-09-01', 1, 'day')` | `2026-09-02T00:00:00.000Z` |
`2026-09-02` |
| `DATEADD('2026-11-01', 1, 'day')` | LA `2026-11-02T01:00:00.000Z`; UTC
and Shanghai `2026-11-02T00:00:00.000Z` | `2026-11-02` |
| `DATEADD('2026-03-08', 1, 'day')` | LA `2026-03-08T23:00:00.000Z`; UTC
and Shanghai `2026-03-09T00:00:00.000Z` | `2026-03-09` |
| `DATEADD('2026-09-01', 1, 'month')` | LA `2026-10-02T00:00:00.000Z`;
UTC and Shanghai `2026-10-01T00:00:00.000Z` | `2026-10-01` |
| `DATEADD('2026-01-31T12:00:00.000Z', 1, 'month')` (instant) |
`2026-03-03T12:00:00.000Z` in every zone | same |
| `DATEADD('2026-10-31T12:00:00.000Z', 1, 'day')` (instant) | LA
`2026-11-01T13:00:00.000Z`; UTC and Shanghai `2026-11-01T12:00:00.000Z`
| same |
| `DATEADD('2026-09-01', 3, 'hours')` | `2026-09-01T03:00:00.000Z` |
same |
| `DATEFORMAT('2026-09-01', 'YYYY-MM-DD')` | LA `2026-08-31` |
`2026-09-01` |
| `DATEFORMAT('2026-09-01', 'DD/MM/YY HH:mm:ss')` | LA `31/08/26
17:00:00`, Shanghai `01/09/26 08:00:00` | `01/09/26 00:00:00` |
| `DATEFORMAT('2026-09-01T03:00:00.000Z', 'YYYY-MM-DD HH:mm')` (instant)
| LA `2026-08-31 20:00`, Shanghai `2026-09-01 11:00`, UTC `2026-09-01
03:00` | same |
| `DATEDIFF('2025-12-31', '2026-01-01', 'year')` | LA 0 | 1 |
| `DATEDIFF('2026-08-31', '2026-09-01', 'month')` | LA 0 | 1 |
| `TODAY()` and `DATEFORMAT(NOW(), 'YYYY-MM-DD')` at `2026-09-28T05:30Z`
| LA `2026-09-28` and `2026-09-27` | same |

Server parity, `@objectstack/formula` 17.4.0 as installed here (CEL
`celEngine.evaluate`), the same in LA and Shanghai:
`addMonths('2026-01-31', 1)` is `2026-02-28T00:00Z`,
`addMonths('2024-02-29', 12)` is `2025-02-28T00:00Z`,
`addDays('2026-11-01', 1)` is `2026-11-02T00:00Z`,
`addDays('2026-10-31T12:00:00.000Z', 1)` is `2026-11-01T12:00Z`,
`addMonths('2026-01-31T12:00:00.000Z', 1)` is `2026-02-28T12:00Z`, and
`addDays('2026-02-30', 1)` is `2026-03-03T00:00Z`, which head `DATEADD`
answers as the same instant string.

## Tests and gates

Every heavy run went through objectstack's `os-verify-lock.sh`; each
VERDICT line is quoted. The final head is `5b48d0292`, the
contract-review repair; its readings are in the first bullet below.

- **Repair round, at `5b48d0292`** (contract review `5869002399`, item
1): instants are back on the base path. A probe of base `3b469c8ea`
against head under UTC, Los Angeles and Shanghai reads every instant row
the same on both: `DATEFORMAT('2026-09-01T03:00:00.000Z', 'HH:mm')` is
`03:00` / `20:00` / `11:00`; `DATEADD('2026-10-31T12:00:00.000Z', 1,
'day')` is `2026-11-01T12:00:00.000Z` / `2026-11-01T13:00:00.000Z` /
`2026-11-01T12:00:00.000Z`; `DATEADD('2026-01-31T12:00:00.000Z', 1,
'month')` is `2026-03-03T12:00:00.000Z` in all three. Ten more rows over
instants, epoch milliseconds, a zone-less date-time and an impossible
day are also all equal, and the date-only rows still move. Under
os-verify-lock: closure build exit 0; `type-check` for core and
plugin-timeline echoes `tsc --noEmit && tsc -p tsconfig.test.json` and
`Done`; `pnpm exec vitest run packages/core/ packages/plugin-timeline/
scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2`
gives `Test Files 220 passed (220)`, `Tests 4037 passed | 58 skipped
(4095)`, VERDICT command-exit 0. Red on base, with the base
`FormulaFunctions.ts` blob `b5e63812fe3f` proven on disk: the formula
pin in the forks child gives `Tests 15 failed | 24 passed (39)`. The
date-only rows are red (the month and year clamp and the output shape in
all three zones, the stored day's `DATEFORMAT` in Los Angeles and
Shanghai, `DATEDIFF` in Los Angeles), and all six instant controls are
green. The driver gives `Tests 2 failed | 18 passed (20)`. Restored:
blob `edb61cdf519c` equals HEAD, `git diff HEAD` empty. Ablation (an
instant's hour read with `getUTCHours`): exactly the Los Angeles and
Shanghai instant-format controls go red, `Tests 2 failed | 37 passed
(39)`; restored. Gates exit 0: `check-changeset-presence`,
`changeset:check`, `check-changeset-overwrite`,
`check:pending-changeset-literals`, `check:changeset-claims`,
`check:control-bytes`, `check:new-line-citations` (0 new citations); NOT
GOVERNED. ESLint on the two changed code files: 0 errors,
`FormulaFunctions.ts` 26 warnings, as on base. The 11 changeset-reading
`scripts/__tests__` suites were not re-run locally (two lock
queue-timeouts); CI's eight test shards, which include `scripts/**`, are
green. CI on `5b48d0292`: 43 check-runs, 40 success, 3 skipped, 0
failed.
- **Red first, base `3b469c8ea` plus the two pins** (comparison
worktree, `OBJECTUI_DATE_ZONE_CHILD=1 pnpm exec vitest run` over the two
pins `--pool=forks`): `Tests 27 failed | 24 passed (51)`, VERDICT
command-exit 1. Every red asserts a head value of the tables above (19
in the formula pin, over its three zones, and 8 in the gantt pin); every
rig, fixture-validity and control case green.
- **Reverse validation after commit, at `2006ddb03`** (the driver
itself): the base blobs of `renderer.tsx` (`445a11147770`) and
`FormulaFunctions.ts` (`b5e63812fe3f`) checked out and proven on disk
(each equal to its base blob, not its head blob), then `pnpm exec vitest
run scripts/__tests__/date-only-zone-pins-10183.test.ts`: `Tests 3
failed | 17 passed (20)`, the two new pin rows and the child-exit row;
the 17 other pins green. Restored by a trap with `git checkout HEAD --`
on absolute paths: each blob equal to HEAD (`e0bc666ef587`,
`717f3bd2cdc7`), `git diff HEAD` empty.
- **Head suites, at `2006ddb03`:** `pnpm exec vitest run
packages/plugin-timeline/ packages/core/
scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2`:
`Test Files 220 passed (220)`, `Tests 4035 passed | 54 skipped (4089)`,
VERDICT command-exit 0. The driver row is green, so every zone case of
every registered pin ran and passed in its forks child.
- **At `8dd4543cc`** (one commit later, which types `readFormulaDate`'s
argument instead of casting it): closure build `pnpm
--workspace-concurrency=2 --filter '@object-ui/plugin-timeline^...'
--filter '@object-ui/core^...' run build` exit 0; `pnpm
--workspace-concurrency=2 --filter @object-ui/plugin-timeline --filter
@object-ui/core run type-check` echoes `tsc --noEmit && tsc -p
tsconfig.test.json` and `Done` for each; `pnpm type-check:scripts` exit
0; `pnpm exec vitest run packages/core/src/evaluator/
scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2`:
`Test Files 18 passed (18)`, `Tests 455 passed | 23 skipped (478)` (the
23 are the formula pin's zone cases, run inside the driver's child).
VERDICT command-exit 0. `--listFiles` on each package's test program
lists its new pin once.
- **Two ablations, at `8dd4543cc`** (objectstack
`scripts/ablation-replace.mjs`, wrap mode, each run through the pin on
the forks pool with the driver's flag; no `dist` involved, the pins
import `../renderer` and `../FormulaFunctions` relatively):
- the extent's min end printed with `toISOString()` again, the parse
left on the shared step (anchor `minDate: toDateInputValue(new
Date(minTimestamp)),`, 1 to 0, blob `e0bc666ef587` to `3265f47d94fa`).
Predicted and measured: exactly the two Shanghai rows that compute the
extent red (the producer and the computed day axis), `Tests 2 failed |
16 passed (18)`; every LA row green. So the east rows are the ones that
catch a parse-only repair.
- months moved with UTC setters but without the clamp (anchor
`addMonthsUtc(date, amount);`, 1 to 0, blob `1695444571e2` to
`a50648d3900b`). Predicted and measured: exactly the month-clamp and
instant-clamp rows red in each of the three zones, `Tests 6 failed | 27
passed (33)`.
- Both restored by the tool: blob equal to HEAD, `git diff HEAD` empty.
- **Schema catalog render, suite zone:** `pnpm exec vitest run
examples/schema-catalog/test/catalog-gallery-render.test.tsx
examples/schema-catalog/test/smoke.test.tsx`: `Test Files 2 passed (2)`,
`Tests 1020 passed (1020)`.
- **Every `scripts/__tests__` gate, at `8dd4543cc`:** `pnpm exec vitest
run scripts/__tests__/ --maxWorkers=2`: `Test Files 177 passed | 2
skipped (179)`, `Tests 5334 passed | 2 skipped (5336)`, VERDICT
command-exit 0.
- **Gates at `8dd4543cc`, each exit 0:** `check-changeset-presence` ("4
source file(s) of 2 released package(s) changed, and this change
declares 1 changeset(s)"), `changeset:check` (fixed group, no major),
`check-changeset-overwrite`, `check:pending-changeset-literals`,
`check:changeset-claims` ("No pending changeset names a file this change
touches"), `check:control-bytes`, `check:new-line-citations` (`VERDICT
new-cross-file-line-citations: 0 new citation(s)`),
`check:test-path-roots`. A control-byte self-scan over the 7 changed
files: no hits.
- **ESLint, narrowed** to the 5 changed code files (root
`eslint.config.js`, `--no-inline-config --format json`, 5 results): 0
errors. `FormulaFunctions.ts` 26 warnings and `renderer.tsx` 14, each
equal to its base blob via `--stdin`; the two pins and the driver 0. The
config sets no `parserOptions.project` or `projectService`, so the rules
are not type-aware and this diff cannot move an untouched file's
verdict. The repo-wide lint is CI's.
- **Server parity** was read with `@objectstack/formula` 17.4.0 as
installed here, above; the rule was read in objectstack
`packages/formula/src/stdlib.ts` (`addMonthsUtc`, `addDaysUtc`) at
objectstack `origin/main` `50e273fd`.

## Acceptance notes

- **Decisions taken here, for the contract reviewer.**
- The gantt gate now refuses a day its month does not have
(`2026-02-30`), because the gate must read what the extent and the
headers read: the shared step refuses it (objectui#10026), and a gate
that let it through would hand the reduce an Invalid Date. The message
is the existing "not a valid date" key.
- An instant's gantt extent is the VIEWER's day of that instant, where
it was its UTC day, so its bar sits at its local hour on an axis of the
viewer's days, the day its tooltip names.
- An instant in `DATEADD` / `DATEDIFF` / `DATEFORMAT` is unchanged from
the base (contract review `5869002399`, item 1). Only an argument that
is a calendar day, a date-only value naming a day its month has, takes
the UTC calendar. An instant is still moved and read with local setters
and getters in the zone the formula runs in, so `DATEFORMAT` prints its
clock in the viewer's zone: `DATEFORMAT('2026-09-01T03:00:00.000Z',
'HH:mm')` is `20:00` in Los Angeles, `11:00` in Shanghai and `03:00` in
UTC, as on the base. `DATEADD` of an instant by a day follows the
viewer's calendar across a DST change, and a month from January 31st on
an instant still overflows into March. Triage `5861646819` says a
`datetime` keeps its instant, and ruling `5864664470`'s parameter names
only the date-only month overflow; putting instants on the UTC calendar,
or clamping their months, would be a separate decision. So
`DATEFORMAT(NOW(), 'YYYY-MM-DD')` still names the viewer's day, which
can differ from `TODAY()`'s UTC day for part of every day, as on the
base.
- A date-only string naming a day its month does not have is not refused
by the formula functions: the engine rolls it forward as the server's
parse does, and `DATEADD` hands it back as an instant, as before. A
date-time written without an offset is still read in the viewer's zone
by the engine's parse.
- **DST.** Two local midnights across a DST change are 23 or 25 hours
apart, so on the gantt a bar's edge can sit up to one hour's share of
the axis off its column's edge. The pins stay clear of DST dates on the
gantt.
- **Not changed, pre-existing and zone-independent (noted, not filed):**
the gantt's percentage scale spans min to max midnight while the header
row draws one equal-width column per bucket from min to max inclusive,
so on a day axis a bar is placed on a scale one column narrower than the
header row (the computed-axis pin's second bar starts at 50% under three
columns). Measured: the style percentages; the column widths are
inferred from `flex-1`, not measured.
- **Slice 1's owed items stay for a slice 4** (measured, not small):
- The `timeZone` "persisted data stays real instants" prose sits in
`@object-ui/types` (`GanttConfig.timeZone`'s JSDoc), in `plugin-gantt`
(`GanttViewProps.timeZone`'s JSDoc, "Writes still persist real
instants", and `makeTzShift`'s JSDoc), and in the spec's `view.zod.ts`
describe, which the `@object-ui/types` zod mirror reuses and which only
objectstack can edit. That is two more packages here and a cross-repo
half.
- The calendar's DST-day move needs a drag measured across the November
change before any edit.
- The calendar no-schema fallback, the `endDateField` write and the
gantt no-schema path off UTC each need a new zone pin.
- **Bundle.** No new cross-package import: the renderer's import line is
unchanged, and `FormulaFunctions` imports `utils/date-display` inside
core, a module core's barrel already re-exports. `Bundle Analysis` is
left to CI.
- **Governed surface.** `check-governed-queue-guard --test` over the 7
paths: NOT GOVERNED (control: `AGENTS.md` exit 3).

## Serial

- Branch base `3b469c8ea`. `git merge-tree --write-tree` of head
`8dd4543cc` against `main` `06a96e948`: clean (exit 0), and nothing
`main` gained since the base touches these 7 paths.

Implemented by the dev agent for `domain:ui` seat objectstack-ai#1, session
`https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`.

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

_Body amended by the `domain:ui` seat objectstack-ai#1 after contract review
`5869002399` and the repair commit `5b48d0292`; the repair text is the
dev's._

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
… bare apiMethods-card number (objectui#10803, batch 7) (objectstack-ai#10962)

Part of objectstack-ai#10803
Clause-②: no

Dispatched implementation of the `domain:ui` seat objectstack-ai#1 claim (comment
`5867587761`) on objectui#10803, batch 7, session
`https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`. Citations
only: no sentence's claim moves, and every edited pending changeset's
frontmatter is byte-identical. The only runtime text that moves is two
console warnings, one in `@object-ui/app-shell` and one in
`@object-ui/plugin-detail`, which lose their dead pointer and nothing
else (amendment `5860244997`, Q1 = A; `patch` changeset). No test pins
any changed text: the literal-anchor sweep below finds no specific
anchor, so no test file is edited.

This batch carries release `5866922219`'s two lists:
- the **30 `objectstack#N` citations that answer 404**, in the card's
two classes (pending changeset prose and non-test `packages/*/src`);
- the **28 bare `objectstack-ai#3391` lines in 10 files** that mean objectstack's
apiMethods whitelist card, which batch 6 fixed at 2 other sites.

## Why `Part of`, not a closing line

Both lists read **0** after this batch (**Census**). The brief's rule
was a closing line if the card's lists all read 0. They do, but reading
the sentences found **10 more lines in the same two classes that cite a
dead objectstack number written bare**, three numbers in all
(**Acceptance notes** 1). Triage item 3 puts a dead number found later
in these classes on this card, so the card is not finished. Whether it
carries them as a batch 8 or closes is the seat's call.

## Premise, re-measured on `origin/main` `3b469c8ea` (the branch point)

- **Every distinct `objectstack#N` in the two in-scope classes.** 372
numbers (the one objectstack issue URL in these classes names 6227,
which is among them). Each was read once with REST `GET
/repos/objectstack-ai/objectstack/issues/N`:
  - 341 answer 200;
- 1 answers 301: objectstack#14026, transferred to objectui#10102, which
batch 6 re-qualified;
  - **30 answer 404**, exactly the 30 batch 6 listed.
- **The 30, read again.** A second `issues/N` read of each answers 404
(30 of 30), and `pulls/N` answers 404 for all 30. Lit controls in the
same run: objectstack#3391, objectstack-ai#3720 and objectstack-ai#3546 answer 200 as issues, and
`pulls/13267` answers 200.
- **objectstack-ai#3391 and objectstack-ai#3546, both repositories.**
- objectui#3391 is the record-header api-action placeholder card,
unrelated.
- objectstack#3391 is the apiMethods whitelist contract card: "UI 操作按钮与
apiMethods 白名单一致性契约落地". Its body names the effective operation set,
`/me/permissions`, the 405 import refusal and export derived from list,
which is what each of the 28 sentences says.
  - objectui#3546 is the missing-i18n-keys card, unrelated.
- objectstack#3546 is "detail/form 面的 edit/delete 按钮接入服务端 effective
操作集", the inline-edit gate the two paired lines describe.
- **objectstack history.** Read from a full, not shallow, treeless clone
of objectstack `main` (`git rev-parse --is-shallow-repository`: false).
- Every objectstack sha this PR cites is an ancestor of objectstack
`main` (`git merge-base --is-ancestor`, exit 0): the 24 this PR adds to
the tree, the 3 its edited sentences already cited (`c459da6bc`,
`89448a52b`, `9bd4344e4`), and the 7 this body names besides.
  - `git rev-parse --short=9` returns the same 9 characters for each.
- Control legs in the same clone: the head of the open PR
objectstack#20421 (`a22b90fc0`) answers exit 1; the known ancestor
`51789064` answers exit 0.
- **The one objectui sha.** `7a197e7c5` is an ancestor of the branch
point, exit 0. Control legs: the head of PR objectui#10945 answers exit
1, and `5f789538d` answers exit 0. This checkout is not shallow.
- **A cross-check, not the method.** objectstack's own sweep of dead
tracker citations in its tree (objectstack#19123's landing `66e266c93`,
its stages `21ab41041`, `5cf58eb16` and `0d7ed5a37`, and `f415bcf18`)
anchored eight of these numbers in its own files. For each of the eight
(objectstack-ai#5970, objectstack-ai#6483, objectstack-ai#9934, objectstack-ai#10485, objectstack-ai#11330, objectstack-ai#11846, #12868 and #17147) it
chose the same commit this PR cites.
- Every edited changeset is pending: it is present in `.changeset/` on
`main`.

## Census (the enumeration pin for this batch)

The 30 numbers (REF = a commit or tree):

```
git grep -nE 'objectstack#(5970|5976|6038|6124|6281|6331|6450|6483|6515|9933|9934|10354|10485|10695|11330|11507|11513|11658|11703|11753|11846|12009|12868|13117|13670|16126|17147|17762|17987|18012)([^0-9]|$)' REF -- '.changeset/*.md' 'packages/*/src/**' ':!**/__tests__/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/test/**' ':!**/tests/**' | wc -l
```

The bare `objectstack-ai#3391`, with the same pathspec:

```
git grep -nE '(^|[^0-9A-Za-z_#/])objectstack-ai#3391([^0-9]|$)' REF -- '.changeset/*.md' 'packages/*/src/**' ':!**/__tests__/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/test/**' ':!**/tests/**' | wc -l
```

| REF | the 30 | bare `objectstack-ai#3391` |
|:--|:--|:--|
| `3b469c8ea` (branch point) | **82**: 26 changeset lines in 24 files,
56 src lines in 39 files | **28** in 10 files |
| `c292a6400` (this head) | **0** | **0** |
| this head merged with `main` `5c94589f0` (`git merge-tree
--write-tree`, clean, tree `bba27eece`) | **0** | **0** |
| `5c94589f0` (`main` alone) | 82 | 28 |

- Lit controls on the same instruments at this head: live
objectstack#10856 reads 4 lines (4 at the branch point);
`objectstack#3391` reads 32 lines, against 3 at the branch point (28
re-qualified here, and this PR's sweep changeset names it once).
- **Out of scope, as it stands** (whole tree at this head, unfiltered):
- the 30 numbers: 84 test lines in 47 files, 1 scripts line, 2 lines in
2 `apps/console` files, 2 package READMEs (`auth`, `react`), 11 lines of
published `CHANGELOG.md` history in 8 files; 0 `.github`, 0 governed, 0
`content/docs`;
- bare `objectstack-ai#3391`: 12 test lines in 9 files and 23 `CHANGELOG.md` lines in
8 files.

## Citation form

- **An objectstack commit** is written the way batch 6 wrote its
stand-ins: objectstack and the 9-character backticked sha of the commit
on objectstack `main` that landed the change the sentence rests on.
- **A dead card beside its own dead pull request** collapses to that
pull request's squash commit (objectstack-ai#5970 with PR objectstack-ai#6450, objectstack-ai#10485 with PR
objectstack-ai#10695).
- **A dead number beside the live landing it already names** is dropped:
objectstack-ai#11846 beside PR objectstack#12718, #16126 beside PR objectstack#16920,
#12868 beside objectstack `c459da6bc`, and objectstack-ai#12009 beside objectstack
`89448a52b` (**Special cases** 3).
- **A ruling the dead card carried** is cited by its date, with the
commit that executed or recorded it, as batches 3 to 6 did for objectui
rulings.
- **One sentence cites this repository's commit**, `7a197e7c5`, because
the change it names landed here (objectstack-ai#6331).
- **Runtime text carries no sha.** In the two console warnings the dead
pointer is dropped (**Special cases** 8).
- **The bare `objectstack-ai#3391`** becomes `objectstack#3391`, and on the two lines
that write `objectstack-ai#3391/objectstack-ai#3546`, `objectstack#3391/objectstack#3546`.

## Mapping, the 30 numbers

Lines / files are the branch-point census for that number (a line naming
two of them counts under both).

| dead number | resolution | what that commit carries | lines / files |
|:--|:--|:--|:--|
| objectstack-ai#5970 | objectstack `97e7e3caa` | "unify ActionSchema.visible/disabled
on one condition shape (objectstack-ai#6450)", body "(objectstack-ai#5970)": `visible` gains the
boolean arm | 2 / 1 |
| objectstack-ai#6450 (PR) | the same `97e7e3caa`, its squash | as above; the card /
PR pair collapses | 2 / 1 |
| objectstack-ai#5976 (PR) | objectstack `795b6e1aa`, its squash | "5 值子集改名
`HttpMethodSubset`" | 1 / 1 |
| objectstack-ai#6038 | objectstack `7618ee814` | "key a container's default `list`
`_views` name by the runtime identity": leg 2 of 3 of the
objectstack#5164 ruling, the `packages/lint` half | 1 / 1 |
| objectstack-ai#6124 (PR) | objectstack `b3c1f3cd5`, its squash | "key `_views`
translations by the runtime view identity"; "The extractor now ASKS the
composer for the key" | 1 / 1 |
| objectstack-ai#6281 (PR) | objectstack `85ec26d28`, its squash, 2026-08-07 | "SDUI
props — enforce or remove (objectstack-ai#5775) (objectstack-ai#6281)": the shared
`PageContainerProps`, whose single key is `children`, for `page:section`
/ `page:footer` / `page:sidebar`, which were `EmptyProps` | 1 / 1 |
| objectstack-ai#6331 | objectui `7a197e7c5` | this repository's "SchemaForm reads the
canonical `visibleWhen`, reviving every metadata-form predicate
(objectstack#6331)" | 1 / 1 |
| objectstack-ai#6483 | objectstack `ee58392e1` | "ADR-0005 白名单强制 … (objectstack-ai#6483)"; its diff
carries the sentence the comment quotes, "Runtime-created sets … ride
`allowRuntimeCreate` (still `true`) and keep working" | 1 / 1 |
| objectstack-ai#6515 (PR) | objectstack `2fdb36eb9`, its squash | "SpecifierSchema
gains a closed `valueDomain` enum": "`bcp47_locale` is deliberately not
in the vocabulary", because `localization.locale`'s options ARE the
shipped catalogs | 1 / 1 |
| objectstack-ai#9933 | objectstack `d5552ca13` | "admit columnState as an explicitly
runtime-only view-overlay key" (subject ending "(objectstack-ai#9996)"; "(objectstack-ai#9933)" is
on the message's first body line), on the overlay faces including
`viewItemWireFields` | 3 / 3 |
| objectstack-ai#9934 | objectstack `79c46da90` | "producer-side user-facing marking
for hook refusal messages — userMessage channel (objectstack-ai#9934)":
`ApiErrorSchema.userMessage`, the contract half of the objectui#5210
split | 10 / 9 |
| objectstack-ai#10354 (PR) | objectstack `9e04c3e35`, its squash | "let the publish
door state the package it is promoting"; its changeset and code comment
carry the key-presence / `no_draft` warning `ResourceEditPage.tsx`
points at | 4 / 3 |
| objectstack-ai#10485 | objectstack `35ad101bc` | "retire the `themes` carrier key
and ThemeSchema (objectstack-ai#10485, ADR-0049) (objectstack-ai#10695)": "Ruled B (退役授权面,
2026-08-21)", "delete ui/theme.zod.ts whole" | 15 / 13 |
| objectstack-ai#10695 (PR) | the same `35ad101bc`, its squash | as above; the card /
PR pair collapses | 6 / 6 |
| objectstack-ai#11330 | objectstack `a9ee98992` | "manifest.runtime trust-tier text
states publish-gate-only enforcement truthfully", the trust-tier half
(**Special cases** 2) | 1 / 1 |
| objectstack-ai#11507 | objectstack `88b9d749a` | "declare sys_activity.type as an
open, author-extensible vocabulary": "Maintainer ruling 2026-08-24,
direction 4" | 13 / 9 |
| objectstack-ai#11513 | objectstack `e170b0ae5` | "lock package-declared permission
sets at the save door; clone to customize", quoting the 2026-08-24
ruling 「同意 第一步(创业阶段,Salesforce 式)」 | 3 / 3 |
| objectstack-ai#11658 | objectstack `1a6a19c31` | "open RecordActivityProps.types to
author-contributed activity kinds"; its message names objectstack-ai#11658 as the card
it settles, and it executes the 2026-08-24 ruling | 1 / 1 |
| objectstack-ai#11703 | objectstack `5cb62d88b` | "make clone_permission_set carry
all five copied facets"; its message names objectstack-ai#11703 as the card it
settles: the silent-grant-loss shape | 1 / 1 |
| objectstack-ai#11753 | "the 2026-08-25 ruling whose spec half is objectstack
`0e4e51b0a`" | `ActionParamSchema.carryOver`, whose changeset reads
"(objectstack-ai#11753 ruling, spec half; objectstack-ai#11992)" and "The maintainer's 2026-08-25
ruling on objectstack-ai#11753" | 2 / 2 |
| objectstack-ai#11846 | objectstack `0c2334f6c`; dropped beside PR objectstack#12718
| "retire preview mode — the RuntimeMode 'preview' value and the whole
PreviewModeConfig block (#12718)" | 3 / 3 |
| objectstack-ai#12009 | dropped beside objectstack `89448a52b` | the card of the
`AUTH_SSO_PROVIDER_SCHEMA` removal, whose landing the line already cites
(**Special cases** 3) | 1 / 1 |
| #12868 | dropped beside objectstack `c459da6bc` | the line already
cites the commit that executed the ruling; objectstack's own `f415bcf18`
anchors #12868 to the same `c459da6bc` | 1 / 1 |
| #13117 (PR) | objectstack `225e7690f`, its squash | "Readiness read
for the Phase-2 members … global:search and global:notifications both
have shipped platform data sources, so per the ruling both STAY
declared" | 1 / 1 |
| #13670 | "maintainer ruling 2026-08-31, option 2, recorded in
objectstack `8c6a7fc0b`" | "The #13670 ruling settled the question:
text's intended evaluation channel is `content` alone" | 1 / 1 |
| #16126 | dropped beside PR objectstack#16920 | PR objectstack#16920
(200) names #16126 in its body as the card it settles; merged 2026-09-08
as `859ded3ec` | 2 / 2 |
| #17147 | objectstack `aaacf1d5c` | "the install-time granted
permission set is REGISTERED at load and refuses nothing — say so, and
pin the measurement (#17147)", the measurement on `9bd4344e4` | 2 / 2 |
| #17762 | objectstack `4342c9923` | "guard three data lookups against
Object.prototype fall-through"; its message names #17762 as a card it
settles, `classifyFilterToken` among the three lookups | 1 / 1 |
| #17987 | objectstack `e233db9db` | "declare element-level `navigation`
on object-kanban / object-calendar …"; its message names #17987 as the
card it settles, and its Downstream note: objectui#8652 waits on it,
unlock criterion a released, installable `@objectstack/spec` (**Special
cases** 1) | 2 / 2 |
| #18012 | objectstack `176b03582` | "`$between` requires two non-blank
endpoints (#18012)": "Ruling executed: decision batch objectstack-ai#146 item 5,
**letter A**" | 3 / 3 |

The 28 `objectstack-ai#3391` lines, all now `objectstack#3391`: `ObjectDataPage.tsx`
(3) and `ObjectView.tsx` (2) in app-shell; `managedBy.ts` (5);
`MePermissionsProvider.tsx` (2), `PermissionContext.ts`,
`PermissionProvider.tsx`; `fieldWriteGate.ts`; `ImportWizard.tsx` (6),
`ObjectGrid.tsx` (4); `ListView.tsx` (3). Each was read: every one names
the server's effective API operation set, `/me/permissions`
`apiOperations`, or the 405 import refusal.

## Special cases (the judgement calls)

1. **#17987, two sentences.**
- `ObjectTree.tsx`: "blocked on objectstack#17987, whose unlock
criterion is a released `@objectstack/spec` carrying the declaration
being installable here" becomes "blocked on objectstack `e233db9db`,
whose unlock criterion …". That commit's Downstream note states the same
criterion.
- `ObjectCalendar.tsx`: "that card is `pm:blocked` on objectstack#17987"
becomes "that card waits on objectstack `e233db9db`". The label word is
not kept, because objectui#8652's label reads `pm:on-hold` today
(measured); "waits on" is the phrase `e233db9db`'s own note uses for
that card.
2. **objectstack-ai#11330.** "it is objectstack#11330's half of the same panel"
becomes "it is the trust-tier half of the same panel, which objectstack
`a9ee98992` settled separately". `aaacf1d5c`'s message calls objectstack-ai#11330 "the
sibling half of this very sentence", ruled the same way on 2026-08-30,
and `a9ee98992` (2026-08-30) is that half's landing.
3. **objectstack-ai#12009 collapses into the sha beside it.** objectui#6910's body and
ruling comment `5534414562` name "objectstack#12009 / PR #13413"
together as the one `AUTH_SSO_PROVIDER_SCHEMA` precedent, a card and its
pull request. Batch 6 replaced PR #13413 with its squash `89448a52b`, so
the card goes the way of batch 3's objectstack-ai#5401 / objectstack-ai#5505 pair.
4. **objectstack-ai#11753, two sites.** The card carried the ruling, and `0e4e51b0a`
is its spec half. Both sites keep "ruling" as the antecedent that
`ActionParamDialog.tsx`'s next paragraph ("The ruling's point …") reads.
5. **objectstack-ai#10354 in `ResourceEditPage.tsx`.** "since objectstack#10354
`doPublish` states" gains a comma, "since objectstack `9e04c3e35`,
`doPublish` states", so two adjacent code spans do not read as one.
6. **objectstack-ai#11507 in the 8137 changeset.** "objectstack#11658 executing the
maintainer's 2026-08-24 ruling on objectstack#11507" becomes
"objectstack `1a6a19c31` executing the maintainer's 2026-08-24 ruling":
the executing commit is named, and the ruling is cited by its date.
7. **Line breaks moved** where the stand-in is longer or shorter:
`ActionRunner.ts` (two sites), `ActionParamDialog.tsx`, `theme.ts`,
`theme.zod.ts` (two sites), `index.zod.ts` and the metadata-admin
`i18n.ts` comment, where "ruling on" became "ruling of 2026-08-24,".
8. **The runtime strings.** Only the listed text moves.

| file | member | before | after |
|:--|:--|:--|:--|
| `app-shell/src/layout/activityItemType.ts` | the `console.warn` in
`warnUnmappedActivityType` | "… `sys_activity.type` is author-extensible
(objectstack#11507, ruled 2026-08-24) and is not validated on write …" |
"… `sys_activity.type` is author-extensible (ruled 2026-08-24) and is
not validated on write …" |
| `plugin-detail/src/renderers/recordActivityFeed.ts` | the `warnOnce`
message in `warnUnknownActivityType` | "… `sys_activity.type` is
author-extensible (objectstack#11507, ruled 2026-08-24) and is not
validated on write …" | "… `sys_activity.type` is author-extensible
(ruled 2026-08-24) and is not validated on write …" |

No test, doc or changeset quotes either message with the pointer: the
census reads 0 in `.changeset/`, and the anchor sweep finds no test
literal that drops.
9. **`objectstack-ai#3391/objectstack-ai#3546`.** On the two lines that pair them (`managedBy.ts`,
`ObjectGrid.tsx`), both halves are qualified, as batch 6 qualified both
halves of "#13337/#13086". The other bare `objectstack-ai#3546` lines are not in this
batch's lists and are left (**Acceptance notes** 2).

## The literal-anchor sweep (both test-pin classes, ruling `5861900779`)

- **Instrument.** Every string, template and regex literal in all 4073
tracked test and script files (106544 distinct literals), read with the
TypeScript scanner.
- **Candidate filter.** A literal is a candidate if it matches the
diff's removed lines with two lines of context, raw or
comment-flattened: 1983.
- **Test.** Does its occurrence count DROP between `3b469c8ea` and
`c292a6400` in any of the 74 changed files, raw or comment-flattened?
136 do.
- **Every one is generic:** digits, punctuation, single words ("object",
"blocked", "locked"), character classes, and two regexes that read no
changed file: `/objectui#\d+|objectstack#\d+/` in
`registry-inputs-spec-parity`, which asserts over its own ledger's
reasons, and the older spelling of the three submitRedirect tests'
ruling matcher, quoted in their own doc comments (the live
`CITES_ITS_RULING` asserts over their own refusal text). None is a
changed phrase, a dead number or a changed warning.

## Held

**By the serial rule: nothing.** Open PRs were mapped at branch time (9
open) and again after the push, before this PR opened (11 open). The
second mapping came after the push, not before it; the same three files
were shared both times.

Three open PRs share a file with this PR:
- _Both PRs below have merged since this PR opened (objectui#10945 as
`06a96e948`, objectui#10908 as `b45d463a9`). The trial merge with
today's `main` is clean, and both censuses read 0 on it (contract review
`5870922323`), so nothing is owed. The two rows are kept as the record
at the time._
- **objectui#10945, `RecordDetailView.tsx`.** The blob at its merge-base
equals the branch point's. Its hunks are the imports and one block far
below; this PR's one changed line in that file is far from both.
- **objectui#10908, `types/src/zod/index.zod.ts`.** Its one insertion is
in the export list, far below this PR's two changed comment lines.
- **objectui#10278, `plugin-grid/src/ObjectGrid.tsx`.** The file drifted
between its merge-base and the branch point, so this PR's four changed
lines were mapped onto its merge-base by a line alignment: the nearest
of its hunks is more than 150 lines from any of them.

Trial merges with this head (`git merge-tree --write-tree`):
- clean for objectui#10952, objectstack-ai#10950, objectstack-ai#10949, objectstack-ai#10947, objectstack-ai#10945, objectstack-ai#10944,
objectstack-ai#10930, objectstack-ai#10908 and objectstack-ai#10777;
- objectui#10278 conflicts in `ObjectGrid.tsx`, `plugin-grid/README.md`
and `content/docs/plugins/plugin-grid.mdx`, and conflicts in the same
three files against `main` alone;
- objectui#5400 (Version Packages) regenerates and is not a hold.

`.changeset/9954-read-rate-banner.md` is held by this seat's
objectui#10913 dispatch (PR objectui#10949) and is untouched here. It
carries none of this batch's numbers.

## Changesets

- `.changeset/10803-dead-citation-sweep-seventh-batch.md`, EMPTY
frontmatter. It covers the comment-only edits in 17 released packages;
no published behaviour changes through them. It points at the second
file for the runtime text.
- `.changeset/10803-seventh-batch-runtime-strings.md`,
`'@object-ui/app-shell': patch` and `'@object-ui/plugin-detail': patch`:
the two warnings lose their pointer. What renders, and when and how
often each warning fires, are unchanged.

## Proof of prose-only (C4), against `3b469c8ea`

- **Source.** Each of the 48 touched `.ts` / `.tsx` files was parsed at
`3b469c8ea` and at this head with TypeScript 6.0.3's `createSourceFile`,
and re-printed by `createPrinter({ removeComments: true })`.
  - 46 of 48 prints are identical.
- `activityItemType.ts` and `recordActivityFeed.ts` are equal once the
one listed substitution each (**Special cases** 8) is applied to the
base print, each matched once.
  - 0 parse diagnostics.
- Lit controls on the same instrument: editing a string literal moves
the print; re-spacing a comment does not.
- **Changesets.** The frontmatter block of every one of the 24 edited
changesets is byte-identical at `3b469c8ea` and this head (24 of 24,
md5). The overwrite gate below agrees.
- **Scope of the diff:** 74 files, +157 / −115: 24 edited and 2 new
changesets, and 48 non-test source files in 17 released packages. No
test file.

## Gates, on this head `c292a6400`

Each line is the gate's own verdict and exit code, captured by
redirect-then-`$?`.

- `node scripts/check-changeset-presence.mjs`, exit 0: "48 source
file(s) of 17 released package(s) changed, and this change declares 2
changeset(s): .changeset/10803-dead-citation-sweep-seventh-batch.md,
.changeset/10803-seventh-batch-runtime-strings.md."
- `pnpm changeset:check`, exit 0: "All workspace packages are in the
changeset fixed group." / "No changeset declares a `major` bump."
- `node scripts/check-changeset-overwrite.mjs` (report-only), exit 0: "2
changeset(s) added, 24 modified, 0 deleted". `declared at base` equals
`declares now` for each of the 24.
- `pnpm check:changeset-claims` (report-only), exit 0:
- "Every one of those 1 address(es) either names the tree it was read
from, or points at a line this change does not move";
- "Every package declared across those 22 body(ies) is either not
negated …";
- the standing notice "87 pending changeset(s) describe a file this
change touches". Read against the diff: a pending changeset quoting a
replaced pointer would itself carry a dead number and sit in the census,
which reads 0.
- `pnpm check:control-bytes`, exit 0: "check-control-bytes: OK (scanned
9229 tracked text file(s); skipped 85 binary)." A `grep -P` control-byte
self-scan of the 74 files finds none.
- `pnpm check:new-line-citations`, exit 0: "VERDICT
new-cross-file-line-citations: 0 new citation(s), enforcement
report-only -> exit 0".
- `pnpm check:pending-changeset-literals`, exit 0: "No test source names
a pending changeset."
- Also run over the touched comments:
- `pnpm check:spec-symbols`, exit 0: "spec member citations: 1421
sources + 184 documentation pages; nothing cites a key its spec symbol
does not declare.";
  - `pnpm check:installed-pin-claims`, exit 0 ("OK");
- `pnpm check:comment-mask-corpus`, exit 0 (1 disagreeing file, within
the ceiling objectui#7882 holds open);
- `node scripts/check-hand-rolled-comment-mask.mjs`, exit 0 ("OK every
carrier is a DEBT entry, and every DEBT entry still carries one.");
- `pnpm check:handler-key-reads`, exit 0 ("every judged read is a
declared member of it").
- The governed-surface predicate over the 74 paths, exit 0: "NOT
GOVERNED — 74 path(s) checked against 5 governed surface(s); none
matched." Lit control `AGENTS.md`: exit 3.

**Tests and type-check**, through the shared verify lock, on
`c292a6400`. Each is `VERDICT command-exit 0`.
- `scripts/__tests__/`, the whole directory, whose whole-tree scanners
read the touched files and changesets: `Test Files 177 passed | 2
skipped (179)`, `Tests 5332 passed | 2 skipped (5334)`. The two skipped
files are the network-escape fixtures that run only as a child.
- `packages/types/`, `core/`, `react/`, `i18n/`, `providers/`,
`permissions/` and `data-objectstack/`, whole packages, in one run:
`Test Files 704 passed (704)`, `Tests 13170 passed | 13 skipped
(13183)`.
- The eight touched plugin packages (calendar, designer, detail, form,
grid, kanban, list, tree): `Test Files 787 passed | 1 skipped (788)`,
`Tests 7521 passed | 27 skipped (7548)`.
- `packages/components/`: `Test Files 324 passed | 1 skipped (325)`,
`Tests 3148 passed | 24 skipped (3172)`.
- `packages/app-shell/`: `Test Files 854 passed | 1 skipped (855)`,
`Tests 8789 passed | 9 skipped (8798)`.
- Type-check: `turbo run build` of the 28-package dependency closure
(`Tasks: 28 successful, 28 total`), then `pnpm
--workspace-concurrency=2` with the 17 package filters `run type-check`:
17 script echoes, 17 `Done`. A first attempt before the build exited 2
on an unbuilt dependency (`Cannot find module '@object-ui/types'`) and
measured nothing.
- No red leg: the sweep found no anchor to move, so there is no pin
whose old copy should fail.

CI on `c292a6400`: 43 check-runs, 40 success, 3 skipped, 0 failed; `Spec
Main Shape Gate` success.

## Acceptance notes

1. **Dead objectstack numbers written bare: 10 more lines in the same
two classes.** A bare number resolves to this repository, where each of
these is a live, unrelated card, so no `objectstack#` census sees them.
- **Measurement.** The bare-number instrument of PRs objectui#10875 /
objectstack-ai#10892 / objectstack-ai#10914 reads 965 distinct numbers at this head. The 916 between
100 and 25000 were each read once as objectstack issues: 877 answer 200
and 39 answer 404. Reading the sentences of those 39, three mean an
objectstack card or pull request (below); the other 36 cite objectui
cards or objectui pull requests.
- **objectstack-ai#9934**, 7 lines in 7 files:
`.changeset/7980-agent-key-envelope-read.md`, and in app-shell
`index.ts`, `apiErrorEnvelope.ts` (2), `PackageFormDialog.tsx`,
`StudioDesignSurface.tsx` and `packages-io.ts`. All mean the
`userMessage` channel, objectstack `79c46da90`.
- **"PR objectstack-ai#6281"**, 2 lines in `containers.tsx`, beside objectstack#5775.
The landing is objectstack `85ec26d28`.
- **"objectstack PR objectstack-ai#8452"**, 1 line in `useRecordCrudVerdicts.ts`. The
landing is objectstack `27358d517` ("add batch recordIds to
security/explain (objectstack-ai#8326) (objectstack-ai#8452)").
- None of them sits in a sentence this PR edits, so they are outside
this batch's lists and left. Carrier: this card, triage item 3.
2. **The rest of the bare `objectstack-ai#3546` population.** Five more comment lines
write objectstack#3546 as a bare `objectstack-ai#3546` (`RecordDetailView.tsx` 2,
`RelatedRecordActionsBridge.tsx`, `record-details.tsx`,
`fieldWriteGate.ts`), and one writes it as `objectui#3546`
(`plugin-detail`'s `index.tsx`). Each names the server's effective API
operation set on a detail or form surface. It is live-but-wrong, not a
404, like the `objectstack-ai#3391` class this batch closed. Carrier: none.
3. **A stale label in a comment.** The `ObjectCalendar.tsx` sentence
said objectui#8652 is `pm:blocked`; that card reads `pm:on-hold` today.
**Special cases** 1 says how the repaired sentence avoids the label.
4. **Filenames are not citations.**
`.changeset/17147-plugin-disclosure-not-enforced.md` carries one of the
30 numbers in its name; it stays, as in PRs objectui#10707, objectstack-ai#10797,
objectstack-ai#10854, objectstack-ai#10869, objectstack-ai#10875, objectstack-ai#10892 and objectstack-ai#10914.

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

_Tests block completed by the `domain:ui` seat objectstack-ai#1 from the dev report
`5870507620` (the suites that finished after this PR opened), and three
figures corrected after contract review `5870922323` (the objectstack-ai#9933 and
objectstack-ai#6515 quotes, and the Held rows); no code claim moved._

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…itText and cancelText (objectui#10935) (objectstack-ai#10986)

Fixes objectstack-ai#10935
Clause-②: yes

`MasterDetailForm` resolves its three `I18nLabel` members, `title`,
`submitText` and `cancelText`, with `pickLocalized` against the active
UI language, the way `ObjectMetricWidget` does (triage comment
5867953691). `MasterDetailFormSchema` types the three as `I18nLabel`, as
`@objectstack/spec`'s `ComponentPropsMap['object-master-detail-form']`
does; the zod arm is not narrowed. Draft, head `c357b233a` (the repair
round; the first round was `dbb004b16`): a dispatched dev's PR (claim
comment 5869374008), so ready and landing belong to the seat.

## What changed

- **`packages/plugin-form/src/MasterDetailForm.tsx`**
- `MasterDetailFormSchema.title`, `.submitText` and `.cancelText` go
from `string` to `I18nLabel` (imported as a type from
`@object-ui/types`).
- `MasterDetailForm` reads `useObjectTranslation().language` once, the
source `ObjectMetricWidget` (and the `MetricWidget` it forwards to)
resolves its `I18nLabel` members against. `title` resolves once and
feeds the built-in edit-save toast and the parent `ObjectForm`'s `title`
(the `string` that `ObjectFormSchema.title` declares). `submitText` and
`cancelText` resolve where the two Buttons read them.
- Defaults: with nothing authored the text is unchanged ('Create' or
'Save', 'Cancel', 'Created' or 'Saved'). The button fallback is now `||`
where it was `??`, so an authored empty string, or a map with no string
entry, shows the English default. An authored empty string used to
render an empty button; a map with no string entry used to throw as a
React child.
- **`packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx`**
(new): the pins, below.
- **`packages/plugin-form/src/MasterDetailForm.test.tsx`**: the existing
host-string `cancelText` test was named "i18n is the host's job" and
commented "the plugin is locale-agnostic". This change makes both false,
so the name and comment are reworded. The assertion is unchanged.
- **`packages/types/src/zod/objectql.zod.ts`**: comment only. The
`ObjectMasterDetailFormBlockSchema` docblock said
`MasterDetailFormSchema` types the three labels "as a plain string",
which this change makes false. It now says `I18nLabel` and names the
type pin.
- **`packages/plugin-form/src/index.tsx`**: the
`object-master-detail-form` inputs `title`, `submitText` and
`cancelText` declare `type: ['string', 'object']`, with descriptions
that teach the per-locale map (repair round, `c357b233a`).
- **`apps/console/src/__tests__/`**: three `MEMBER_PINS` rows in
`registry-inputs-spec-parity.test.ts`, and the new public-door pin
`masterDetailFormI18nLabelManifest.test.ts` (repair round, `c357b233a`).
- **`.changeset/10935-master-detail-i18n-labels.md`**:
`'@object-ui/plugin-form': minor`, with the `Clause-②: yes` line.

## Premise checks, on `origin/main` `328abeb55`

- **The three raw reads** are where the card puts them. They are
`schema.cancelText ?? 'Cancel'` and `submitText` (`schema.submitText ??
…`) as Button children, and the `${schema.title} saved` toast in
`handleSaved`. `title` has one more read on this node: the
`parentSchema` memo forwards it to the parent `ObjectForm`. That forward
now gets the resolved string, which is the same one-line repair.
- **Locale source.** `ObjectMetricWidget` resolves `label` / `title`
with `pickLocalized(…, language)`, where `language` comes from
`useObjectTranslation()`, the UI language. This file already imported
`useDisplayLocale()`, but that is the NUMBER locale: it puts the
tenant's localization before the UI language. So it is not the source
for label text, and it is not used for it.
- **Spec-row census.** The row has exactly three members that are
`I18nLabel`: `title`, `submitText` and `cancelText`. `details`,
`sections` and `fields` are `z.array(z.unknown())`, so no nested label
is typed there. The census came from enumerating the installed spec row
(17.4.0) at runtime, not from reading the source. Nothing else rides
this PR.
- **Published or not.** `src/index.tsx` exports `MasterDetailFormSchema`
from the package entry (`exports['.']` is `dist/index.d.ts`). After the
build, `dist/MasterDetailForm.d.ts` reads `I18nLabel` for all three. So
the type widening is published: `minor`, and `Clause-②: yes` stands.
- **Consumers of the widened type.** `git grep MasterDetailFormSchema`
in `*.ts` / `*.tsx` outside `plugin-form` finds only two comment or
string mentions in `@object-ui/types`, and no code that reads the type
(control: the same grep hits `plugin-form/src/index.tsx`). `ObjectForm`
writes the three members from its own `string` fields, which a widening
keeps compiling.

## Evidence

All at `dbb004b16` unless a line says otherwise. The repair round's
readings at `c357b233a` are in the next bullet. The runs went through
the shared verify lock, on a shared box.

- **Repair round, at `c357b233a`** (contract review `5871765302`): the
public-door pin with `index.tsx` at `dbb004b16` gave `6 failed | 7
passed (13)` (the three arm rows and three map rows); at the head, 13
passed. Registry-reading suites: `Test Files 10 passed (10) / Tests 309
passed (309)`. The cli ratchet and the schema-catalog gallery: `2 passed
/ 601 passed`. `plugin-form` and `apps/console` type-check exit 0. Gates
exit 0, including `check-changeset-presence` and
`check:new-line-citations`. CI: 43 check-runs, 40 success, 3 skipped, 0
failed, `Spec Main Shape Gate` included.

**Pins, through the real `SchemaRenderer` and the registry.** Each node
is authored in the `{ type, properties }` form under an `I18nProvider`
set to `zh`. Every map lists `en` first, so falling back to `en` or to
the first entry fails the row.

- The locale-map documents the rows mount pass `safeValidateSchema`.
- A map `submitText` renders `保存订单` on the Save button.
- A map `cancelText` renders `返回` on the Cancel button. `onCancel` sits
beside the document as a host key: the arm refuses it as a runtime slot,
and the button renders only when it is present.
- A map `title` toasts `采购单 saved` on an edit save.
- Control: plain strings render as authored (`Save PO`, `Discard`, `PO
saved`).
- Control: with nothing authored, the defaults are unchanged (`Create`,
`Cancel`, `Save`, `Saved`).
- Type pin: `assertionLabelMembersAreI18nLabel`, an `Expect`/`Equal`
tuple that holds all three members equal to `I18nLabel | undefined`.

**Red on base, then green (ablation).** `MasterDetailForm.tsx` was
committed first, then replaced on disk by its `328abeb55` blob, under a
trap-restore. The hash matched the base blob and the `pickLocalized`
count went from 5 to 0.

- `vitest run
packages/plugin-form/src/MasterDetailForm.i18nLabels.test.tsx` gave
`Tests 3 failed | 3 passed (6)`. The `submitText` and `cancelText` rows
failed on "Objects are not valid as a React child (found: object with
keys {en, zh-CN})". The `title` row failed on ``expected [ '[object
Object] saved' ] to deeply equal [ '采购单 saved' ]``. The validator row
and the two controls passed.
- `tsc -p tsconfig.test.json` failed on the three `Expect` lines
(`TS2344: Type 'false' does not satisfy the constraint 'true'`).
- Restored: the hash equals the HEAD blob, and `git diff HEAD` is empty.
At HEAD the same file gives `Tests 6 passed (6)`, and with
`MasterDetailForm.test.tsx` `Tests 16 passed (16)`.

**Consumer probe of the built `.d.ts`.** A one-shot file, outside the
repo, imports `MasterDetailFormSchema` through a `paths` entry pointing
at `packages/plugin-form/dist/index.d.ts`. Assigning locale maps to
`title` and `submitText` compiles. With the `@ts-expect-error` dropped
from a `submitText: 42` line, it fails with `TS2322 … not assignable to
type 'string | (Record … ) | undefined'`, which proves the rebuilt
declaration is read and is not `any`.

**Suites, type-check and lint.**

- `vitest run packages/plugin-form/`, run together with a one-off
manifest probe file (since deleted), gave `Test Files 138 passed (138)`,
`Tests 1600 passed | 1 skipped`, at `c4d7c74f6`. That is 137 plugin-form
files plus the probe. The two later commits change test files, a
type-only pin and one comment in `@object-ui/types`. The two changed
plugin-form test files were re-run at `dbb004b16`, as above.
- `vitest run packages/types/` gave `Test Files 268 passed (268)`,
`Tests 5955 passed`.
- `vitest run scripts/` gave `Test Files 177 passed | 2 skipped (179)`,
`Tests 5333 passed | 2 skipped`.
- `pnpm --filter @object-ui/plugin-form type-check` (`tsc --noEmit &&
tsc -p tsconfig.test.json`) exits 0. The test project lists the new pin
file (`--listFilesOnly`).
- `pnpm --filter @object-ui/types type-check` exits 0.
- Before these, the plugin-form dependency closure was built (`turbo run
build --filter='@object-ui/plugin-form^...'`, 11 of 11 tasks, restored
from the turbo cache), and plugin-form itself was built.
- `eslint` over the four touched source files: 0 errors. The new test
file has no warnings.

**Gates** (each exit 0): `check:new-line-citations` (0 new citations),
`check:control-bytes`, `check-changeset-presence`, `changeset:check`,
`check:changeset-claims`, `check:pending-changeset-literals`,
`check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:test-path-roots`,
`check:phantom-deps`, `check:handler-key-reads`, `check:i18n-keys`,
`check:unreferenced-sources`. `check-governed-queue-guard --test` over
the five paths answers NOT GOVERNED.

**Bundle.** No new module enters the graph: `@object-ui/i18n` was
already imported by this file, and only `pickLocalized` and
`useObjectTranslation` join the named imports. A single-file esbuild
minify of `MasterDetailForm.tsx`, base against head, gives +95 bytes
minified and +50 bytes gzip. That is a proxy for the plugin-form chunk,
not the console's eager-closure gate: see NOT MEASURED.

**Bundle, repair round (from delta review `5872990965`).**
`apps/console`'s `register-plugins.ts` imports `@object-ui/plugin-form`
eagerly, so the three new input descriptions ship in the eager closure.
The budget comment on `c357b233a` reads **3104.4 KB** against the 3104.5
KB ceiling (PASS), against 3103.9 KB on `dbb004b16` and on every other
PR built after objectui#10992. So this round costs about +0.5 KB, and
landing it leaves about 0.1 KB of eager headroom on `main`. The
exhausted headroom is carried by objectui#10996.

## Acceptance notes

- **NOT MEASURED: `check:eager-closure` / Bundle Analysis.** Reason: it
needs a full console build. The proxy delta is above.
- **NOT MEASURED: `check:readme-exports`.** Reason: it refuses to judge
while 24 packages are unbuilt ("population collapsed"). This PR touches
no README and no export name.
- **The registry `inputs` for these three keys now declare `type:
['string', 'object']`** (`object-master-detail-form` in
`plugin-form/src/index.tsx`), with descriptions that teach the `{ en,
'zh-CN' }` map. This repairs defect 1 of contract review `5871765302`.
Through the production public door
(`ComponentRegistry.getPublicConfigs()` → `manifestFromConfigs` →
`validateTree`), the `'string'`-only declaration reported
`type-mismatch` on a legal locale map, with the message `prop
"submitText" expected a string`, and the same for `title` and
`cancelText`. My first probe missed it because it read
`getAllConfigs()`, which keys the block by its namespace, so the bare
type answered `unknown-component`.
`apps/console/src/__tests__/masterDetailFormI18nLabelManifest.test.ts`
pins the door. With `index.tsx` at `dbb004b16` it gave `6 failed | 7
passed (13)`; at `c357b233a` all 13 pass. A string, and a number that is
still reported, are the controls. The console parity gate's three
`MEMBER_PINS` rows point at `MasterDetailForm.i18nLabels.test.tsx`.
- **The English defaults stay English**, as before: 'Create', 'Save',
'Cancel', 'Created', 'Saved', 'Saving…', and the " saved" suffix after
an authored `title`. This PR only resolves authored labels.
- **The same shape on the sibling `object-form` node** is in the report
as a finding for the seat to judge, and is not touched here. The spec
row takes a locale map for `submitText`, and a probe through the real
`SchemaRenderer` renders `Component "form" failed to render` with
"Objects are not valid as a React child". `ObjectFormSchema` types it
`string`.

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

_Body amended by the `domain:ui` seat objectstack-ai#1 with the dev's repair-round
text, after contract review `5871765302`; no code claim moved beyond
that round's commit._

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…re-qualify objectstack#3546 (objectui#10803, batch 8) (objectstack-ai#11012)

Fixes objectstack-ai#10803
Clause-②: no

Dispatched implementation of the `domain:ui` seat objectstack-ai#1 claim (comment
`5873413663`) on objectui#10803, batch 8, session
`https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`. Citations
only: no sentence's claim moves, the one edited pending changeset keeps
a byte-identical frontmatter, and no runtime string moves, so the only
new changeset is an empty-frontmatter sweep. No test pins any changed
text (**The literal-anchor sweep**), so no test file is edited.

This batch carries release `5872268200`'s two lists (the seat's ruling A
in ACCEPT `5870953708`, which review `5870922323` read the same way):
- the **10 in-class lines that cite a dead objectstack number written
bare**: `objectstack-ai#9934` seven times, "PR `objectstack-ai#6281`" twice, "objectstack PR
`objectstack-ai#8452`" once;
- the **6 live-but-wrong `objectstack-ai#3546` lines** that mean objectstack#3546:
five written bare, one written with this repository's name.

Numbers are written in code spans in this body when they are the dead or
wrong spelling, so GitHub does not link them to the unrelated objectui
items they would resolve to.

## Why `Fixes`

Both lists read **0** after this batch (**Census**), and the closure
census over the card's two classes (pending `.changeset/*.md` prose and
non-test `packages/*/src`) finds nothing else dead:
- **Bare numbers**, batch 7's instrument (printed under **Census**): 965
distinct at the branch point, 961 at this head. The 916 between 100 and
25000 at the branch point were each read once as objectstack issues: 877
answer 200 and 39 answer 404. The 39 were read again as objectstack
issues and as pulls (404 each) and as objectui issues (200 each). Three
of them are this batch's (`objectstack-ai#9934`, `objectstack-ai#6281`, `objectstack-ai#8452`), gone at this head.
The other 36 remain, and each sentence was read: every one cites an
objectui card or pull request, and each answers 200 in objectui with the
subject its sentence names.
- **The instrument's blind spot** (a number written after `/`, `-` or
`.`, as in `objectui#9469/objectstack-ai#8649`): 46 numbers between 100 and 25000
appear only in that form at this head. Read as objectstack issues: 45
answer 200; one answers 404, `objectstack-ai#8649`, which sits in two lines as
`objectui#9469/objectstack-ai#8649` and means objectui#8649 (200).
- **Qualified citations** at this head: every distinct `objectui#N` and
objectui issue or pull URL (2723 numbers) answers 200; every distinct
`objectstack#N` and objectstack issue or pull URL (345 numbers) answers
200, except objectstack#14026, which answers 301 (transferred to
objectui#10102, re-qualified by batch 6).

So nothing dead is left in the card's classes, and nothing in them
resolves to the wrong repository as far as these instruments see.
**Acceptance notes** 1 and 2 record what is outside the classes, and
what could not be measured.

## Premise, re-measured on `origin/main` `665025908` (the branch point)

- **The three dead numbers.** objectstack `issues/N` and `pulls/N`
answer 404 for 9934, 6281 and 8452 (the in-range census read each as an
issue a second time). Lit controls in the same run: objectstack issues
3546, 3391, 5775 and 8326 answer 200; objectstack `pulls/13267` and
`pulls/16920` answer 200.
- **What they resolve to here.** objectui `objectstack-ai#9934` is a CI finding about
the `needs:contract-review` label; objectui PR `objectstack-ai#6281` is an app-shell
Studio grid change; objectui `objectstack-ai#8452` is a `not_contains` operator bug.
None is what its sentence meant.
- **`objectstack-ai#3546`, both repositories.** objectstack#3546 (200) is "detail/form
面的 edit/delete 按钮接入服务端 effective 操作集", the effective operation set on
detail and form surfaces, which each of the six sentences describes.
objectui `objectstack-ai#3546` (200) is the missing-i18n-keys card, unrelated. The 96
`objectui#3546` lines in the locale packs and the "slice seven" line in
`.changeset/3880-es-done-listo.md` are that i18n card, and are
untouched.
- **The landing commits**, each read over REST (`commits/SHA`) and
checked with `compare/SHA...main`, which answers `behind_by 0` for each,
i.e. each is an ancestor of objectstack `main`:
- `79c46da90`, "feat(contract): producer-side user-facing marking for
hook refusal messages — userMessage channel (objectstack-ai#9934) (objectstack-ai#9992)",
2026-08-19: `ApiErrorSchema.userMessage`, and the dispatcher door
(`packages/runtime/src/http-dispatcher.ts`) emits it as a declared
sibling of `code` and `message`;
- `85ec26d28`, "spec: SDUI props — enforce or remove (objectstack-ai#5775) (objectstack-ai#6281)",
2026-08-07: retires `page:card.body` in favour of `children`, with an
ADR-0087 D2 conversion;
- `27358d517`, "feat(spec,rest): add batch recordIds to security/explain
(objectstack-ai#8326) (objectstack-ai#8452)", 2026-08-13.
- These are the landings release `5872268200` names; review `5870922323`
read all three against their messages. Batch 7 already cites `79c46da90`
and `85ec26d28` at other sites in these classes.
- The one edited changeset,
`.changeset/7980-agent-key-envelope-read.md`, is pending: it is present
in `.changeset/` on `main`. This checkout is not shallow.

## Census (the enumeration pin for this batch)

The two lists (REF = a commit or tree):

```
git grep -nP '(?:^|(?<=[^\w#&/.\-]))#(9934|6281|8452|3546)(?![0-9A-Za-z_])' REF -- '.changeset/*.md' 'packages/*/src/**' ':!**/__tests__/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/test/**' ':!**/tests/**' | wc -l
git grep -n 'objectui#3546' REF -- '.changeset/*.md' 'packages/*/src/**' ':!**/__tests__/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/test/**' ':!**/tests/**' ':!packages/i18n/src/locales/**' | wc -l
```

| REF | bare four | `objectui#3546` outside the locale packs |
|:--|:--|:--|
| `665025908` (branch point) | **15**: `objectstack-ai#9934` 7, `objectstack-ai#6281` 2, `objectstack-ai#8452` 1,
`objectstack-ai#3546` 5 | **2**: the `plugin-detail` line and the i18n card's own line
in `3880-es-done-listo.md` |
| `8fe632d96` (this head) | **0** | **1**: the `3880-es-done-listo.md`
line, which is the i18n card and stays |

- Lit controls at this head: `objectstack#3546` reads 10 lines against 3
at the branch point (the 6 re-qualified here, and one in this PR's sweep
changeset); the 96 `objectui#3546` lines in the locale packs are
unchanged.
- The bare-number instrument, batch 7's (the census of **Why `Fixes`**):

```
git grep -hoP '(?:^|(?<=[^\w#&/.\-]))#\d+(?![0-9A-Za-z_])' REF -- '.changeset/*.md' 'packages/*/src/**' ':!**/__tests__/**' ':!**/*.test.*' ':!**/*.spec.*' ':!**/test/**' ':!**/tests/**' | sort -u | wc -l
```

965 at the branch point, 961 at this head: exactly `objectstack-ai#3546`, `objectstack-ai#6281`,
`objectstack-ai#8452` and `objectstack-ai#9934` are gone, and nothing is new. The blind-spot reading
replaces the lookbehind class with `[^\w#&]`.

## Citation form

- **An objectstack commit** is written as batches 6 and 7 wrote their
stand-ins: objectstack and the 9-character backticked sha of the commit
on objectstack `main` that landed the change the sentence rests on.
- **A bare `objectstack-ai#3546`** becomes `objectstack#3546`, including inside the
`[objectstack-ai#3546]` tags that open four comments; the one written with this
repository's name is re-qualified the same way.

## Mapping

| site | before | after |
|:--|:--|:--|
| `.changeset/7980-agent-key-envelope-read.md` | "(the `objectstack-ai#9934` channel,
whose presence *is* the marking)" | "(the objectstack `79c46da90`
channel, …)" |
| app-shell `index.ts`, the note above the `readEnvelopeFailureText`
export | "(the `objectstack-ai#9934` channel, which rides through the 5xx prose
withhold untouched)" | "(the objectstack `79c46da90` channel, …)" |
| app-shell `utils/apiErrorEnvelope.ts`, the `userMessage` bullet | "the
END USER (`objectstack-ai#9934`)" | "the END USER (objectstack `79c46da90`)" |
| app-shell `utils/apiErrorEnvelope.ts`, the `errorFromThrown` bullet |
"… has emitted the channel since `objectstack-ai#9934`" (a quotation) | "… has emitted
the channel since [objectstack `79c46da90`]" (**Special cases** 1) |
| app-shell `PackageFormDialog.tsx`, `readEnvelopeUserMessage`'s
docblock | "(`error.userMessage`, `objectstack-ai#9934`)" | "(`error.userMessage`,
objectstack `79c46da90`)" |
| app-shell `StudioDesignSurface.tsx`, the package-switcher failure read
| "the producer's `objectstack-ai#9934` channel" | "the producer's channel
(objectstack `79c46da90`)" |
| app-shell `packages-io.ts`, `duplicatePackage`'s failure arm | "has
emitted since `objectstack-ai#9934`" | "has emitted since objectstack `79c46da90`" |
| components `containers.tsx`, the `PageCardRenderer` `body` read |
"objectstack#5775 (PR `objectstack-ai#6281`, ADR-0087 D2)" | "objectstack#5775
(objectstack `85ec26d28`, ADR-0087 D2)" |
| components `containers.tsx`, the `card` registration's `children`
input | "objectstack#5775 (PR `objectstack-ai#6281`)" | "objectstack#5775 (objectstack
`85ec26d28`)" |
| plugin-grid `hooks/useRecordCrudVerdicts.ts` | "shipped in objectstack
PR `objectstack-ai#8452`" | "shipped in objectstack `27358d517`" |
| app-shell `RecordDetailView.tsx`, two comments | "[`objectstack-ai#3546`] …" |
"[objectstack#3546] …" |
| app-shell `RelatedRecordActionsBridge.tsx` | "[`objectstack-ai#3546`] Intersect the
child object's …" | "[objectstack#3546] …" |
| plugin-detail `renderers/record-details.tsx` | "[`objectstack-ai#3546`] Also AND
inline-editability …" | "[objectstack#3546] …" |
| plugin-form `fieldWriteGate.ts` | "`objectstack-ai#3546` intersects that with the
server's effective API operation set" | "objectstack#3546 intersects …"
|
| plugin-detail `index.tsx`, the `inlineEdit` input comment |
"(`objectui#3546`)" | "(objectstack#3546)" |

Each `objectstack-ai#9934` sentence is about the `userMessage` channel that
`79c46da90` added; each `objectstack-ai#6281` sentence is about the `page:card` `body`
retirement that `85ec26d28` carries; the `objectstack-ai#8452` sentence is about the
batch `recordIds` form that `27358d517` shipped.

## Special cases (the judgement calls)

1. **A quotation.** The `apiErrorEnvelope.ts` bullet quotes the note
beside `sendThrownError` in objectstack's
`packages/rest/src/package-routes.ts`, verbatim. That note still reads
"since objectstack-ai#9934" on objectstack `main` (`e956924` at the time of this PR);
there the number means objectstack's card, which answers 404, and here
it resolves to the unrelated objectui item. The quote keeps its words
and takes the commit in square brackets, the editorial mark for a
substituted word. The note's claim is the one `79c46da90` carries: its
`http-dispatcher.ts` hunk adds the `userMessage` spread to the
dispatcher door's `extra`.
2. **Line breaks moved** only where the stand-in made a line markedly
longer: app-shell `index.ts`, `StudioDesignSurface.tsx` and
`packages-io.ts`. Elsewhere the one line just grows, as batch 7's
`objectstack#3391/objectstack#3546` lines did.
3. **Wording kept.** "the `objectstack-ai#9934` channel" becomes "the objectstack
`79c46da90` channel" in two places, and "the producer's channel
(objectstack `79c46da90`)" in `StudioDesignSurface.tsx`, where "the
producer's objectstack … channel" would read as if the producer were
objectstack. No other word moves.

## The literal-anchor sweep (both test-pin classes, ruling `5861900779`)

- **Instrument.** Every string, template and regex literal in the 4108
tracked test and script files (104609 distinct strings, 2339 regexes),
read with the TypeScript scanner.
- **Test.** Does its occurrence count DROP between `665025908` and this
head in any of the 14 changed files, raw or comment-flattened?
- **Result.** 30 distinct literals drop, every one generic: digits,
punctuation, whitespace and character classes (`#\d+`, `\d{3}`, `[:#]`,
`\s+`), the bare words `objectui` and `objectui#`, `/objectui#\d+/`, and
comma-and-line-break fragments. None is a changed phrase, a dead number
or a `objectstack-ai#3546` tag. A search of tests for the changed spellings finds them
only in test doc comments (listed in **Acceptance notes** 1), which no
assertion reads.

## Held

**By the serial rule: nothing.** Open PRs were mapped at the branch
point before the first edit (7 open) and again before the push (7 open;
objectui#11003 and objectstack-ai#10997 have since merged, and objectui#11010 and
objectstack-ai#11006 have opened).

- **objectui#10990** and **objectui#11010** share
`packages/app-shell/src/index.ts`. objectui#10990's hunk is in the
side-effect import list, near the Cloud widgets; objectui#11010's is one
line in the `views` type export list. This PR's two changed lines are in
the note above the `readEnvelopeFailureText` export, more than ninety
lines above both hunks.
- **objectui#5400** (Version Packages) carries
`.changeset/7980-agent-key-envelope-read.md`, which it consumes at
release; it regenerates and is not a hold. If it lands first, that
file's one changed line has already published, and this PR's hunk there
conflicts with the deletion.
- Trial merges with this head (`git merge-tree --write-tree`): clean for
objectui#11010, objectstack-ai#11006, objectstack-ai#10990, objectstack-ai#10930 and objectstack-ai#10777. objectui#10278
conflicts in `ObjectGrid.tsx`, `plugin-grid/README.md` and
`content/docs/plugins/plugin-grid.mdx`, and conflicts in the same three
files against the branch point alone; none is in this diff.

## Changesets

- `.changeset/10803-dead-citation-sweep-eighth-batch.md`, EMPTY
frontmatter. It covers the comment-only edits in 5 released packages
(`app-shell`, `components`, `plugin-detail`, `plugin-form`,
`plugin-grid`) and the one edited pending changeset; no published
behaviour changes through them. It writes no bare number itself.
- No runtime string moves, so there is no `patch` changeset (amendment
`5860244997` Q1 = A applies to none of these lines: the `plugin-detail`
`index.tsx` line is a comment beside the `inlineEdit` input, not its
`description` string).

## Proof of prose-only (C4), against `665025908`

- **Source.** Each of the 12 touched `.ts` / `.tsx` files was parsed at
`665025908` and at this head with TypeScript 6.0.3's `createSourceFile`,
and re-printed by `createPrinter({ removeComments: true })`: 12 of 12
prints are identical, with 0 parse diagnostics. Lit controls on the same
instrument: editing a string literal moves the print; re-spacing a
comment does not.
- **Changesets.** The frontmatter block of
`.changeset/7980-agent-key-envelope-read.md` is byte-identical at
`665025908` and this head (md5); the overwrite gate below agrees.
- **Scope of the diff:** 14 files, +39 / −20: 1 edited and 1 new
changeset, and 12 non-test source files in 5 released packages. No test
file.

## Gates, on this head `8fe632d96`

Each line is the gate's own verdict and exit code, captured by
redirect-then-`$?`.

- `node scripts/check-changeset-presence.mjs`, exit 0: "12 source
file(s) of 5 released package(s) changed, and this change declares 1
changeset(s): .changeset/10803-dead-citation-sweep-eighth-batch.md."
- `pnpm changeset:check`, exit 0: "All workspace packages are in the
changeset fixed group." / "No changeset declares a `major` bump."
- `node scripts/check-changeset-overwrite.mjs` (report-only), exit 0: "1
changeset(s) added, 1 modified, 0 deleted"; for the modified one,
`declared at base` equals `declares now` ("@object-ui/app-shell: minor,
@object-ui/console: patch").
- `pnpm check:changeset-claims` (report-only), exit 0: "Every package
declared across those 1 body(ies) is either not negated …", and the
standing notice "15 pending changeset(s) describe a file this change
touches". Read against the diff: a pending changeset quoting a replaced
spelling would carry a bare number the census sees, and the census reads
0 in `.changeset/`.
- `pnpm check:control-bytes`, exit 0: "check-control-bytes: OK (scanned
9291 tracked text file(s); skipped 85 binary)." A `grep -P` control-byte
self-scan of the 14 files finds none.
- `pnpm check:new-line-citations`, exit 0: "VERDICT
new-cross-file-line-citations: 0 new citation(s), enforcement
report-only -> exit 0".
- `pnpm check:pending-changeset-literals`, exit 0: "No test source names
a pending changeset."
- Also run over the touched comments, on the tree this head carries:
`pnpm check:spec-symbols` exit 0 ("spec member citations: 1424 sources +
184 documentation pages; nothing cites a key its spec symbol does not
declare."); `pnpm check:installed-pin-claims` exit 0 ("OK"); `pnpm
check:comment-mask-corpus` exit 0 (1 disagreeing file, within the
ceiling objectui#7882 holds open); `node
scripts/check-hand-rolled-comment-mask.mjs` exit 0 ("OK every carrier is
a DEBT entry, and every DEBT entry still carries one."); `pnpm
check:handler-key-reads` exit 0 ("every judged read is a declared member
of its arm").
- The governed-surface predicate over the 14 paths, exit 0: "NOT
GOVERNED — 14 path(s) checked against 5 governed surface(s); none
matched." Lit control `AGENTS.md`: exit 3.

**Tests and type-check**, through the shared verify lock, on
`8fe632d96`.
- `scripts/__tests__/`, the whole directory, whose whole-tree scanners
read the touched files and changesets: `Test Files 177 passed | 2
skipped (179)`, `Tests 5350 passed | 2 skipped (5352)`, `VERDICT
command-exit 0`.
- `packages/components/`, `plugin-detail/`, `plugin-form/` and
`plugin-grid/`, whole packages, in one run: `Test Files 847 passed | 2
skipped (849)`, `Tests 8441 passed | 33 skipped (8474)`, `VERDICT
command-exit 0`; the source files were byte-identical to this head's.
- `packages/app-shell/`, whole package: `Test Files 1 failed | 863
passed | 1 skipped (865)`, `Tests 1 failed | 8835 passed | 9 skipped
(8845)`. The one failure is `AppContent.inaccessibleAppStrand.test.tsx`,
'bounces to the DECLARED landing where the deployment declares one', a
network-escape guard trip (a real socket at
`localhost:3000/api/v1/meta`) under the full suite on a shared box. That
file is not in this diff, and C4 shows no code token moves; re-run alone
three times on this head it reads `Tests 6 passed (6)` each, and the CI
test shards are green on this head.
- Type-check: `turbo run build` of the 28-package dependency closure
(`Tasks: 28 successful, 28 total`), then `pnpm
--workspace-concurrency=2` with the 5 package filters `run type-check`:
5 script echoes, 5 `Done`, `VERDICT command-exit 0`.
- No red leg: the sweep found no anchor to move, so there is no pin
whose old copy should fail.

CI on `8fe632d96`: 43 check-runs, 39 success, 3 skipped, 1 failure;
`Spec Main Shape Gate` success. The failure is `Bundle Analysis`, step
"Check console performance budget", which fails the same way on `main`
at this PR's branch point `665025908` and at `d6d8fb9d7` and `2049b03df`
after it; this diff moves no code token.

## Acceptance notes

1. **Outside the card's two classes, as it stands** (whole tree at this
head, bare spelling): `objectstack-ai#9934` in 3 test doc-comment lines in 2 files and
in 1 `apps/console` source line (`AgentConnectSection.tsx`, "the objectstack-ai#9934
channel"); `objectstack-ai#6281` in 1 test doc-comment line; `objectstack-ai#8452` in 1 test
doc-comment line; bare `objectstack-ai#3546` in 23 test lines in 14 files and 19
published `CHANGELOG.md` lines in 8 files. `apps/console`, tests and
`CHANGELOG` history are outside the classes this card carries (triage
`5858031244`; release `5861553562`: recorded, not carried).
2. **Not measured: `framework#N` and `cloud#N`.** The two classes also
cite 82 distinct `framework#N` and 43 distinct `cloud#N` numbers.
Neither repository is on this card's lists, and both answer 403 through
this container's proxy, so no reading of them is offered.
3. **Left as the brief allows.** The two test comments that call
objectui#8652 `pm:blocked` (it reads `pm:on-hold`) and batch 7's
sweep-changeset phrase "declared separately below" are in files this PR
does not touch, and stay.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…jectui#10998) (objectstack-ai#11029)

Fixes objectstack-ai#10998
Clause-②: no

A console page's localization of two server codes it already receives;
no published type, schema, spec row or documented contract moves.

## What changed

- **`apps/console` `RegisterPage`**: the error-code map it passes to
`RegisterForm` gains `SELF_REGISTRATION_CLOSED` →
`auth.register.errors.selfRegistrationClosed` and
`EMAIL_DOMAIN_NOT_ALLOWED` →
`auth.register.errors.emailDomainNotAllowed`. A code the page does not
map still shows the server's own message.
- **`packages/i18n`**: all ten locale packs carry both keys beside
`auth.register.errors.userExists`. No existing value changes.
- **Pin**: `registerRefusalCodes-10998.test.tsx` in the console's auth
tests.
- **Changeset**: `patch` for `@object-ui/console` and `@object-ui/i18n`.

No server change, no new error code, no auth-flow change.

## Premise, checked against `origin/main` before editing

- **Fall-through.** `RegisterForm`'s submit handler sets `(code &&
errorMessages?.[code]) || authError.message`, so an unmapped code
renders the server's `message`. `createAuthClient().signUp` keeps the
server's `code` on the thrown error (`toAuthError`).
- **The codes and the door.** objectstack `origin/main` (`fb386074`):
`packages/plugins/plugin-auth/src/audience-posture.ts` exports
`SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED`, and
`decideAudienceAdmission` returns them for the `invite_only` and
`email_domain` postures. The `/sign-up/email` before-hook in
`AuthManager` (`auth-manager.ts`) throws `APIError('FORBIDDEN', { code,
message })` from that verdict, so `POST /api/v1/auth/sign-up/email`
answers 403 with either code. Both are rows of the spec's error-code
ledger (`error-code-ledger.zod.ts`).
- **Where the strings live.** `auth.register.errors.userExists` is
defined in all ten packs (en, zh, ja, ko, de, fr, es, pt, ru, ar), and
`all-locales-key-parity` requires every pack to define every `en` key.
So both new keys are in all ten.

## Wording

- zh: 「本环境未开放自助注册,请联系管理员获取邀请。」 and 「该邮箱的域名不允许在此注册,请使用组织邮箱,或联系管理员获取邀请。」.
The punctuation is full-width, as in the rest of the zh pack.
- en: "Self-registration is not open on this environment. Ask an
administrator for an invitation." and "This email's domain is not
allowed to register here. Use your organization email, or ask an
administrator for an invitation."
- The other eight packs are translated, not copied from English, because
their neighbouring `userExists` is translated in every pack. "Ask an
administrator" follows each pack's existing wording for that phrase.
- No internal terms: no posture name and no code name reaches the user.

## Verification (at `7a33b2363`, the head of this branch)

- **Pin.** `pnpm exec vitest run
apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx`:
`Tests 5 passed (5)`. It renders the shipped page, the real
`RegisterForm`, a real `AuthProvider` over a real `createAuthClient`,
and a real `I18nProvider`. Only `fetch` is stubbed, and it answers
`/sign-up/email` with `403 { code, message }`. Under zh, each code shows
the zh pack's text and not the server's message. An unmapped code still
shows the raw server message. Under en, the en pack text shows. The
expected text is read from the packs, so the pin holds the mapping and
not the wording.
- **Red leg**, against `main`'s map, with the fix committed at
`8e62629df`. `RegisterPage.tsx` was replaced by its merge-base blob:
`616839c0` on disk, equal to the base blob, with 0 occurrences of
`SELF_REGISTRATION_CLOSED`. Result: `Tests 3 failed | 2 passed (5)`. The
zh case received `"Self-registration is closed on this environment
(audience posture invite_only). Ask an administrator for an
invitation."`, which is the card's symptom. It was restored with `git
checkout HEAD --`. Blob `91230581` equals the HEAD blob, and `git diff
HEAD` is empty.
- **Suites.** `pnpm exec vitest run apps/console/ packages/i18n/`: `Test
Files 202 passed (202)`, `Tests 2644 passed | 13 skipped (2657)`. That
is all 129 console test files and all 73 i18n test files, including
`all-locales-key-parity` and `untranslated-identity-4376`.
- **Type-check.** `pnpm --filter @object-ui/i18n run type-check` exited
0. `pnpm --filter @object-ui/console run type-check` exited 0, after
building the console's dependency closure with turbo
`--filter=@object-ui/console^...`. Both touched console files are in its
`tsc` program (`--listFiles`).
- **ESLint** on the 12 touched source files (`--format json`,
`--no-inline-config`): 12 linted, 0 errors, 2 warnings. Both warnings
are `react-hooks/set-state-in-effect` on the page's two pre-existing
effects, on lines this diff does not touch. The root `eslint.config.js`
is the only config and is not type-aware, so this diff cannot move a
verdict on an untouched file. The repo-wide `pnpm lint` is CI's.
- **Gates**, all exit 0: `check:i18n-keys`, `check:i18n-drift` ("2
key(s) added", 0 en values changed), `check:i18n-dead-keys`,
`check:i18n-designer-parity`, `check:control-bytes`,
`check:new-line-citations` ("0 new citation(s)"),
`check-changeset-presence`, `changeset:check`,
`check-changeset-overwrite`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check-lint-coverage`,
`check-test-path-roots`, `check-vi-mock-inherit`,
`check-vi-mock-override-shape`, `check-vi-mock-specifiers`,
`check-type-check-coverage`, `check:phantom-deps`, `check:self-import`,
`check:unreferenced-sources`, `check:esm-specifiers`,
`check:handler-key-reads`.
- **Eager closure.** No import is added to the console. `en` is the
eager catalogue and grows by two strings. After `pnpm --filter
@object-ui/console run build`, `check:eager-closure` passes with
`i18n-locale-en 40.9 KB / 48.8 KB ceiling` and aggregate headroom 44.2
KB. `check:eager-locale-catalogues` passes: exactly one catalogue is
eager.
- **`scripts/__tests__`.** The 65 files whose source names a touched
path (by `git grep`) passed: `Tests 2782 passed`. The other 114 tracked
test files there were also run: `112 passed | 2 skipped` files.
- **`check:changeset-claims`** reported one pending changeset naming
`en.ts`. That is the `console.nav` paragraph of
`6661-app-launcher-nav-menu-renderers.md`. This diff does not touch
`console.nav`, and the paragraph stays true.

## Acceptance notes

- `@object-ui/app-shell`'s `DefaultRegisterPage`, which
`examples/console-starter` mounts at `/register`, passes no
`errorMessages` at all. On that page every sign-up refusal, the
user-exists codes included, shows the server's message. It is outside
this card's claimed file surface and is not touched here. Carrier: none.
- The same `/sign-up/email` door also answers `AUTH_CONFIG_ERROR` when
the audience configuration itself is unusable. That message is for the
operator and names the setting. It is left unmapped on purpose (the card
names two codes) and shows raw, as any unmapped code does.

Session: `session_01DuWo5bdP9SdVebamn99GGk` (dispatched `os-dev`,
`domain:ui` seat objectstack-ai#1).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…through the i18n catalogue (objectui#11039) (objectstack-ai#11066)

Fixes objectstack-ai#11039
Clause-②: no

## What

The form family's own feedback chrome now resolves through the i18n
catalogue instead of English literals: the default success toast, the
note it carries when a declared `navigateOnSuccess` was refused, the
thank-you heading, the loading line, the load-failure heading, and the
default submit and cancel labels. plugin-form goes through
`createSafeTranslation`: a new shared hook in `formChrome.ts` (read by
`ObjectForm`'s default arm, `TabbedForm`, `SplitForm` and
`MasterDetailForm`) and the tables `WizardForm`, `DrawerForm` and
`ModalForm` already had. `pnpm check:i18n-keys` holds every table to the
`en` pack, so a provider-less host still renders English. The console's
`FormPage` uses the console's own hook, `useObjectTranslation`, with the
same pack keys; `main.tsx` mounts the `I18nProvider` above every console
route.

An authored value still wins, and it is untouched: each default sits
behind the same `||` / `??` as before, after objectui#11050's resolution
of the seven `I18nLabel` members (a locale map, or a plain string). No
schema key is added, renamed or retyped.

## The enumeration (on `origin/main` at `6466a09df`, after PR 11050)

| English default, as it was | Where | Author key that overrides it |
Key now |
|---|---|---|---|
| `Created` / `Saved` success toast | default arm (three toasts, plus
the confirmation after a refused `redirect`), `WizardForm` (the same
four) | `successMessage`; a `thank-you` behaviour's `message` |
`form.created` / `form.saved` (new) |
| `Created`, `Saved`, and "TITLE saved" | `MasterDetailForm`'s built-in
save toast (shown only without a host `onSuccess`) | `title` names the
record; no message key | `form.created`, `form.saved`, `form.savedNamed`
(new, `{{title}}`) |
| the refused-navigation note | default arm, `WizardForm` | none: chrome
| `form.navigateRefused` (new) |
| `Thanks!` | default arm, `WizardForm` | a `thank-you` behaviour's
`title` | `publicForm.thankYouTitle` (existing) |
| `Loading form...` | default arm, `TabbedForm`, `SplitForm`,
`WizardForm`, `DrawerForm` | none | `publicForm.loading` (existing) |
| `Error loading form` | those five and `ModalForm` | none |
`form.errorLoading` (new) |
| `Create` / `Update` submit label | default arm (both render branches),
`TabbedForm`, `SplitForm`, `DrawerForm`, `ModalForm` | `submitText` |
`form.create` / `form.update` (existing) |
| `Cancel` | `DrawerForm`, `ModalForm` | `cancelText` | `common.cancel`
(existing) |
| `Save` / `Create` / `Cancel` | `MasterDetailForm` | `submitText` /
`cancelText` | `common.save` / `form.create` / `common.cancel`
(existing) |
| `Loading…` | `FormPage` | none | `common.loading` (existing) |
| `Submitted` toast | `FormPage` | none | `form.submitted` (new) |
| `Thanks!` (two panels) | `FormPage` | the form's `thank-you` `title` |
`publicForm.thankYouTitle` |
| `Your submission has been received.` (two panels) | `FormPage` | the
form's `thank-you` `message` | `publicForm.thankYouMessage` (existing) |

The card named the toast, `Thanks!`, the loading line, the load-failure
heading and the note. The submit and cancel defaults are the same class
in the same files (the brief's example); `FormPage`'s loading line, its
`Submitted` toast and the message under its `Thanks!` are the card's
classes in the card's file. Beyond this line, see Acceptance notes.

### Keys: reused where one already meant the same thing

- `publicForm.loading` (`Loading form…`), `publicForm.thankYouTitle` and
`publicForm.thankYouMessage` were in all ten packs with nothing reading
them: `pnpm check:i18n-dead-keys` listed all three as confirmed dead at
the merge base. They say exactly this, so they are reused, and they are
off that list now (105 confirmed dead before, 102 after).
- `form.saveSuccess` (`Saved successfully`, also confirmed dead) is not
reused for the toast: it has no no-argument create twin
(`form.createSuccess` takes `{{object}}`), so reusing it would either
pair `Created` with `Saved successfully` or rewrite both toasts' English
on every form. `form.created` / `form.saved` keep the toast
byte-identical in English.
- `MasterDetailForm`'s edit Save is `common.save`, not
`form.saveRecord`: seven packs word `form.saveRecord` as "save record"
(de `Datensatz speichern`), while this button's English is the plain
verb, which `common.save` is in every pack.
- No existing key says "Error loading form"; `form.errorLoading` follows
`grid.errorLoading`.

### `NAVIGATE_ON_SUCCESS_REFUSED_NOTE`: an end user sees it, so it joins

It is passed as the `description` of `toast.success(...)` (sonner,
through `@object-ui/components`), which renders under the toast's title,
so the submitter reads it. Its own doc block says so ("What the
submitter is told"), quoting the maintainer ruling of 2026-08-17: "the
success toast carries a note that the declared navigation was not
performed". The zh pin reads it on the toast (red on the base). The
constant still exists: it is now read from the `formChrome.ts` table, so
it has one spelling, and `WizardForm` re-exports it, because the tests
import it from there. `navigateOnSuccess` stays as written in every
pack, since it is the key's name.

### Wording

zh: 已创建, 已保存, "TITLE已保存", 已提交, 表单加载失败, and 此表单声明的 `navigateOnSuccess`
跳转目标被拒绝,因此未执行跳转。 The reused keys read 正在加载表单…, 感谢您的提交! and 我们已成功收到您的信息。
The zh `publicForm.thankYouTitle` changes its ASCII `!` to the
full-width `!`, the zh pack's punctuation, now that a form shows it. The
other eight packs follow each pack's own neighbours
(`form.createSuccess`, `grid.errorLoading`, `publicForm.loading`). No
new value carries an ellipsis; the reused `publicForm.loading` has
U+2026 in all ten packs.

**Three English strings change their rendered text**, each because it is
now a key whose English differs from the old literal. The changeset says
so:

- `Loading form...` becomes `Loading form…` (U+2026);
- `Thanks!` becomes `Thank you!` (default arm, `WizardForm`,
`FormPage`);
- `FormPage`'s `Your submission has been received.` becomes `Your
submission has been received successfully.`

Every other English default renders the same text as before.

## Pins

- `packages/plugin-form/src/formChrome.i18n-11039.test.tsx`: 29 cases
through `ObjectForm` under a real `I18nProvider`. Each zh case first
waits for a probe outside the form to read the zh pack. The zh loading
line in the simple, tabbed, split, wizard and drawer forms; the zh
load-failure heading in all six; 创建 / 更新 in simple, tabbed, split,
drawer and modal; 取消 in drawer and modal; 已创建 (simple, wizard) and 已保存
(simple edit) toasts; the zh thank-you heading (simple, wizard); and the
zh note on a refused `navigateOnSuccess` (simple, wizard). Three
`CONTROL` cases: an `I18nLabel` map `successMessage` with a plain
`submitText`; a map `submitText` with a plain `successMessage` on a
wizard, whose toast keeps the authored message and carries the zh note;
and an authored thank-you `title` with a map `cancelText`. Plus an `en`
case: `Loading form…`, `Error loading form`, `Create`, `Created` with
the English note, and `Thank you!`.
- `apps/console/src/components/FormPage.chromeI18n-11039.test.tsx`: 5
cases on the real `/f/:slug` route, with nothing wrapped around it: the
zh loading line 加载中…, the zh toast 已提交, the zh thank-you heading and
message, a `CONTROL` with a declared thank-you `title` and `message`,
and `en`.

**Red leg**, at `80db9c47a`. The eight edited containers (`ObjectForm`,
`WizardForm`, `TabbedForm`, `SplitForm`, `DrawerForm`, `ModalForm`,
`MasterDetailForm`, `FormPage`) were replaced by their merge-base blobs,
each hash-checked on disk (`t('form.errorLoading')` in `ObjectForm.tsx`
went from 1 to 0, and the `Loading form...` literal came back).
Everything else stayed at HEAD. Over the two pins and the eight moved
fixture files: **43 failed, 52 passed**. The formChrome pin: 27 of 29
fail. Two `CONTROL` cases pass, because authored values always won, so
they are the must-not-change guards. The wizard `CONTROL` fails only on
the note, which is chrome. The FormPage pin: 4 of 5 fail, and its
`CONTROL` passes. The script restored with `git checkout HEAD --` under
a `trap`, verified every file against its HEAD blob hash, and read an
empty `git diff HEAD`. Green at the same head: 29 and 5 passed.

## Fixtures that moved with the change (pin updates, not regressions)

- `ObjectForm.i18nLabels.test.tsx` (zh): the create button is 创建, and
the nothing-authored control's toast is 已创建.
- `MasterDetailForm.i18nLabels.test.tsx` (zh): the toasts read 采购单已保存
and PO已保存, and the nothing-authored control reads 创建, 取消, 保存 and 已保存.
- `ObjectForm.submitRedirect.test.tsx`,
`WizardForm.submitRedirect.test.tsx`: `Thank you!`.
- `FormPage.submit`, `.outcomeToast`, `.recordId`, `.redirect`: each now
installs the en catalogue the way the app does (`createI18n`, whose
global the harness resets after every test), and asserts the new
message. The negative assertions moved to the new string too; left alone
they would have passed vacuously.
- `examples/schema-catalog/test/catalog-gallery-render.test.tsx`:
`FORM_LOADING` is `Loading form…`. Its wait is negative
(`not.toContain`), so the old literal would not have turned red: it
would have silently stopped waiting. The comment's stale cross-file line
citation is now a content citation.

Also: the pending objectui#10935 changeset said the master-detail
defaults "are still English". Its prose now says this change moves them;
its frontmatter is untouched (`check-changeset-overwrite` reports it
under its case 2, a deliberate correction; report-only). The
`object-master-detail-form` registry descriptions of `submitText` /
`cancelText` now say the default is in the active UI language.

## Verification (all at `80db9c47a`)

- `pnpm exec vitest run packages/i18n/ packages/plugin-form/`: Test
Files 216 passed (216), Tests 2910 passed, 14 skipped. This includes
`all-locales-key-parity`, `en-zh-key-parity`, `ellipsis-glyph-3878`,
`untranslated-identity-4376`, `de-quote-pairing-3876` and
`raw-key-call-sites-3546`.
- `pnpm exec vitest run apps/console/`: Test Files 131 passed (131),
Tests 1475 passed.
- `packages/plugin-view/`, plus app-shell's
`ObjectFormDesigner.test.tsx` and `RecordFormPage.i18n.test.tsx`, plus
the gallery test: 63 files, 1207 passed.
- `scripts/__tests__`, derived with `git grep -l -E` over the touched
names and paths (the form files, `FormPage`, `formChrome`,
`i18n/src/locales`, `plugin-form/src`, `.changeset`, `schema-catalog`,
`apps/console/src/components`): 46 files, Tests 2171 passed.
- Type-check: `@object-ui/i18n` and `@object-ui/plugin-form` Done, and
`@object-ui/console` exit 0, each after building its dependency closure.
`tsc -p tsconfig.test.json --listFilesOnly` lists the plugin-form pin,
and the console's `--listFilesOnly` lists its pin.
- `pnpm check:i18n-keys` exit 0 (37 factory tables, 912 rows, 912
matching). `pnpm check:i18n-drift` exit 0 (0 en values changed, 6 keys
added). `pnpm check:control-bytes` OK. `pnpm check:new-line-citations`:
0 new. `node scripts/check-changeset-presence.mjs`, `pnpm
changeset:check` and `pnpm check:pending-changeset-literals` pass. `pnpm
check:changeset-claims` (report-only) names seven pending changesets
that mention a touched file (`6237-wizard-step-config-split`,
`6661-app-launcher-nav-menu-renderers`,
`8738-object-form-fields-description`, and four `console-form…` ones).
All re-read, all still true: each is about another aspect of those
files.
- eslint on the 31 touched ts/tsx files (`--format json` reports 31):
with inline config honoured, as each package's `lint` runs, 0 errors and
210 warnings; with `--no-inline-config`, 1 error and 217 warnings, and
those per-file counts equal the merge-base blobs linted the same way
through `--stdin --stdin-filename`. The one error is an existing,
inline-suppressed `react-hooks/static-components` in `FormPage`'s widget
row. The narrowing excludes nothing: the population is the changed files
from the diff against the merge base, `eslint.config.js` sets no
`parserOptions.project` or `projectService` (no type-aware linting), and
no rule under `eslint-rules/` reads a file, so this diff cannot move a
verdict on an untouched file.

## Bundle

Console built at the merge-base sources and at HEAD: the sources were
swapped, rebuilt, restored and rebuilt, and a marker was checked in dist
both ways. The eager closure is 330 of 2446 chunks both times, so no
chunk entered it, and 3,181,419 became 3,181,741 bytes gzipped (+322;
raw +1,788). By chunk: `i18n-locale-en` +241 raw / +82 gzipped (the en
pack), `plugin-form` +1,471 / +252, `index` (`FormPage`) +76 / −7. No
new external module entered either chunk: `FormPage` already imported
`@object-ui/i18n`, and the new `formChrome.ts` imports only
`createSafeTranslation`, which plugin-form already did.
`check-eager-closure-budget`: 3107.2 KB of 3149.4 KB (headroom 42.2 KB,
42.6 KB at the base). `check-eager-locale-catalogues`: exactly one eager
catalogue, `en`.

## Acceptance notes

Left English on purpose. Each is chrome, not a default behind an author
key the card named, and each is noted rather than widened into here:
- `FormPage`: the submit button's `Submit` / `Submitting…` /
`Uploading…`, `Redirecting…`, and the `Required: …` refusal. The zh pin
submits through that English `Submit` under a zh catalogue.
- `MasterDetailForm`'s line-item chrome: `Loading columns…`, `Subtotal`,
`Tax (…%)`, `Total`, `Line item — row N`, `Apply`, `Add`, and `Saving…`
on its Save button.
- `ModalForm`'s master-detail dialog: its `sr-only` description, `Enter
the record and its line items, then save.`
- `ObjectForm`'s field-security hint, `You do not have edit access to
this field.`
- `EmbeddableForm` takes its chrome from host-supplied `texts` (the
`publicForm.*` vocabulary in `public-forms.md`), which is a different
mechanism.

No README or `content/docs` page describes these defaults (grepped for
the literals; the one `Thanks!` in `public-forms.md` is an authored
`title` in an example), so no docs change is due.

Dispatched dev run for the `domain:ui` seat objectstack-ai#1, session
`session_01DuWo5bdP9SdVebamn99GGk`, claim comment 5885696581.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…and row queries, so the groups are the searched ones (objectui#11021) (objectstack-ai#11178)

Part of objectstack-ai#11021
Clause-②: no

Dispatched by the `domain:ui` seat 2 PM loop, session
`https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec`, claim comment
5904959620 on objectui#11021.

## What this does

A grid that groups on the server (objectui#7189) asks two queries: the
group header query (the set of groups and every count) and one row query
per open group. Each group's row query stripped `$search` /
`$searchFields`, and the header query was asked with `where` only. So a
grid carrying a search term drew the UNSEARCHED groups, and said nothing
about it.

`@objectstack/spec` 17.5.0, installed here since objectui#11086,
declares ADR-0061 `search` / `searchFields` on `EngineAggregateOptions`,
and the platform's grouped branch honours them (objectstack#20487). This
PR sends the term on BOTH queries, as one pair:

- `packages/plugin-grid/src/useServerGrouping.ts`
- `useServerGroupHeaders` takes `search` / `searchFields` and sets them
on every depth's compiled `EngineAggregateOptions`, keyed on content.
- Each group's row query no longer strips `$search` / `$searchFields`.
The stripping and its comment are retired. `$filter` is still replaced,
because the view's filter is already inside the compiled group `where`.
- New export `groupSearchOf` reads the header's pair off the very row
query each group's page is asked with, so the two cannot disagree.
- `packages/plugin-grid/src/ObjectGrid.tsx`: only the one
`useServerGroupHeaders` call changes. It spreads
`groupSearchOf(groupRowQuery)`. The import line changes with it.
- `@object-ui/data-objectstack` is unchanged, and this was measured
rather than assumed:
- `queryGroupHeaders` hands the compiled query to `client.data.query`,
which posts it verbatim to `POST /data/:object/query` (read in the
installed `@objectstack/client` 17.5.0).
- The platform's grouped branch passes `search` / `searchFields` to
`engine.aggregate`. That is objectstack `1c1b8c80`, and `git merge-base
--is-ancestor` against the `@objectstack/spec@17.5.0` tag answers exit
0.
- The row pages now take the adapter's raw-GET route, which a search
already selects. That route lowers the object-form `$filter` through the
same `convertFiltersToAST` as the non-search route, and grouped rows
with `$expand` already used it.
- Docs: the plugin-grid README and
`content/docs/plugins/plugin-grid.mdx` said "A toolbar search has no
counterpart on the group header query". This PR makes that false, so the
paragraph now says the grid sends the pair on both queries. The
`ListView` sentence stays, because it is still true.
- Changeset: `'@object-ui/plugin-grid': patch`.

## Why `Part of`, and the half this PR leaves

Measured on `origin/main` `0ffc423b1` with a throwaway render probe,
which is not committed:

1. **A server-grouped `ObjectGrid` renders no search box.** The grid's
term has one writer, the flat table's box. When grouped, the grid draws
group sub-tables with `searchable: false`, and a toolbar holding only
row height and export. Probe: the flat grid shows the box; the grouped
grid shows none, and the DOM holds 0 inputs.
2. **`ListView` withholds the grouped fetch from the grid while its
toolbar search is active.** The console's list views are ListView. Its
`gridOwnsGroupedFetch` ends in `!searchTerm`, with the comment
"`$search` has no counterpart on the header query the platform answers".
It fetches one window with `$search` and hands that window to the grid,
which groups it in the browser. Once the matches exceed the page size,
the counts are the window's.

So today the fixed code has one door: a term typed while the grid is
flat, then grouping switched on at runtime. At that door, before this
PR, with the term `Plant`:
- the header query carried no `search`;
- 0 of 5 group row finds carried `$search`;
- the screen drew all five unsearched groups (86/61/31/1/7).

The card's first acceptance line ("in a grouped list view, a toolbar
search shows only the groups that contain matching rows") needs the
other half through `ListView`: it must lift its `!searchTerm` carve-out
and hand its term to the server-grouped grid. That is outside this
dispatch's file surface and needs a channel decision, so it goes to the
seat and is not done here. This PR is that half's prerequisite. Without
it, a term handed to the grid would still be stripped from the rows and
never reach the headers.

## Pins:
`packages/plugin-grid/src/__tests__/serverGroupedSearch-11021.test.tsx`

The pins run through the real grid, against a double that answers both
queries with ONE matcher. The term `objectstack-ai#1` matches a SUBSET of each unit
(numbers 1 and 10 to 19), so a search sent on only one of the two
queries makes the counts and the rows disagree.

- **SEARCHED**
- The headers read `Northgate Operations 11 · Northgate Plant 11 ·
Northgate Quality 11 · Riverside Plant 1`. The Depot's one row does not
match, so it has no group.
  - Each group draws exactly the rows its header counted.
- The header query equals the spec's compiled query plus `search` /
`searchFields`.
  - Every group row page carries the same `$search` / `$searchFields`.
- **UNSEARCHED**: neither query carries a search key, and the five units
read 86/61/31/7/1.
- **Clearing**
  - Back in flat mode, the box still holds the term.
  - Clearing it and grouping again restores 86/61/31/7/1.
- The settled header query, and each unit's latest row page, carry no
search.

**Ablation.** One-shot, from the committed `aa46977f9`, through
`scripts/ablation-replace.mjs` (objectstack). For each leg:
- the anchor hit once;
- the on-disk anchor count and the blob moved;
- the restore was proven by blob == HEAD and an empty `git diff HEAD`,
and the tree diff afterwards was 0 bytes.

The pin imports `../ObjectGrid` by relative path, so no `dist/` sits in
between.
- **Leg A**, the header query without the term (the
`...groupSearchOf(groupRowQuery),` line deleted): SEARCHED and Clearing
go red, `Tests 2 failed | 1 passed (3)`. The failure: `expected {
'Northgate Operations': 86, …(4) } to deeply equal { 'Northgate
Operations': 11, …(3) }`.
- **Leg B**, the rows strip the term again: SEARCHED goes red, `Tests 1
failed | 2 passed (3)`. The failure: `expected [ 'Northgate Operations
#0', …(85) ] to have a length of 11 but got 86`.

## Gates, at HEAD `aa46977f9`

Each gate ran from the worktree root, with its exit code captured before
any pipe. objectui has no `dispatch-gates` derivation, so this list is
hand-derived from the root `package.json` and `.github/workflows/`
against the actual diff.

- `pnpm exec vitest run packages/plugin-grid/` (under the verify lock):
exit 0, `Test Files 171 passed (171)`, `Tests 1582 passed (1582)`.
- The new pins together with objectui#7189's suite: exit 0, `Tests 14
passed (14)`, which is 3 new and 11 existing.
- The five `plugin-list` suites that render a grouped grid over
`queryGroupHeaders` (`ListView.groupedGridOwnsFetch-7189`,
`speculativeFls-7216`, `groupingProjection-7179`, `expandFls-7215`,
`groupingNeedsHeaderQuery-10881`): exit 0, `Tests 44 passed (44)`. These
are not owed, because the package's published surface is unchanged:
`useServerGrouping` is not re-exported from the entry.
- `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...'
build`, the 14-package closure the type-check resolves through `dist/`:
exit 0.
- `pnpm --filter @object-ui/plugin-grid type-check`, which echoes `tsc
--noEmit && tsc -p tsconfig.test.json`: exit 0. `--listFiles` counts the
new pin once in the test program.
- `pnpm exec eslint` on the three changed source and test files, with
the package's own spelling (its `lint` is `eslint .`): exit 0, with 0
errors.
- For both source files, the per-rule warning set is identical to BASE
`0ffc423b1`, linted from stdin under the same path. The warnings are
pre-existing.
- The new pin carries 7 `no-explicit-any` warnings, the same style as
the objectui#7189 suite.
- Linting is not type-aware (`eslint.config.js` sets no `projectService`
or `parserOptions.project`), so this diff cannot move the verdict on any
untouched file.
- An extra run with `--no-inline-config` answers 1 error. It sits on a
base line whose `eslint-disable-next-line react-hooks/static-components`
that flag ignores, and it is not this diff.
- `node scripts/check-changeset-presence.mjs`: exit 0, "3 source file(s)
of 1 released package(s) changed, and this change declares 1
changeset(s)".
- `pnpm check:control-bytes`: exit 0.
- `pnpm check:new-line-citations`: exit 0, `VERDICT
new-cross-file-line-citations: 0 new citation(s)`.
- `check-changeset-no-major`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:installed-pin-claims`,
`check:test-path-roots`, `check-vi-mock-specifiers`,
`check:unreferenced-sources`, `check:spec-symbols`, `check:doc-fences`,
`check-doc-links`, `check-doc-expression-carriage`, `check:doc-types`,
`check-doc-example-ids`, `check-type-check-coverage` and
`check:lint-coverage`: all exit 0.
- `check-governed-queue-guard --test` over the six paths: NOT GOVERNED.
- NOT MEASURED: `check:readme-exports`. Its reading is a prerequisite
refusal ("type entry `./dist/index.d.ts` is not on disk -- run `pnpm
build` first", across every README), not a verdict. This diff changes no
fenced block in either doc: `git diff` shows 0 changed fence lines. CI
builds before it runs the gate.

## Acceptance notes

- **Re-entry staleness.** It predates this PR (it has existed since
objectui#7189), and this PR does not fix it.
- While the grid is flat, `groupRowQuery` keeps the row query of the
last grouped phase. On re-entering grouped mode, the header query is
asked first with that stale query and then with the fresh one.
- Measured after clearing the term: the header queries went out as
`search: "objectstack-ai#1"`, then `search: null`. The painted header sets were the
stale searched set, then the unsearched one.
- The same staleness applies to `$filter`, sort and projection. The
clearing pin therefore reads the settled answer.
- The fix is to reset `groupRowQuery` when the grid leaves server
grouping. That is a second hunk in `ObjectGrid.tsx`, held back to keep
this PR to the one hook call: parked draft objectui#10278 conflicts in
that file.
- In `ListView`, re-entry happens every time a toolbar search is
cleared.
- **A stale comment.** The comment above `manualSearchOn` in
`ObjectGrid.tsx` says that grouped grids "hold every row they display,
so their box keeps filtering client-side". A server-grouped grid holds
one page per group and has no box. It is left as is, under the same
one-hook-call limit.
- **The spec compiler takes no search.** `compileListViewGroupQuery` has
no `search` option, so the pair is set on the options it compiles, under
the spec's own key names. A spec-side option would let the compiler own
the pair. It is not needed for correctness.

## Handed to the seat, not done here

- **`ListView`'s `!searchTerm` carve-out on `gridOwnsGroupedFetch`**
(`packages/plugin-list/src/ListView.tsx`).
- Its stated reason is false against the installed 17.5.0 plus this PR.
- Under a toolbar search, the console's grouped list view groups one
window, so its counts are page slices once the matches exceed the page
size.
- It is the remaining half of objectui#11021's own acceptance, so the
report names it as that card's sub-issue.
  - The same stale sentence stands in its comment.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…rch groups on the server, with the searched counts (objectui#11021) (objectstack-ai#11243)

Fixes objectstack-ai#11021

Clause-②: no

The ListView half of objectui#11021. The grid half (PR objectui#11178,
merged as `7e4fa1bb2`) made a server-grouped grid send its search term
on the group header query and on every group's row query. This PR makes
the console's grouped **list view** use it. With this, all four of the
card's acceptance lines hold through the list view, so the PR closes the
card.

## What changes

- **`packages/plugin-list/src/ListView.tsx`**: `gridOwnsGroupedFetch`
loses its `!searchTerm` carve-out and its comment. That comment gave
"`$search` has no counterpart on the header query" as the reason, which
is false against the installed `@objectstack/spec` plus PR
objectui#11178. Under a toolbar search, a grouped grid over a data
source that answers `queryGroupHeaders` is still handed **no rows**. It
gets the list's term as its `search` host prop (an empty one too: the
list owns the term), and the view's `searchableFields` on its node,
beside the effective `filter` it already carried. `ListView`'s own fetch
sends that same pair as `$search` / `$searchFields`.
- **`packages/plugin-grid/src/ObjectGrid.tsx`**, only in the three
regions the claim names:
- **Host search.** `hostOwnsSearch` / `querySearchTerm`: a host that
passes `search` owns the term, under host-driven paging (unchanged) and
also when the grid fetches for itself. The load effect's `$search` reads
that term, so `groupSearchOf(groupRowQuery)` carries it to both group
queries. With no `search` prop, the term is the grid's own box, as
before.
- **Re-entry reset.** `groupRowQuery` goes back to `null` when the grid
leaves server grouping. Before, re-entering grouping asked the first
header query with the query held from the last grouped phase, and
painted that answer before the fresh one (see the measurements).
- **Comments.** The false `manualSearchOn` comment is rewritten to say
what the code does, and so is the `manualSearch` comment.
`ObjectGridExternalPaginationProps` gains one paragraph: `search` is
read outside host-driven paging too.
- These regions are disjoint from objectui#9547's hunks (merged into
this branch from `main`, and the merge was clean).
- **`packages/plugin-grid/package.json`**: the `@objectstack/spec` floor
goes `^17.4.0` to `^17.5.0` on both lines. `pnpm install` rewrote one
lockfile specifier line, and the resolved version did not move (it was
already `17.5.0`).
- **Prose this change made false** (AGENTS.md objectstack-ai#2): three docs and one
comment said a `ListView` keeps grouping its own window under a search.
Those are `packages/plugin-list/README.md`,
`packages/plugin-grid/README.md`,
`content/docs/plugins/plugin-grid.mdx`, and a `useGroupedData.ts`
comment. The grid half's still-pending changeset
`.changeset/11021-grouped-grid-search-both-queries.md` ended on the same
sentence, and that paragraph is removed; this is a prose correction to a
not-yet-released declaration, and its package names are untouched. Three
plugin-list test comments carried the same claim and are corrected too.
- **Changeset**: `.changeset/11021-listview-grouped-search.md`, with
`@object-ui/plugin-list` and `@object-ui/plugin-grid` at patch.

## Measurements of the dispatch's mechanism assumptions (all at
`origin/main` `3c13675e5f`)

1. **Carve-out: confirmed, and reproduced red first** through the real
`ListView` + real `ObjectGrid`, with no source edits. The new pin
`packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx`
uses 186 rows in five units (86/61/31/7/1) and the term `objectstack-ai#1`. The term
matches 34 rows, and the view's page is 20. On base, the searched
grouped list drew `{ Northgate Operations: 11, Northgate Plant: 9 }`:
the page of 20 grouped in the browser. The query's answer is `{ 11, 11,
11, 1 }`. The result was `Tests 3 failed | 2 passed (5)`.
2. **Channel: confirmed, one channel.** `ListView` fed the grid no
search at all before, since its flat grid is handed a window that is
already searched. The grid's existing host prop `search` now carries the
term. The measured widening beyond "server-grouped mode" is deliberate.
Under `gridOwnsGroupedFetch`, the grid can still fetch a flat or
bucketed window of its own, when the grouping key is unreadable or
known-masked. Reading `search` only when server-grouped would drop the
term there, which is a regression, so a passed `search` is read wherever
the grid fetches for itself. Searchable fields ride the node's declared
`searchableFields`, the key the grid's own `$searchFields` already
reads. No new prop was added. The box stays hidden: a host that owns the
term but passes no `onSearchChange` gets none.
3. **Re-entry staleness: confirmed and fixed.** On base, the tightened
grid pin (search, flat, clear, regroup) saw a header query after
re-entry that carried `search: 'objectstack-ai#1'` and `searchFields: ['subject']`. A
second ablation leg (below) switched the query-level check off and left
only the paint check. Without the reset, **both** pins then went red on
a painted searched header set, `{ 11, 11, 11, 1 }`, before the
unsearched one. With the reset, no re-entry header query carries the
term and no searched set paints, while at least one set was sampled.
4. **`manualSearchOn` comment: rewritten.** `manualSearchOn` itself does
not change. A grouped grid renders no flat table, and each group's table
is `searchable: false`, so this flag governs only the flat table.
5. **Floor: raised.** The specifier moved; the resolved version stayed
`17.5.0`. `pnpm install --frozen-lockfile` answered "Lockfile is up to
date".

## Tests

- New
`packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx`
mounts the real `ListView` and the real registered `object-grid`. It has
five cases:
  - the fixture precondition: the matches outnumber the page;
- SEARCHED: only the groups holding matches, each count equal to its
matches, each group drawing exactly those rows, and both queries
carrying `search` / `searchFields`;
- UNSEARCHED: the five whole units, with no search key on either query;
- clearing through the toolbar box, which restores the five units, and
whose last header query and each unit's latest row page carry no term;
- re-entry: grouping off, the term cleared, grouping on. No header query
after re-entry carries the term, and no searched group set is painted.
- `plugin-grid` `serverGroupedSearch-11021.test.tsx`: the clearing case
now asserts no stale ask and no stale paint on re-entry. It used to
tolerate both, in a comment. A new case covers a host's `search` prop
driving both queries and clearing them.
- `plugin-list`: `ListView.groupedGridOwnsFetch-7189.test.tsx` gains the
searched handoff (no rows; `search` and `searchableFields` equal to the
list's own `$search` / `$searchFields`; the filter unchanged) and an
ungrouped control (a searched window, no term handed). In
`ListView.groupingNeedsHeaderQuery-10881.test.tsx`, the case that pinned
"a grouped grid handed a searched window" pinned the very branch this PR
deletes. It is replaced by the same question asked of the new behaviour:
no window and no host paging.

**Ablation**, one-shot, from committed `620c07cd76`, through
objectstack's `scripts/ablation-replace.mjs`. Every mutation was proven
on disk (anchor x1 to x0, replacement x0 to x1, blob moved). Every
restore was proven by the blob equal to HEAD and an empty `git diff
HEAD`, and no permanent test file was left. The pins resolve
`@object-ui/plugin-grid` and `@object-ui/plugin-list` through the vitest
alias to `src`, so no `dist/` is in the path.

- Leg A, the reset line removed: `Tests 2 failed | 7 passed (9)`. The
grid clearing case and the list re-entry case went red on a re-entry
header query carrying `search: "objectstack-ai#1"`.
- Leg A-paint, the reset removed and both query loops skipped: `Tests 2
failed | 7 skipped (9)`, both on `expect(counts).not.toEqual(SEARCHED)`.
- Leg B, `&& !searchTerm` restored: `Tests 5 failed | 10 passed (15)`.
SEARCHED, clearing and re-entry through the list failed, and so did the
7189 searched handoff and the 10881 replacement.
- Leg C, host `search` read only under host paging: `Tests 4 failed | 5
passed (9)`. The grid host-search case failed, and so did the three
searched list cases.

## Gates

Branch head `cee5c02158`: `620c07cd76` merged with `main` at
`dded788ada`, which brought in objectui#9547. Every exit was captured
before any pipe. Heavy runs went through the shared verify lock.

**At `cee5c02158`:**

- `pnpm install --frozen-lockfile`: exit 0, "Lockfile is up to date".
- `pnpm turbo run build --filter='@object-ui/app-shell^...'
--concurrency=2`: exit 0, 28/28 tasks. This closure contains both
`@object-ui/plugin-list^...` and `@object-ui/plugin-grid^...`.
- Type-check, `pnpm --filter @object-ui/plugin-grid type-check && …
plugin-list … && … app-shell …`: exit 0. Each run echoed `tsc --noEmit
&& tsc -p tsconfig.test.json`.
- `pnpm exec vitest run packages/plugin-grid/`: exit 0, `Test Files 174
passed (174)`, `Tests 1596 passed (1596)`.
- The pins plus the importer set, 14 files: exit 0, `Tests 148 passed
(148)`. That is the new app-shell pin, the six changed plugin-list
files, `displayPageSizeFromSpec-9853`, four plugin-view `ObjectView`
suites, two app-shell `ObjectView` suites and `data-objectstack`
`queryGroupHeaders-7189`.
- The 18 suites that `scripts/markdown-test-inputs.mjs --list` names as
readers of the five changed markdown files (`doc-version-claims.test.ts`
among them): exit 0, `Tests 841 passed (841)`.
- Exit 0:
- `check-changeset-presence` ("10 source file(s) of 3 released
package(s) changed, and this change declares 1 changeset(s)");
  - `check-changeset-no-major`, `check-changeset-fixed`;
- `check-changeset-claims` and `check-changeset-overwrite` (both
report-only);
- `check:pending-changeset-literals`, `check:installed-pin-claims`,
`check:control-bytes`;
  - `check:new-line-citations` ("0 new citation(s)");
- `check:doc-fences`, `check-doc-links` ("Links are valid across 17 scan
roots"), `check-doc-expression-carriage`, `check:doc-types`,
`check-doc-example-ids`;
- `check-lockfile-integrity` ("VERDICT clean"), `check-lockfile-dedupe`
("VERDICT deduped"), `check:phantom-deps`;
- `check:test-path-roots`, `check-vi-mock-specifiers`,
`check:unreferenced-sources`, `check-type-check-coverage`,
`check-lint-coverage`, `check:spec-symbols`;
  - `markdown-test-inputs --audit`;
- `check-governed-queue-guard --test` over the changed paths ("An
ordinary pull request").
- **NOT MEASURED:**
- `check:spec-floors` exited 1 on 5 `no-artifact` findings, for
`app-shell`, `cli`, `plugin-gantt`, `plugin-timeline` and `plugin-tree`:
packages outside this diff that this worktree did not build. That is a
prerequisite refusal, not a verdict. `plugin-grid`, whose floor moved,
was built and judged, with no finding.
- `check:readme-exports` exited 1 on 88 "`./dist/index.d.ts` is not on
disk" refusals, none of them for the two READMEs changed here. This diff
changes 0 fence lines in any doc.

**At `620c07cd76`** (the same diff before the merge):

- `pnpm exec vitest run packages/plugin-list/`, in two shards: exit 0
both times, 55 + 54 files, 387 + 814 tests. The merge changed no
`plugin-list` file.
- `pnpm exec vitest run scripts/__tests__/`, in two shards: exit 0 both
times, 177 files passed and 2 skipped of 179, 5371 tests passed and 2
skipped.
- `tsc -p tsconfig.test.json --listFiles` counts: the new app-shell pin
appears once, the grid pin once, and the five changed plugin-list suites
once each.
- eslint on the 10 changed TypeScript files: 0 errors. Compared per rule
with base, the only delta is +1 `react-hooks/set-state-in-effect` in
`ObjectGrid.tsx`, from the reset effect. The config is not type-aware,
so no untouched file's verdict can move.

CI is not waited on; its verdict is the merge queue's.

## Deviations from the declared file surface (each forced by what the
change made false, or by the pin the dispatch asks for)

- The real-`ListView`-plus-real-`ObjectGrid` pin lives in
**`packages/app-shell/src/__tests__/`**, not
`packages/plugin-list/src/__tests__/`. `plugin-list` declares no
`plugin-grid` edge, so a pin there would need either a
`vitest.config.mts` `heavyDomTests` entry (a root config that dozens of
`scripts/__tests__` suites read) or an undeclared test import
(`check:phantom-deps`). `app-shell` already declares both packages, the
route `displayPageSizeFromSpec-9853.test.tsx` takes.
- The docs, the `useGroupedData.ts` comment, the pending grid-half
changeset and the three test comments listed above.

## Acceptance notes

- `ObjectGrid.tsx` gains one `react-hooks/set-state-in-effect` warning,
from the reset effect. The file carries that pattern already (its
`setSearchTerm('')` reset on an object change), and eslint reports 0
errors. Every other changed file's per-rule warning set is identical to
base.
- Commits `7470afd89b` and `620c07cd76` end with a `Co-Authored-By`
trailer that carries a model name, copied from the harness's attribution
reminder. The model-free pair is what AGENTS.md asks for, and the merge
commit carries it. History is not rewritten: objectui forbids
force-push.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
… a rule-list filter draws its number (objectui#11526) (objectstack-ai#11535)

Fixes objectstack-ai#11526

Clause-②: no

## What this changes

An authored `object-metric` whose `aggregate.groupBy` is the structured
node (`{ field, dateGranularity }`) and whose `filter` is the bag's rule
list (`ViewFilterRule[]`, as `ObjectMetricPropsSchema` in
`@objectstack/spec/ui` declares it) passes both authoring faces
(`safeValidateSchema`, which `objectui validate` runs, and
`StrictAnyComponentSchema`; the pin asserts both, beside the block's own
arm `ObjectMetricBlockSchema`), and on `ObjectStackAdapter` it drew
`UnloweredAggregateWhereError` instead of its number.
`ObjectMetricWidget` now lowers the rule list for the spec-shape
aggregate query only, in a new module function `specShapeWhere` in
`packages/plugin-dashboard/src/ObjectMetricWidget.tsx`.

Files:

- `packages/plugin-dashboard/src/ObjectMetricWidget.tsx`:
`specShapeWhere`, called at the one spec-shape call site in
`computeOne`.
-
`packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.ruleFilterSpecShape-11526.test.tsx`:
the pins.
- `.changeset/11526-object-metric-rule-filter-spec-shape.md`:
`@object-ui/plugin-dashboard` patch.

No `@object-ui/core` edit, no adapter edit, no exported type moves (so
`Clause-②: no` holds).

## The lowering, named

- **What the legacy aggregate wire applies** (string or absent
`groupBy`, the control that draws the number):
`lowerAnalyticsFilterForWire` in
`packages/data-objectstack/src/index.ts`. It runs `translateFilterArray`
(rule list to filter AST, same file) and then `parseFilterAST` from
`@objectstack/spec/data` (filter AST to `FilterCondition`). Both adapter
functions are module-private.
- **Why it is not imported or re-exported.**
`@object-ui/data-objectstack` depends on `@object-ui/core`, so a
re-export from core would be a dependency cycle. `plugin-dashboard` does
not depend on the adapter package and reads any `DataSource` (AGENTS.md
objectstack-ai#1). Moving the translator into core would be a logic move in a package
this claim does not cover. The brief's re-export fallback is therefore
not available; see Acceptance notes.
- **What the widget uses instead:** the same two stages, from where they
are public. Stage 1 is `toFilterNode`
(`packages/core/src/utils/filter-converter.ts`, exported from
`@object-ui/core`). It is core's sink for a `ViewFilterRule[]`, and
`object-grid` lowers its own rule-list `filter` through it (via
`toFilterNodeSafely`). Stage 2 is `parseFilterAST`, the call the adapter
makes. No conversion is written in the widget. The `isFilterAST` gate
between them hands an array that is not a filter on unparsed, because
`parseFilterAST` answers `undefined` for it, which would post an
unfiltered aggregate. Handed on, the adapter's refusal answers it.
- **One-time measurement (not a standing pin), real adapter, fetch
stubbed, `@objectstack/spec` 17.5.0.** I compared
`parseFilterAST(toFilterNode(rules))` with the `where` the legacy wire
posts for the same rules. They are byte-equal for each operator in
`VIEW_FILTER_OPERATORS` (20 at this spec version), for a two-rule list
(`$and`), and for the legacy alias `eq`. Where a rule is refused, both
paths refuse. An array comparand on `equals` and an empty `icontains`
comparand are refused by both, with different error classes: the
adapter's `UnlowerableAnalyticsFilterError` / `MalformedFilterError`,
and core's `FilterOperatorError`. A scalar on `in` is refused by
`parseFilterAST` on both paths. Nothing in this repo re-derives this
equality, and the docblock says so.

## Reverse verification and ablation

One-time proofs, run from the committed fix (`b60869195`). The probe
that drives a real `ObjectStackAdapter` lived in the worktree only
during these runs and is not in this PR.

**Prediction, written before the runs:** BASE and the ablation are both
red on the pin's four lowering rows (structured groupBy plus rules;
multi-rule count; every operator; empty list) and green on the boundary
row and the two controls. With the real adapter, the structured groupBy
plus rules document draws the refusal, and both controls draw 42.

| leg | new pin | real adapter: structured groupBy, rule list | real
adapter: string groupBy, rule list | real adapter: structured groupBy,
record filter |
| --- | --- | --- | --- | --- |
| BASE (`ObjectMetricWidget.tsx` at `58d1f5f4b`) | 4 failed, 3 passed |
draws "aggregate('opportunity'): the spec-shape branch received a
`where` array that is not a filter ..."; nothing posted | 42; analytics
`where` `{"stage":"won"}` | 42; spec-shape `where` `{"stage":"won"}` |
| ablation (`specShapeWhere(filterForRun)` replaced by `filterForRun`) |
4 failed, 3 passed | draws the same refusal; nothing posted | 42 | 42 |
| fix (`b60869195`) | 7 passed | 42; spec-shape `where`
`{"stage":"won"}`, the same bytes the legacy wire posts | 42 | 42 |

Observed matches predicted. The empty-list row's BASE red is a change in
the posted body (`where: []` becomes no `where`), not the card's defect.
The platform engine reads `where: []` as no filter (objectstack's
`engine-filter-array-lowering.test.ts`, the row 'an empty array is "no
filter", exactly as before').

Landing and restore evidence:

- **Ablation** (objectstack's `scripts/ablation-replace.mjs`): anchor
count went from 1 to 0, and the blob went from `041e0f5d` to `42c7914d`.
Restore gave blob equal to HEAD `041e0f5d` with `git diff HEAD` empty.
- **BASE leg:** restored with `git checkout HEAD --` on the absolute
path. Blob equality with HEAD and an empty `git diff HEAD` were checked
by the leg script, which carries a `trap` restore.
- **No build/dist leg:** the pin imports the widget by relative path,
and `@object-ui/*` resolve to `src` through the vitest alias.

## Tests and gates

The final head is `fea8ccb0e`. From `b60869195` to `fea8ccb0e`, only
comment lines changed (a docblock correction and a test comment).

| at | command | verdict |
| --- | --- | --- |
| `fea8ccb0e` | `pnpm exec vitest run` on the new pin,
`objectMetricStructuredGroupBy-8613`,
`ObjectMetricWidget.drillRoutedToSharedDrawer-8970`,
`ObjectMetricWidget.compareTo`, `objectMetricQueryMembers-8071`, and
`data-objectstack`'s `aggregate-spec-shape-where` | Test Files 6 passed
(6), Tests 59 passed (59) |
| `fea8ccb0e` | `pnpm --filter @object-ui/plugin-dashboard type-check`
(`tsc --noEmit` and `tsc -p tsconfig.test.json`; the new pin is in the
test program per `--listFiles`) | exit 0 |
| `fea8ccb0e` | `pnpm check:control-bytes` / `check:new-line-citations`
/ `node scripts/check-changeset-presence.mjs` / `changeset:check` /
`check:changeset-claims` / `check:pending-changeset-literals` | OK / 0
new citation(s) / 1 changeset for 1 released package / OK / OK / OK |
| `fea8ccb0e` | eslint, narrowed (below) | 2 files, 0 errors |
| `b60869195` | `pnpm exec vitest run packages/plugin-dashboard/` | Test
Files 163 passed (163), Tests 1554 passed, 6 skipped (1560) |
| `b60869195` | the 33 tests outside `plugin-dashboard` that name
`ObjectMetricWidget`, `object-metric` or `objectAggregateSpecQuery`
(`git grep -l`, `apps/console` and `packages/core` included), plus
`aggregate-spec-shape-where` | Test Files 34 passed (34), Tests 2193
passed (2193) |
| `b60869195` | `pnpm turbo run build --filter='./packages/*'`, then
`pnpm --filter @object-ui/console build` (the Bundle Analysis type
program) | 39 of 39 tasks successful; exit 0 |
| `b60869195` | `check:phantom-deps`, `check:spec-symbols`,
`check:test-path-roots`, `check:vi-mock-specifiers` / `-inherit` /
`-override-shape`, `check:unused-deps`,
`check:component-surface-parity`, `check:handler-key-reads`,
`check:unreferenced-sources` | exit 0 each |
| | `node scripts/check-governed-queue-guard.mjs --test` (the 3 paths) |
NOT GOVERNED |

ESLint narrowing, as a measurement:

1. **Population:** `eslint.config.js` lints `**/*.{ts,tsx}` with
`tseslint.configs.recommended` and sets no parser `project`, so linting
is not type-aware.
2. **Count:** `--format json` lists 2 files. `ObjectMetricWidget.tsx`
has 0 errors and 11 warnings, and its BASE blob linted the same way also
has 0 errors and 11 warnings, from the same two rules. The new pin has 0
errors and 0 warnings.
3. **Invariance:** with no type-aware rules, and no `fs` read in any
`eslint-rules/*.js` (grep returns 0 matches; the same pattern hits
`scripts/check-control-bytes.mjs`, the positive control), this diff
cannot move a verdict on an untouched file. Repo-wide lint is CI's.
- Patch round 1 (head `e37c484be`, at the seat's request): both
authoring faces measured and pinned. The card's document, the
showcase-shaped document and all 20 per-operator documents pass
`ObjectMetricBlockSchema`, `safeValidateSchema` (the tolerant face
`objectui validate` runs) and `StrictAnyComponentSchema`. The probe's
controls show each face can refuse: an unknown `properties` key is
refused by all three, and an unknown node-level key is refused by the
strict face only. The changeset and the pin comment now say "passes both
authoring faces" instead of "the strict face". The pin passes 7 of 7,
`plugin-dashboard` type-check is green, and there is no source change.

## Acceptance notes

- **The legacy wire is untouched.** The control pins the raw rule list
on the legacy bag; the adapter lowers that wire.
- **The drill drawer is unchanged.** It still receives `resolvedFilter`,
and the 8970 pin is green. objectui#11506's drill path is not touched.
- **`UnloweredAggregateWhereError` and its check are untouched.** The
refusal of a caller that skips the lowering is pinned by
`data-objectstack`'s `aggregate-spec-shape-where.test.ts` ("throws
UnloweredAggregateWhereError instead of posting a rule array"). The
ablation above shows the refusal coming back end to end once the
lowering call is removed. That file also pins the other half of the
seam: "a FilterCondition OBJECT is not an array, and this gate leaves it
alone".
- **The committed pin uses a recording adapter, not the real one.**
`plugin-dashboard` does not declare `@object-ui/data-objectstack`, and
declaring it (a `package.json` and lockfile edit) is outside this
claim's file surface. The pin therefore asserts the posted `where` is a
`FilterCondition` (`FilterConditionSchema` from
`@objectstack/spec/data`). The adapter's acceptance of that shape is
pinned on the adapter's side, as above.
- **The brief's mechanism assumption 2 was falsified.** The legacy
wire's lowering lives in the adapter package, and a re-export from core
is a cycle. The route changed to the two public stages, per the ruling's
intent: lower at the producer, no hand-written conversion, the refusal
stays, and the bag is not narrowed.
- **Observation, not filed:** this repo has two rule-list translators,
`translateFilterArray` (adapter-private) and `toFilterNode` (core). They
agreed after `parseFilterAST` across the declared vocabulary in the
measurement above, and on refused rules they differ only in error class.
No defect was measured. Carrier: none.
- **Not measured:** `object-chart` makes the same spec-shape call. Its
declared `filter` is a `FilterArray` or a record
(`ObjectChartSchema.filter`), not a rule list, so this card's dialect
does not reach it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants