fix(core,plugins): honour only the data spelling each block's spec row declares - #9234
Conversation
…row declares
The shared record-source ladder returned an authored `data` verbatim for every
block, so two spellings of one key were live at once and in opposite
directions: `object-calendar`, whose `ComponentPropsMap` row is
`z.array(z.unknown())`, honoured the `{ provider, items }` config object the
row refuses by kind; `object-grid` and `object-map`, whose rows are the
`ViewData` object union, lifted a bare array their rows refuse by kind. Both
shapes are rejected by `os validate` and the save gate, and the renderer
honoured them anyway — the second de-facto contract AGENTS.md #0.1 exists to
stop.
`resolveRecordSourceConfig` now takes the arm the calling block's published
`data` row declares, and rung 1 applies only on that arm. The arm is a required
parameter rather than a lookup on `schema.type`, because every one of these
renderers is registered twice (`object-grid` and the `view:grid` alias, and so
on), so a table keyed by type would answer for one tag and miss the other.
Per block, measured on the `@objectstack/spec` this repo resolves (17.4.0):
object-calendar honours the array arm; object-grid, object-map and object-gantt
honour the ViewData arm, and grid's and map's normalizing heads are removed;
object-tree publishes no `data` row on any face, so neither arm of the ruling
reaches it and nothing about it changes.
Stored documents authored against the tolerated spellings stop rendering those
rows — accepted by the ruling, with no transition window. Two pins that
contradicted their block's row are corrected to the spec, never the spec to the
code: the `object-grid.data` declaration pin's docblock and the shared ladder's
own.
Part of #8348
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…lock
Measured while pinning the grid half: an authored `data` array reaches
`ObjectGrid` and `ObjectMap` twice. `SchemaRenderer` spreads every non-metadata
node key as a React prop and both wrappers forward it, and each component lifts
an array arriving on that prop to `{ provider: 'value', items }` at higher
priority than the ladder — the channel a host such as `ListView` uses for rows
it already fetched, indistinguishable at the component boundary from an
authored key.
So removing the ladder head is real at the ladder and is NOT, on its own, an
end-to-end change for a node rendered through `SchemaRenderer`. The first draft
of the grid pin asserted otherwise and went red, which is how this was found.
Both pins now state the verdict per carrier and the surviving one is reported
rather than changed: collapsing it would take the host path with it.
`object-calendar` is unaffected by this caveat and keeps the full end-to-end
change — its boundary keeps `data` only when `Array.isArray`, so the config
object the ruling removes had exactly one carrier.
The changeset and both renderer docblocks are corrected to match, and the grid
fixtures that spelled inline rows as a bare array move to the declared
`{ provider: 'value', items }` form — except the two that deliberately keep the
array, one because it uses the props carrier and one because it asserts the
retirement.
Part of #8348
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
PM ruling on both open questions, and Contract review — head
|
Part of #8348 — the ladder half of decision batch #83. ⛔ Deliberately not a closing keyword: the card also carries findings this PR reports rather than repairs (see the last section), so the PM lands it and decides the card's state.
The ruling this implements
Decision batch #83 (2026-09-08), maintainer verbatim, kept in the original and untranslated:
⇒ option A. A renderer honours the
dataspelling its block's published row declares and no other: whatos validateand the save gate refuse, the renderer refuses too. Where a pin contradicts its block's row, the pin is corrected to the spec — never the spec to the code.What changed
resolveRecordSourceConfig(@object-ui/core) now takes a second, required argument: the arm the calling block's publisheddatarow declares (view-data,array, orundeclared). Rung 1 of the ruled three-rung ladder applies only on that arm; rungs 2 and 3 are untouched, as is their order.The arm is passed at the call site rather than looked up from
schema.type. Every one of these renderers is registered twice —object-gridplus theview:gridaliasgrid,object-calendarpluscalendar, and so on — so a table keyed by type would answer for one tag and silently miss the other. A required parameter makes the arm a compile-time obligation at each site instead; it is what caught all five sites in onetype-checkrun.The delivery surface: all five renderers, enumerated from the ladder itself
git grep -n resolveRecordSourceConfig -- packages apps examplesreturns exactly five calling renderers —ObjectCalendar.tsx,ObjectGantt.tsx,ObjectGrid.tsx,ObjectMap.tsx,ObjectTree.tsx— which is also the list the shared function's own docblock names.object-kanbanis deliberately not among them: it walks its own resolver, pinned bypackages/types/src/__tests__/object-kanban-record-source-7780.test.ts, which asserts zeroresolveRecordSourceConfigin that block.Per block, judged against its own row
Rows measured on
@objectstack/spec17.4.0, which is whatpnpm-lock.yamlresolves for this repo, read fromnode_modules/@objectstack/spec/dist/uiin this worktree afterpnpm install— not from anobjectstackcheckout, so the reading is the published package.datarowobject-calendarComponentPropsMaprow:z.array(z.unknown()).optional(), "Pre-fetched records — skips the internal fetch"array{ provider, … }config object stops being a record sourceobject-gridComponentPropsMaprow: theViewDataunion; its own description says "the bare-array shortcut is refused"view-datagetDataConfig'sArray.isArrayhead removedobject-mapComponentPropsMaprow;ObjectMapSchema.data=ViewDataSchema.optional()view-dataobject-ganttComponentPropsMaprow;ObjectGanttSchema.data=ViewDataSchema.optional()view-dataobject-treeundeclaredobject-calendar: a stored calendar authoreddata: { provider: 'value', items: [...] }(or any other provider object) stops drawing those rows. The ladder falls pastdatatostaticData, then toobjectName, so it queries its object instead, or draws nothing when it names neither. Accepted by the ruling under the standing 2026-08-27 posture: no transition windows, no staged deprecation. Pinned end-to-end, through the card's own harness.object-gridandobject-map: ⛔ MEASURED, and it corrected this PR's own first draft. Removing the ladder head is real at the ladder and is not, on its own, an end-to-end change for a node rendered throughSchemaRenderer. An authoreddataarray arrives twice: asschema.data, where the head used to lift it, and as thedataReact prop, becauseSchemaRendererspreads every non-metadata node key and both wrappers forward it — and each component lifts an array on that prop at higher priority than the ladder (ObjectGrid'spassedData;ObjectMap's objectui#5003 precedence). So such a node still draws its rows. The first version of the grid pin asserted the opposite and went red, which is how this was found; both pins now state the verdict per carrier.That surviving carrier is the one a host such as
ListViewlegitimately uses for rows it pre-fetched, and at the component boundary it is indistinguishable from an authored key. Collapsing the two would take the host path with it, so it is reported (below) rather than changed in passing.object-gantt: nothing observable moves — the array carried noprovider, matched no fetch branch, and this block's wrapper forwards no host props.⇒ the ruling's user-visible cost lands on
object-calendar, which is where the card was filed. On the object-armed blocks this PR removes the second read, not the last one.Authored usage of the tolerated spellings — swept, and REPORTED not migrated
Per the ruling the seat reports real authored instances rather than silently migrating them. The sweep found none outside test fixtures. Counts read before paging, no truncated enumeration:
{ provider, items }underdataonobject-calendar: intersecting the 84 files namingobject-calendarwith the files namingprovidergives 43 files, all of them source, tests, changelogs or prose — zero authored documents. Noexamples/**calendar schema carries a"provider"key at all.dataonobject-grid/object-map/object-gantt/object-tree: 98 hits for adata:-followed-by-[shape acrosscontent,examples,apps,e2e,public; every one is afindresponse envelope, adata-table/chart/kanbanblock (none of which is on this ladder), or a calendar — where the array is the declared spelling. Zero on the four object-armed blocks.examples/schema-catalog/src/schemas/plugin-calendar/month-view-calendar.json,week-view-calendar.json) author"data": [ … ], which is the arm the spec row declares. They render exactly as before.What the sweep did move is test fixtures, and each carries the reason in place: calendar fixtures that spelled inline rows as
data: { provider: 'value', items }now usestaticData(the block's declared inline-rows door, resolving to the identical config one rung down); the calendar memo fixture that spelled its binding asdata: { provider: 'object', object }now usesobjectName; the map memo fixture now usesdata: { provider: 'value', items }.Pins corrected to the spec (⛔ never the spec to the code)
packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts— named by the ruling. Its assertions already agreed with the row; two prose claims did not, and both are corrected: (1) it said the parity gate "judges againstComponentPropsMap['object-grid'].data, which isz.array()and DISAGREES" — measured on 17.4.0 the two authorities have converged, the row is theViewDataunion; (2) it said "the renderer's array tolerance is NOT asserted away here. It stays as back-compat" — the ruling removes the tolerance, so declaration and read now say the same thing.packages/core/src/utils/record-source.tsdocblock — named by the ruling. It stated as a fact about the whole ladder that "an array underdatacannot be published". True of theViewDatablocks, false ofobject-calendar, whose row is the array. Replaced with the measured per-block table.packages/plugin-map/src/ObjectMap.schemaDataShorthand.test.tsx— its subject was the array shorthand, justified by "a live convention in six sibling blocks", an argument from what the code does. Rewritten around the row: the shorthand's retirement is pinned, and the objectui#5305 rows survive as the ⛔ CONTROLS (the declared form still paints, still takes the no-fetch path, still repaints on changed rows; thedataprop still outranks the schema).packages/plugin-calendar/src/__tests__/ObjectCalendar.navigationRecordSource-7638.test.tsx— two rows pinned thatdata: { provider: 'object', object }re-points the calendar's record source. On this block that spelling is off-arm, so both are rewritten to the ruled outcome, with a new control showing the declared doors still route.packages/types/src/__tests__/object-calendar-record-source-7313.test.ts— it source-greps the ladder for rung order. Kept, re-aimed at the new spelling, and one false green repaired while in there: its "the renderer still calls the ladder" row looked for the bareresolveRecordSourceConfig(schema), whichObjectCalendar.tsxsatisfies from a docblock line that merely names the function — so it would have stayed green through a call site that had stopped existing. It now asserts the call with its arm.New pins
packages/core/src/utils/__tests__/record-source-config.behaviourNeutrality-7632.test.ts(extended) — the arm verdict at the resolver, with the pre-collapse bodies kept as the ⛔ CONTROL that makes "changed" a measurement, plus a non-vacuity row proving the ruled-away family is non-empty.packages/plugin-calendar/src/__tests__/ObjectCalendar.dataObjectArmRefused-8348.test.tsx— the card's own harness: a real node through the realSchemaRendererwith a recording data source. Row 1 is the card's measurement inverted; row 2 shows the accepted cost on screen (the block now queries); rows 3-5 are controls holding the declared carriers still; row 6 reads the spec row rather than restating it.packages/plugin-grid/src/__tests__/gridBareArrayDataRefused-8348.test.tsx— the same shape for the grid's half of the ruling.Verification
All numbers below are from the final head,
81fe3e18f, which already contains the merge oforigin/main(objectui#9224 / objectui#8674 landed; the one plausible meeting point,packages/plugin-grid/src/index.tsx, produced no conflict). Every test command runs from the repo root — a package-directoryvitestis refused by this repo's own guard — and through the shared verify lock. Exit codes captured by redirect-then-capture, never through a pipe.pnpm exec vitest run packages/core/ packages/plugin-calendar/ packages/plugin-map/ packages/plugin-gantt/ packages/plugin-tree/ packages/types/Test Files 466 passed (466)·Tests 7650 passed (7650)pnpm exec vitest run packages/plugin-grid/ packages/plugin-designer/Test Files 146 passed (146)·Tests 1262 passed (1262)pnpm exec vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts apps/console/src/__tests__/public-block-binding-reach.test.tsxTest Files 2 passed (2)·Tests 214 passed (214)pnpm --filter … run type-checkerror TSpnpm lintisturbo run lint)pnpm --filter … run lintno-explicit-any/react-refreshnoisenode scripts/check-changeset-presence.mjs✅ 23 source file(s) of 7 released package(s) changed, and this change declares 1 changeset(s)majorbumpnode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.node scripts/check-governed-queue-guard.mjs --test …✅ NOT GOVERNED — 19 path(s) checked against 5 governed surface(s); none matched.grep -naPover the diff for[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]The fixture triage is visible in the run history rather than hidden: the six-package run reported 16 failures on its first pass, 2 on its second and 0 after the last two assertions were re-aimed;
packages/plugin-grid/went 23 → 1 → 0 the same way. Every one was a fixture or a pin whose subject the ruling moves, and each carries its reason in place.The repo-wide
eslint .reading, with its three parts so the narrowing above is a measurement and not an omission. (1) Population read from eslint's own config, not guessed:npx eslint . --no-inline-config --format jsonreports 4855 files. (2) All 23 of this PR's changed lintable files are inside that population, and linting exactly those reports 0 errors (498 warnings, all pre-existing rule noise on files that already carried it). (3) The flat run reports 95 errors across 79 files repo-wide; the intersection with this PR's changed set is empty, and those files are untouched here. That flat run is not the CI gate in any case —pnpm lintisturbo run lint, the per-package scripts already reported above.⛔ Not measured here, by design: CI's own convergence. The lint and test farms are CI's run, and this report is delivered when the local checks are done rather than after waiting on them.
Bump level: minor, chosen not copied. The lane's test — does existing stored data render differently — is demonstrably YES here, which is breaking.
majoris unavailable by repo rule: the 39-package fixed group tracks@objectstack's major, andscripts/check-changeset-no-major.mjsfails any changeset declaring one. So the repo's own idiom applies:minor, with the breaking semantics spelled out in the changeset body, per block.Reported, not fixed
object-treehas no publisheddatarow on any face — notComponentPropsMap(noobject-treekey exists in it at all), notObjectTreeSchemain@object-ui/types(which declaresobjectNamerequired and nodata/staticData), not the plugin's registrationinputs. Both arms of the ruling decide by reference to the block's row, so neither reaches this block, and inventing one would be a contract decision rather than an implementation of this one. Its rung 1 is therefore left exactly as it was, markedundeclaredat the call site with the reasoning in place. The block still honours both aViewDataobject and a bare array underdata— and it has a third carrier besides, arest.data ?? schema.dataarray read inside its own fetch effect.object-mapandobject-gantthave noComponentPropsMaprow either. They are decided here because a published row does exist for them — this repo's ownObjectMapSchema.data/ObjectGanttSchema.data. Worth a maintainer's eye: three of the five ladder blocks have no spec row, so "judge against the spec row" resolves through@object-ui/typesfor two of them and not at all for the third.SchemaRendererspreads every non-metadata node key as a React prop, sodataauthored on a node reaches a block both asschema.data— which this PR now judges against the block's row — and as thedataprop, which several blocks lift with an unconditionalArray.isArray. The two are indistinguishable at the component boundary, because the prop is also how a host hands down rows it already fetched.packages/plugin-calendar/src/__tests__/ObjectCalendar.recordSourceMembers-8314.test.tsxalready documents this arrangement for the calendar and warns that a pin written from the read that looks right cannot fail. Deciding it is a contract question about the props channel, not about the ladder, so it is left whole here and reported.ObjectCalendarSchema.datain@object-ui/typescontradicts the spec row — filed as objectui#9239. Both faces of this repo's own mirror —packages/types/src/zod/objectql.zod.tsandpackages/types/src/objectql.ts— declaredata?: ViewDataforobject-calendar, whileComponentPropsMap['object-calendar'].dataisz.array(z.unknown()). Under the ruling's direction the mirror follows the spec, but this is a declaration on a published contract package, not a pin: moving it changes whatsafeValidateSchemaaccepts from authors, and it is held in place by a type-level equality withobject-gantt, by the objectui#7313 document fixtures, and by the plugin docs page. Filed as objectui#9239 rather than folded in, so the contract change gets its own review. It is not a defect this PR introduces — the divergence predates it, and the renderer's read is now on the spec's side of it.This pull request was generated by Claude Code, in session
session_01UzHd6hDYatoDn17BuwKxnZ— recorded here as prose because aPATCHrewrites the footer block (AGENTS.md, "GitHub 会改写你写进 issue/PR 正文的字节").Generated by Claude Code