Skip to content

feat(spec): a repeater's property-panel table has column names, and an untitled item schema is now loud - #17500

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-17232-repeater-item-titles
Sep 10, 2026
Merged

feat(spec): a repeater's property-panel table has column names, and an untitled item schema is now loud#17500
os-bill merged 3 commits into
mainfrom
claude/issue-17232-repeater-item-titles

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

Part of #17232

Clause-②: no — .meta({ title }) is JSON-Schema presentation metadata and the new file is a test. Neither moves what any schema accepts; the round adds no key, removes none, and renames none. Measurement agrees with the declaration made at dispatch: packages/spec's full suite (471 files / 13296 tests) is green with no parse-behaviour change, and check:authorable-surface is green with no baseline movement.

Authored by the domain:spec execution seat in session session_01MkQhmuuJAVDjmeWNixwDDH, under the claim comment on #17232.

The defect: the silence, not the 21 titles

Studio renders a type: 'repeater' form field as a table whose column headers read items.properties[k].title ?? k off the JSON Schema served by GET /meta/types. That schema is derived in packages/metadata-protocol/src/protocol.ts by toJsonSchemaSafe, i.e. z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' }). The bundle overlay resolveMetadataFormSchemaTitles only ever REPLACES a title that is already there, so an item schema carrying no .meta({ title }) falls through to the raw machine key — in every locale, English included.

PR #17227 titled exactly one repeater and was scoped by dispatch to that one. Triage's ruling on this card is that the class is the defect and that a mechanism making an untitled item schema loud beats adding titles by hand. This PR follows triage, not the card's own per-domain split.

Survey — re-measured on origin/main at e758131b39, derived not regexed

Every count below comes from the platform's own predicate, walking each *.form.ts and resolving the repeater's dotted path through the derived JSON Schema. Two things a source regex gets wrong here, and both moved the answer.

22 repeater fields across 11 *.form.ts files. The card's premise holds. Lit control: git grep -oP "type: 'repeater'" over the 11 files returns 22. Dark control: the other 6 form files (agent, tool, hook, position, permission, email-template) return 0, and the pin asserts the four registered ones contribute no carrier. A naive grep -c would have counted object.form.ts at 1 line while the file also carries value: 'repeater' as a picklist OPTION on line 104 — a mention, not a declaration.

The class is 20 untitled tables today, not 21. action.locations is a repeater over an array of enum STRINGS: its items has no properties at all, so it renders no column headers and leaks no key. It is not a carrier. Dropping it from the count is a correction, not a convenience — the pin names it explicitly so an object-shaped repeater cannot land in that slot silently.

37 .meta({ title occurrences across packages/spec/src/**/*.zod.ts — 32 driver-config, 1 system/translation.zod.ts, 4 ui/dashboard.zod.ts from #17227. The card's premise holds exactly. Spot control reproduced: git grep -nP 'meta\(\{ title' packages/spec/src/ui/view.zod.ts packages/spec/src/ui/dataset.zod.ts returned nothing.

view.columns is a carrier, and only a per-arm derivation sees it. ViewMetadataSchema is a four-arm union in which columns is an object array on the list arm and an INTEGER (form body columns) on the form arm. A derivation that merges arm properties keeps whichever zod emitted last and reports the integer — which is exactly what a first pass did. The pin keeps every union arm as a separate candidate and prefers an object-item array.

What changed

Thirteen carriers titled, 83 row properties: action.params, app.areas, dataset.dimensions, dataset.measures, flow.nodes, flow.edges, flow.variables, page.variables, page.regions, page.interfaceConfig.sort, report.order, report.blocks, skill.triggerConditions. page.interfaceConfig.sort is titled through the shared SortItemSchema it composes (shared/enums.zod.ts).

The mechanism is packages/spec/src/kernel/repeater-item-titles.test.ts — a pin, not a scripts/ gate, so it touches no fenced path and runs in the package suite it belongs to. It enumerates every repeater from every *.form.ts, derives each row schema through z.toJSONSchema, and requires a title on every authorable row property. Carriers still owed one sit in an EXACT, shrink-only ledger: a repeater absent from the ledger must be fully titled, and a ledger entry whose debt is paid must be deleted. A new repeater is therefore red on the day it lands.

Two exclusions, each with its own control test:

  • a retiredKey() tombstone is a parse-time refusal, not an authorable column — flow.nodes[].outputSchema is excluded, and the control asserts its live sibling inputSchema IS read;
  • a scalar-item repeater has no row properties to name, pinned by name.

A bug in the pin itself, found by the pin. A .meta({ title }) on a schema zod hoists into $defs is emitted as a SIBLING of the $ref{ "title": "Filter", "$ref": "#/$defs/__schema0" } — which is precisely where the console reads items.properties[k].title from. Following the ref first reported two genuinely titled properties (dataset.measures.filter, report.blocks.runtimeFilter) as untitled. Corrected to read the property node first, which is what Studio does.

Reverse verification

The pin can fail, proved twice, both on committed trees.

Natural red, before any title landed (7815458710): 13 failed / 13 passed, one failure per free carrier, each naming its own untitled properties.

Ablation at 11c36afa2c, with on-disk proof and a restore leg. .meta({ title: 'Name' }) was removed from FlowVariableSchema.name: anchor grep 1 before / 0 after, replacement grep 1, blob 93f7c830 moved to d9869057. Mutated run: 1 failed / 25 passed, and the one failure is flow:variables ... expected [ 'name' ] to deeply equal [] — the named carrier, not a collateral. Restore leg: blob back to 93f7c830, byte-identical to HEAD, git diff HEAD empty.

Localisability, answered against the code

Hard-coded English is the intended authoring layer, not a gap. packages/spec/src/system/translation.zod.ts states it in those words: a row property "is rendered from the JSON Schema (items.properties[k].title), not from a form-field spec, so its label here is applied by resolveMetadataFormSchemaTitles as the schema node's title; the zod item schema's own .meta({ title }) is the English name it overlays." Localisation is a separate additive layer keyed metadataForms.TYPE.fields.REPEATER.PROPERTY.label, and the English catalog leaf is generated from the form's declared child label, not from the zod title.

This round enumerates no repeater children in any form file, so os i18n extract emits no new catalog key and no catalog moves — dashboard-header-children.test.ts stays green (11 passed). Until those leaves are authored, a non-English panel shows the English title instead of the machine key, which is strictly better than today.

What remains, named and counted

Seven carriers stay on the ledger, 64 row properties, because their item schemas live in files held by other in-flight PRs. This PR OBSERVES them and does not edit them, which is why the ledger states the whole class rather than the slice one round could reach.

Ledger entry Row props owed Item schema lives in Held by
dashboard:widgets 17 ui/dashboard.zod.ts #17474
dashboard:globalFilters 10 ui/dashboard.zod.ts #17474
view:columns 14 ui/view.zod.ts #17360
view:tabs 9 ui/view.zod.ts #17360
view:sort 2 ui/view.zod.ts #17360
field:options 6 data/field.zod.ts #17477
object:fields.options 6 data/field.zod.ts (the same SelectOptionSchema) #17477

Six retired-key tombstones are excluded from those totals by the pin, five on dashboard.widgets and one on flow.nodes.

The catalog gap, counted rather than machine-translated: a full localisation of all 21 object-row carriers is 604 leaves across the four shipped locales (en, zh-CN, ja-JP, es-ES), of which 348 belong to the 87 properties titled today. Each also needs its children enumerated in the owning *.form.ts so the extractor emits the key. No product copy was invented for any locale.

Verification

Package suite pnpm --filter @objectstack/spec exec vitest run --project local — 471 files / 13296 tests passed, exit 0.
pnpm --filter @objectstack/spec typecheck — exit 0 (tsc --noEmit, check:scripts-typecheck, check:test-typecheck; the new file compiles clean and adds no ledger entry).
Consumer of this exact mechanism: packages/rest/src/meta-types-schema-titles.test.ts — 3 passed, after building the @objectstack/rest dependency closure.
Catalog pin: packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts — 11 passed.
Gates green: check:api-surface, check:authorable-surface, check:exported-any, check:export-origins, check:docs, check:liveness, check:variant-docs, check:llms-txt, check:skill-refs, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:spec-parsed-alias, check:type-check-coverage, check:published-files, check-adr-0087-registration, check-empty-changeset, check-changeset-no-major, check-spec-docblock-symbol-anchors, check-comment-mask-adoption, check-keyed-text-bounds, check-undeclared-dep-imports, check-registry-log-declared, check-closing-keyword-parity.
Lint is the whole repo, not a narrowing: eslint . --no-inline-config --format json at c29949f4 — 6569 files, 0 errors, 0 warnings.

The clause-② checker disagrees with this declaration, and the disagreement is measured

node scripts/pm/check-clause2-carriers.mjs --pair 17500 exits 4 (report-only; it writes no label and the lint workflow runs only its --self-test). It reads 8 T1 "widening tells" — "a new key on a Zod object schema" — at ai/skill.zod.ts:198,201, ui/action.zod.ts:434, ui/dataset.zod.ts:126, ui/page.zod.ts:47,409, ui/report.zod.ts:86,222.

The declaration stays no, and the reason is a measurement rather than an assertion. Every one of those 8 keys already exists on origin/main: operator, value, requiresFeature, type, width, type, direction, type are each declared 1–3 times in their own file at e758131b39, against a dark control (a key that genuinely is not there) reading 0. The diff pairs them: each flagged + line has a matching - line differing only by the appended .meta({ title: ... }), shown here for the two in page.zod.ts. Across every edited *.zod.ts, added key-lines and removed key-lines are 67 and 67 — a one-for-one rewrite with zero net key additions.

The tell is a false positive of a line-based detector: an existing key whose line is rewritten to append a chained call reads as an added key when the + side is scored on its own. Nothing here changes what any schema accepts, and the package's 13296 tests — including the strict-object refusal pins — are green. Recorded rather than routed around: the seat, not this branch, owns the Clause-② line on the claim comment and any needs:contract-review label that follows from it.


Generated by Claude Code

Studio renders a `type: 'repeater'` form field as a table whose column
headers read `items.properties[k].title ?? k` off the JSON Schema served
by `GET /meta/types`. An item schema with no `.meta({ title })` falls
through to the raw machine key in every locale, English included.

The class was silent: one repeater was titled and the next one to land
would reproduce the defect with every gate green. This pin enumerates
every repeater declared across every `*.form.ts` in the package, derives
each row schema through the platform's own `z.toJSONSchema` predicate,
and requires a title on every authorable row property — with an exact,
shrink-only ledger for the carriers still owed one.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
Every row property of `action.params`, `app.areas`, `dataset.dimensions`,
`dataset.measures`, `flow.nodes`, `flow.edges`, `flow.variables`,
`page.variables`, `page.regions`, `page.interfaceConfig.sort`,
`report.order`, `report.blocks` and `skill.triggerConditions` now carries
a `.meta({ title })`, so Studio's property-panel table shows an authoring
name instead of the raw machine key.

`page.interfaceConfig.sort` is titled through the shared `SortItemSchema`
in `shared/enums.zod.ts`, which is the item schema it composes.

The pin's title read is corrected in the same commit: a `.meta({ title })`
on a schema zod hoists into `$defs` is emitted as a SIBLING of the `$ref`
(`{ title, $ref }`), which is where the console reads it from — following
the ref first reported a titled property as untitled.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
Also drops an unused `depth` parameter from the pin's `deref` helper,
which `check:test-typecheck` reads as TS6133 inside its checked zone.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ai protocol:ui tests labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/analytics.mdx (via DatasetMeasureSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/storage-service.mdx (via record_id (literal, a string literal in PageVariableSchema))
  • content/docs/kernel/runtime-services/audit-service.mdx (via record_id (literal, a string literal in PageVariableSchema))
  • content/docs/permissions/record-view-auditing.mdx (via record_id (literal, a string literal in PageVariableSchema))
  • content/docs/protocol/kernel/http-protocol.mdx (via record_id (literal, a string literal in PageVariableSchema))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via record_id (literal, a string literal in PageVariableSchema))
  • content/docs/ui/apps.mdx (via NavigationAreaSchema (symbol, a top-level const))
  • content/docs/ui/pages.mdx (via record_id (literal, a string literal in PageVariableSchema))

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

  • content/docs/releases/v16.mdx (via ActionParamSchema (symbol, a top-level const), record_id (literal, a string literal in PageVariableSchema))
  • content/docs/releases/v17/17-0.mdx (via ActionParamSchema (symbol, a top-level const), FlowEdgeSchema (symbol, a top-level const), FlowVariableSchema (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 — 135 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 2bed4c328d48dc36db69cd999200ef366e73c543packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2bed4c328d48dc36db69cd999200ef366e73c543

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

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Seat at-tier review — PASS with one advisory, zero must-fix.

Head c29949f492bcb90bd5afd7169a0f52c9b96d73a8. Readings 2026-09-10T18:37–18:39Z, gates from a clean worktree at origin/main 634f23de26.

Landing pre-checks

# check reading
at-tier verdict PASS (this comment). Clause ② declared no and the declaration is measured, not deferred to — see below
--pair 17500 exit 4, C5-only. Disposition: filed, ⛔ not held
check-governed-merges --test, final 10 paths exit 0 (not governed); lit control AGENTS.mdexit 3
checks on this head 34 distinct names, 0 pending, 0 failing; combined status success

② — why exit 4 does not hold this PR

The standing C5 ruling: an exit 4 whose only rows are tells measured false, on a pair whose declaration limb is satisfied, is a card to file, not a hold. Both conditions hold, and I measured the tells myself rather than accepting the round's reading:

(1) all 8 flagged keys pre-exist on merge-base e758131b3900   1·1·2·3·1·3·1·2    dark control 0
(2) key-declaration lines in the diff: 67 added / 67 removed
    normalising the appended .meta({title}) and the reflow comma:
      identical multisets : True     NET NEW KEYS : 0     per-key-name delta : NONE

(2) is the discriminating leg — (1) alone is compatible with a file gaining a fourth type: on a new schema. Recorded on #17300, which now stands at six PRs / 16 instances / zero true positives. Clause-②: no stands: .meta({ title }) is JSON-Schema presentation metadata and moves no accept set.

The mechanism is sound, and bidirectional as claimed

I read the assertion block in full rather than inferring it from the failure messages. The ledger is genuinely shrink-only in both directions, plus a third guard:

  • not in ledger + untitled → fails, and the message explicitly forbids the tempting fix: "⛔ do not add this carrier to LEDGER". A new untitled repeater reds on arrival.
  • in ledger + fully titled → fails with "delete its LEDGER entry". A paid debt cannot quietly outlive itself.
  • ledger names a carrier no form declares → fails. A stale entry is a rule guarding nothing.

And the population is derivedz.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any', io: 'input' }), the platform's own predicate — not a hand-maintained list, with FORMS.length pinned at 15 and a retired-key exclusion control. That is what makes the survey trustworthy, and it is what caught the card's own premise error (20 untitled carriers, not 21: action.locations is a repeater over enum strings with no items.properties).

⚠️ Advisory (not blocking) — "shrink-only" holds at CARRIER granularity, not PROPERTY granularity

LEDGER is a ReadonlySet<string> of ids — measured, it carries no counts. For a ledgered carrier the assertion is untitled.length > 0.

⇒ A carrier already on the ledger can gain a new untitled row property and stay green. The ratchet catches a new untitled carrier on arrival, but not a new untitled property on an already-owed carrier.

That is tolerable today: all six ledgered carriers are known, and #17505 / #17506 / #17507 exist to empty them. It is worth knowing because the natural reading of "exact, shrink-only ledger" is stronger than what is enforced. If a count were stored per entry, the debt could only shrink. ⛔ Not requested as a change here — the ledger empties before it would matter, and widening the pin now would collide with those three cards.

Also confirmed

  • Touches no fenced file: none of ui/view.zod.ts, ui/dashboard.zod.ts, data/field.zod.ts appears in the 10 final paths. The pin observes those carriers without editing them, which is the shape triage preferred.
  • The $ref-sibling title bug the pin found in itself — a .meta({ title }) on a schema zod hoists into $defs is emitted as a sibling of the $ref, so deref'ing first reports two genuinely-titled properties as untitled — was corrected to read the property node as Studio does. That one would have shipped as two false ledger entries.

⛔ Card #17232 stays OPEN: this PR is Part of, and 64 row properties plus the catalog work remain (#17505, #17506, #17507, #17508).

Enqueueing.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 10, 2026 18:38
@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit d2badf7 Sep 10, 2026
42 checks passed
@os-bill
os-bill deleted the claude/issue-17232-repeater-item-titles branch September 10, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants