feat(spec): curated fieldGroups and indexes repeaters on the object form (#19332, flight G2a) - #20449
Conversation
…orm (#19332, flight G2a) The object form offers object.fieldGroups and object.indexes as repeaters with declared sub-rows: the six canonical field-group keys, and the three keys the SQL driver reads for an index, with unique offering only the global and organization scopes. The three deprecated collapse aliases of a field group get nested omit rows in the reconciliation ledger. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…s repeaters The 22 new metadata-form leaves are regenerated from the object form, and authored in zh-CN, ja-JP and es-ES instead of left as English fills. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…e two repeaters; add the changeset The object form's new fieldGroups (basics) and indexes (advanced) repeaters move three measured population pins mechanically: the open section leaves 100 -> 114, the collapsed section leaves 61 -> 69 (advanced 52 -> 60), and the translated-label control 633 -> 644. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
… field group's The fieldGroups repeater's icon sub-row (a group header's Lucide icon) is a second, different subject from the withdrawn options-repeater icon; the pin now names both by where they sit. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…rties The fieldGroups and indexes repeaters' row schemas carry a title on every authorable property, as the repeater-title class guard requires: IndexSchema name / fields / unique and ObjectFieldGroupSchema's nine keys, the three deprecated collapse aliases included. Titles only; no accept set moves. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
Conflict in object-lifecycle-panel-echo-decisions.test.ts resolved by stacking both intents on the translated-label control: 633 - 10 (the retired view tabs repeater, #20301) + 11 (this flight's rows) = 634. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 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 648bbfe62a884636466fcc9945ebee17e84f9a2e && git checkout 648bbfe62a884636466fcc9945ebee17e84f9a2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b285508188ebe9cf14cd1ee621ea857f688a938b c22fc1e2f5e74f32d6bb64befe03dba065027ae5 && git checkout -B drift-repro b285508188ebe9cf14cd1ee621ea857f688a938b && git merge --no-ff c22fc1e2f5e74f32d6bb64befe03dba065027ae5
node scripts/docs-audit/affected-docs.mjs --json b285508188ebe9cf14cd1ee621ea857f688a938b
|
Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #19332 (body + all 19 comments), PR #20449 (body, 11-file list, net diff vs Check-runs on the head (the gate verdicts): 42 completed — 37
① Derived judgments
② Semver level
Clause-②: no ③ Boundary flagsDev flags (patch-round report
The brief's four claims:
Carrier-none notes in the PR body, each verified at the pin and none barring the PR (the ruling prescribed this row and this face): Escalated for the landing seat, not a defect of this diff: Implemented-by: VERDICT: PASS |
Part of #19332
Flight G2a of ruling 5861442317.
Clause-②: no
Status: draft, no open gap
The first round stopped at one red class guard,
packages/spec/src/kernel/repeater-item-titles.test.ts(#17232), because its fix sat in a file the claim forbade. The seat amended claim5869305892in place (its "Amended 2026-09-28T13:26Z" line).packages/spec/src/data/object.zod.tsjoined the surface for.meta({ title })on the item properties ofIndexSchemaandObjectFieldGroupSchemaonly. The 12 titles landed ine8bdb1ad(Row titles below), and the guard is green.What
Two live keys that
ObjectSchemadeclares had no form row, so an author could reach them only through the Source tab. Each is now atype: 'repeater'row on the object form with hand-written sub-rows, as the ruling says: 「G2 (…) — hand-written curated sub-rows, plus the three nestedomitrows underfieldGroups(the[DEPRECATED → collapse]aliases,object.zod.ts:1206-1210) …indexes.uniqueoffersglobal/organizationonly.」 The four-locale catalogue rows are in this PR.object.fieldGroupsobject.form.ts, Basics, besidehighlightFieldskey,label(required text);icon(text);description(textarea);collapse(select:none/expanded/collapsed);visibleWhen(type: 'code',language: 'expression')object.form.tsfields.options(declared, labelled sub-rows).key/label/iconcopy the plain text rowsname/label/iconin Basics;descriptioncopies Basicsdescription;collapsecopies thelifecycle.classselect (every member is a spellable option value);visibleWhencopiesfields.visibleWhen(object.form.ts, same node type,EvaluatedExpressionInputSchema)object.indexesobject.form.ts, Advanced, besidedatasourcename(text);fields(widget: 'string-tags', required);unique(select:global/organizationonly)fields.optionsrepeater face;fieldscopies thehighlightFieldsrow (a chip input overstring[]);uniqueis a declared select for the reason belowLedger: three nested
omitrows atobject/fieldGroupsinmetadata-form-zod-reconciliation.test.ts, one per alias:defaultExpanded(line 1206 onmain),collapsible(1208),collapsed(1210). Row shape: thepage/interfaceConfig/sourceViewrow at the top of the ledger, which is the existing nestedomitfor a deprecated alias.indexesneeds no ledger row: its other two keys,typeandpartial, areretiredKey()tombstones and are excused automatically.Row titles:
object.zod.tsgives both row schemas a JSON Schematitleon every property, 12 in all (Row titles below).The help text states what the runtime does, and each claim was checked against its reader:
iconrenders on the record detail page only. At the.objectui-shapinf8a9d0fb, plugin-detailDetailSectiondraws it; plugin-formfieldGroups.tsdoes not copy it, becauseObjectFormSectiondeclares noicon.descriptionandcollapserender on both the entry form and the detail page (fieldGroups.tsandderiveFieldGroupDetailSections).visibleWhengates the entry form's whole group (projectSectionDivider). The help text claims only the form.keythat is not snake_case, or is duplicated, is refused by the parse (probe below). A field whosegroupnames no declared key is ungrouped (deriveFieldGroupLayout).indexes:SqlDriver.syncTableIndexesis additive only, so a sync never drops an index. An unsetnamebecomesbuildIndexName(idx_TABLE_COLUMNS,uniq_…for a unique index).Where a misspelt field name is refused, read from the code
The ruling's 「a misspelling is refused loudly at parse」 does not hold here, the same as for G1b's lists.
indexes[].fields: no authoring door judges it.{ fields: ['statsu'] }on an object that declares onlystatus(probe below).validate-object-field-refs.tsexcludes the list by design, as a storage question for the registration path. No other lint rule, and so neither the publish door noros validate, reads it for existence.syncDeclaredIndexesskips the whole index and logsskipping declared index … column(s) not materializedatwarn.fieldGroupshas no field-name list. A field joins a group through its owngroupkey, so there is no name here for a misspelling to hide in.unique: how the row treats a storedtrueorfalseboolean | 'global' | 'organization', defaultfalse.true. No option can spell a boolean either:FormSelectOptionSchemarefusesvalue: true(probe). So the row is a select that declares exactly the two scopes.globalandorganization. Both parse today, and neither is the spelling protocol 18 refuses.trueorfalseis left untouched on save. objectui'sRepeaterField.updateat the pin writes{ ...row, ...patch }, souniquechanges only when the author picks a scope.String(value), and no option matches'true'or'false'. Reading the Radix select, the trigger is then blank and does not show the placeholder. This is a code reading only, with no browser run. The help text therefore claims only the merge: "The deprecated bare true (it means global) is not offered; an index that carries it keeps it until you pick a scope."EmbeddedItemEditor.tsxFALLBACK_SCHEMAS.index): it offersglobal/organizationfor a new index and leaves a legacy boolean alone.Row titles (landed by claim amendment)
repeater-item-titles.test.ts(spec: every repeater item schema except dashboard header.actions still has no JSON Schema title, so 21 property-panel tables render machine keys in every locale #17232) is the class guard for "a repeater's property-panel table shows raw keys". It derives each repeater's row schema fromz.toJSONSchema(…, { io: 'input' })and requires atitleon every authorable row property. That includes the three deprecated aliases, because the guard reads the schema, not the form. Its ledger says 「⛔ Never add an entry to LEDGER to make this file green」.e8bdb1ad). 12.meta({ title })calls inpackages/spec/src/data/object.zod.ts, the ones the first round measured:IndexSchema:name→ 'Name',fields→ 'Fields',unique→ 'Unique';ObjectFieldGroupSchema:key→ 'Key',label→ 'Label',icon→ 'Icon',description→ 'Description',visibleWhen→ 'Visible When',collapse→ 'Collapse',defaultExpanded→ 'Default Expanded',collapsible→ 'Collapsible',collapsed→ 'Collapsed'.view.form.tsrow-property convention.object.zod.tsyields the previous commit's file byte for byte. The guard and the reconciliation test are green together (2 files, 85 tests).check:generatedreads "All 15 generated artifacts are up to date" on the merged head, so nothing regenerated and no accept set moved. The changeset now names the titles as part of the served JSON Schema and staysClause-②: no.Residue of the reconciliation gate (dispatch assumption 1)
The gate's own helper block was copied verbatim into a scratch probe that was never committed. At base, that block is lines 1-808, prefix sha256
91478ba8d05c70b7…, the same prefix G1b read. The probe ran in a scratch worktree detached at the basee4d3f2ca. Residue = offerable root keys − offered − rootomitrows, per type, withviewapart.Controls, asserted inside the probe:
nameis offered by 17 of 17 forms;object.zzFabricated19332G2aandobject.nameare in no residue;object.activityMilestones(G2b) is in the residue on both trees.e4d3f2caa6f19eb2,git diffagainst it empty)Removed:
object.fieldGroups,object.indexes. Added: none. The four left are the G2b keys:object.activityMilestones,object.publicSharing,object.userActions,field.inlineColumns.Nested reading on the same tree, through the gate's own
reconcileNestedLists:object.fieldGroupsreadszodOnly = [collapsed, collapsible, defaultExpanded]. With them, it reads[].object.indexesreadszodOnly = []andunanchored = false, with keysfields, name, partial, type, uniqueand retiredpartial, type.So the three rows are exactly what the gate needs.
Parse probe on the same tree:
IndexSchema:'global'and'organization'pass;trueandfalsepass (17.x);'tenant'fails withinvalid_union.ObjectSchema:true);collapse: 'collapsed'besidecollapsible: true, collapsed: falsekeeps'collapsed';defaultExpanded: false) derives'collapsed';'Bad Key'and a duplicate key are both refused.Pins moved (measured, mechanical)
object-collapsed-sections-echo-decisions.test.tsadvancedindexes+ 3 sub-rows, 8 leavesfieldGroups+ 6 sub-rows, 14 leavesobject-lifecycle-panel-echo-decisions.test.ts.labelcontrolorigin/mainmerge brought #20357's move of the same pin to 623 (itsviewtabsrepeater left, 10 labels), and the conflict was resolved by stacking both intents: 633 − 10 + 11 = 634packages/spec/src/data/field-rows-option-description.test.tsiconinputs on the object formfieldGroups.iconsub-row. Outside the claim's named surface: a population pin the new row moves mechanically. The pin now names both inputs by where they sit, and the options repeater's no-iconassertions are untouchedNo lint census pin moved:
packages/lint/src/validate-predicate-path-refs.test.tspasses unchanged (1 file, 54 tests).Verification
Test runs went through
scripts/pm/os-verify-lock.sh. Real lines. The suites ran on the merged head215603c8, whose tree equals the final headc22fc1e2except the changeset prose:pnpm --filter @objectstack/spec test215603c8Test Files 564 passed (564)·Tests 16641 passed | 1 todo (16642)pnpm --filter @objectstack/spec test:repo215603c8Test Files 37 passed (37)·Tests 684 passed (684)pnpm --filter @objectstack/platform-objects test215603c8Test Files 55 passed (55)·Tests 911 passed (911). The echo pins hold after the merge: 634, 69 / 60 and 114repeater-item-titles.test.ts+metadata-form-zod-reconciliation.test.tse8bdb1adpnpm --filter @objectstack/spec typecheck215603c8check:test-typecheck: OK — … 53 file(s) / 251 error(s) / 138 pinned signature(s) heldpnpm check:i18n(after the closure build it names, 59 tasks, at215603c8)215603c8check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys). The textually merged catalogues equal a fresh extractpnpm --filter @objectstack/spec check:generated215603c8All 15 generated artifacts are up to datepnpm --filter @objectstack/platform-objects typecheck92fb68dacheck:test-typecheck: OK — … 1 file(s) / 3 error(s) / 2 pinned signature(s) heldsrc/validate-predicate-path-refs.test.ts92fb68datest/i18n-coverage.test.ts,test/i18n-duplicate-demand.test.ts92fb68dasrc/protocol.meta-types-*.test.ts92fb68daCatalogues:
node scripts/check-i18n-bundles.mjs --writeregenerated the 22enleaves. The 66 translated leaves were then authored in zh-CN, ja-JP and es-ES, with no en echo. A second--writekept every translated value and left no source-hash row, net zero.Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 86 commands at the final headc22fc1e2. That is the first round's 85 pluscheck:skill-identifier-liveness, whichobject.zod.tsbrings in. All 86 ran on that head, each exit code went to disk before it was read, and every first run exited 0.--ranreports:86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED.No ablation. This PR adds rows, three ledger rows and pin moves, and no guard. The nested with/without reading above is the measurement that the ledger rows are load-bearing.
Acceptance notes
fieldGroupsrow on the Studio object edit page. At the pin,ResourceEditPage.tsxCANVAS_OWNED_KEYS.object = ['fields', 'fieldGroups'], because the form designer owns both. The row still reaches every other consumer of the served form:getMetaTypes(), the catalogues, and the reconciliation direction. The ruling chose the row knowing the designer edits groups. Carrier: none.ObjectGroupInspectorcomment is stale. It says a group'siconanddescriptionhave no consumer, butDetailSectionrenders both at the pin. It is only a comment. Carrier: none.fieldGroups.visibleWhensharesCodeWidget's envelope bound. A stored ADR-0089 envelope shows as[object Object], and the first edit overwrites it. Carried by objectui#10963, the class fix for everytype: 'code'expression row.uniqueselect cannot be cleared. Once a scope is picked, making the index non-unique again means removing and re-adding the entry, or editing the source. This is a generic select bound. Carrier: none.true/falsedisplay is not browser-run. That it shows blank is a reading of the Radix select, not a measurement.origin/mainwas merged once, withscripts/pm/os-regen-merge.sh, at6e3e5462, because feat(spec)!: retire the list view's owntabskey; named presets arelistViewsentries #20357 had moved the catalogues and the lifecycle pin. The only conflict was that pin (resolved above). No os-regen path needed main's side, and the rebuild regenerated nothing.origin/mainhas moved since, but not onto any file in this diff, so it was not merged again.Out-of-scope finding (the seat folds it into #20432's family)
ObjectSchemaparses an index column that names no field.os validatehave no rule for it.warn. For auniqueindex, that leaves a declared constraint unenforced while the system looks normal. The sibling duplicate-row skip in the same function logs aterrorthroughlogDurabilityFailure.relatedListColumns,lookupColumns,lookupFilters[].fieldordependsOnpasses every authoring door, and fails only at view or picker time #20432 (field-name reference integrity).indexes fields unknown column,declared index skipped column not materialized,index field reference integrity,unique index not enforced warn.Generated by Claude Code