Skip to content

fix(spec): title every authorable row property on dashboard.widgets and dashboard.globalFilters - #18505

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-17505-dashboard-repeater-row-titles
Sep 16, 2026
Merged

os-warren merged 1 commit into
mainfrom
claude/issue-17505-dashboard-repeater-row-titles

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #17505

Clause-②: yes — no authorable key moves, but every titled row property gains a title node in the emitted JSON Schema, a published artifact. Changeset is minor accordingly (AGENTS.md Post-Task Checklist step 3: yes takes at least minor). The needs:contract-review carrier is the seat's and is already on the card; this PR neither hangs nor strips it.

What this does

Studio renders a type: 'repeater' field as a table whose column headers read items.properties[k].title ?? k off the schema from z.toJSONSchema(...). With no title the fallback arm runs in every locale, English included, so the maker saw requiresService, filterBindings and optionsFrom inside an otherwise translated panel. resolveMetadataFormSchemaTitles only ever REPLACES a title already present, so the English default has to live on the schema.

This applies the mechanism ruled in #16458 and already landed on dashboard.header.actions (PR #17227) and on the ai/skill, ui/report and ui/page carriers (PR #17500). No new contract decision.

The row-property set, re-derived on today's tree

The card's 17 + 10 = 27 is a 2026-09-10 reading, so it was re-derived through the platform's own predicate (z.toJSONSchema over getMetadataTypeSchema, io: 'input') rather than by regexing source — using the derivation helpers copied byte-identical out of repeater-item-titles.test.ts itself.

carrier authorable row properties untitled before untitled after
dashboard:widgets 17 17 0
dashboard:globalFilters 10 10 0

It is still 27, and the card is NOT stale. #17616 removed stageOrder from widget types that do not read it, but stageOrder lives inside options — one level BELOW the repeater's row properties, where options itself is the single row property. A refusal at that depth cannot move this count.

The emitted schema, before and after

Read as Studio reads it — the property node first, never the $ref target, because a .meta({ title }) on a schema zod hoists into $defs is emitted as a sibling of the $ref.

  • before: widgets.items.properties.id.title = undefined
  • after: widgets.items.properties.id.title = "Widget ID", in both io: 'input' and io: 'output'
  • after: globalFilters.items.properties.targetWidgets.title = "Target Widgets"

What must NOT get a title, and what was checked

  • The five retiredKey() tombstones on this rowactionUrl, actionType, actionIcon, responsive, aria — are untouched and still emit title: undefined in both io modes. A tombstone declares a key unwritable; an authoring label would advertise it as writable. They are spec: retiredKey() tombstones are emitted into a repeater row's items.properties, so the served schema offers six columns the parse door refuses #17502's subject and are not addressed here.
  • Every other repeater carrier is unchanged in the same run. The other twenty carriers hold their exact counts — field:options 6, object:fields.options 6, view:columns 14, view:sort 2, view:tabs 9 still untitled — so no shared item schema leaked a title sideways.
  • Non-repeater and composite siblings are unchanged: dashboard top-level columns and header.showTitle still emit no title, which is what keeps the two absence controls in packages/rest/src/meta-types-schema-titles.test.ts valid (3/3 green).

One control had to be re-pointed, not deleted

dashboard.test.ts used widgets[].id as its "a sibling with no authored title has none" control — proving the emitter invents no titles. This change titles that property, so the control was re-pointed onto a tombstone (widgets[].actionUrl) and given a lit leg (widgets[].id is now 'Widget ID'). It now pins the tombstone rule instead of borrowing a coincidence, which is strictly stronger than what it replaced.

The ledger is paid, not weakened

repeater-item-titles.test.ts is exact in both directions, so a paid debt left in the set is as red as an unpaid one. Both dashboard:* entries are deleted; five remain. Verified by name in the verbose run: dashboard:widgets and dashboard:globalFilters now execute without the (ledger: still owed titles) arm, while the five survivors keep it.

Verification

check result
pnpm --filter @objectstack/spec build exit 0 (lock verdict command-exit 0)
pnpm --filter @objectstack/spec test 483 files / 13773 tests passed
pnpm --filter @objectstack/spec typecheck exit 0
pnpm --filter @objectstack/spec check:generated all 15 artifacts up to date, check:authorable-surface included
packages/rest meta-types-schema-titles.test.ts 3/3 passed
packages/platform-objects translations 8 files / 100 tests passed

check:authorable-surface green is the load-bearing one for the declared fence: packages/spec/authorable-surface.base.json did not move and was not touched, so the in-flight anchor-only PR that owns it is unaffected.

Acceptance notes


Generated by Claude Code

…nd globalFilters

Studio renders a `type: 'repeater'` field as a table whose column headers read
`items.properties[k].title ?? k` off the JSON Schema derived by
`z.toJSONSchema(...)`. With no `title` the fallback arm runs in every locale,
English included, so the maker saw raw machine keys inside an otherwise
translated panel. This is a missing authoring label in the contract, not a
translation gap: the bundle overlay only ever REPLACES a title already present,
so the English default has to live on the schema.

Adds `.meta({ title })` to the 17 authorable row properties of
`dashboard.widgets[]` and the 10 of `dashboard.globalFilters[]`, beside the
existing `.describe()` rather than in place of it, following the mechanism
already landed on `dashboard.header.actions` and the skill/report/page carriers.

The shrink-only ledger in `repeater-item-titles.test.ts` is EXACT in both
directions, so both paid entries are deleted from it rather than left behind.

No `retiredKey()` tombstone is titled: the five on this row declare their keys
unwritable, and an authoring label would advertise them as writable. The
sibling control in `dashboard.test.ts` used `widgets[].id` as its untitled
example, which this change titles; it is re-pointed onto a tombstone, which
makes the control pin the tombstone rule instead of merely borrowing a
coincidence.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

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

  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const))

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

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const), GlobalFilterSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const))

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
  • 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 fed4a15ab5b50633553a939e4a9793e5a17ac530packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7e1c708d8b560533019311acc7d473ff0e5d232b — the merge of head 82838f4fac598d4374617a4b05a9435aeb138b07 into base fed4a15ab5b50633553a939e4a9793e5a17ac530, 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 7e1c708d8b560533019311acc7d473ff0e5d232b && git checkout 7e1c708d8b560533019311acc7d473ff0e5d232b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fed4a15ab5b50633553a939e4a9793e5a17ac530 82838f4fac598d4374617a4b05a9435aeb138b07 && git checkout -B drift-repro fed4a15ab5b50633553a939e4a9793e5a17ac530 && git merge --no-ff 82838f4fac598d4374617a4b05a9435aeb138b07

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

⚠️ 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 fed4a15ab5b50633553a939e4a9793e5a17ac530 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 82838f4fac598d4374617a4b05a9435aeb138b07

① Derived judgments

Population re-derived through the platform's own predicate (z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any', io }), forms walked as the pin walks them) on the PR head and on its parent bf61f0a196, in both io modes:

  1. dashboard.widgets[] — 17 authorable row properties gain a title: id→Widget ID, title→Title, description→Description, type→Visualization Type, chartConfig→Chart Configuration, colorVariant→Color Variant, requiresObject→Requires Object, requiresService→Requires Service, filter→Filter, compareTo→Compare To, dataset→Dataset, dimensions→Dimensions, values→Values, layout→Layout, options→Options, filterBindings→Filter Bindings, suppressWarnings→Suppress Warnings. Untitled 17→0 in input and output. Right. filter is the one $ref-hoisted property and its node reads {description, title, $ref} — title as sibling of the $ref, exactly where Studio reads; $defs (1 entry) gained no title.
  2. dashboard.globalFilters[] — 10 authorable row properties gain a title: name, field, object, label, type→Input Type, options, optionsFrom→Options From, defaultValue, scope, targetWidgets→Target Widgets. Untitled 10→0 in both io modes. Right. 17 + 10 = 27; the card's count was never stale.
  3. The five retiredKey() tombstones on the widget row stay untitledactionUrl, actionType, actionIcon, responsive, aria: each node is {description: "[REMOVED] …", not: {}} with title === undefined in both io modes, before and after. globalFilters holds 0 tombstones. Right. The sixth tombstone the card counts is flow:nodes.outputSchema (derived: flow:nodes tomb 1), on neither of this card's carriers.
  4. No title leaked sideways: registry-wide census over all 26 registered metadata types — titles 137→164 (input) and 61→88 (output), i.e. exactly +27 in each mode, every one at dashboard#/properties/{widgets,globalFilters}/items/properties/<k>, none under any $defs, 0 removed, 0 changed. The other 20 repeater carriers hold identical authorable and untitled counts base→head; the five ledger survivors read field:options 6, object:fields.options 6, view:columns 14, view:sort 2, view:tabs 9. Right.
  5. Ledger deletion (repeater-item-titles.test.ts, two dashboard:* entries removed) — a file outside the declared face. Measured necessary: with head titles and the base ledger the pin reds on both entries (… is fully titled now — delete its LEDGER entry); with the base titles and the head ledger it reds on both carriers with the 17- and 10-key untitled lists. The pin is exact in both directions. Right.
  6. dashboard.test.ts control re-pointed from widgets[].id to the actionUrl tombstone, plus a lit leg — the second file outside the face. Measured necessary: head titles + the old control reds in both io modes (expected 'Widget ID' to be undefined). The new control catches a titled actionUrl in both io modes (2 reds) and cannot pass vacuously (a missing node throws; the [REMOVED] description leg guards the node identity). Right as a repair; over-claimed as a rule pin — see ③.
  7. Accept set unchanged: check:authorable-surface exit 0 at head and base, no tracked artifact moved (git status --porcelain empty after the run), authorable-surface.base.json blob 4c329b0d identical parent↔head, the shipped-artifact diff contains no non-title difference, and stageOrder is still emitted at widgets.items.properties.options.properties.stageOrder. Right — no key added, removed, or renamed.
  8. Published payload changed, title-only: 8 shipped json-schema/**/*.json files differ base→head (ui/Dashboard.json +27, ui/DashboardWidget.json +17, ui/GlobalFilter.json +10, four api/*InstalledPackage*.json +27 each via the embedded manifest, objectstack.json +162), 324 differences, all of them an added string title; zero other differences. Right, and this is what decides ②.
  9. dashboard.form.ts left untouched, deferred to [Decision] Repeater row-property localisation: 604 catalog leaves across four locales — who authors the translated copy? #17508: the doc (content/docs/protocol/kernel/i18n-standard.mdx L436-441 on head) states a child the form does not enumerate has no catalog key and that a composite's children are enumerated all-or-none; enumeration is what makes os i18n extract emit REPEATER.PROPERTY keys, and dashboard-header-children.test.ts L82 requires translated locales to carry their own copy — product copy, which is [Decision] Repeater row-property localisation: 604 catalog leaves across four locales — who authors the translated copy? #17508's open decision. Precedent feat(spec): a repeater's property-panel table has column names, and an untitled item schema is now loud #17500 titled 13 carriers with zero form enumeration. Titles are effective without enumeration because the panel falls back to the schema-derived list. Right.
  10. Changeset @objectstack/spec: minor, body line Clause-②: yes at line start; PR body line likewise. Right — see ②.

② Semver level

minor. Rule, AGENTS.md on origin/main (blob fb8bae3e90, identical on the PR head), Post-Task Checklist step 3, lines 1067-1068: "The declaration is Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing): yes takes at least minor, (narrowing) is BREAKING, no (widening) malformed." Lines 1065-1066 set the floor for the other reading: "A bug fix in a released package takes a patch changeset — never none."

The sharper question, answered on what ships: packages/spec/package.json files includes json-schema, and npm pack --dry-run at head lists 1,527 json-schema/** files (plus 201 src/**/*.zod.ts). .meta({ title }) adds no key to the accept set (no authorable key moves; parse behaviour and the authorable-surface projections are byte-identical) — but it does add a node to a published payload: 324 new title strings across 8 shipped JSON Schema files, and build-schemas.ts L615 names json-schema/ "a public contract surface". So the declaration yes stands on the public-surface limb of the gate's own criterion (SKILL.md L476: 放宽接受集或扩大公开面) and minor follows from L1068. Had the seat read no (as #17500 did for the same mechanism), the level would still be minor, not patch: an additive, consumer-observable annotation in a published artifact is semver-minor. Either way the declared level follows. The repo's own gate agrees: check-clause2-carriers.mjs --pair 18505 → exit 0, both carriers hold needs:contract-review (PR labels and card labels read back), PR is draft.

③ Boundary flags

  • Card staleness (answered): the seat's warning that fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it #17616 "probably moved" the count was a prediction, not a reading. Population is 27 on today's tree in both io modes. Two independent reasons it could not move: stageOrder sits at depth 2 (options.properties.stageOrder, options being the row property), and fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it #17616 is a superRefine refusal, not a schema removal — the key is still declared (dashboard.zod.ts L279 on head) and still emitted. The round's phrase "removed stageOrder" is loose; its depth argument is right.
  • Re-pointed control is per-instance, not a class pin (escalated as an optional follow-up): with the head trio intact, titling the aria tombstone or the actionType tombstone passes all 109 tests in dashboard.test.ts + repeater-item-titles.test.ts (exit 0 both). The ledger pin is blind to a titled tombstone by design (it drops [REMOVED] properties before checking titles; 26/26 green under a titled actionUrl). So the spec suite pins "the tombstone rule" for 1 of 5 tombstones on this row. The PR body's "one of them" is honest; the seat's "pins the tombstone rule" is over-stated. Not blocking — this PR titles no tombstone, and fix(metadata-protocol): GET /meta/types stops publishing properties no instance can satisfy #18231 (in the PR's ancestry) strips every unsatisfiable node from the served schema regardless of title (unauthorable-nodes.ts L78 treats title as an annotation). Recommended shape, verified against the tree: in the spec: the dashboard authoring form has no channel for ITEM-level property names — header.actions[] columns render as raw keys in every locale, and columns documents a default it never declares #16458 block, iterate the entries of js.properties.widgets.items.properties whose description starts with [REMOVED] (5 in both io modes) and assert title undefined for each; keep the 'Widget ID' lit leg. Dedupe words: tombstone title class-wide control dashboard.test.ts widgets.
  • Rest "absence controls" claim is a false causal link (answered, no code effect): packages/rest/src/meta-types-schema-titles.test.ts imports only vitest and RestServer and feeds a hand-written DASHBOARD_SCHEMA() fixture through a mocked getMetaTypes; its columns.title/showTitle.title controls cannot be affected by any zod change. The live-schema fact (columns.title and header.showTitle.title undefined at head, both io modes) is true but does not "keep those controls valid".
  • Sixth tombstone attribution (answered): card/triage say "six (5 on widgets)"; the sixth is flow:nodes.outputSchema. spec: retiredKey() tombstones are emitted into a repeater row's items.properties, so the served schema offers six columns the parse door refuses #17502 is now closed via fix(metadata-protocol): GET /meta/types stops publishing properties no instance can satisfy #18231; nothing to file.
  • Session: omission on the claim comment (seat's own, advisory): the pair gate prints ATTRIBUTION NOT VERIFIED; reading stands, exit 0.
  • NOT MEASURED: Console Pin Gate (skipped by path filter needs.filter.outputs.console), Packed-tarball smoke (opt-in) (skipped), and the platform-objects dashboard-header-children pin locally (Cannot find package '@objectstack/spec/ui' — unbuilt closure, exit 1 = prerequisite, not red). Compensating reading: the PR diff touches nothing under packages/platform-objects; the en catalog blob is identical parent↔head (b2447223e8), lit header.actions.* = 4, dark widgets.*|globalFilters.* = 0.
  • CI (latest run per check NAME on the head sha, 35 names): 30 success, 5 skipped, 0 failed, 0 cancelled, 0 in progress; the fence step (Check the authorable key surface…, lint.yml L5216) runs in Type Check · source gates (success), not in "Lint & Repo Gates". Annotations on the inspected runs: 0. Combined status success.
  • Docs drift (advisory): content/docs/data-modeling/analytics.mdx names DashboardWidgetSchema; it makes no claim about column headers or titles that this diff could falsify.
  • Nits, not required: the pin's header (L33-36) still says "for the four below" against five remaining entries (pre-existing); the new test comment calls the tombstone "the one widget row property that must stay untitled" — there are five.

Implemented-by: claude/issue-17505-dashboard-repeater-row-titles
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Seat provenance (PM note, not part of the record)


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing status — pre-check complete, held on one unavailable route

Pre-check, all three conditions, read after the last state write:

  1. At-tier PASS record naming the current headfix(spec): title every authorable row property on dashboard.widgets and dashboard.globalFilters #18505 (comment), head 82838f4fac598d4374617a4b05a9435aeb138b07, which is the head read back at 2026-09-16T17:46:25Z. ✓
  2. node scripts/pm/check-clause2-carriers.mjs --pair 18505 → exit 0 at 2026-09-16T17:44:2xZ, i.e. after the carrier clear, not before it. The declaration is read from the correction comment (Clause-②-correction: 5700636082) in the fixed spelling, both carriers agree, and the script names this record as the review of record on this head. ✓
  3. Every check green, latest run per check NAME on this head, read at 2026-09-16T17:46:16Z after the label write re-triggered Check Changeset (success, 17:45:13Z): 35 names — 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, 0 cancelled, 0 in progress. A skipped lane is no verdict, not a green one. ✓

What is holding it. POST /repos/objectstack-ai/objectstack/pulls/18505/ccr/ready_for_reviewHTTP 503, body GitHub is temporarily unavailable. Retry shortly. (17:46:25Z). The same route answered 200 on PR #18501 at 17:17:41Z and has returned 503 on every call since ~17:24Z, on this PR and on PR #18486 — so it is the route, not this PR. Credential is live (core quota 14688/15000 at 17:44Z) and every other REST route used above answered normally.

⛔ No backup channel is being spent on this: the bare PATCH /pulls/{n} with {"draft": false} returns 200 and changes nothing (measured 2026-09-11), and swapping channels to get around a refusal is the thing the channel table forbids. A retry against the same route is armed; the read-back (draft: false), never the status code, is the verdict. The enqueue follows the flip, after condition 3 is re-read — flipping to ready starts a queue guard, so the roster is read again on the far side.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:ui size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Untitled repeater row properties: dashboard.widgets (17) and dashboard.globalFilters (10) — fence dropped, dispatchable now

2 participants