feat(spec): a repeater's property-panel table has column names, and an untitled item schema is now loud - #17500
Conversation
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>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Seat at-tier review — PASS with one advisory, zero must-fix.Head Landing pre-checks
② — why exit 4 does not hold this PRThe 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: (2) is the discriminating leg — (1) alone is compatible with a file gaining a fourth The mechanism is sound, and bidirectional as claimedI 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:
And the population is derived —
|
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, andcheck:authorable-surfaceis green with no baseline movement.Authored by the
domain:specexecution seat in sessionsession_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 readitems.properties[k].title ?? koff the JSON Schema served byGET /meta/types. That schema is derived inpackages/metadata-protocol/src/protocol.tsbytoJsonSchemaSafe, i.e.z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' }). The bundle overlayresolveMetadataFormSchemaTitlesonly 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/mainate758131b39, derived not regexedEvery count below comes from the platform's own predicate, walking each
*.form.tsand 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.tsfiles. 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 naivegrep -cwould have countedobject.form.tsat 1 line while the file also carriesvalue: 'repeater'as a picklist OPTION on line 104 — a mention, not a declaration.The class is 20 untitled tables today, not 21.
action.locationsis a repeater over an array of enum STRINGS: itsitemshas nopropertiesat 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({ titleoccurrences acrosspackages/spec/src/**/*.zod.ts— 32 driver-config, 1system/translation.zod.ts, 4ui/dashboard.zod.tsfrom #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.tsreturned nothing.view.columnsis a carrier, and only a per-arm derivation sees it.ViewMetadataSchemais a four-arm union in whichcolumnsis 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.sortis titled through the sharedSortItemSchemait composes (shared/enums.zod.ts).The mechanism is
packages/spec/src/kernel/repeater-item-titles.test.ts— a pin, not ascripts/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 throughz.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:
retiredKey()tombstone is a parse-time refusal, not an authorable column —flow.nodes[].outputSchemais excluded, and the control asserts its live siblinginputSchemaIS read;A bug in the pin itself, found by the pin. A
.meta({ title })on a schema zod hoists into$defsis emitted as a SIBLING of the$ref—{ "title": "Filter", "$ref": "#/$defs/__schema0" }— which is precisely where the console readsitems.properties[k].titlefrom. 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 fromFlowVariableSchema.name: anchor grep 1 before / 0 after, replacement grep 1, blob93f7c830moved tod9869057. Mutated run: 1 failed / 25 passed, and the one failure isflow:variables ... expected [ 'name' ] to deeply equal []— the named carrier, not a collateral. Restore leg: blob back to93f7c830, byte-identical toHEAD,git diff HEADempty.Localisability, answered against the code
Hard-coded English is the intended authoring layer, not a gap.
packages/spec/src/system/translation.zod.tsstates it in those words: a row property "is rendered from the JSON Schema (items.properties[k].title), not from a form-field spec, so itslabelhere is applied byresolveMetadataFormSchemaTitlesas the schema node'stitle; the zod item schema's own.meta({ title })is the English name it overlays." Localisation is a separate additive layer keyedmetadataForms.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 extractemits no new catalog key and no catalog moves —dashboard-header-children.test.tsstays 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.
dashboard:widgetsui/dashboard.zod.tsdashboard:globalFiltersui/dashboard.zod.tsview:columnsui/view.zod.tsview:tabsui/view.zod.tsview:sortui/view.zod.tsfield:optionsdata/field.zod.tsobject:fields.optionsdata/field.zod.ts(the sameSelectOptionSchema)Six retired-key tombstones are excluded from those totals by the pin, five on
dashboard.widgetsand one onflow.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.tsso 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/restdependency 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 jsonatc29949f4— 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 17500exits 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" — atai/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 onorigin/main:operator,value,requiresFeature,type,width,type,direction,typeare each declared 1–3 times in their own file ate758131b39, 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 inpage.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 theClause-②line on the claim comment and anyneeds:contract-reviewlabel that follows from it.Generated by Claude Code