Skip to content

feat(spec): item-level property names for the dashboard header-action repeater - #17227

Merged
os-bill merged 9 commits into
mainfrom
claude/issue-16458-item-level-property-names
Sep 9, 2026
Merged

feat(spec): item-level property names for the dashboard header-action repeater#17227
os-bill merged 9 commits into
mainfrom
claude/issue-16458-item-level-property-names

Conversation

@os-bill

@os-bill os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16458

Clause-②: yes

Upstream half of objectui#8218. Not on any release path.

What the maker saw

Studio's interface property panel renders dashboard.header.actions[] as a table whose
column headers read items.properties[k].title ?? k from the JSON Schema derived by
z.toJSONSchema(DashboardSchema). None of the four item fields carried a title, so the
fallback arm ran for every locale, English included. As triage insisted, this is not a
translation gap — it is a missing authoring label in the contract, and it was fixed as one.

Mechanism A, as ruled: author title on the Zod item schema, and teach the resolver to
overlay bundle labels onto the derived JSON Schema. B is untouched and still available.

Acceptance, item by item

item state evidence
① item schemas carry a title landed DashboardHeaderActionSchema's four fields author .meta({ title }); dashboard.test.ts pins the derived schema
② a locale bundle can name an item-level property, mechanism written down landed new export resolveMetadataFormSchemaTitles; write-up in content/docs/protocol/kernel/i18n-standard.mdx
③ catalogs carry the header.* children landed all four generated catalogs, plus the four header.actions.PROP row keys
③ catalogs name refreshInterval, not refreshIntervalSeconds premise falsified — deliberately NOT done see below
columns declares .default(12) stopped, by the dispatch's own stop condition see below

⛔ Item ③'s first half prescribes the opposite of the truth

The card and its triage both read refreshInterval as the spec's live field and
refreshIntervalSeconds as a catalog entry that "can never match". The direction is
inverted.
refreshInterval was renamed to refreshIntervalSeconds in @objectstack/spec
17 (#15680, ruling B on #14478) and is now a retiredKey tombstone — authoring it is a
parse error, already pinned in dashboard.test.ts. The live authorable key is
refreshIntervalSeconds, which the four catalogs and dashboard.form.ts already named.

Carrying that acceptance out literally would have written the tombstoned key into all four
catalogs and manufactured exactly the never-matching entry the card set out to remove.

The misreading is a substring artefact. Triage's control reading was "refreshInterval
occurs 6x in dashboard.zod.ts" — but every one of those occurrences is inside
refreshIntervalSeconds, in the rename comment, or in the tombstone's own prose:

grep -nP '\brefreshInterval\b(?!Seconds)' packages/spec/src/ui/dashboard.zod.ts
964:   * Renamed from `refreshInterval` (#15680, ruling B on #14478): the unit lived
974:  refreshInterval: retiredKey(
975:    '`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` ...

grep -rnP '\brefreshInterval\b(?!Seconds)' packages/platform-objects/src/apps/translations/
(no match; control: refreshIntervalSeconds matches once per catalog, 4 of 4)

So the catalogs needed no repair here. What they lacked was a guard, and this PR adds
one: dashboard-header-children.test.ts now pins that every catalog names the live key and
never the tombstone, and that the key the catalogs name is the key dashboardForm declares.

Ablation (one-time, restored; mutation proven on disk by byte hash before either leg):
renaming refreshIntervalSeconds to refreshInterval in en.metadata-forms.generated.ts
— the card's literal acceptance — turns the pin red:

MUTATED  exit 1 — Tests 2 failed | 9 passed
         AssertionError: en names the live key: expected 'undefined' to be 'string'
         AssertionError: expected [ 'name', 'label', ...(15) ] to include 'refreshIntervalSeconds'
RESTORED exit 0 — Tests 11 passed   (restored blob == HEAD blob, git diff HEAD empty)

⚠️ Item ④ stopped — and the stop condition is stronger than the card assumed

The dispatch required the round to stop and drop .default(12) if any consumer's behaviour
moved. It moves. The card reasoned the renderer already falls back to 12, which would make
the default behaviour-preserving. Measured at objectui origin/main,
packages/plugin-dashboard/src/DashboardRenderer.tsx, it does not:

if (schema.columns != null) return schema.columns;
... maxSpan > 4 ? 12 : 4                      // INFERRED, and 4 is the common arm
const hasExplicitColumns = schema.columns != null || inferredColumns !== 4;

A columns-less dashboard is inferred from the widget spans — 4, not 12, whenever no
widget spans past 4 — and the next line switches the entire layout on that value
(positioned grid vs responsive auto-flow). Declaring the default would both retire the
inference and flip every auto-flow dashboard into the positioned grid. A default that
silently materialises a key is expensive to take back, so item ④ stayed out and the
changeset records the measurement rather than the premise. gap / refreshIntervalSeconds
were audited alongside and likewise left alone.

Changeset — graded here, not inherited

@objectstack/spec minor, @objectstack/rest patch, @objectstack/platform-objects patch.
All three publish, measured rather than assumed:

  • spec ships src/**/*.zod.ts in files[], so the dashboard.zod.ts edit publishes on its
    own, and dist carries the new resolveMetadataFormSchemaTitles export.
  • rest ships dist; rest-server.ts changed.
  • platform-objects ships dist — and the catalogs really do reach it. Grepped after build:
    header.actions.actionType is present in dist/plugin.mjs, dist/plugin.js and
    dist/metadata-translations/index.js, with a pre-existing key as the lit control.

Verification

Full package suites, never a subset:

@objectstack/spec              test 470 files / 13209 tests passed   typecheck OK
@objectstack/rest              test 186 files /  3076 tests passed   typecheck OK
@objectstack/platform-objects  test  39 files /   562 tests passed   typecheck OK

Gate families derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
(no hand-fed path list), exit codes landed to disk before reading, reconciled with --ran:

Run reconciliation — 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN.

pnpm --filter @objectstack/spec check:generated — "All 15 generated artifacts are up to
date"; the tree is clean after regeneration, so nothing was hand-edited.

⚠️ One gate needed a raised ceiling to be measurable at all: pnpm check:type-check-debt
returns PREREQUISITE NOT MET (exit 3, an OOM — not a red) under the standard
NODE_OPTIONS=--max-old-space-size=4096, because the gate hands its own child tsc a
CI-shaped 6144MB ceiling that the caller's clamp starves. At 8192MB on this box it is green:
check-type-check-coverage --re-measure: OK — 5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number.

Verified on final head 24402bf36. Heavy runs went through scripts/pm/os-verify-lock.sh;
two queue-timeout verdicts along the way were recorded as NOT MEASURED and re-run, never
as passes.

验收备注

  • ① was NOT swept across every repeater item schema, per the dispatch: only
    header.actions is fixed here. The general case is reported as an out-of-scope finding.
  • objectui ships a private overlay for header.showTitle / header.showDescription /
    header.actions. Those three now come from the platform catalog, so that overlay is
    redundant and can shrink — ⛔ not touched here (cross-repo); to be noted on objectui#8218.
  • needs:contract-review is on both carriers and stays on. This PR is parked outside the
    queue deliberately: the review is blocked on tier budget, which is expected and safe.
    ⛔ Do not arm auto-merge, do not flip ready, do not clear the label.

维护者速读(草稿)

改了什么 — Studio 属性面板里「页眉 → 操作按钮」表格的四个列头,过去在所有语言下都显示
机器键(label / actionUrl / ...),包括英文。现在它们在契约里有了名字,并且任何语言的
词条都能覆盖它。顺带把页眉三个子项的翻译从 objectui 的私有补丁挪回平台目录。

为什么改 — 这不是翻译缺失。取值路径是 items.properties[k].title ?? k,而没有任何
item schema 带 title,所以回退分支永远执行。分诊席点名拒绝按 i18n 修:那样英文面照
样漏,且每加一个语言再漏一次。

风险与代价(含回滚) — 只增不减:没有删除任何键、没有改变任何接受集、没有移动任何解析
输出。回滚即 revert 本 PR,无数据迁移、无存量兼容问题。⚠️ 卡片要求的第 ④ 项(给 columns
声明默认值 12)故意没做 —— 实测渲染端在没写 columns 时推断出的是 4 而不是 12,
并且据此在「定位网格」与「自适应流式网格」之间切换;声明默认值会让所有流式看板改变布局。
这类"悄悄把键实体化"的默认值一旦发布就很难收回,所以按派发令的停止条件停下了。

席位意见 — (待补)

你要做的 — 这张卡挂着 needs:contract-review,PR 停在队列外等契约复核,这是预期状态。
⛔ 请不要合并、不要摘标签。复核时最值得看的一点:卡片第 ③ 项的验收口径是反的(它要求
把目录改成一个已经作废、写了就报错的键),本 PR 拒绝执行并加了一条门禁把这个方向钉死。

🤖 Generated with Claude Code

https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH


Generated by Claude Code

… repeater (#16458)

DashboardHeaderAction fields carry a JSON Schema title; a new
resolveMetadataFormSchemaTitles overlays metadataForms.<type>.fields.<path>.label
onto a derived JSON Schema (stepping through an array's items so a repeater row
property is <repeater>.<property>); GET /meta serves the localised schema beside
the localised form; dashboardForm enumerates the header composite's children so
the extractor emits their catalog keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…adataFormSchemaTitles (#16458)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…and the header.actions row properties (#16458)

Regenerated with pnpm i18n:extract after dashboardForm enumerated the header
composite's children; the zh-CN / ja-JP / es-ES leaves are hand-translated, so
the provenance companions carry no entry for them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…inst the card's inverted acceptance (#16458)

Item ③'s first half prescribed the opposite of the truth: the card and its
triage read `refreshInterval` as the spec's field and `refreshIntervalSeconds`
as a catalog entry that "can never match". The direction is inverted —
`refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec
17 (#15680, ruling B on #14478) and is now a retiredKey tombstone that refuses
at parse. The catalogs already named the live key; carrying the acceptance out
literally would have written the tombstone into all four.

The misreading came from a substring match — `refreshInterval` occurs in
dashboard.zod.ts only inside `refreshIntervalSeconds`, in the rename comment
and in the tombstone's own prose.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
The card reasoned that `.default(12)` on `DashboardSchema.columns` would be
behaviour-preserving because the renderer already falls back to 12. Measured at
objectui origin/main, it does not: a `columns`-less dashboard is inferred from
the widget spans (4 unless a span exceeds 4, then 12), and the very next line
switches positioned-grid vs responsive auto-flow on that value. The stop
condition therefore fired on a stronger reading than the one it was written
for, and the changeset now records what was measured rather than the premise.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/rest, @objectstack/spec, touching 22 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/export-origins/system.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via actionType (literal, a string literal in sections))
  • content/docs/deployment/validating-metadata.mdx (via actionType (literal, a string literal in sections), actionUrl (literal, a string literal in sections), header.actions (literal, a string literal in dashboard; a string literal on a changed line))
  • content/docs/protocol/kernel/i18n-standard.mdx (via DashboardHeaderActionSchema (symbol, a top-level const), resolveMetadataFormSchemaTitles (symbol, a top-level function), actionUrl (literal, a string literal in sections), fields.items.label (literal, a string literal in translationDataShape; a string literal on a changed line), header.actions (literal, a string literal in dashboard; a string literal on a changed line), header.actions.actionUrl (literal, a string literal in dashboard), header.actions.label (literal, a string literal in dashboard; a string literal in translationDataShape; a string literal on a changed line), header.showTitle (literal, a string literal in dashboard))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class), actionType (literal, a string literal in sections), actionUrl (literal, a string literal in sections), header.actions (literal, a string literal in dashboard; a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via actionType (literal, a string literal in sections), actionUrl (literal, a string literal in sections))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/export-origins/system.json) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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

Which tree this was computed on

This run read content/docs from aa95d5e8b44d0548785d53fe75ad7a8886f6640b — the merge of head 24402bf3600e9b2697489836c3986a6b48fee639 into base bccf311100cad7affccd6cbfcddbd81fe734d97d, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa95d5e8b44d0548785d53fe75ad7a8886f6640b && git checkout aa95d5e8b44d0548785d53fe75ad7a8886f6640b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bccf311100cad7affccd6cbfcddbd81fe734d97d 24402bf3600e9b2697489836c3986a6b48fee639 && git checkout -B drift-repro bccf311100cad7affccd6cbfcddbd81fe734d97d && git merge --no-ff 24402bf3600e9b2697489836c3986a6b48fee639

node scripts/docs-audit/affected-docs.mjs --json bccf311100cad7affccd6cbfcddbd81fe734d97d

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

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

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

Projects

None yet

2 participants