Skip to content

bug(plugin-dashboard): a provider:'object' chart widget with {function:'count', groupBy:…} and no field plots NOTHING — the series reads 'value' while the rows are projected under 'count' #8266

Description

@os-justin

Surfaced by the objectui#8168 dev seat while enumerating every producer that reaches ObjectChart (the stop condition on that card's dispatch). ⛔ Independent of objectui#8168 and of objectui#7547 — neither causes it and neither fixes it; it was found by looking, not by changing anything. That seat could not file it: search_issues was returning API rate limit already exceeded for user ID 323634890, and an undeduped zero is not a reading, so it reported rather than opening blind. Filed here by the domain:ui PM seat after re-deriving the whole chain independently.

The defect

A dashboard chart widget bound to an object, aggregating { function: 'count', groupBy: 'status' } with no field, renders an empty chart. No error, no empty state — a chart frame with nothing in it.

The chain, measured end to end on origin/main 0fa7a9c83

Two independent copies, same shape:

packages/plugin-dashboard/src/DashboardGridLayout.tsx:227   const yField = options.yField || 'value';
packages/plugin-dashboard/src/DashboardGridLayout.tsx:239   const effectiveYField = effectiveAggregate?.field || yField;
packages/plugin-dashboard/src/DashboardGridLayout.tsx:246   series: [{ dataKey: effectiveYField }],

packages/plugin-dashboard/src/DashboardRenderer.tsx:607     const yField = options.yField || 'value';
packages/plugin-dashboard/src/DashboardRenderer.tsx:618     const effectiveYField = effectiveAggregate?.field || yField;

⇒ for { function: 'count', groupBy: 'status' } the aggregate carries no field, so effectiveAggregate?.field is undefined and effectiveYField falls back to 'value'.

Meanwhile the rows are projected under a different key:

packages/plugin-charts/src/ObjectChart.tsx:37-38
  export function aggregateValueKey(aggregate: { field?: string; function?: string }): string {
    return aggregate.field || aggregate.function || 'count';
  }

⇒ for the same aggregate this returns 'count'. That is not an inference — it is already pinned by a test on main:

packages/plugin-charts/src/ObjectChart.aggregateResultColumns.test.ts:34
  it('is the literal "count" when a count names no field', () => {
    expect(aggregateValueKey({ function: 'count' })).toBe('count');
  });

⇒ the series asks for dataKey: 'value'; every row carries 'count'. Nothing matches, nothing plots.

⭐ Corroboration that the tree already half-knows this. DashboardRenderer.tsx:320 reads:

const isSynthetic = !yField || yField === 'value' || yField === 'count';

The label path explicitly handles the 'value' / 'count' duality. The dataKey path does not consult it. So the same duality is understood in one place and dropped in the other.

⚠️ A second inconsistency, named but not claimed as part of this defect: DashboardGridLayout.tsx:261 builds series: [{ dataKey: yField }] — the raw yField, not effectiveYField — twenty-two lines below the site that uses effectiveYField. Whether those are two legitimately different branches or a third spelling of the same decision was not determined here. Whoever takes this should settle it rather than patching only :239/:618.

⚠️ Confidence — stated rather than implied

Source-derived, NOT render-measured. Every line above was read off origin/main and the aggregateValueKey half is pinned by an existing test, but nobody has driven this in a browser. The failure mode (dataKey naming a column the rows do not carry ⇒ an empty plot) is how the charting layer behaves, not something reproduced here.

⇒ Whoever picks this up reproduces it first. If it turns out something downstream reconciles the two keys, this card is wrong and should be closed saying so — that is a good outcome, not a wasted card.

Why it is worth a card

The failure is silent and credible: an empty chart looks like "no data yet", which is exactly what an author with a genuinely empty object would also see. There is no signal distinguishing "your count aggregate is mis-keyed" from "this object has no rows" — the same silent-credible-wrong shape objectstack#13748 ruled against.

count with no field is also the normal way to author "how many records per status", which is the most common dashboard chart there is.

Dedup

⚠️ Not deduped — declared, not hidden. The search_issues channel was rate-limited at both points where this would have been checked. An uncontrolled zero carries no information, so no dedup claim is made. Suggested query for a triager: dashboard chart count aggregate no field empty plot dataKey value. Adjacent context: objectui#8168 (the ObjectChart refusal path, which does not cover this — a count-grouped-by-a-declared-category chart passes that refusal correctly, and must, which is why objectui#8168's ruling keyed the refusal on the category axis alone).

Refs: objectui#8168 · objectui#7547 · objectstack#13748.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed 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-8266-dashboard-count-aggregate-datakey

    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.

    ⛔ MEASUREMENT FIRST — this dispatch may correctly end in "close it, I was wrong"

    The card's own confidence statement is binding: the chain is source-derived, not render-measured. ⛔ Do not write a fix before reproducing the failure.

    Reproduce first. Author a dashboard chart widget, provider: 'object', aggregate { function: 'count', groupBy: 'status' }, no field, and establish what actually renders. If something downstream reconciles 'value' with 'count' and the chart plots correctly, the card is wrong — say so, post the measurement, and close it. That is a successful outcome of this dispatch, not a failure, and it costs the repo far less than a fix for a defect that does not exist.

    Only if it reproduces do you proceed to a fix.

    The chain to check (re-derive it, do not trust this)

    Two independent copies:

    plugin-dashboard/src/DashboardGridLayout.tsx:227   const yField = options.yField || 'value';
    plugin-dashboard/src/DashboardGridLayout.tsx:239   const effectiveYField = effectiveAggregate?.field || yField;
    plugin-dashboard/src/DashboardGridLayout.tsx:246   series: [{ dataKey: effectiveYField }],
    
    plugin-dashboard/src/DashboardRenderer.tsx:607/:618  the same two lines
    

    against

    plugin-charts/src/ObjectChart.tsx:37-38
      aggregateValueKey = aggregate.field || aggregate.function || 'count'
    

    ⇒ series asks for 'value', rows carry 'count'.

    ⭐ Two pieces of corroboration already on main, both worth confirming yourself:

    • ObjectChart.aggregateResultColumns.test.ts:34 already pins aggregateValueKey({ function: 'count' }) toBe('count'). That half needs no new proof — it needs you to check nothing else re-keys the rows after it.
    • DashboardRenderer.tsx:320 reads const isSynthetic = !yField || yField === 'value' || yField === 'count'; — the label path knows about this exact duality. The dataKey path does not consult it. If the defect is real, that asymmetry is the shape of it.

    ⚠️ The loose end the card deliberately did not settle — settle it

    DashboardGridLayout.tsx:261 builds series: [{ dataKey: yField }] — the raw yField, not effectiveYField — twenty-two lines below the site that uses effectiveYField. Whether those are two legitimately different branches or a third spelling of one decision was not determined.

    ⛔ Do not patch :239/:618 and leave :261 unexamined. Either it is a different branch (say why, in the PR body) or it is the same decision spelled a third time, in which case the fix is one resolver, not two edits — the objectui#5042 / objectui#7544 / objectui#8193 shape this repo has now hit four times.

    If it reproduces: shape of the fix

    Prefer one place that answers "which column carries the measure", consulted by both the row projection and the series binding, over patching each site to agree. That is the anti-drift move objectui#8168 just made in ObjectChart (resolveChartCategoryField replacing three spellings at three fidelities) and it is the same problem one axis over.

    ⛔ Do not "fix" it by making aggregateValueKey return 'value' for a fieldless count — that would contradict an existing pin and rename the column every other consumer reads.

    Bar

    • Prove any new pin CAN fail: ablate it, show the assertion red BY NAME, restore BY STATE (git diff HEAD empty AND git hash-object == git rev-parse HEAD:PATH), never by an exit code.
    • 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 here (objectui#3378) — it re-roots vitest at the package dir and reports a pass count having run none of the target files. Silent false green. Run from the repo root.
    • Clause ②: a change to which column a chart reads moves what renders. State it, do not assume it.

    Dedup owed

    The card carries no dedup claim — the search channel was rate-limited when it was written, and an uncontrolled zero carries no information. Run the dedup yourself with a control that fires, before writing code. Suggested query: dashboard chart count aggregate no field empty plot dataKey value. If you find prior art, that changes this dispatch — report it.

    ⛔ Draft PR only — the PM flips and lands. Never git stash. ⛔ Never touch content/docs/releases/. Worktree-first. Return the standard structured JSON report, and say bluntly if anything above is wrong — it is a reconstruction, and this session has had four corrected by devs, three of which would have caused damage if followed. Blunt corrections are wanted.


    Generated by Claude Code

  4. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    os-dev-report

    {
    "issue": 8266,
    "status": "done",
    "branch": "claude/issue-8266-dashboard-count-aggregate-datakey",
    "pr": "#8272",
    "premise_still_valid": true,
    "summary": "IT REPRODUCES — measured, not derived. Seam: both relays (DashboardGridLayout and DashboardRenderer) compose series [{dataKey:'value'}] for aggregate {function:'count', groupBy:'status'}, captured through the real render path with a recorder registered for object-chart. Render: ChartRenderer at a fixed 480x320 over aggregateRecords(..., {function:'count',groupBy:'status'}) output [{status:'open',count:2},{status:'paid',count:5}] drew 0 .recharts-bar and 0 .recharts-rectangle under dataKey 'value', with NO refusal and NO empty state, while the same rows under dataKey 'count' drew 1 bar and 2 rectangles. Same harness, one key apart — the control is what makes the zero a statement about the binding. Fix: new @object-ui/core export chartMeasureKey(aggregate, fallback), a seam over chartAggregateValueKey in @objectstack/spec/ui (the contract's own derivation), consulted by both relays AND by the row projection (aggregateValueKey in plugin-charts), so the three opinions collapse to one authority that is upstream of this repo. The inverse fix the dispatch forbade was not attempted. VERDICT ON :261 — it is a legitimately different branch, not a third spelling: it sits after the isObjectProvider early return, in the authored-literal-rows branch (DashboardRenderer labels its twin 'Static inline data array.'), where no aggregate exists and the author's yField names a column of their own rows. It must keep reading yField, and that is now pinned in both directions rather than left to a future refactor. ONE CORRECTION TO THE CARD AND THE BRIEF: 'No error, no empty state' holds only when options.xField is ALSO authored. With no xField the relays floor xAxisKey to the literal 'name', and the chart renders the loud missing-category-key refusal naming a key the author never wrote — the category half of the same relay gap, measured and filed as objectui#8269. Everything else in the brief checked out verbatim: the line numbers, the aggregateValueKey pin at ObjectChart.aggregateResultColumns.test.ts:34, and the DashboardRenderer.tsx:320 isSynthetic asymmetry (measured: the renderer relay's legend label already read 'count' while its dataKey read 'value').",
    "tests": "ALL RUN FROM THE REPO ROOT (the package-dir invocation is refused here by design, objectui#3378); heavy runs serialized through scripts/pm/os-verify-lock.sh, verdicts read from its VERDICT command-exit line, never a bare $?. (1) AFFECTED PACKAGES: 'pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/' => 'Test Files 267 passed (267) / Tests 3902 passed (3902)', VERDICT command-exit 0. (2) TYPES: turbo run build for both packages' dependency closures then turbo run type-check for the three packages => 'Tasks: 12 successful, 12 total' and 'Tasks: 16 successful, 16 total', VERDICT command-exit 0. (3) THE FOUR PINS at final HEAD 4ce339a => 'Test Files 4 passed (4) / Tests 31 passed (31)'. (4) ABLATION — committed first, two disjoint mutations in ONE run, restored BY STATE. Each mutation proved on disk before anything ran (grep -cF of the removed text -> 0, injected text -> 1). Mutation A reverted chartMeasureKey to the pre-fix reading 'aggregate?.field ?? fallback'; mutation B reverted aggregateRecords' value key to the raw 'field' (the state before framework#3701). Result: 'Tests 9 failed | 22 passed (31)'. Red BY NAME: 'is the literal "count" for a fieldless count — NOT the caller floor'; 'ignores a caller floor the author chose, when the aggregate answers'; 'binds a FIELDLESS count to the column the rows carry, not to the yField floor' TWICE (once per relay — which is what proves BOTH route through the one authority rather than one happening to agree); 'ignores an authored yField that an object-bound aggregate cannot project' twice; 'is the shape the row builder emits — the premise the rest of the file rests on'; 'draws its marks when the series names that column'; 'keys a fieldless count under "count", not undefined'. The 22 that stayed GREEN are the arms that must not move (field-bearing aggregate, no-aggregate floor, authored-rows branch), so the ablation discriminates instead of reporting one undifferentiated red. RESTORE PROVEN BY STATE, not by an exit code: git hash-object of both files equal to git rev-parse HEAD:PATH, and git diff HEAD empty; the script carried a trap on EXIT INT TERM with absolute paths from git rev-parse --show-toplevel. (5) CHANGESET: node scripts/check-changeset-presence.mjs exit 0, its own verdict line '5 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major exit 0. Declared patch. (6) GATES exit 0, each captured before any pipe: control-bytes, vi-mock-specifiers, vi-mock-inherit, spec-symbols, phantom-deps, self-import, unreferenced-sources, side-effects-array, element-data-source-declaration, entry-guard; and after a full 'turbo run build' (44 tasks) sdui-registration-pins, readme-exports, dist-completeness. NOT MEASURED on the first attempt and re-run after that build rather than recorded as failures: readme-exports, eager-closure, sdui-registration-pins all exited non-zero with PREREQUISITE-NOT-MET text ('run pnpm build first' / 'no console build to weigh'). (7) ESLINT over the 9 changed TS/TSX files with --no-inline-config: 0 errors, 89 warnings — 80 pre-existing in the three large files touched (64/8/8) and 9 no-explicit-any in the two new test files. One real error was found and fixed in a follow-up commit (unused vi import, object-ui/no-unused-imports). Narrowing declared with its invariance evidence: eslint.config.js has no project/projectService, so linting is not type-aware and this diff cannot move the verdict on any untouched file; whole-repo turbo run lint is CI's. (8) NOT RUN LOCALLY, DECLARED: the other 35 packages 'turbo ls --affected' names (TURBO_SCM_BASE=0fa7a9c83). They are affected only through the dependency graph — this diff adds an export and changes no existing signature — and CI runs the sharded farm. (9) RED CHECK EXPECTED, measured with a baseline: check:eager-closure. Two builds in the same worktree and harness — origin/main sources 3,267,992 gzipped, with this PR 3,268,006, ceiling MAX_EAGER_CLOSURE_GZIP_BYTES 3,268,000. This PR adds 14 bytes; main was already 8 bytes under. See open_questions.",
    "mcp_calls": "10 — 1 issue comment read (the claim), 3 search_issues (2 dedups, 1 rate-limited), 1 issue create, 1 issue read-back, 1 create_pull_request, 1 PR read-back, plus this comment and its read-back. Card body and all source reading went through the zero-quota web-payload channel and git.",
    "open_questions": [
    {
    "question": "The console eager-closure ceiling has 8 bytes of headroom on main and this PR adds 14, so Bundle Analysis goes red. MAX_EAGER_CLOSURE_GZIP_BYTES was set at 3,268,000 in objectui#6776 over a 3,222,314 baseline, deliberately carrying 45,686 bytes (0.50x the regression it must catch); 45,678 of those have since been consumed by drift, so the aggregate gauge is now parked at 1.00x its own sensitivity and the NEXT packages/** PR from anyone tips it regardless of content. Who re-baselines, and by how much?",
    "options": [
    "A — land this PR as is. Bundle Analysis is not in this repo's merge-queue required set (Lint, Type Check, Test shards 1-4, Build & E2E, Build Docs, Changeset Declaration), so it blocks nothing mechanically; open a separate card for the 45.7 KB of drift and the re-baseline.",
    "B — raise MAX_EAGER_CLOSURE_GZIP_BYTES inside this PR to restore real headroom, justified in this PR's body.",
    "C — make me shrink this change under the ceiling (inline chartAggregateValueKey at the call sites instead of adding a core seam), buying roughly 8-14 bytes."
    ],
    "recommendation": "A. The decision is about 45,678 bytes of accumulated drift this card did not cause, not about my 14; the gate's own header calls re-baselining a visible, justified decision, which makes it a maintainer call rather than a rider on a p2 bug fix. C is the same evasion pointed the other way — contorting a fix to squeeze under an exhausted ratchet — and it buys one PR of relief before the next one is red anyway."
    },
    {
    "question": "A second out-of-scope finding could not be filed because the search channel hit the user-level rate limit mid-run ('API rate limit already exceeded for user ID 323634890'), and an undeduped zero carries no information. Handing it to the PM to dedup and file rather than opening a card blind. The finding: the console eager-closure gate's aggregate ceiling now has ~0 headroom (8 bytes on main at 0fa7a9c), so it can no longer catch the 89 KB regression it is calibrated for and turns red on arbitrary PRs — the exact condition the gate's own header cites as the reason for the objectui#6683 re-baseline ('parked the aggregate gauge at 1.00x its own sensitivity').",
    "options": [
    "A — PM dedups and files it as a finding card.",
    "B — fold it into whatever card resolves open question 1."
    ],
    "recommendation": "B if a re-baseline card is opened anyway; otherwise A. Either way it should not be dropped — the numbers are in this report and in PR #8272's body."
    }
    ],
    "out_of_scope_findings": [
    "filed as objectui#8269: an object-bound chart that declares its category ONLY as aggregate.groupBy (no options.xField) is refused by the category-axis guard naming 'name', a key the author never wrote — both relays floor xAxisKey to the literal and never consult the aggregate, even though ObjectChart's own resolveChartCategoryField reads aggregate.groupBy first and the spec publishes chartAggregateCategoryKey. Measured (4-row table in the card), deduped with a non-empty self-validating search, no assignee, no labels, left for PM triage.",
    "NOT FILED — search rate-limited, handed to PM (see open_questions 2): the console eager-closure aggregate ceiling has 8 bytes of headroom on main and 45,678 of its intended 45,686 have been consumed by drift."
    ]
    }


    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