Repository navigation
ObjectChart has no refusal path for an unresolvable legacy binding — the prerequisite for retiring the forced-path 'name' / 'value' floors at three faces (objectui#7547) #8168
Description
Split out of objectui#7547 by its implementing seat, on a measurement that contradicted the card's assumed route. Filed as a sub-issue rather than decided inside #7547.
What #7547 assumed, and what is actually there
#7547 routes the chart 'name' / 'value' floors down the objectui#7029 / #7070 / #7500 road: delete the literal, and the renderer's own refusal screen becomes reachable. objectui#7070 wrote the discipline that road depends on, verbatim in its seam note:
#7029's mechanic is only correct where a refusal path exists
For calendar that path was ObjectCalendar's "Calendar configuration required" screen; for gantt, ObjectGantt's; objectui#7500 got ObjectTimeline's from objectui#7459, which landed as its own card first.
ObjectChart has no such screen. Measured on 20316bac3:
packages/plugin-charts/src/ObjectChart.tsx—runAggregatepassesschema.aggregatestraight tods.aggregate(objectName, { field, function, groupBy, filter })with no guard ongroupBy, and theds.findleg hands the same bag toaggregateRecords, which buckets onrecord[groupBy] ?? 'Unknown'.- The only two loud states are
error(set from a thrown fetch,data-testid="chart-error",role="alert") and the generic "No data yet" empty state. Neither is a statement about an absent binding, and which one appears forgroupBy: undefinedis decided by the data source, not by this component.
So deleting the floors today does not produce an honest refusal — it produces a backend-dependent outcome. That is why #7547 measured this, left the floors standing, and reported instead of deciding.
The three faces still carrying the floors
Line numbers on 20316bac3:
packages/plugin-list/src/ListView.tsx:2727-2728—chartBinding.valueField || 'value'andchartBinding.categoryField || 'name'. The branch already names this card's parent in a comment ("The floors themselves are objectui#7547 ... deliberately untouched here").packages/plugin-view/src/ObjectView.tsx:1518-1519— the same pair.packages/app-shell/src/views/ObjectView.tsx:2157-2159—chartConfig.xAxisField || 'name'andyAxisFields[0] || 'value'. The site triage found that the original card did not account for.
Corroborating evidence that 'value' in particular is harmful
ObjectChart.tsx already carries a note about the same literal arriving from the dashboard wiring, which is independent of this route:
The upstream dashboard wiring defaults
field: 'value'for charts without an explicit valueField, which crashes on SQL drivers ("no such column: value") since dashboards typically count rows, not a measure column.
The structured-aggregate leg works around it by omitting field for count. The legacy leg does not.
What this card is
Give ObjectChart the refusal ObjectTimeline got in objectui#7459: when the legacy shape is routed with no declared category and no declared measure, say so instead of aggregating on a name nobody wrote. resolveListChartBinding in ListView.tsx already computes exactly that predicate (resolves, with the floors deliberately not applied), so the question is answered upstream and only needs a screen.
Once it exists, retiring the six literals at the three faces above is mechanical and is the remainder of objectui#7547.
Not a decision this card should make alone
Whether the refusal belongs in ObjectChart (reached by every producer, including the dashboard widgets that still supply 'name' / 'value' themselves) or at the three relays is the design question. Stating it here rather than assuming it.
Dedup
search_issues on this repo for a chart refusal path / unresolved legacy aggregate binding: one unrelated hit (objectui#6864, aggregate()'s spec-shape branch dropping filter and measure). Positive control in the same session returned objectui#7544 as its first result, so the near-zero is a reading.
Refs: objectui#7547 · objectui#7029 · objectui#7070 · objectui#7459 · objectui#7500 · objectui#7544 · objectstack#13748.
Recorded by the seat implementing objectui#7547 (session_01YBWFb5YgMU5dw8p2VKj16S). Unassigned, for triage.
Activity
'location', chart'name'/'value', gantt'progress'/'dependencies', gallery'image') — the objectui#7029 / #7070 / #7500 class, unreachable through the switcher but reached by an authoredtype:or a page's leading whitelist entry #7547os-justin commentedon Sep 7, 2026
Claim:
session_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8168-objectchart-refusal-pathPM dispatch. Assignee and this claim are set by the PM seat for the dev seat that will do the work; the dev inherits both and posts no second claim.
PM ruling on the open design question: the refusal belongs in
ObjectChartThis card explicitly declines to decide it alone — "whether the refusal belongs in
ObjectChart… or at the three relays is the design question. Stating it here rather than assuming it." Deciding it, so the dev is not deciding it alone either.Ruling: renderer-level, in
ObjectChart, following the precedent the card itself cites. Grounds, measured onorigin/mainf57ca757brather than taken from the card:packages/plugin-calendar/src/ObjectCalendar.tsxpackages/plugin-gantt/src/ObjectGantt.tsxpackages/plugin-timeline/src/ObjectTimeline.tsxpackages/plugin-charts/src/ObjectChart.tsxThree for three, all at the renderer, none at a relay. And the zero is a reading, not a dead query:
configuration requiredreads 0 inObjectChart.tsxwhile the lit controlchart-errorin the same file reads 3.Putting it at the three relays would instead mean writing the same predicate three times, at three producers, in three packages — and would still leave every other producer (the dashboard widgets among them) reaching
ObjectChartwith no refusal. One choke point, reached by everything, is what the other three views already have.⛔ The one place this ruling could be wrong — STOP, do not push through it
The card names the wrinkle honestly:
ObjectChartis reached by dashboard widgets that supply'name'/'value'themselves. A renderer-level refusal fires on those too.⇒ Measure that before you ship, not after. Enumerate every producer that reaches
ObjectChartwith a legacy binding and determine, for each, whether the new refusal would fire on a configuration that renders correctly today. If any does, STOP and escalate to the PM — do not weaken the refusal to accommodate it, do not special-case the dashboard path, and do not ship a screen that reddens a working board. That outcome would mean the ruling above is wrong and the relay route deserves reconsideration; report it as such.The card also supplies the predicate:
resolveListChartBindinginListView.tsxalready computesresolveswith the floors deliberately not applied. Reuse it rather than re-deriving a second opinion — a second spelling of the same predicate is the objectui#5042 / objectui#7544 defect shape.Scope: this card is the REFUSAL PATH only
⛔ Do NOT retire the six literals in this PR. The card is explicit that retiring them is "the remainder of objectui#7547" and is mechanical once the refusal exists. Landing the screen is this card's whole deliverable. Retiring the floors is #7547's, in its own change, after this lands.
Measured on
f57ca757b(card measured20316bac3):packages/plugin-list/src/ListView.tsxpackages/plugin-view/src/ObjectView.tsxpackages/app-shell/src/views/ObjectView.tsx⛔ A line number is a hint about where to look, never an instruction about what to change. Match on text.
|| 'name'/|| 'value'across those three files returns 14 hits, of which only 6 are chart. The other eight aretitleField/labelFieldfloors for timeline, gantt, gallery, kanban, map and tree — those are other legs of the parent objectui#7547 and are ⛔ out of scope here. Anyone reading "retire the six literals" and reaching for a global replace takes eight sites that are not this card's and not even this card's parent's current leg. Named here so it cannot happen quietly.Bar
git diff HEADempty ANDgit hash-object==git rev-parse HEAD:PATH), never by an exit code.ObjectTimeline's refusal (objectui#7459) as the shape to match — same package conventions, same testid/role idiom, so the four views read alike.⛔ Draft PR only — the PM flips and lands. Never
git stash. Never touchcontent/docs/releases/. Worktree-first.Generated by Claude Code
os-justin commentedon Sep 7, 2026
os-dev-report
{
"issue": 8168,
"status": "done",
"branch": "claude/issue-8168-objectchart-refusal-path",
"pr": "#8244",
"premise_still_valid": true,
"summary": "ObjectChart now refuses an object-bound chart that declares no category axis, following ObjectTimeline's shape (objectui#7459): role=alert, data-testid chart-missing-category-axis, naming the bindings the author can declare, placed above the error and loading gates because it is a static authoring fact no fetch outcome changes. The card's premise held on my base 7444916, verified by measurement rather than by reading the card: 'configuration required' reads 0 in ObjectChart.tsx while the lit control 'chart-error' in the same file reads 3; runAggregate still passes schema.aggregate through with no guard on groupBy and the ds.find leg still buckets on record[groupBy] ?? 'Unknown'. The refusal keys on the CATEGORY alone rather than mirroring resolveListChartBinding.resolves, which is Boolean(category AND value) and so refuses when EITHER is missing: a measure may legitimately be absent because count takes no field, so the strict mirror would have refused count-grouped-by-a-declared-category, and an existing test (ObjectChart.elementDataSource.test.tsx:115) already renders such a shape. Deliberately NOT retired: the six 'name'/'value' floors at the three relay faces, verified still present on this branch's HEAD; the diff touches none of those three files. Assignee was already set by the PM and the newest (and only) Claim comment on this card names session_01YBWFb5YgMU5dw8p2VKj16S and branch claude/issue-8168-objectchart-refusal-path, so I inherited both and posted no second claim.",
"stop_condition": {
"verdict": "PASS - no producer that renders correctly today would be refused. Proceeded.",
"producers_enumerated": [
"1. plugin-list/src/ListView.tsx:2768 case 'chart' legacy leg - category is chartBinding.categoryField || 'name'. FLOORED, never refused.",
"2. plugin-view/src/ObjectView.tsx:1536 case 'chart' legacy leg - chartCfg.xAxisField || chartCfg.categoryField || 'name'. FLOORED, never refused.",
"3. app-shell/src/views/ObjectView.tsx:2218 chart viewDef legacy leg - chartConfig.xAxisField || 'name'. FLOORED, never refused.",
"4. plugin-dashboard/src/DashboardRenderer.tsx:620 series+objectProvider widget - xAxisKey is options.xField || 'name', assigned unconditionally BEFORE the isObjectProvider branch, so it is set on both legs including the raw-record leg where effectiveAggregate is genuinely undefined. FLOORED, never refused.",
"5. plugin-dashboard/src/DashboardGridLayout.tsx:241 series+objectProvider widget - same unconditional options.xField || 'name'. FLOORED, never refused.",
"6. directly authored object-chart metadata (the SDUI path, plus the chart-family aliases apps/console/src/register-plugins.ts:77 registers onto the same block) - the only producer that can reach the refusal today, and only when the author declared no category by any spelling, which is the defect the card describes rather than a working chart."
],
"key_finding": "The two dashboard widgets the card flagged are producers 4 and 5. Their || 'name' floors are NOT among objectui#7547's six (those live only at faces 1-3) and are untouched here, so the dashboard surfaces cannot reach this refusal before or after #7547 lands. That is what makes the renderer-level ruling safe.",
"live_candidates_found_and_measured": "Two sweeps looked like real breaks on reading and were RUN rather than reasoned about. (a) apps/console public-block-binding-reach.test.tsx builds its probe from a block's declared registry inputs; for object-chart that is objectName + filter + aggregate:{} + drillDown:{} with NO xAxisKey, so the refusal DOES fire on it. It stays green because the refusal is a render-time early return while the fetch is a hook effect that still runs, so objectName still reaches the data layer. (b) app-shell widget-dom-leak-sweep.test.tsx authors data on top of objectName, which the authored-rows carve-out covers. Both green: 2 files, 218 tests.",
"third_candidate": "A spec-shape xAxis:{field} with no xAxisKey renders correctly today because ChartRenderer normalizes DOWNSTREAM of ObjectChart. A predicate reading schema.xAxisKey alone would have refused it. The resolver asks normalizeChartSchema instead, and the case has its own test arm."
},
"predicate_reuse": "Reused in substance; it could NOT be called, and this is a correction to the brief. resolveListChartBinding reads { chart, options.chart } with xAxisField / categoryField / yAxisFields - the block an author writes on a LIST VIEW. By the time a schema reaches ObjectChart the relays have already translated that into { objectName, aggregate, xAxisKey, series }, and producers 4-6 never had such a block at all. Called here it would read undefined off schema.chart for EVERY chart this component renders and answer unresolvable for all of them: it would refuse everything. The card's sentence 'the question is answered upstream and only needs a screen' is therefore not quite right - the upstream answer does not travel downstream, because the relays translate rather than forward it. What I did reuse is the anti-drift move itself, which is what objectui#5042 / objectui#7544 are actually about: 'which column is the category' was spelled THREE times in ObjectChart.tsx at three fidelities (the optionMeta probe at the old L395-397, the isStructured test in runAggregate, and groupByField at the old L770) and this refusal would have been a fourth. resolveChartCategoryField states it once and the optionMeta probe now reads it too, so the probe and the refusal cannot disagree. It is module-scoped, not added to the package index, so no new public surface. runAggregate's isStructured (payload SHAPE, not field name) and groupByField (which column the FETCHED rows carry) are different questions and are deliberately left alone.",
"tests": "All from the repo root. plugin-charts + i18n: 111 files, 1505 tests passed. plugin-dashboard + runner + plugin-list + plugin-view: 205 files, 2049 tests passed. console public-block-binding-reach + app-shell widget-dom-leak-sweep: 2 files, 218 tests passed. New pin ObjectChart.absentCategoryAxisRefusal-8168.test.tsx: 22 tests passed. type-check on plugin-charts and i18n: both Done, with 'Scope: 2 of 47 workspace projects' and both script names echoed so it is not a zero-match false green; each is 'tsc --noEmit && tsc -p tsconfig.test.json' so the new test file IS typechecked. turbo run lint --concurrency=2 on both packages: 3 tasks successful, 0 errors (345 + 33 warnings; --max-warnings is deliberately unset repo-wide per .github/workflows/lint.yml). Gates, exit code captured before any pipe and the verdict read from the gate's own printed line: check-changeset-presence 0 ('11 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major 0, check-control-bytes 0 (6557 tracked text files), check:i18n-keys 0, check:i18n-drift 0 ('0 en value(s) changed (1 key(s) added, 0 removed)'), check:i18n-dead-keys 0 (a report not a gate, per its own header), check-vi-mock-specifiers 0, check-vi-mock-inherit 0, check-governed-queue-guard --test over the 13 diff paths: NOT GOVERNED. NOT MEASURED, not red: check-readme-exports exits 1 with 'type entry ./dist/index.d.ts is not on disk - run pnpm build first' for 400 self-imports across app-shell/auth/cli/collaboration and others - a whole-repo-build prerequisite, and none of the unjudged imports names a file in this diff. NOT RUN: check:eager-closure (needs a console build; the ten short locale strings land in the i18n-locales chunk, ceiling 455,000, headroom about 8,924 bytes which is roughly sixty keys at the measured ~147 gzipped bytes each). ABLATION: mutation prefixed the guard's condition with 'false &&'. On-disk proof rather than an exit code - anchor grep -cF 1 to 0, mutant 0 to 1, git hash-object 611bd182 to 4ce5ea38. Result 5 assertions RED BY NAME: 'refuses an object-bound chart that declares no category by any spelling', 'refuses a legacy aggregate whose measure is declared but whose groupBy is not', 'refuses a structured GroupBy node that names no field', 'is a static authoring fact: it does not wait for, or depend on, the fetch', 'is distinguishable from the failure state and from the empty state'. The 17 does-not-fire arms stayed green, the expected asymmetry since they assert an absence the mutation also produces. Restore BY STATE: git diff HEAD 0 files, and git hash-object 611bd1823be0ecd1ed82ec805a831da44c0a99bc equals git rev-parse HEAD:packages/plugin-charts/src/ObjectChart.tsx. The script carried trap ... EXIT INT TERM with an absolute path from git rev-parse --show-toplevel. The test imports './ObjectChart' relatively, so it resolves through src and the dist preflight does not apply. Scope proof: git diff --name-only over 13 files touches none of the three relay files, and all six chart floors are verified present on this branch's HEAD.",
"mcp_calls": "6 - issue_read(get_comments) x1, create_pull_request x1, pull_request_read(get) x1, search_issues x2 (both failed, see out_of_scope_findings), add_issue_comment x1 (this comment). Card body was read for free through the public-repo page payload channel; the claim comment went through MCP because claim-bearing fields must never be read from that channel. REST was probed first and is 403 for this seat (repo-scoped read on the issues endpoint), while /rate_limit reads 15000/hr, so the credential is alive and repo-scoping is refused - the session-gate-closed seat shape. Declaring the channel switch.",
"corrections_to_the_brief": [
"The naive '|| name' / '|| value' grep over the three relay files returns 19 hits on my base 7444916, not 14. Six are chart (matching the brief exactly) and THIRTEEN are the titleField / labelField floors, not eight. The over-reach the brief warns about is larger than it states.",
"All three re-measured chart anchors are correct and did NOT drift further between f57ca75 and 7444916: ListView 2767-2768, plugin-view 1535-1536, app-shell 2218/2220. I matched on text, not on the numbers.",
"The brief's toolchain instruction to run 'pnpm --filter PKG exec vitest run FILE' is REFUSED in this repo by an explicit guard. objectui#3378: running vitest from a package dir re-roots it there, the root projects match nothing, only the absolute-path apps/console project resolves, and it reports 'Test Files 22 passed' having run none of the target package's files. Correct form is from the repo root: pnpm exec vitest run PATH.",
"The brief says 'same package conventions, same testid/role idiom'. Those two point in different directions here and I followed the brief's named precedent. Within plugin-charts the established refusal idiom is a module-local ChartRefusal shell in AdvancedChartImpl.tsx using role=status and data-chart-error=CODE, on the explicit reasoning that 'a refusal is a state, not an alert'. Across the four object-bound views the idiom is data-testid + role=alert + text-destructive. I used ObjectTimeline's, as instructed, and it is also the right call inside this file: chart-empty-state already uses role=status and its comment names that as the machine check distinguishing it from the role=alert chart-error box, so an authoring error belongs on the alert side. Flagging the divergence rather than hiding it.",
"ObjectCalendar's and ObjectGantt's refusals carry NO testid and NO role - only ObjectTimeline's does. So 'the four views read alike' is true of timeline and this one, and aspirational for the other two.",
"The PM table's zero for ObjectTimeline under 'configuration required' is a wording artifact, not a missing screen: timeline's copy is 'required ordering' / 'Timeline date axis required'. Re-measured on 7444916: calendar 2, gantt 1, timeline 0, ObjectChart 0, with the lit control chart-error reading 3 in ObjectChart.tsx. The ObjectChart zero is a reading."
],
"open_questions": [
{
"question": "The refusal keys on the CATEGORY alone, which is narrower than resolveListChartBinding.resolves (Boolean(category AND value), whose negation refuses when EITHER is absent). Already implemented as A and pinned; raising it because it is a deliberate deviation from the predicate the brief pointed at, and the PM should get to see it rather than find it.",
"options": [
"A - refuse only when NO category resolves. count takes no field, so a measure may legitimately be absent; this never refuses a chart that renders today, and it guards exactly the defect the card names ('no guard on groupBy'). It is ObjectTimeline's clause 2 ('keys on the START axis alone') in this file's vocabulary.",
"B - mirror resolves exactly and refuse when either is absent. Strictly wider, and measurably wrong here: it refuses count-grouped-by-a-declared-category, and ObjectChart.elementDataSource.test.tsx:115 already renders xAxisKey:'name' with no series and no aggregate, so B would turn an existing green test red.",
"C - refuse only when BOTH are absent, which is the card's literal prose. Leaves the card's own named defect unrefused for { aggregate: { field: 'amount', function: 'sum' } } with no groupBy: a declared measure would rescue an absent category, and that is precisely the bag that reaches ds.aggregate with groupBy undefined."
],
"recommendation": "A, implemented. B is refuted by an existing test; C is refuted by the card's own defect description. A is also the only one of the three that is safe under the stop condition for every producer AND still makes objectui#7547's retirement honest."
}
],
"out_of_scope_findings": [
"NOT FILED - dedup channel unavailable, handing to the PM to file. MCP search_issues failed twice, deterministically, with 'failed to fetch issue field values: API rate limit already exceeded for user ID 323634890' - the MCP server's unconditional Projects field_values fetch, exhausted at the shared-identity level. REST is 403 for this seat, and the public-repo payload channel explicitly does not cover search. Per the rule that a rate-limited dev hands findings back rather than opening cards blind, the three below are reported, not filed.",
"FINDING 1 (defect, plugin-dashboard, both surfaces): a dashboard chart widget with provider:'object' and an authored aggregate of { function: 'count', groupBy: 'status' } and no field plots NOTHING. DashboardRenderer.tsx:620 and DashboardGridLayout.tsx:241 both compute effectiveYField = effectiveAggregate?.field || yField where yField = options.yField || 'value', so series[0].dataKey becomes 'value'; but ObjectChart's aggregateValueKey({ field: undefined, function: 'count' }) returns the literal 'count', so aggregateRecords projects rows as { status, count } and recharts finds no 'value' key. Independent of objectui#7547 and of this card. Derived by reading the two assignments plus aggregateValueKey, NOT reproduced in a browser - state that when triaging.",
"FINDING 2 (defect, plugin-charts): ObjectChart.tsx's groupByField (the display-key read, currently 'schema.aggregate?.groupBy || schema.xAxisKey') does not resolve a STRUCTURED GroupBy node, unlike runAggregate and the optionMeta probe. For aggregate.groupBy = { field, dateGranularity } it yields the object itself, so labelToRaw reads row[object] via a stringified key and the drill-down label-to-raw map is built on a key no row carries. Pre-existing and latent. I deliberately did NOT touch it: fixing it changes drill behaviour and is outside a refusal-path card. My new resolveChartCategoryField handles the node correctly, so the fix is now a one-line re-point with a shared spelling to re-point to.",
"FINDING 3 (observation, finding label): the comment above chart.scatterOneMeasure in all ten locale packs says 'this pack is eagerly loaded and the framework chunk's gzip ceiling has ~0.2 KB of headroom'. Stale - scripts/check-eager-closure-budget.mjs now carries a separate i18n-locales chunk ceiling (455,000) with about 8,924 bytes of headroom, and framework's is 71,000. The stale sentence would deter a future author from adding a needed key for a budget reason that no longer applies to that chunk."
]
}
Generated by Claude Code