Skip to content

finding(plugin-dashboard): an object-bound chart that declares its category as aggregate.groupBy is refused for lacking a name column — both relays floor xAxisKey to 'name' and never consult the aggregate #8269

Description

@os-justin

Found while fixing objectui#8266 (the measure axis of the same relay gap) by the dev seat on branch claude/issue-8266-dashboard-count-aggregate-datakey. Filed separately rather than fixed there: it is the CATEGORY half, it changes what renders for a different authoring shape, and objectui#8266's own dispatch scoped that PR to the series binding. objectui#8266 is not addressed by this card and this card is not addressed by objectui#8266 — neither fixes the other.

The defect

A dashboard chart widget bound to an object whose category is declared ONLY as aggregate.groupBy — with no options.xField — renders the category-axis refusal, naming a key the author never wrote:

This chart cannot plot its category axis: no row has a `name` field.

The author wrote groupBy: 'status'. Nothing on screen says groupBy is the key that was ignored, and name appears nowhere in their metadata.

The chain, on origin/main 0fa7a9c83

Both dashboard relays floor the category binding on a literal, without ever looking at the aggregate that decides it:

packages/plugin-dashboard/src/DashboardGridLayout.tsx:226   const xAxisKey = options.xField || 'name';
packages/plugin-dashboard/src/DashboardRenderer.tsx:607     const xAxisKey = options.xField || 'name';

The rows an object-bound aggregate returns are keyed by the RAW groupBy field (status), so no row carries name, and AdvancedChartImpl's hasNoCategoryKey guard (framework#4033) fires correctly on a binding that was wrong before it got there.

⭐ The tree already holds the resolver this needs, in the component one layer down: resolveChartCategoryField in packages/plugin-charts/src/ObjectChart.tsx reads aggregate.groupBy FIRST, then xAxisKey, then xAxis.field — that is objectui#8168's anti-drift move. ObjectChart uses it to decide whether to REFUSE, and then still forwards schema.xAxisKey verbatim as the render binding. So the same component both knows the category is declared and passes on a key that is not it. The contract's own derivation is also published — chartAggregateCategoryKey in @objectstack/spec/ui, the sibling of the chartAggregateValueKey that objectui#8266's fix adopted for the measure axis.

Measurement, not inference

Rendered through ChartRenderer at 480x320 (ResponsiveContainer mocked, the only harness in this repo that can count marks), over the rows a fieldless count actually returns, [{status:'open',count:2},{status:'paid',count:5}]:

xAxisKey series[0].dataKey result
status value surface drawn, ticks open/paid, 0 marks, no refusal — this is objectui#8266
status count 2 marks, y ticks 0..8 — objectui#8266 fixed
name value refusal missing-category-key, text naming name
name count refusal missing-category-key, text naming name

The last row is the point: objectui#8266's fix does NOT reach this shape. A widget that declares aggregate.groupBy and no xField is still refused after it.

Why it is worth a card

Unlike objectui#8266 this one is LOUD, so it is a lesser harm — but the diagnostic is wrong-cause. It names a binding the author did not write and does not mention the one they did, so it points at the wrong layer. groupBy alone with no xField is a perfectly reasonable way to author a grouped chart, and the renderer already agrees it is a valid category declaration — it says so by not refusing at the ObjectChart level.

Scope note for whoever takes it

The fix shape is the mirror of objectui#8266's: have the relays resolve the category through one authority rather than a literal floor. Clause about moving pictures applies — a widget that renders a refusal today would start drawing, so it needs a render measurement, not only a seam assertion. Check also whether the label-resolution path (resolveGroupByLabels, which rewrites the groupBy column in place) leaves the resolved key still valid at the point the axis reads it.

Dedup

Run on the search_issues channel from this session against objectstack-ai/objectui, phrased as a description of the defect. The result was NON-EMPTY (20 hits), so it is self-validating and no separate control was needed. Nearest neighbours, none of them this: objectui#7547 (the same literal-floor class, but at the ListView / plugin-view ObjectView / app-shell ObjectView faces, not the two dashboard relays), objectui#4695 (a cartesian chart handed no series binding at all), objectui#4683 and objectui#4507 (the series-axis and pivot halves of the hasNoCategoryKey doctrine), objectui#8168 (the ObjectChart refusal this card's diagnostic comes from).

Refs: objectui#8266 · objectui#8168 · objectui#7547 · framework#4033.

Recorded by the objectui#8266 dev seat, session session_01YBWFb5YgMU5dw8p2VKj16S, from a render measurement taken in that card's worktree. This paragraph is prose rather than a footer block because issue creation strips attribution footers (AGENTS.md, the body-bytes section).

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 7, 2026
  2. self-assigned this
    on Sep 7, 2026
  3. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    Claim: session_01YBWFb5YgMU5dw8p2VKj16S · branch claude/issue-8269-dashboard-category-axis-groupby

    PM dispatch. Assignee and this claim are set by the PM seat for the dev seat; the dev inherits both and posts no second claim.

    ⭐ The measure half landed — this is the mirror, and the pattern is fresh

    objectui#8272 landed at fc32921aa. Verified on today's main:

    files
    chartMeasureKey (the landed measure authority) 7
    chartAggregateCategoryKey (the spec's published category sibling) 0
    resolveChartCategoryField (objectui#8168's resolver, local to plugin-charts) 2

    ⇒ the contract already publishes the category derivation and this repo uses it nowhere, while its measure twin is now used in seven places. The asymmetry is the card.

    Follow objectui#8272's shape: a seam over the spec's own derivation, consulted by every site that decides the binding — not a literal floor repaired in place, and ⛔ not a second local resolver. That PR's authority ended up upstream of this repo, which is the contract-first answer; do the same here.

    ⚠️ The card's own framing is right and worth holding: ObjectChart already knows the category is declared (resolveChartCategoryField reads aggregate.groupBy first — that is why it does not refuse at its own level) and then forwards schema.xAxisKey verbatim anyway. The component contradicts itself; that contradiction is what you are closing.

    ⚠️ FOUR consumers, not two — measured, and the card names only the floors

    DashboardGridLayout.tsx:227   const xAxisKey = options.xField || 'name';   ← the floor
    DashboardGridLayout.tsx:253       xAxisKey: xAxisKey,
    DashboardGridLayout.tsx:268       xAxisKey: xAxisKey,
    DashboardRenderer.tsx:607     const xAxisKey = options.xField || 'name';   ← the floor
    DashboardRenderer.tsx:632         xAxisKey: xAxisKey,
    DashboardRenderer.tsx:658         xAxisKey: xAxisKey,
    

    ⇒ each floor feeds two call sites. Settle every one, exactly as objectui#8272's dev had to settle DashboardGridLayout.tsx:261: some of these sit in the authored-literal-rows branch, after the isObjectProvider early return, where there is no aggregate and the author's xField names a column of their own rows — there the floor is correct and must stay. ⛔ Do not repair all four uniformly, and ⛔ do not repair two and leave two unexamined. Pin whichever verdict you reach, in both directions.

    ⚠️ The trap the card names — take it seriously

    Check also whether the label-resolution path (resolveGroupByLabels, which rewrites the groupBy column in place) leaves the resolved key still valid at the point the axis reads it.

    A resolver that returns the right key is worthless if a later pass renames the column under it. Measure the ordering, do not reason about it.

    The bar this card sets on itself — hold to it

    a widget that renders a refusal today would start drawing, so it needs a render measurement, not only a seam assertion.

    The card was filed off a render measurement (ChartRenderer at 480×320, ResponsiveContainer mocked, counting marks) and its 4-row table is the baseline. Reproduce that table, then show the name/count row changing from refusal to marks drawn. A seam test that proves the key resolves is necessary and not sufficient.

    Bar

    • Prove every new pin CAN fail: ablate, red BY NAME, restore BY STATE (git diff HEAD empty AND git hash-object == git rev-parse HEAD:PATH), never by an exit code. A leg that reddens everything discriminates nothing.
    • Zero-hit greps need a control that fires; prefer grep -cF and print the matched lines.
    • ⚠️ pnpm --filter PKG exec vitest run FILE is refused by an explicit guard (objectui#3378) — it re-roots vitest and reports a pass count having run none of the target files. Run from the repo root.
    • Clause ②: a widget that refuses today would start rendering. State it explicitly; that is the whole user-visible change.
    • Changeset per the repo's rules — read check-changeset-presence.mjs's own verdict. This repo forbids major; breaking ships minor.

    Scope

    ⛔ objectui#8266 / PR #8272 is the measure axis and is done — do not revisit it. ⛔ objectui#7547's literal floors at the ListView / plugin-view ObjectView / app-shell ObjectView faces are a different set of files and not this card (objectui#8244 deliberately left all 19 of those hits untouched; keep it that way).

    ⛔ Draft PR only — the PM flips and lands. Never git stash. ⛔ Never touch content/docs/releases/. Worktree-first.


    Generated by Claude Code

  4. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 8269,
      "status": "done",
      "branch": "claude/issue-8269-dashboard-category-axis-groupby",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8298",
      "premise_still_valid": true,
      "summary": "Both dashboard relays floored the category binding on `options.xField || 'name'` and handed it to the object-chart node without consulting the aggregate. `chartCategoryKey` is a new @object-ui/core export delegating to `chartAggregateCategoryKey` in @objectstack/spec/ui (the contract's published CATEGORY sibling of the `chartAggregateValueKey` PR #8272 adopted for the measure axis; it had 0 readers in this repo before this change). Both relays consult it on the object-provider branch only. All FOUR xAxisKey consumers settled: DashboardGridLayout 263 and DashboardRenderer 640 (object provider) CHANGED; DashboardGridLayout 278 and DashboardRenderer 666 (authored literal rows, reached only after isObjectProvider returns false) FLOOR KEPT and pinned in both directions. DatasetWidget's xAxisKey comes from buildChartSeries on the ADR-0021 dataset path, a different authority explicitly carved out of the object-bound refusal, untouched. NOTE ON THE BRIEF: it said the stale worktree held nothing salvageable. It actually held the previous run's uncommitted work (3 modified + 3 new files). I saved it to scratchpad, reset the worktree to origin/main, and re-derived everything independently; the two converged on the same seam name and shape, which is corroboration rather than inheritance.",
      "tests": "BASELINE REPRODUCED (ChartRenderer at 480x320, ResponsiveContainer mocked, rows built by aggregateRecords = [{status:open,count:2},{status:paid,count:5}]), measured on 3c6394cb2. status/value: 0 marks, 0 series, no refusal, ticks [open,paid]. status/count: 2 marks, 1 series, ticks [open,paid,0,2,4,6,8]. name/value: 0 marks, refusal missing-category-key, text 'This chart cannot plot its category axis: no row has a name field.'. name/count: identical refusal. The card's 4-row table reproduces exactly, y ticks included. AFTER TABLE, driven end to end through the real ObjectChart pipeline (runAggregate, comparison merge, resolveGroupByLabels, ChartRenderer) against a data source whose status field carries picklist options: composed xAxisKey 'name' (what both relays composed before) gives 0 marks and the missing-category-key refusal; composed xAxisKey 'status' (what they compose now) gives 2 marks, 1 series, ticks [Open cases,Paid cases,0,2,4,6,8]. Clause 2 holds: a widget that rendered a refusal now draws. ORDERING TRAP: resolveGroupByLabels replaces the VALUES under the existing key and stashes the raw value beside them under a key spelled __raw_ plus that same key name; it never renames the column, so the resolved key is still valid where the axis reads it. Measured, not reasoned: marks drawn UNDER HUMANIZED TICKS is the two-in-one reading (the rewrite ran, and the key survived it, since a renamed column would have made hasNoCategoryKey refuse). Also recorded: ObjectChart already derived the label pass's own key as groupBy.alias || groupBy.field, i.e. the same derivation the new seam shares, so the label pass was always keyed correctly and the pre-change defect was purely the axis binding beside it. ABLATION, two legs, each mutating on disk with grep -cF counted before and after, each restoring BY STATE (git diff HEAD empty AND git hash-object equal to git rev-parse HEAD:PATH) with trap on EXIT INT TERM, never by an exit code. LEG A neuters the seam (body to `return fallback;`): 16 failed / 18 passed. chart-category-key.test.ts 5 failed (contract-answers block) and 3 PASSED (caller-floor block, which a floor-only implementation still satisfies). ObjectChart.categoryAxisKeyRender-8269 3 failed and 5 PASSED (the baseline matrix hard-codes its pairs, which is what makes it a baseline). DashboardChart.categoryAxisKey-8269 8 failed (4 object arms x 2 surfaces) and 10 PASSED (both floor arms, both literal-rows arms, the measure-key arm). LEG B reverts only the relay wiring: 8 failed / 26 passed, and ONLY the plugin-dashboard seam file failed, so the seam file is what pins the wiring and is not redundant with the other two. The two legs redden disjoint predicted sets. SUITES: `pnpm exec vitest run packages/core/ packages/plugin-dashboard/ packages/plugin-charts/` gave 'Test Files 270 passed (270) / Tests 3936 passed (3936)'. `turbo run type-check` for the three packages: 'Tasks: 16 successful, 16 total' (it builds the dependency closure first, and packages/core/dist/utils/chart-category-key.{js,d.ts} plus the index.d.ts re-export were verified present afterwards). eslint --no-inline-config on the seven changed files: exit 0, 27 warnings, all of classes the surrounding files already carry, and lint.yml sets no --max-warnings. GATES GREEN: check:control-bytes, check:unreferenced-sources, check:self-import, check:spec-symbols, check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit, check:side-effects-array, changeset:check, check-changeset-presence.mjs (its own verdict line: '7 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'). check:governed-queue-guard --test on all eight paths: 'NOT GOVERNED'. NOT MEASURED (prerequisite missing, not a finding, CI supplies both): check:readme-exports exits 1 with 378 findings, every one 'type entry ./dist/index.d.ts is not on disk - run pnpm build first', zero of them naming any file in this diff and zero naming packages/core/README.md; check:eager-closure exits 2 and prints itself that it is 'a broken gauge, not a sensitive gate' without apps/console/dist/eager-closure.json. All vitest and turbo runs went through the container's shared heavy-verify lock (os-verify-lock.sh, slot objectui-8269); every VERDICT line read 'command-exit 0' except the two deliberate ablation runs.",
      "mcp_calls": "7 - two issue_read (card body, claim comment), two search_issues that BOTH failed with 'API rate limit already exceeded for user ID 323634890', one create_pull_request, one pull_request_read (body read-back: intact, GitHub appended its own attribution footer block, not edited), one add_issue_comment (this one)",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED - no dedup channel was reachable, so this is handed to the PM to file rather than filed blind. Finding, observation class (label `finding`, no `pm:queue`): for a STRUCTURED groupBy that declares an `alias`, ObjectChart's label pass and its option-metadata probe key off two different names, and one of them cannot hit. `resolveGroupByLabels` is called with groupByField = `gbRaw.alias || gbRaw.field` (ObjectChart.tsx around 700) and then looks up `objectSchema.fields[groupByField]` - keyed by the projected COLUMN name, so with an alias the field lookup misses and every value silently degrades to humanizeLabel instead of the picklist/lookup label. The sibling probe at ObjectChart.tsx:484 uses resolveChartCategoryField, which returns `node.field` and is correct for that use. ChartGroupBySchema admits `alias` (verified: safeParse of {field:'status',alias:'bucket'} succeeds), so the shape is authorable; a repo-wide grep found zero authored chart groupBy aliases outside my new tests, so it is latent rather than currently reachable. NOT this card: this card is about the axis BINDING, which is now correct for the aliased case too. DEDUP CHANNELS BOTH DOWN: MCP search_issues returned 'API rate limit already exceeded for user ID 323634890' on two attempts, and a repo-scoped REST read (curl api.github.com/repos/objectstack-ai/objectui/issues/8269) returned HTTP 403, which is the documented proxy behaviour in AGENTS.md. Per the dispatch rule that a finding must never disappear because a channel broke, it is reported here instead of filed unchecked."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpluginpm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions