Skip to content

Commit 4f740b0

Browse files
authored
feat(spec,lint): <ObjectChart>'s author contract is the spec ChartConfig shape again (#3729) (#3750)
#3701 trimmed xAxis/yAxis/series out of the <ObjectChart> contract because the renderer read xAxisKey/series[].dataKey and silently dropped the ChartConfig shapes — an honest record of the runtime gap, not the target state. objectui#2883 closed the gap the other way round (the renderer honors ChartConfig through one normalization boundary), so the contract follows the protocol again (ADR-0082 D1: the spec schema IS the protocol). Contract: type, xAxis, yAxis, series, subtitle, showDataLabels, annotations and interaction are published from ChartConfigSchema; the internal chartType/xAxisKey/series[].dataKey spellings leave the author contract. annotations and interaction gained the .describe() they never had. The `type` exception: on any surface that flattens chart config into a props bag, `type` is already the SDUI envelope's component discriminator. The collision is created by the flattening and is resolved there (objectui's react-page wrapper), so the contract can publish `type` as the spec spells it. The generator's blanket `type` skip is now overridable by an explicit dataProps allow-list, since for this one block `type` is a real author prop. Lint: validate-react-page-props reads the axes in the spec spelling — xAxis.field, yAxis[].field, series[].name — and keeps accepting the internal spellings silently, because dashboards and the console's own chart-view wiring emit them. react-chart-axis-inert is retired: the props it warned about are honored now, so the warning would be a false positive. Spec: chart-aggregate.ts records the constraint the result-column convention rests on — an inline aggregate is SINGLE-MEASURE; two measures over one field would collide, and resolving that needs an author-chosen name per measure, which is what a dataset is. Two taxonomy corrections: grouped/stacked bar and stacked area are absent from ChartTypeSchema because stacking is a property of the SERIES (ChartSeries.stack), not a chart family; and ChartInteraction.zoom is marked declared-not-delivered rather than reading as shipped.
1 parent 2c19383 commit 4f740b0

16 files changed

Lines changed: 280 additions & 109 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
`<ObjectChart>`'s author contract is the spec `ChartConfig` shape again (issue #3729)
7+
8+
#3701 trimmed `xAxis`/`yAxis`/`series` out of the `<ObjectChart>` contract
9+
because the renderer read `xAxisKey`/`series[].dataKey` and silently dropped the
10+
ChartConfig shapes — an honest record of the runtime gap, not the target state.
11+
objectui#2880 closed the gap the other way round (the renderer now honors
12+
`ChartConfig` through one normalization boundary), so the contract follows the
13+
protocol again (ADR-0082 D1: the spec schema IS the protocol).
14+
15+
**Contract.** `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`,
16+
`annotations` and `interaction` are published from `ChartConfigSchema`; the
17+
internal `chartType`/`xAxisKey`/`series[].dataKey` spellings leave the author
18+
contract. `annotations` and `interaction` gained the `.describe()` they never
19+
had, so the generated contract stops publishing bare `object[]` with no meaning.
20+
21+
**The `type` exception.** `ChartConfig.type` is the chart family, but on any
22+
surface that flattens chart config into a props bag `type` is already the SDUI
23+
envelope's component discriminator — an author writing `type="bar"` used to
24+
replace `object-chart` and the block stopped resolving. The collision is created
25+
by the flattening and is resolved there (objectui's react-page wrapper), so the
26+
contract can publish `type` as the spec spells it. The contract generator's
27+
blanket `type` skip is now overridable by an explicit `dataProps` allow-list,
28+
since for this one block `type` is a real author prop.
29+
30+
**Lint.** `validate-react-page-props` reads the axes in the spec spelling —
31+
`xAxis.field`, `yAxis[].field`, `series[].name` — and keeps accepting the
32+
internal spellings silently, because dashboards and the console's own chart-view
33+
wiring emit them. `react-chart-axis-inert` is retired: the props it warned about
34+
are honored now, so the warning would be false. The three binding-integrity
35+
rules from #3701 are unchanged.
36+
37+
**Spec.** `chart-aggregate.ts` records the constraint the whole result-column
38+
convention rests on: an inline `aggregate` is SINGLE-MEASURE. Keying rows by the
39+
raw field name only works because there is exactly one measure to key; two
40+
measures over one field would collide, and resolving that needs an author-chosen
41+
name per measure — which is what a dataset is. Widening `ChartAggregateSchema`
42+
into a measures array would silently invalidate every axis binding these rules
43+
validate, so the boundary is now written down rather than left to be rediscovered.
44+
45+
The chart taxonomy note is corrected too: grouped/stacked bar and stacked area
46+
are absent from `ChartTypeSchema` not because they render as their base chart,
47+
but because stacking is a property of the SERIES (`ChartSeries.stack`), not a
48+
chart family — one `bar` family plus a series stack group expresses all three.
49+
`ChartInteraction.zoom` is now marked declared-not-delivered in its own
50+
description rather than reading as shipped.

content/docs/deployment/validating-metadata.mdx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,18 +191,17 @@ column, `field` names the value column (the literal `count` for a fieldless
191191
two conventions up is the usual cause of a chart that renders axes and no bars.
192192

193193
```jsx
194-
<ObjectChart objectName="invoice" chartType="bar"
194+
<ObjectChart objectName="invoice" type="bar"
195195
aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}
196-
xAxisKey="status" series={[{ dataKey: 'sum_total' }]} />
197-
// ↑ a dataset-style measure name; the rows
198-
// are keyed `total` → error
196+
xAxis={{ field: 'status' }} series={[{ name: 'sum_total' }]} />
197+
// ↑ a dataset-style measure name; the rows
198+
// are keyed `total` → error
199199
```
200200

201201
Both halves are checked: `aggregate.field` / `aggregate.groupBy` must be fields
202-
the object declares, and `xAxisKey` / `series[].dataKey` must name a column the
203-
aggregate actually returns (plus `<field>__comparison` when a comparison overlay
204-
is on). `ChartConfig`'s `xAxis` / `yAxis` shapes are **not** read by this block
205-
and are reported as inert (warning).
202+
the object declares, and the axes — `xAxis.field`, `yAxis[].field`,
203+
`series[].name` — must name a column the aggregate actually returns (plus
204+
`<field>__comparison` when a comparison overlay is on).
206205

207206
Skipped, to keep false positives at zero: any prop whose value is not a static
208207
literal (it comes from React state or a variable), a usage carrying a `{...spread}`,

content/docs/references/ui/chart.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ Inline aggregation for an object-bound chart
108108
| **height** | `number` | optional | Fixed height in pixels |
109109
| **showLegend** | `boolean` || Display legend |
110110
| **showDataLabels** | `boolean` || Display data labels |
111-
| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | |
112-
| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | |
111+
| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` |
112+
| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | Interaction toggles: `{ tooltips?, brush?, zoom?, clickAction? }` |
113113
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
114114

115115

@@ -150,9 +150,9 @@ Type: `string`
150150

151151
| Property | Type | Required | Description |
152152
| :--- | :--- | :--- | :--- |
153-
| **tooltips** | `boolean` || |
154-
| **zoom** | `boolean` || |
155-
| **brush** | `boolean` || |
153+
| **tooltips** | `boolean` || Show the hover tooltip |
154+
| **zoom** | `boolean` || Pan/zoom the plot — NOT implemented by the default renderer; use `brush` |
155+
| **brush** | `boolean` || Show the range selector under the plot |
156156
| **clickAction** | `string` | optional | Action ID to trigger on click |
157157

158158

content/docs/references/ui/report.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ const result = JoinedReportBlock.parse(data);
9090
| **height** | `number` | optional | Fixed height in pixels |
9191
| **showLegend** | `boolean` || Display legend |
9292
| **showDataLabels** | `boolean` || Display data labels |
93-
| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | |
94-
| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | |
93+
| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` |
94+
| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | Interaction toggles: `{ tooltips?, brush?, zoom?, clickAction? }` |
9595
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
9696

9797

examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { definePage } from '@objectstack/spec/ui';
1818
* cross-object reads declaratively (zero data code).
1919
* (This comparison absorbed the former Account Cockpit page.)
2020
*
21-
* The chart's axes are bound explicitly (#3701) to show the object-bound
22-
* result-column rule: an inline `aggregate` returns rows keyed by the RAW
23-
* FIELD NAMES — `status` (its `groupBy`) and `total` (its `field`) — not by a
24-
* dataset-style measure name. `os validate` now checks both halves.
21+
* The chart is written in the spec `ChartConfig` shape (#3729) and its axes are
22+
* bound explicitly (#3701) to show the object-bound result-column rule: an
23+
* inline `aggregate` returns rows keyed by the RAW FIELD NAMES — `status` (its
24+
* `groupBy`) and `total` (its `field`) — not by a dataset-style measure name.
25+
* `os validate` checks both halves.
2526
*
2627
* Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`;
2728
* data blocks and the drawer bring their own compiled styling. The drawer sets
@@ -119,7 +120,7 @@ function Page() {
119120
<Stat label="Open AR" value={related.openInvoices} accent="hsl(38 92% 50%)" />
120121
</div>
121122
122-
<ObjectChart objectName="showcase_invoice" chartType="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxisKey="status" series={[{ dataKey: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />
123+
<ObjectChart objectName="showcase_invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]} series={[{ name: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />
123124
124125
<RecordRelatedList objectName="showcase_account" recordId={sel} relationshipField="account" columns={['name', 'status', 'total']} limit={5} showViewAll={true} title="Invoices" />
125126

packages/lint/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ export {
8080
REACT_CHART_FIELD_UNKNOWN,
8181
REACT_CHART_AGGREGATE_INVALID,
8282
REACT_CHART_AXIS_UNKNOWN,
83-
REACT_CHART_AXIS_INERT,
8483
} from './validate-react-page-props.js';
8584
export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js';
8685
export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';

packages/lint/src/validate-react-page-props.test.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
REACT_CHART_FIELD_UNKNOWN,
66
REACT_CHART_AGGREGATE_INVALID,
77
REACT_CHART_AXIS_UNKNOWN,
8-
REACT_CHART_AXIS_INERT,
98
} from './validate-react-page-props.js';
109

1110
const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] });
@@ -154,13 +153,42 @@ describe('validateReactPageProps — <ObjectChart> bindings (#3701)', () => {
154153
expect(f).toEqual([]);
155154
});
156155

157-
it('warns that the ChartConfig axis shapes are inert on this block', () => {
156+
// ── the spec ChartConfig shape is the contract again (#3729) ────────────
157+
158+
it('accepts the spec axis shape', () => {
159+
const f = validateReactPageProps(
160+
chartPage(chart(`objectName="invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total' }]}`)),
161+
);
162+
expect(f).toEqual([]);
163+
});
164+
165+
it('flags a spec yAxis[].field that is not a result column', () => {
166+
const f = validateReactPageProps(
167+
chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} yAxis={[{ field: 'sum_total' }]}`)),
168+
);
169+
expect(f.some((x) => x.rule === REACT_CHART_AXIS_UNKNOWN && /sum_total/.test(x.message))).toBe(true);
170+
});
171+
172+
it('flags a spec series[].name that is not a result column', () => {
158173
const f = validateReactPageProps(
159-
chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} yAxis={[{ field: 'total' }]}`)),
174+
chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} series={[{ name: 'sum_total' }]}`)),
160175
);
161-
const inert = f.find((x) => x.rule === REACT_CHART_AXIS_INERT);
162-
expect(inert?.severity).toBe('warning');
163-
expect(inert?.hint).toMatch(/series=/);
176+
const hit = f.find((x) => x.rule === REACT_CHART_AXIS_UNKNOWN);
177+
expect(hit?.hint).toMatch(/series\[\]\.name/);
178+
});
179+
180+
it('flags a spec xAxis.field bound to the VALUE column', () => {
181+
const f = validateReactPageProps(
182+
chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'total' }}`)),
183+
);
184+
expect(f.some((x) => x.rule === REACT_CHART_AXIS_UNKNOWN && /VALUE column/.test(x.message))).toBe(true);
185+
});
186+
187+
it('still accepts the internal spelling — dashboards emit it', () => {
188+
const f = validateReactPageProps(
189+
chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxisKey="status" series={[{ dataKey: 'total' }]}`)),
190+
);
191+
expect(f).toEqual([]);
164192
});
165193

166194
// ── false-positive guards ───────────────────────────────────────────────

packages/lint/src/validate-react-page-props.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
// (e.g. `onSucces` → `onSuccess`) → warning. We do NOT flag arbitrary
1515
// unknown props (the contract's data props are a curated subset) — only the
1616
// likely typos, to keep false positives near zero.
17-
// - <ObjectChart>'s data BINDINGS, by reading the attribute VALUES (#3701).
18-
// See the block comment above `checkObjectChart` for why that block, and
19-
// why only now.
17+
// - <ObjectChart>'s data BINDINGS, by reading the attribute VALUES (#3701,
18+
// retargeted to the spec shape in #3729). See the block comment above
19+
// `checkObjectChart`.
2020
//
2121
// Reading values is opt-in per block and per prop: everything below evaluates
2222
// only STATIC literals (`objectName="invoice"`, an `aggregate={{…}}` object
@@ -185,13 +185,20 @@ function attrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribute): un
185185
//
186186
// * `aggregate.field` / `aggregate.groupBy` are RAW FIELD names → check them
187187
// against the object's declared fields.
188-
// * `xAxisKey` / `series[].dataKey` are RESULT COLUMN names → check them
189-
// against what this aggregate produces.
188+
// * the axes are RESULT COLUMN names → check them against what this
189+
// aggregate produces.
190+
//
191+
// The axes are read in the SPEC spelling — `xAxis.field`, `yAxis[].field`,
192+
// `series[].name` (#3729). #3701 had to read `xAxisKey`/`series[].dataKey`
193+
// because those were the only spellings the renderer honored; objectui#2880
194+
// made it honor ChartConfig, so the gate follows the protocol again. The
195+
// internal spellings are still accepted, silently: dashboards and the console's
196+
// own chart-view wiring emit them, and they remain a valid (if unpublished)
197+
// way to write the same binding.
190198

191199
export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown';
192200
export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid';
193201
export const REACT_CHART_AXIS_UNKNOWN = 'react-chart-axis-unknown';
194-
export const REACT_CHART_AXIS_INERT = 'react-chart-axis-inert';
195202

196203
const CHART_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const;
197204

@@ -261,21 +268,6 @@ function checkObjectChart(
261268
const push = (severity: ReactPropSeverity, rule: string, message: string, hint: string) =>
262269
findings.push({ severity, rule, where, path, message, hint });
263270

264-
// The ChartConfig axis shapes reach this block but are never read by it —
265-
// it consumes `xAxisKey` + `series[].dataKey`. Silently inert props are what
266-
// ADR-0078 forbids, so say so rather than let the chart render blank.
267-
for (const inert of ['xAxis', 'yAxis'] as const) {
268-
if (!values.has(inert)) continue;
269-
push(
270-
'warning',
271-
REACT_CHART_AXIS_INERT,
272-
`<ObjectChart> prop "${inert}" is not read by this block — it renders nothing.`,
273-
inert === 'xAxis'
274-
? 'Use xAxisKey="<result column>" (the aggregate\'s groupBy).'
275-
: 'Use series={[{ dataKey: "<result column>" }]} (the aggregate\'s field).',
276-
);
277-
}
278-
279271
// Inline `data` wins over the aggregate query: the columns then come from
280272
// the author's own rows, which this rule cannot see. Nothing further to say.
281273
if (values.has('data')) return;
@@ -353,23 +345,41 @@ function checkObjectChart(
353345
);
354346
};
355347

356-
axisRef(strOf(values.get('xAxisKey')), 'xAxisKey');
348+
// The category axis: spec `xAxis: { field }`, the report surface's bare
349+
// string, or the internal `xAxisKey`. All three name the same column.
350+
const xAxisRaw = values.get('xAxis');
351+
const categoryAxis =
352+
strOf(values.get('xAxisKey')) ??
353+
strOf(xAxisRaw) ??
354+
(isRec(xAxisRaw) ? strOf(xAxisRaw.field) : undefined);
355+
const categoryProp = values.has('xAxisKey') ? 'xAxisKey' : 'xAxis.field';
356+
axisRef(categoryAxis, categoryProp);
357+
358+
// The value axes: spec `yAxis: [{ field }]` (or a single object / bare
359+
// string) and spec `series: [{ name }]` / internal `series: [{ dataKey }]`.
360+
const yAxisRaw = values.get('yAxis');
361+
const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== undefined ? [yAxisRaw] : [];
362+
for (const a of yAxisList) {
363+
axisRef(strOf(a) ?? (isRec(a) ? strOf(a.field) : undefined), 'yAxis[].field');
364+
}
365+
357366
const series = values.get('series');
358367
if (Array.isArray(series)) {
359368
for (const s of series) {
360-
if (isRec(s)) axisRef(strOf(s.dataKey), 'series[].dataKey');
369+
if (!isRec(s)) continue;
370+
const dataKey = strOf(s.dataKey);
371+
axisRef(dataKey ?? strOf(s.name), dataKey ? 'series[].dataKey' : 'series[].name');
361372
}
362373
}
363374

364375
// A category axis bound to anything but the groupBy is always wrong, and the
365376
// check above lets it through when it happens to equal the VALUE column.
366-
const xAxisKey = strOf(values.get('xAxisKey'));
367-
if (xAxisKey && keys.category && xAxisKey !== keys.category && xAxisKey === keys.value) {
377+
if (categoryAxis && keys.category && categoryAxis !== keys.category && categoryAxis === keys.value) {
368378
push(
369379
'error',
370380
REACT_CHART_AXIS_UNKNOWN,
371-
`xAxisKey "${xAxisKey}" is the aggregate's VALUE column, not its category column.`,
372-
`The category axis is keyed by groupBy — use xAxisKey="${keys.category}".`,
381+
`${categoryProp} "${categoryAxis}" is the aggregate's VALUE column, not its category column.`,
382+
`The category axis is keyed by groupBy — bind it to "${keys.category}".`,
373383
);
374384
}
375385
}

packages/spec/scripts/build-react-blocks-contract.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,15 @@ function dataProps(schema: any, allow?: string[]): Prop[] {
6666
}
6767
const props = js?.properties ?? {};
6868
const required: string[] = Array.isArray(js?.required) ? js.required : [];
69-
const SKIP = new Set(['aria', 'type', 'id', 'className', 'style']);
70-
let entries = Object.entries(props).filter(([name]) => !SKIP.has(name));
69+
// Envelope/plumbing props that are never part of an author contract. `type`
70+
// is the awkward one: for most blocks it IS the SDUI component discriminator,
71+
// but `ChartConfig.type` is the chart family and a real author prop — so an
72+
// explicit `dataProps` allow-list wins over the skip (#3729).
73+
const SKIP = new Set(['aria', 'id', 'className', 'style']);
74+
const allowed = new Set(allow ?? []);
75+
let entries = Object.entries(props).filter(
76+
([name]) => !(SKIP.has(name) || (name === 'type' && !allowed.has('type'))),
77+
);
7178
if (allow && allow.length) {
7279
const order = new Map(allow.map((n, i) => [n, i]));
7380
entries = entries.filter(([n]) => order.has(n)).sort((a, b) => order.get(a[0])! - order.get(b[0])!);

packages/spec/src/ui/chart-aggregate.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@
5858
* `chart-measure-unknown` catches. The two paths key their rows differently
5959
* because only one of them has an author-chosen name to key by.
6060
*
61+
* ## The constraint this convention rests on: inline aggregate is SINGLE-MEASURE
62+
*
63+
* Keying rows by the raw field name only works because an inline `aggregate`
64+
* computes exactly ONE measure — one `function` over one `field`. Two measures
65+
* over the same field (`sum(amount)` and `avg(amount)`) would collide on the
66+
* column name, and the only way out would be a derived name (`sum_amount`) —
67+
* i.e. re-inventing what a dataset `DatasetMeasure.name` already is.
68+
*
69+
* So do not "just add" a measures array here. Multi-measure IS the dataset
70+
* path's job, and the boundary between the two is exactly this: a chart that
71+
* needs more than one measure needs an author-chosen name per measure, which
72+
* means it needs a dataset (ADR-0021 Level B). Widening `ChartAggregateSchema`
73+
* would silently invalidate every axis binding this convention validates.
74+
*
6175
* ## No business logic here (Prime Directive #2)
6276
*
6377
* The helpers below are pure contract derivations — they map a declaration to

0 commit comments

Comments
 (0)