From 21e8217bec493167fc64d6df732df0c9903aad4f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:47:42 +0000 Subject: [PATCH 1/5] fix(lint): run validateChartBindings on view and object publishes The list-view chart walk now opens the two standalone view shapes the publish door carries (the flattened list overlay and the ViewItem config), and the reference-integrity suite crosses the rule onto view and object writes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --- .../lint/src/reference-integrity-suite.ts | 43 ++++++++++++------- packages/lint/src/validate-chart-bindings.ts | 20 ++++++++- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 66a98277e7c..4b4905650a8 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -422,23 +422,34 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // every page-door binding as unresolvable. Its page-typed sibling above // takes the default for the same reason. { name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings }, - // [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling, and ONLY - // `report`. On a report write this member is the subject-reader: it opens - // with `recordsOf(stack.reports)` and resolves that report's own `dataset` / - // `rows` / `columns` / `values` and its chart's axes against `stack.datasets` - // — the one collection this resolution needs and the one the per-write - // snapshot already carries (#7529), so it has no missing-collection - // false-positive channel. ⛔ NOT `dashboard` or `view`: on those writes the - // report/board surfaces the rule walks are the collections the snapshot does - // NOT carry, which is the `dataset` door's own DARK pin one file over. + // [#19542] `runtimeTypes` gains `report` under the ADR-0049 ruling. On a + // report write this member is the subject-reader: it opens with + // `recordsOf(stack.reports)` and resolves that report's own `dataset` / + // `rows` / `columns` / `values` and its chart's axes against + // `stack.datasets` — the one collection this resolution needs and the one + // the per-write snapshot already carries (#7529), so it has no + // missing-collection false-positive channel. // - // ⚠️ A REFUSAL widening, like #15254's and #19143's: a report republished - // with a dangling dataset binding or a dimension the dataset does not - // declare is now refused (422) rather than stored silently. MEASURED over - // the shipped report corpus at the door's own snapshot shape before - // crossing: 9 reports (showcase 4, todo 5), 8 of them binding a real dataset - // with `rows`/`values` — 0 findings, with a lit synthetic probe refused. - { name: 'validateChartBindings', runtimeTypes: ['flow', 'report'], run: validateChartBindings }, + // [#26847] `view` and `object` join for the same reason. A list-view chart + // (`views[].list` / `listViews`, the flattened overlay, the ViewItem + // `config`, and an object's own `listViews`) binds `dataset` + `dimensions` + // + `values` and resolves against `stack.datasets` ALONE — the collection + // the snapshot carries — so the door refuses an unknown dataset + // (`chart-dataset-unknown`), dimension or measure (`chart-dimension-unknown` + // / `chart-measure-unknown`) the way `os validate` already does. The walk's + // report, page and dashboard surfaces read collections a view or object + // snapshot does not carry, so on those writes they find nothing: they are + // empty there, never dangling. ⛔ Still NOT `dashboard` or `page`: on those + // writes the surfaces the rule walks are the subject, and their sibling + // rules own them. + // + // ⚠️ REFUSAL widenings, like #15254's and #19143's: a report, view or object + // published with a dangling chart binding is now refused (422) rather than + // stored silently. Each was MEASURED over the shipped corpus at the door's + // own snapshot shape before crossing — `report`: 9 reports (showcase 4, + // todo 5), 0 findings; `view` / `object`: MEASURED_VIEW_CORPUS — with a lit + // synthetic probe refused at each door. + { name: 'validateChartBindings', runtimeTypes: ['flow', 'report', 'view', 'object'], run: validateChartBindings }, // [#26548] The same report, one key on: `dateRange.dimension` resolved // against the bound dataset's `type: 'date'` dimensions (named, or the ONE // candidate when omitted). Directly after its sibling because the two read diff --git a/packages/lint/src/validate-chart-bindings.ts b/packages/lint/src/validate-chart-bindings.ts index 039a9f9ec93..6188d4362c7 100644 --- a/packages/lint/src/validate-chart-bindings.ts +++ b/packages/lint/src/validate-chart-bindings.ts @@ -27,7 +27,13 @@ * `rows` / `columns` / `values`) is still checked, below. * 2. **List-view charts** — `ListChartConfigSchema` (`dataset` + * `dimensions` + `values`), reachable through `views[].list`, - * `views[].listViews.`, and `objects[].listViews.`. + * `views[].listViews.`, `objects[].listViews.`, and the two + * standalone `views[]` shapes `PUT /api/v1/meta/view` carries: the + * flattened list overlay (`viewKind: 'list'`, the chart at the TOP + * level) and the ViewItem record (`viewKind: 'list'`, one level down + * inside `config`). Without those two rungs a view written through the + * publish door is a shape this walk never opens, so crossing the rule + * onto `view` writes would have been a no-op that reads as coverage. * 3. **Dataset-bound page chart components** — a `PageComponent` whose * `properties` carry a `dataset` (the `object-chart` component). Same * binding shape as a list chart, but it arrives through the untyped @@ -363,7 +369,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { if (!stack || typeof stack !== 'object') return findings; const datasets = indexDatasets(stack); - if (datasets.size === 0 && !stack.reports && !stack.views && !stack.pages) return findings; + if (datasets.size === 0 && !stack.reports && !stack.views && !stack.objects && !stack.pages) return findings; /** * Resolve one bound dataset NAME, reporting `chart-dataset-unknown` when it @@ -624,6 +630,16 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { const view = views[vi]; if (!isRec(view)) continue; const viewName = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; + // The two standalone shapes, recognised as `validateListViewFieldRefs` + // recognises them: a flattened overlay carries its chart on the view + // itself, a ViewItem record inside `config`. + if (view.viewKind === 'list') { + if (isRec(view.config)) { + checkListChart(view.config, `view "${viewName}" (ViewItem record) · chart`, `views[${vi}].config`); + } else { + checkListChart(view, `view "${viewName}" (flattened list overlay) · chart`, `views[${vi}]`); + } + } checkListChart(view.list, `view "${viewName}" · list chart`, `views[${vi}].list`); if (isRec(view.listViews)) { for (const [key, lv] of Object.entries(view.listViews)) { From 45ada0d60c1158f189f2c45dbf27acb76bad1076 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:51:50 +0000 Subject: [PATCH 2/5] test(lint,rest): pin chart list-view refusals at the view and object doors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --- .../lint/src/reference-integrity-suite.ts | 6 +- .../lint/src/validate-chart-bindings.test.ts | 146 ++++++++++++++++++ .../rest/src/meta-view-chart-binding.test.ts | 86 ++++++++++- 3 files changed, 233 insertions(+), 5 deletions(-) diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 4b4905650a8..80df8f2220d 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -447,8 +447,10 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // published with a dangling chart binding is now refused (422) rather than // stored silently. Each was MEASURED over the shipped corpus at the door's // own snapshot shape before crossing — `report`: 9 reports (showcase 4, - // todo 5), 0 findings; `view` / `object`: MEASURED_VIEW_CORPUS — with a lit - // synthetic probe refused at each door. + // todo 5), 0 findings; `view` / `object`: every view and every object with + // `listViews` in the four example apps and platform-objects, of which two + // list views chart (showcase `task` and `project`), 0 findings. A lit + // synthetic probe is refused at each door. { name: 'validateChartBindings', runtimeTypes: ['flow', 'report', 'view', 'object'], run: validateChartBindings }, // [#26548] The same report, one key on: `dateRange.dimension` resolved // against the bound dataset's `type: 'date'` dimensions (named, or the ONE diff --git a/packages/lint/src/validate-chart-bindings.test.ts b/packages/lint/src/validate-chart-bindings.test.ts index 9b23c50db64..f49664adbfa 100644 --- a/packages/lint/src/validate-chart-bindings.test.ts +++ b/packages/lint/src/validate-chart-bindings.test.ts @@ -9,6 +9,8 @@ import { CHART_AXIS_NOT_SELECTED, } from './validate-chart-bindings.js'; import { explainRule } from './rule-explanations.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; // [#22161] Each finding of the two converted ids is one verdict sentence; the // reasoning it used to carry is the id's `os explain` entry. Every call below @@ -851,6 +853,150 @@ describe('validateChartBindings — list-view charts', () => { }); }); +// [#26847] The two standalone `views[]` shapes `PUT /api/v1/meta/view` carries, +// and the publish door itself. Measured on main before the change: a chart list +// view naming no declared dataset, or a measure its dataset lacks, published +// 200 through that door as a ViewItem record, a flattened overlay and a +// container `listViews` member alike, while `os validate` refused the file. +describe('[#26847] list-view charts at the view and object publish doors', () => { + const chart = (over: Record = {}) => ({ + chartType: 'bar', + dataset: 'task_metrics', + dimensions: ['status'], + values: ['est_hours'], + ...over, + }); + const listView = (over: Record = {}) => ({ + type: 'chart', + label: 'Hours by status', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['status'], + chart: chart(over), + }); + const SHAPES: Array<[string, (over?: Record) => Record, string]> = [ + [ + 'a ViewItem record', + (over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', label: 'Hours', config: listView(over) }), + 'views[0].config.chart', + ], + [ + 'a flattened list overlay', + (over) => ({ name: 'showcase_task.hours', object: 'showcase_task', viewKind: 'list', ...listView(over) }), + 'views[0].chart', + ], + [ + 'a container listViews member', + (over) => ({ name: 'showcase_task', object: 'showcase_task', listViews: { hours: listView(over) } }), + 'views[0].listViews.hours.chart', + ], + ]; + + describe.each(SHAPES)('the rule walks %s', (_label, view, at) => { + it('chart-dataset-unknown at `chart.dataset`', () => { + const findings = validateChartBindings({ ...baseStack(), views: [view({ dataset: 'no_such_dataset' })] }); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_DATASET_UNKNOWN, 'error', `${at}.dataset`]]); + }); + + it('chart-measure-unknown at `chart.values[i]`', () => { + const findings = validateChartBindings({ ...baseStack(), views: [view({ values: ['est_hours', 'ghost_measure'] })] }); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[CHART_MEASURE_UNKNOWN, 'error', `${at}.values[1]`]]); + }); + + it('CONTROL — a fully resolved chart reports nothing', () => { + expect(validateChartBindings({ ...baseStack(), views: [view()] })).toEqual([]); + }); + }); + + it('a ViewItem record is judged at `config` only — its envelope carries no chart to walk twice', () => { + const item = { ...SHAPES[0][1]({ dataset: 'no_such_dataset' }), chart: chart({ dataset: 'no_such_dataset' }) }; + expect(validateChartBindings({ ...baseStack(), views: [item] }).map((f) => f.path)).toEqual([ + 'views[0].config.chart.dataset', + ]); + }); + + it('an object list view names a dataset in a stack that declares none: refused, not skipped', () => { + const findings = validateChartBindings({ + objects: [{ name: 'showcase_task', fields: {}, listViews: { hours: listView() } }], + }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [CHART_DATASET_UNKNOWN, 'objects[0].listViews.hours.chart.dataset'], + ]); + }); + + describe('at the runtime publish door', () => { + const { datasets } = baseStack(); + const objects = [ + { + name: 'showcase_task', + label: 'Task', + sharingModel: 'private', + fields: { + status: { type: 'text', label: 'Status' }, + estimate_hours: { type: 'number', label: 'Estimate' }, + }, + }, + ]; + const chartRules = (fs: Array<{ rule: string; path: string }>) => + fs.filter((f) => f.rule.startsWith('chart-')).map((f) => [f.rule, f.path]); + + it('`validateChartBindings` reaches `view` and `object` writes', () => { + for (const type of ['view', 'object']) { + expect( + REFERENCE_INTEGRITY_RULES.find((r) => r.name === 'validateChartBindings')?.runtimeTypes, + type, + ).toContain(type); + } + }); + + describe.each(SHAPES)('a view written as %s', (_label, view, at) => { + const write = (over?: Record) => + runRuntimeAuthoringRules({ type: 'view', item: view(over), context: { objects, datasets } }); + + it('⭐ LIT — a chart naming no declared dataset is REFUSED at `chart.dataset`', () => { + const result = write({ dataset: 'no_such_dataset' }); + expect(chartRules(result.errors)).toEqual([[CHART_DATASET_UNKNOWN, `${at}.dataset`]]); + }); + + it('⭐ LIT — a chart whose `values` names no measure of its dataset is REFUSED at `chart.values[i]`', () => { + const result = write({ values: ['ghost_measure'] }); + expect(chartRules(result.errors)).toEqual([[CHART_MEASURE_UNKNOWN, `${at}.values[0]`]]); + }); + + it('⭐ CONTROL — the same view bound to a declared dataset and measure publishes clean', () => { + const result = write(); + expect(result.rulesRun).toContain('validateReferenceIntegrity'); + expect(result.errors).toEqual([]); + }); + }); + + describe('an object write carrying a built-in chart list view', () => { + const write = (over?: Record) => + runRuntimeAuthoringRules({ + type: 'object', + item: { ...objects[0], listViews: { hours: { ...listView(over), data: undefined } } }, + context: { objects, datasets }, + }); + + it('⭐ LIT — an unknown dataset is REFUSED', () => { + // The door names an object entry by name rather than position. + expect(chartRules(write({ dataset: 'no_such_dataset' }).errors)).toEqual([ + [CHART_DATASET_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.dataset'], + ]); + }); + + it('⭐ LIT — an unknown measure is REFUSED', () => { + expect(chartRules(write({ values: ['ghost_measure'] }).errors)).toEqual([ + [CHART_MEASURE_UNKNOWN, 'objects.showcase_task.listViews.hours.chart.values[0]'], + ]); + }); + + it('⭐ CONTROL — a resolved chart publishes clean', () => { + expect(write().errors).toEqual([]); + }); + }); + }); +}); + describe('validateChartBindings — dataset-bound page chart components', () => { it('checks dimensions/values and a ChartConfig-style yAxis', () => { const findings = validateChartBindings({ diff --git a/packages/rest/src/meta-view-chart-binding.test.ts b/packages/rest/src/meta-view-chart-binding.test.ts index 39db8ac2c86..f13ff5259f8 100644 --- a/packages/rest/src/meta-view-chart-binding.test.ts +++ b/packages/rest/src/meta-view-chart-binding.test.ts @@ -11,6 +11,9 @@ * list overlay member accepted `type: 'chart'` with no `chart` block, and an * `options.chart` bag holding only `chartType`. * + * #26847 extends the file one binding deeper: a chart whose `dataset` names no + * declared dataset, or whose `values` names no measure of it, is refused too. + * * Refusal cases assert the ADR-0112 envelope — `code` AND `status` — and that * no row reached `sys_metadata`. The last case measures the READ side of the * narrowing on a row stored before it: served as stored, flagged in its @@ -97,15 +100,27 @@ async function boot() { if (!route) throw new Error(`${method} ${META_ITEM} is not registered`); return route; }; - const call = async (method: string, name: string, body?: unknown) => { + const call = async (method: string, name: string, body?: unknown, type = 'view') => { const res = makeRes(); - await routeFor(method).handler({ params: { type: 'view', name }, query: {}, headers: {}, body } as any, res); + await routeFor(method).handler({ params: { type, name }, query: {}, headers: {}, body } as any, res); return res; }; + // [#26847] The dataset the fixture views bind, published through its own + // door: the view door resolves a chart's `dataset` and `values` against the + // live datasets, so a binding nobody declares is refused. + const published = await call('PUT', DATASET.name, DATASET, 'dataset'); + if (published._status !== 200) throw new Error(`dataset fixture refused: ${JSON.stringify(published._json)}`); const storedRows = async (name: string) => engine.find('sys_metadata', { where: { type: 'view', name } }); return { engine, put: (name: string, body: unknown) => call('PUT', name, body), get: (name: string) => call('GET', name), storedRows }; } +const DATASET = { + name: 'lead_metrics', + label: 'Lead metrics', + object: 'crm_lead', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'amount_sum', aggregate: 'sum', field: 'amount' }], +}; const BINDING = { chartType: 'bar', dataset: 'lead_metrics', dimensions: ['stage'], values: ['amount_sum'] }; const chartView = (name: string, extra: Record) => ({ name, @@ -117,7 +132,7 @@ const chartView = (name: string, extra: Record) => ({ ...extra, }); -type Issue = { path?: string; code?: string; message?: string }; +type Issue = { path?: string; code?: string; message?: string; rule?: string }; const issuesOf = (res: any): Issue[] => (res._json?.issues ?? []) as Issue[]; const NO_BLOCK_VERDICT = @@ -194,3 +209,68 @@ describe("#22491 PUT /api/v1/meta/view refuses a `type: 'chart'` list view that expect(issuesOf(again).some((i) => i.path === 'chart')).toBe(true); }); }); + +// [#26847] The same door, one binding deeper: a chart that DOES name a dataset, +// but one nobody declares, or a measure its dataset lacks. Measured on main +// `20457f6f` before the change: all six refusals below answered 200 and were +// stored, while `os validate` refused the same file. +describe('#26847 PUT /api/v1/meta/view refuses a chart list view whose dataset or measure does not resolve', () => { + const chartConfig = (chart: Record) => ({ + type: 'chart', + label: 'Pipeline by stage', + columns: ['stage', 'amount'], + chart: { ...BINDING, ...chart }, + }); + const SHAPES: Array<[string, string, (chart: Record) => Record, string]> = [ + [ + 'a view item (`config`)', + 'crm_lead.pipeline_item', + (chart) => ({ name: 'crm_lead.pipeline_item', object: 'crm_lead', viewKind: 'list', label: 'Pipeline', config: chartConfig(chart) }), + 'views[0].config.chart', + ], + [ + 'a flattened list overlay', + 'crm_lead.pipeline_flat', + (chart) => ({ name: 'crm_lead.pipeline_flat', object: 'crm_lead', viewKind: 'list', ...chartConfig(chart) }), + 'views[0].chart', + ], + [ + 'a container `listViews` member', + 'crm_lead', + (chart) => ({ name: 'crm_lead', object: 'crm_lead', list: { type: 'grid', columns: ['stage'] }, listViews: { pipeline: chartConfig(chart) } }), + 'views[0].listViews.pipeline.chart', + ], + ]; + + describe.each(SHAPES)('%s', (_label, name, body, at) => { + it('`chart.dataset` naming no declared dataset: 422 INVALID_METADATA, chart-dataset-unknown at `chart.dataset`, nothing stored', async () => { + const { put, storedRows } = await boot(); + const res = await put(name, body({ dataset: 'no_such_dataset' })); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows(name)).toEqual([]); + const hit = issuesOf(res).find((i) => i.rule === 'chart-dataset-unknown'); + expect(hit?.path, JSON.stringify(res._json)).toBe(`${at}.dataset`); + }); + + it('`chart.values` naming no measure of its dataset: 422 INVALID_METADATA, chart-measure-unknown at `chart.values[0]`, nothing stored', async () => { + const { put, storedRows } = await boot(); + const res = await put(name, body({ values: ['ghost_measure'] })); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows(name)).toEqual([]); + const hit = issuesOf(res).find((i) => i.rule === 'chart-measure-unknown'); + expect(hit?.path, JSON.stringify(res._json)).toBe(`${at}.values[0]`); + }); + + it('the control: the same view bound to the declared dataset and measure answers 200 and is stored', async () => { + const { put, storedRows } = await boot(); + const res = await put(name, body({})); + + expect(res._status, JSON.stringify(res._json)).toBe(200); + expect(await storedRows(name)).toHaveLength(1); + }); + }); +}); From 0dca5815244853ebee5c0114a199f9b49307d88d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:52:30 +0000 Subject: [PATCH 3/5] docs(changeset): list-view chart bindings refused at the view and object doors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --- ...47-list-view-chart-binding-publish-door.md | 19 +++++++++++++++++++ content/docs/data-modeling/analytics.mdx | 7 +++++++ 2 files changed, 26 insertions(+) create mode 100644 .changeset/26847-list-view-chart-binding-publish-door.md diff --git a/.changeset/26847-list-view-chart-binding-publish-door.md b/.changeset/26847-list-view-chart-binding-publish-door.md new file mode 100644 index 00000000000..14f4a1c12da --- /dev/null +++ b/.changeset/26847-list-view-chart-binding-publish-door.md @@ -0,0 +1,19 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the view and object publish doors refuse a list-view chart whose dataset, dimension or measure does not resolve + +Clause-②: no (narrowing) — a list view whose `chart.dataset` names no declared dataset, or whose `chart.dimensions` / `chart.values` names a dimension or measure its dataset does not declare, is refused at publish (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`); it published `200` before. `os validate` / `os build` already refused it, and still do. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at the runtime publish door: `validateChartBindings` (`@objectstack/lint`) joins the reference-integrity suite's `view` and `object` writes. + +- **Why.** A Studio or MCP author who mistyped a dataset name, or named a measure the dataset lacks, got a saved view whose chart rendered empty or broken, with nothing saying why, while the CLI refused the same file. +- **Before.** Measured on the real `PUT /api/v1/meta/view` route over a real `sys_metadata` store: a chart list view naming `no_such_dataset`, or a `values` entry naming no measure of its dataset, answered `200` and was stored, as a view item (`config`), as a flattened list overlay and as a container `listViews` member alike. +- **Now.** Each is refused `422 INVALID_METADATA` with the verdict `os validate` gives: `chart-dataset-unknown` at `chart.dataset`, `chart-measure-unknown` at `chart.values[i]`, and `chart-dimension-unknown` at `chart.dimensions[i]`. The rule's list-view walk now opens the two standalone view shapes the door carries (the flattened list overlay and the ViewItem `config`), and an object's own `listViews` is judged on an object write. A draft save is not gated, as for every author-time rule. +- **Who moves.** No view or object in `examples/**` or `packages/platform-objects` is refused: measured at the door's own snapshot shape, the two chart list views in the showcase app bind declared datasets and measures. + +**The remedy.** Bind a dataset the stack or package declares, and name its declared dimensions and measures (a measure name such as `amount_sum`, never the base field `amount`), or declare the missing dimension or measure on the dataset. diff --git a/content/docs/data-modeling/analytics.mdx b/content/docs/data-modeling/analytics.mdx index 21d7865b730..ad1dd33ffbe 100644 --- a/content/docs/data-modeling/analytics.mdx +++ b/content/docs/data-modeling/analytics.mdx @@ -285,6 +285,13 @@ and list-view charts to it **by name**. Presentations reference a dataset by its `name` string — the shipped presentation schemas carry no inline/anonymous dataset shape, so even a one-off single-object KPI is a small named dataset. +A list-view chart's binding must resolve. A `dataset` no package declares, or a +`dimensions` or `values` entry the dataset does not declare, is refused at +publish, for a view and for an object's own `listViews` alike, and fails +`os validate` (`chart-dataset-unknown`, `chart-dimension-unknown`, +`chart-measure-unknown`). A `values` entry names a dataset **measure**, never +the base field it aggregates. + A list-view chart draws the rows of its dataset's base `object`, never the list's own records: the list's filter does not reach the dataset query. So a list view's chart must bind a dataset whose `object` is the object the list From 35d40a9e7bf52112cbdb9bd10cbca209bcb02b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:55:36 +0000 Subject: [PATCH 4/5] test(lint): re-judge the door roster pins and the foreign-dataset fixtures Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --- .../lint/src/runtime-gate.object-writes.test.ts | 6 ++++++ .../lint/src/runtime-gate.view-writes.test.ts | 9 +++++++++ .../src/validate-list-view-field-refs.test.ts | 16 ++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index d905847c860..c1f5fa3b97b 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -260,10 +260,16 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // Each was measured over the shipped object corpus before crossing (116 // objects, 0 findings, precision 1.0); the comments on the members carry // the populations, and the four controls below are the non-vacuity half. + // [#26847] `validateChartBindings` is the fourth, for an object's built-in + // `listViews` charts: resolved against `stack.datasets`, a collection every + // per-write snapshot carries. Measured over the shipped object corpus (no + // object list view charts, 0 findings); its door pins live in + // `validate-chart-bindings.test.ts`. expect(crossed).toEqual([ 'validateSearchableFields', 'validateListViewFieldRefs', 'validateObjectFieldRefs', + 'validateChartBindings', ]); // `validateSortableFields` measured equally clean but was NOT crossed: // that is its own adjudication, and this pin is where it has to be made. diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index e35a999e69a..1a1f536b6be 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -244,10 +244,19 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { // #9313 failure inverted: the standalone list view a Studio tenant or an // MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so // a build-time-only rule never reaches the author who made the typo. + // [#26847] The fifth crossing, a different collection on purpose: a list + // view's chart binding (`dataset` / `dimensions` / `values`), resolved + // against `stack.datasets`, which every per-write snapshot also carries. + // The rule's list-view walk opens the flattened overlay and the ViewItem + // `config` for it; its report and page surfaces find nothing on a view + // snapshot. Measured over the shipped view corpus before crossing (two + // chart list views, 0 findings); the door pins live in + // `validate-chart-bindings.test.ts`. expect(crossed).toEqual([ 'validateSearchableFields', 'validateSortableFields', 'validateListViewFieldRefs', + 'validateChartBindings', ]); // And every member still judges flow snapshots — the #4463 P1 surface is // not narrowed by the member axis existing. diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index c157c185d7c..bc16b17d293 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -1418,6 +1418,18 @@ describe('[#26719] a list-view chart bound to a dataset over another object', () } describe('at the runtime save door', () => { + // [#26847] The door also resolves the chart's `dimensions` / `values` + // against the bound dataset (`validateChartBindings`), so each probe + // selects names its dataset declares: the foreign object is the only + // thing wrong with it. + const OWN_SELECTION: Record = { + duly_task_metrics: { dimensions: ['status'], values: ['task_count'] }, + duly_person_metrics: { dimensions: ['name'], values: ['person_count'] }, + }; + const doorChart = (dataset: string, extra: AnyRec = {}): AnyRec => { + const view = chartOn(dataset, extra); + return { ...view, chart: { ...(view.chart as AnyRec), ...OWN_SELECTION[dataset] } }; + }; const SCOPE = { packageId: 'com.qa.gap', dependencies: [] as string[] }; const owned = (o: AnyRec, pkg: string) => ({ ...o, _packageId: pkg }); const objects = [owned(OBJECTS[0], 'com.qa.gap'), owned(OBJECTS[1], 'com.other.app')]; @@ -1427,7 +1439,7 @@ describe('[#26719] a list-view chart bound to a dataset over another object', () object: 'duly_task', viewKind: 'list', label: 'By status', - config: chartOn(dataset), + config: doorChart(dataset), }); const write = (dataset: string) => runRuntimeAuthoringRules({ type: 'view', item: item(dataset), context: { objects, datasets }, packageScope: SCOPE }); @@ -1458,7 +1470,7 @@ describe('[#26719] a list-view chart bound to a dataset over another object', () const writeObject = (dataset: string) => runRuntimeAuthoringRules({ type: 'object', - item: { ...doorTask, listViews: { by_status: chartOn(dataset, { data: undefined }) } }, + item: { ...doorTask, listViews: { by_status: doorChart(dataset, { data: undefined }) } }, context: { objects: [doorTask, objects[1]], datasets }, packageScope: SCOPE, }); From 96844466d4db1163476073fbbe825875c0fe8d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:18:01 +0000 Subject: [PATCH 5/5] docs(changeset): name the two cases the CLI newly reports Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziFPBpGc76tAFHbB8v --- .changeset/26847-list-view-chart-binding-publish-door.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/26847-list-view-chart-binding-publish-door.md b/.changeset/26847-list-view-chart-binding-publish-door.md index 14f4a1c12da..2be6611b3cc 100644 --- a/.changeset/26847-list-view-chart-binding-publish-door.md +++ b/.changeset/26847-list-view-chart-binding-publish-door.md @@ -4,7 +4,7 @@ fix(lint)!: the view and object publish doors refuse a list-view chart whose dataset, dimension or measure does not resolve -Clause-②: no (narrowing) — a list view whose `chart.dataset` names no declared dataset, or whose `chart.dimensions` / `chart.values` names a dimension or measure its dataset does not declare, is refused at publish (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`); it published `200` before. `os validate` / `os build` already refused it, and still do. Nothing is widened. +Clause-②: no (narrowing) — a list view whose `chart.dataset` names no declared dataset, or whose `chart.dimensions` / `chart.values` names a dimension or measure its dataset does not declare, is refused at publish (`PUT /api/v1/meta/view`, `PUT /api/v1/meta/object`); it published `200` before. `os validate` / `os build` already refused it on the shapes they walked, and still do; they now also report it where they skipped it: an `objects[].listViews` chart in a stack that declares no datasets, reports, views or pages, and a `views[]` entry in the flattened list overlay or ViewItem `config` shape. Nothing is widened. Metadata-protocol: narrowed @@ -12,7 +12,7 @@ Metadata-protocol: narrowed **BREAKING** accept-set narrowing at the runtime publish door: `validateChartBindings` (`@objectstack/lint`) joins the reference-integrity suite's `view` and `object` writes. - **Why.** A Studio or MCP author who mistyped a dataset name, or named a measure the dataset lacks, got a saved view whose chart rendered empty or broken, with nothing saying why, while the CLI refused the same file. -- **Before.** Measured on the real `PUT /api/v1/meta/view` route over a real `sys_metadata` store: a chart list view naming `no_such_dataset`, or a `values` entry naming no measure of its dataset, answered `200` and was stored, as a view item (`config`), as a flattened list overlay and as a container `listViews` member alike. +- **Before.** Measured on the real `PUT /api/v1/meta/view` route over a real `sys_metadata` store: a chart list view naming `no_such_dataset`, or a `values` entry naming no measure of its dataset, answered `200` and was stored, as a view item (`config`), as a flattened list overlay and as a container `listViews` member alike. `os validate` / `os build` skipped the same two cases the door now reports: an object's `listViews` chart in a stack with no datasets, reports, views or pages, and the flattened overlay and ViewItem `config` shapes. - **Now.** Each is refused `422 INVALID_METADATA` with the verdict `os validate` gives: `chart-dataset-unknown` at `chart.dataset`, `chart-measure-unknown` at `chart.values[i]`, and `chart-dimension-unknown` at `chart.dimensions[i]`. The rule's list-view walk now opens the two standalone view shapes the door carries (the flattened list overlay and the ViewItem `config`), and an object's own `listViews` is judged on an object write. A draft save is not gated, as for every author-time rule. - **Who moves.** No view or object in `examples/**` or `packages/platform-objects` is refused: measured at the door's own snapshot shape, the two chart list views in the showcase app bind declared datasets and measures.