From 2637b11eda8d0a8c563974027c0b92d0ed21f517 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:43:29 +0000 Subject: [PATCH 01/11] wip(spec): retire a joined report's chart (block key removed, container refused) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 143 ++++++++++++++++-- .../18.ui__JoinedReportBlock__chart.ts | 13 ++ packages/spec/src/migrations/registry.ts | 25 ++- packages/spec/src/ui/report.form.ts | 8 +- packages/spec/src/ui/report.zod.ts | 91 +++++++++-- 5 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__JoinedReportBlock__chart.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7de8807d3f6..349a36f39ed 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10069,13 +10069,14 @@ const chartConfigAriaRemoved: MetadataConversion = { chartConfig: { description: 'Orders by month', aria: { ariaLabel: 'Orders by month' } }, }], }], + // ⚠️ No `blocks[].chart` here since #20161: `report-joined-chart-removed` + // strips a block's WHOLE `chart`, so a fixture carrying one could no + // longer equal its own `after` under the full-table replay (the fixture + // disjointness contract). The block leg of `apply` above still runs on a + // stored row, ahead of that entry, and the compound result is the same. reports: [{ name: 'revenue_by_region', chart: { type: 'bar', aria: { ariaLabel: 'Revenue by region' } }, - blocks: [{ - name: 'by_quarter', - chart: { type: 'line', aria: { ariaDescribedBy: 'legend_1' } }, - }], }], }, after: { @@ -10092,15 +10093,11 @@ const chartConfigAriaRemoved: MetadataConversion = { reports: [{ name: 'revenue_by_region', chart: { type: 'bar' }, - blocks: [{ - name: 'by_quarter', - chart: { type: 'line' }, - }], }], }, - // One notice per stripped SITE — the widget's chart config, the report's own - // chart and the block's chart — not one per key name. - expectedNotices: 3, + // One notice per stripped SITE — the widget's chart config and the report's + // own chart — not one per key name. + expectedNotices: 2, }, }; @@ -10770,6 +10767,129 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { }, }; +/** + * A `joined` report's `chart` leaves, at both of its coordinates (protocol 18, + * #20161 — ADR-0049 enforce-or-remove; triage direction "retire, premise + * first", the premise measured clean: zero joined reports with a `chart` in + * this repo's examples, in the showcase, or in hotcrm). + * + * Nothing ever drew either one. At this repo's `.objectui-sha` pin + * `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`, `DatasetReportRenderer`'s joined + * branch draws each block as a table and returns before the one `report.chart` + * read below it, and there is no read of a block's `chart` anywhere + * (`block.chart`: zero hits over objectui `packages/`, against a lit + * `block.runtimeFilter` control). So both were pure no-ops, and deleting them + * is lossless: the document changes, not a pixel of the rendered report. + * + * Two coordinates, one entry, because they are one retirement — an author + * upgrading a joined report carries both together: + * + * - `reports[].blocks[].chart` — REMOVED from `JoinedReportBlockSchema`'s + * closed shape (its `guidance` table carries the prescription). Stripped + * on every report, not only a `joined` one: the block shape is the same + * schema whatever the container's type, so a block chart is refused under + * any container. + * - `reports[].chart` on a `type: 'joined'` report — still DECLARED (it is + * the live embedded chart of every non-joined report) and refused by the + * joined arm of `ReportSchema`'s refinement. Stripped only there. + * + * `retiredFromLoadPath`: a live author is refused at parse with the + * prescription rather than silently rewritten. The entry exists so stored + * `sys_metadata` report rows written through the Studio form (which offered a + * block `chart` input until this change) replay clean through + * `applyConversionsToStoredItem`, and so `os migrate meta --from 17` lists the + * mechanical edits for author sources. `stripKeys`-shaped deletion is + * idempotent by construction. + * + * ⚠️ Overlap with `chart-config-aria-removed`, which strips `aria` INSIDE a + * block chart and runs earlier in this chain: on a stored block chart carrying + * `aria`, that entry strips `aria` and this one then strips the whole `chart` — + * the compound result is the same document this entry alone produces. Its + * fixture therefore no longer carries a block chart (the fixture-disjointness + * contract), and its apply is left as it shipped. + */ +const reportJoinedChartRemoved: MetadataConversion = { + id: 'report-joined-chart-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'report.blocks[].chart / report.chart on a joined report', + summary: + "a joined report's 'chart' removed from its blocks and refused on the container (#20161 — " + + 'ADR-0049 enforce-or-remove: the joined renderer draws each block as a table and never read ' + + 'either, so the chart parsed and nothing was plotted; a non-joined report keeps its live ' + + "'chart')", + apply(stack, emit) { + return mapCollection(stack, 'reports', (r, path) => { + const own = r.type === 'joined' ? stripKeys(r, ['chart'], emit, path) : r; + const blocks = own.blocks; + if (!Array.isArray(blocks)) return own; + let touched = false; + const rebuilt = blocks.map((b, i) => { + if (!b || typeof b !== 'object' || Array.isArray(b)) return b; + const next = stripKeys(b as Record, ['chart'], emit, `${path}.blocks[${i}]`); + if (next !== b) touched = true; + return next; + }); + if (!touched) return own; + return { ...own, blocks: rebuilt }; + }); + }, + fixture: { + before: { + reports: [ + { + name: 'task_overview', + type: 'joined', + chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, + blocks: [ + { + name: 'open_block', + dataset: 'task_metrics', + rows: ['status'], + values: ['task_count'], + chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' }, + }, + // A block with nothing to strip keeps its identity (copy-on-write). + { name: 'done_block', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }, + ], + }, + // A NON-joined report keeps its container `chart`: it is the live + // embedded chart there. + { + name: 'hours_by_status', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['est_hours'], + chart: { type: 'bar', xAxis: 'status', yAxis: 'est_hours' }, + }, + ], + }, + after: { + reports: [ + { + name: 'task_overview', + type: 'joined', + blocks: [ + { name: 'open_block', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }, + { name: 'done_block', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }, + ], + }, + { + name: 'hours_by_status', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['est_hours'], + chart: { type: 'bar', xAxis: 'status', yAxis: 'est_hours' }, + }, + ], + }, + // One per stripped SITE — the joined container's chart and one block's. + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -10877,6 +10997,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // `element-input-target-variable-removed` (a page component IS a stack // collection member, unlike the `kernel/Manifest:loading` family). 'ui/ElementTextInputProps:targetVariable', + // #20161 (ADR-0049 enforce-or-remove). `JoinedReportBlock.chart` declared an + // inline chart on one block of a `joined` report, and no renderer ever drew it: + // at the `.objectui-sha` pin `f8a9d0fb0596`, `DatasetReportRenderer`'s joined + // branch draws each block as a table and has no read of a block's `chart` at + // all, so the chart parsed, passed `validate-chart-bindings`, and plotted + // nothing. The block shape is `.strict()`, so the key is removed from it and + // its prescription is served from the block schema's `guidance` table. A + // joined report's container `chart` is refused by `ReportSchema`'s refinement + // in the same change; `chart` stays live on every non-joined report. D2: + // `report-joined-chart-removed`. + 'ui/JoinedReportBlock:chart', // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision // batch #107 item 1, verbatim 「撤」). `ListView.pageName` named the published // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index c4239302f40..37ef33194b0 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -79,7 +79,9 @@ export const reportForm = defineForm({ { field: 'label', label: 'Label' }, { field: 'description', label: 'Description' }, { field: 'type', label: 'Block Type' }, - { field: 'chart', label: 'Chart' }, + // A `chart` input sat here until #20161: the key was removed from + // `JoinedReportBlockSchema` because no renderer ever drew a block + // chart, so this control offered a setting that plotted nothing. { field: 'dataset', label: 'Dataset' }, { field: 'rows', label: 'Rows' }, { field: 'columns', label: 'Columns' }, @@ -97,7 +99,9 @@ export const reportForm = defineForm({ collapsed: true, fields: [ { field: 'runtimeFilter', widget: 'json', helpText: 'Render-time scope filter, ANDed at query time' }, - { field: 'chart', type: 'composite', helpText: 'Chart config (type, legend, colors)' }, + // #20161 — a `joined` report draws its blocks as tables and refuses a + // container `chart`, so the control is offered only where it draws. + { field: 'chart', type: 'composite', visibleWhen: "data.type != 'joined'", helpText: 'Chart config (type, legend, colors)' }, ], }, // An "Advanced" section offering `aria` + `performance` used to sit here. diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index f13efbaf468..f96fd4d6166 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -143,6 +143,44 @@ export function reportSelectionOrder( return out; } +/** + * The prescription a `joined` report's `chart` carries, at both of its + * coordinates (#20161, ADR-0049 enforce-or-remove). Measured at this repo's + * `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: + * `DatasetReportRenderer`'s joined branch draws each block as a + * `DatasetMatrixTable` / `DatasetReportTable` and returns before the one + * `report.chart` read below it, and no renderer reads a block's `chart` at all + * — so a chart on a joined report, container or block, parsed and plotted + * nothing, while `validate-chart-bindings` checked its axes as if it would. + * + * - On a BLOCK the key is removed from the closed shape; `strictObject`'s + * `guidance` slot answers it with the prescription below. + * - On the CONTAINER the key stays declared — it is the live embedded chart of + * every non-joined report — and the joined arm of `ReportSchema`'s + * refinement refuses it, beside the selection keys it already refuses. + * + * The ADR-0087 conversion `report-joined-chart-removed` strips both from old + * sources and stored rows; it is retired from the load path, so a live author + * is refused here rather than rewritten. + * + * ⚠️ The version sentence names the npm release this ships in, never the + * protocol major the migration entries are numbered at (ADR-0087, the level + * half of its 2026-09-13 amendment). + */ +const JOINED_BLOCK_CHART_RETIRED = + '`report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) ' + + '— no renderer ever drew it: a joined report draws each block as a table and never reads a ' + + 'block `chart`, so the chart parsed and nothing was plotted. Delete the key. A chart is drawn ' + + 'from a non-joined report\'s own top-level `chart`: to plot one of these slices, give it a ' + + 'report of its own with that `chart`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const JOINED_CONTAINER_CHART_REFUSED = + 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, ' + + 'on the container or on a block. Delete `chart`; to plot one of these slices, give it a ' + + 'non-joined report of its own with that `chart`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + /** * Joined Report Block Schema * @@ -153,15 +191,18 @@ export function reportSelectionOrder( * domain — e.g. "new customers / churned / silent" in a customer-churn * report, or "new / qualified / closed" in a lead-funnel report. * - * Blocks may declare their own filter (combined with the container filter - * via `$and` at render time) and their own grouping / aggregation. + * Blocks may declare their own `runtimeFilter` (ANDed with the container's + * `runtimeFilter` at query time) and their own `rows` / `columns` / `values` + * selection and `order`. * * Notes for implementers: * - `type` defaults to `tabular` — leave a block's type implicit if the * sub-report is just a list. Set explicitly to `summary` or `matrix` for * aggregated blocks. - * - The schema is intentionally permissive about the column shape: blocks - * are not allowed to be themselves `joined` (no recursion). + * - Blocks are not allowed to be themselves `joined` (no recursion). + * - A block is drawn as a table and has no `chart` key: #20161 removed it, + * because nothing ever drew it. Writing it is refused with the upgrade + * prescription (the `guidance` entry below). */ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({ surface: 'this joined report block', @@ -210,6 +251,10 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje '`drilldown` is a container-level key on the report, not per block — move it to the top-level report. A joined report drills through from the container.', protection: '`protection` is the ADR-0010 package-author lock policy, declared once on the REPORT — a block is not separately lockable. Move it to the top-level report.', + // #20161 — a retired key, not a wrong-layer pointer: removed from this + // closed shape, so the prescription is what an author who keeps writing it + // is answered with (build-schemas check (c) proof 4 reads it from here). + chart: JOINED_BLOCK_CHART_RETIRED, }, }, { /** Stable id for the block (used as react key, telemetry, deeplinks). */ @@ -220,8 +265,8 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje description: I18nLabelSchema.optional().meta({ title: 'Description' }), /** Block report type — `joined` is intentionally excluded (no recursion). */ type: z.enum(['tabular', 'summary', 'matrix']).default('tabular').meta({ title: 'Block Type' }), - /** Optional inline chart configuration. */ - chart: ReportChartSchema.optional().meta({ title: 'Chart' }), + // `chart` was declared here until #20161 and nothing ever drew it; it is + // answered by the `guidance` entry above (ADR-0087 `report-joined-chart-removed`). /** * ADR-0021 — the dataset this block binds to (single-form). The block selects @@ -246,8 +291,10 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje * onto `blocks[]`: every one of them SELECTS data, and a joined container * selects nothing itself — each block binds its own `dataset` and picks its own * `rows` / `columns` / `values`. `order` is refused beside them, by its own - * message, for the same reason. Not exported: the refinement below is the only - * reader, and `report.test.ts` pins the list by parse. + * message, for the same reason, and so is `chart` (#20161) — with no pointer + * onto `blocks[]`, because a block draws no chart either. Not exported: the + * refinement below is the only reader, and `report.test.ts` pins the list by + * parse. */ const JOINED_CONTAINER_SELECTION_KEYS = ['dataset', 'rows', 'columns', 'values'] as const; @@ -396,17 +443,26 @@ export const ReportSchema = lazySchema(() => strictObject({ */ drilldown: z.boolean().default(true).describe('Click-through to underlying records'), - /** Visualization */ - chart: ReportChartSchema.optional().describe('Embedded chart configuration'), + /** + * Visualization — an embedded chart plotted from the bound dataset + * (`xAxis` names a dimension, `yAxis` a measure) above the report's table. + * + * Refused on a `joined` report (#20161): a joined report draws each block + * as a table and never reads `chart`, and a block has no `chart` key — so a + * chart there parsed and plotted nothing. + */ + chart: ReportChartSchema.optional().describe('Embedded chart configuration (refused on a joined report, which draws tables only)'), /** * Joined report blocks — only meaningful when `type: 'joined'`. * * A joined report renders multiple independent sub-reports stacked - * vertically in the same view. Each block declares its own object, - * columns, groupings and filter. The container-level `filter` is ANDed - * into every block at query time so a top-level scope (e.g. "this - * quarter") flows down without per-block duplication. + * vertically in the same view, each drawn as a table. Each block binds its + * own `dataset` and selects its own `rows` / `columns` / `values`, with its + * own `runtimeFilter` and `order`. The container-level `runtimeFilter` is + * ANDed into every block at query time so a top-level scope (e.g. "this + * quarter") flows down without per-block duplication. Neither the container + * nor a block carries a `chart`. * * Renderers must ignore `blocks` when `type !== 'joined'`. */ @@ -465,6 +521,13 @@ export const ReportSchema = lazySchema(() => strictObject({ path: ['order'], }); } + // #20161 — the one container key refused WITHOUT a pointer onto `blocks[]`: + // the joined branch never reads `chart`, and a block has no `chart` key to + // move it to. Any present value is refused (it is an object, so there is no + // "empty" reading the selection threshold above could apply to). + if (r.chart !== undefined) { + ctx.addIssue({ code: 'custom', message: JOINED_CONTAINER_CHART_REFUSED, path: ['chart'] }); + } } else { checkReportOrder(r, ctx); } From 29c2368cc6afd0aaf7c5d57378054c116dc67876 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:56:21 +0000 Subject: [PATCH 02/11] wip: lint stops checking joined charts; ledger, docs, form pins, changeset Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../20161-joined-report-chart-retired.md | 69 ++++++++++++ content/docs/ui/reports.mdx | 24 +++- .../lint/src/validate-chart-bindings.test.ts | 54 ++++++++- packages/lint/src/validate-chart-bindings.ts | 22 +++- .../report-form-echo-decisions.test.ts | 28 +++-- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/report.json | 6 +- packages/spec/src/ui/report.test.ts | 106 ++++++++++++++++++ 8 files changed, 283 insertions(+), 28 deletions(-) create mode 100644 .changeset/20161-joined-report-chart-retired.md diff --git a/.changeset/20161-joined-report-chart-retired.md b/.changeset/20161-joined-report-chart-retired.md new file mode 100644 index 00000000000..7025fa522d1 --- /dev/null +++ b/.changeset/20161-joined-report-chart-retired.md @@ -0,0 +1,69 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +'@objectstack/platform-objects': patch +--- + +fix(spec): a `joined` report draws no chart — `blocks[].chart` is removed and a container `chart` on a joined report is refused (#20161) + +Clause-②: no (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, +never by the level). + +A `joined` report draws each of its blocks as a table. The renderer's joined +branch returns before its one read of the report's `chart`, and nothing ever +read a block's `chart` at all. So a chart on a joined report, on the container +or on any block, parsed green, passed the `validate-chart-bindings` lint, and +plotted nothing. Both coordinates now answer at parse: + +``` +FROM ReportSchema.safeParse({ name: 'overview', label: 'Overview', type: 'joined', + chart: { type: 'bar', xAxis: 'status', yAxis: 'task_count' }, + blocks: [{ name: 'open_block', dataset: 'tasks', rows: ['status'], values: ['task_count'], + chart: { type: 'pie', xAxis: 'status', yAxis: 'task_count' } }] }) + -> { success: true } // both charts silently never drawn + +TO -> { success: false, issues: [ + { code: 'unrecognized_keys', path: ['blocks', 0], + message: '… `report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. …' }, + { code: 'custom', path: ['chart'], + message: 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block. Delete `chart`; …' } ] } +``` + +**Fix.** Delete the `chart`. The report renders exactly as before, because +neither value was ever drawn. To plot one of the slices a block shows, give it a +non-joined report of its own with that `chart`. +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +**What does not change.** `chart` on a `tabular` / `summary` / `matrix` report is +untouched: it is that report's live embedded chart. A joined report with no +`chart` parses byte-identically to before, and a block keeps every other key. + +### The retirement kit + +- **Schema.** `JoinedReportBlockSchema` is closed (`strictObject`), so `chart` is + removed from its shape and answered by its `guidance` table with the + prescription (build-schemas check (c) proof 4). `ReportSchema.chart` stays + declared; the joined arm of its refinement refuses it, beside the + `dataset` / `rows` / `columns` / `values` / `order` refusals already there. +- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/JoinedReportBlock:chart`, and + the D2 conversion `report-joined-chart-removed` (protocol 18, retired from the + load path) strips a block's `chart` and a joined container's `chart` from old + sources and stored `sys_metadata` rows as a lossless delete. Stored rows can + carry them: the Studio report form offered a block `chart` input until this + change. +- **Form.** `reportForm` drops the block `chart` input and shows the container + `chart` only when `type` is not `joined`; the `platform-objects` metadata-form + translation bundles drop the `blocks.chart` label in all four locales. +- **Lint.** `validate-chart-bindings` no longer resolves the axes of a block chart + or of a joined container's chart against a dataset: it would be vouching for a + chart that is refused at parse and never drawn. A block's own `dataset` / + `rows` / `columns` / `values` are still checked. +- **Ledger and docs.** `liveness/report.json` names a reader for `chart` only on + non-joined reports and drops `chart` from the `blocks` row; + `content/docs/ui/reports.mdx` lists what a joined container refuses. + + diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx index 54ce5bdaca3..4d3b0e452a4 100644 --- a/content/docs/ui/reports.mdx +++ b/content/docs/ui/reports.mdx @@ -103,9 +103,20 @@ panels over one domain — "open / completed", "new / qualified / closed" — wh is a different slice rather than a different subject. A block is a sub-report, so it takes the same `dataset` / `rows` / `columns` / `values` / -`runtimeFilter` / `order` vocabulary. Four things are **container-level only** and are -rejected on a block: nested `blocks` (no recursion — a block's `type` enum excludes -`joined`), `drilldown`, `protection`, and — as below — `order` on a `joined` container. +`runtimeFilter` / `order` vocabulary, and each block is drawn as a table. Three keys are +**container-level only** and are rejected on a block: nested `blocks` (no recursion — a +block's `type` enum excludes `joined`), `drilldown` and `protection`. + +The container selects nothing itself, so a `joined` report refuses every top-level key that +selects or orders data and points it onto `blocks[]`: `dataset`, and a non-empty `rows`, +`columns`, `values` or `order`. The container keys a `joined` report does read are +`runtimeFilter` (ANDed into every block's own) and `drilldown`. + +**A `joined` report draws no chart.** `chart` on the container is refused with *a `joined` +report draws no chart — it draws each block as a table and never reads `chart`, on the +container or on a block*, and `blocks[].chart` was removed in `@objectstack/spec` 17.5.0: +nothing ever drew either one. To plot one of these slices, give it a report of its own with +a [`chart`](#an-embedded-chart). {/* os:check */} ```typescript @@ -174,9 +185,10 @@ second rejection — write `drilldown: true` / `false` if you mean the report. ## An embedded chart -A report may carry one `chart`. Its `xAxis` and `yAxis` name the **bound dataset's** -dimension and measure — not raw object fields — and are plotted from a second dataset -query, so the chart and the grid cannot disagree. +A non-`joined` report may carry one `chart` (a `joined` report refuses it — see above). Its +`xAxis` and `yAxis` name the **bound dataset's** dimension and measure — not raw object +fields — and are plotted from a second dataset query, so the chart and the grid cannot +disagree. {/* os:check */} ```typescript diff --git a/packages/lint/src/validate-chart-bindings.test.ts b/packages/lint/src/validate-chart-bindings.test.ts index f04dad5618c..2931d67b301 100644 --- a/packages/lint/src/validate-chart-bindings.test.ts +++ b/packages/lint/src/validate-chart-bindings.test.ts @@ -195,7 +195,14 @@ describe('validateChartBindings — report charts', () => { expect(findings[0].hint).toContain('Did you mean "task_metrics"?'); }); - it('checks a joined report block chart against the block dataset', () => { + // #20161 — a joined report draws no chart: `ReportSchema` refuses a container + // `chart` on one and a block has no `chart` key, and the renderer reads + // neither. Until then this rule resolved a block chart's axes against the + // block's dataset, telling the author the chart was well-bound when it would + // never plot. It is silent about them now — while the SAME block's own + // selection is still resolved, which is what makes the silence a decision + // about the chart rather than a block the walk stopped reaching. + it('does NOT check a joined block\'s chart — nothing draws it — while the block\'s own selection is still resolved', () => { const findings = validateChartBindings({ ...baseStack(), reports: [ @@ -206,15 +213,54 @@ describe('validateChartBindings — report charts', () => { { name: 'b1', dataset: 'task_metrics', + rows: ['status_nope'], values: ['task_count'], - chart: { type: 'pie', xAxis: 'ghost_dim', yAxis: 'task_count' }, + chart: { type: 'pie', xAxis: 'ghost_dim', yAxis: 'ghost_measure' }, }, ], }, ], }); - expect(findings).toHaveLength(1); - expect(findings[0].path).toBe('reports[0].blocks[0].chart.xAxis'); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [CHART_DIMENSION_UNKNOWN, 'reports[0].blocks[0].rows[0]'], + ]); + }); + + it('does NOT check a joined container\'s chart either — the joined renderer never reads it', () => { + const findings = validateChartBindings({ + ...baseStack(), + reports: [ + { + name: 'joined', + type: 'joined', + chart: { type: 'bar', xAxis: 'ghost_dim', yAxis: 'ghost_measure' }, + blocks: [{ name: 'b1', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }], + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('…and the same unresolvable axes on a NON-joined report still gate — the chart there is drawn', () => { + // The control for both silences above: identical axis names, one level + // up, on the report type whose chart the renderer plots. + const findings = validateChartBindings({ + ...baseStack(), + reports: [ + { + name: 'r', + type: 'summary', + dataset: 'task_metrics', + rows: ['status'], + values: ['task_count'], + chart: { type: 'bar', xAxis: 'ghost_dim', yAxis: 'ghost_measure' }, + }, + ], + }); + expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [CHART_DIMENSION_UNKNOWN, 'reports[0].chart.xAxis', 'error'], + [CHART_MEASURE_UNKNOWN, 'reports[0].chart.yAxis', 'error'], + ]); }); it('checks report series names as measures', () => { diff --git a/packages/lint/src/validate-chart-bindings.ts b/packages/lint/src/validate-chart-bindings.ts index 084fc7b3ebc..9ececb06e68 100644 --- a/packages/lint/src/validate-chart-bindings.ts +++ b/packages/lint/src/validate-chart-bindings.ts @@ -14,11 +14,17 @@ * * Surfaces covered here: * - * 1. **Report charts** — `report.chart` and `report.blocks[].chart`. + * 1. **Report charts** — `report.chart` on a non-`joined` report. * `ReportChartSchema` narrows `xAxis`/`yAxis` from ChartConfig's * object/array shapes to bare STRINGS, which is why simply pointing the * dashboard rule at reports would find nothing: its `Array.isArray(yAxis)` * guard skips a string silently. `series[].name` keeps the array shape. + * A `joined` report draws no chart (#20161): its container `chart` is + * refused by `ReportSchema` and a block has no `chart` key, so neither is + * a binding this rule checks. Checking one would tell the author their + * axes are fine on a chart that is refused at parse and never drawn — + * an invitation to author it. A block's own SELECTION (`dataset` / + * `rows` / `columns` / `values`) is still checked, below. * 2. **List-view charts** — `ListChartConfigSchema` (`dataset` + * `dimensions` + `values`), reachable through `views[].list`, * `views[].listViews.`, and `objects[].listViews.`. @@ -510,7 +516,8 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { checkAgainst(ds, binding.dataset, binding); }; - // ── 1. Report charts (report.chart + report.blocks[].chart) ── + // ── 1. Report charts (report.chart on a non-joined report) + every report + // surface's own selection, blocks included ── const reports = recordsOf(stack.reports); for (let ri = 0; ri < reports.length; ri++) { const report = reports[ri]; @@ -535,8 +542,13 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { * groups against the ONE resolved dataset, because they sit at different * depths and their findings should say so: `reports[i].rows[j]` under * `report "x"`, `reports[i].chart.yAxis` under `report "x" · chart`. + * + * `drawsChart` is false for a `joined` container and for every block + * (#20161): no renderer draws a chart there and `ReportSchema` refuses one, + * so its axes are not a binding and this rule stays silent about them + * rather than vouching for a chart that will never plot. */ - const checkReportSurface = (container: AnyRec, where: string, path: string) => { + const checkReportSurface = (container: AnyRec, where: string, path: string, drawsChart: boolean) => { const dsName = strName(container.dataset); const ds = resolveDataset(dsName, where, path); if (!ds || !dsName) return; @@ -555,6 +567,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { path, }); + if (!drawsChart) return; const chart = container.chart; if (!isRec(chart)) return; const xAxisName = strName(chart.xAxis); @@ -579,7 +592,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { }); }; - checkReportSurface(report, `report "${reportName}"`, `reports[${ri}]`); + checkReportSurface(report, `report "${reportName}"`, `reports[${ri}]`, report.type !== 'joined'); const blocks = Array.isArray(report.blocks) ? report.blocks : []; for (let bi = 0; bi < blocks.length; bi++) { @@ -589,6 +602,7 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { block, `report "${reportName}" · block "${strName(block.name) ?? `#${bi}`}"`, `reports[${ri}].blocks[${bi}]`, + false, ); } } diff --git a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts index 0db16c59f2b..3b0f3e1935e 100644 --- a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts @@ -41,15 +41,15 @@ // walks it (`walkMetadataForms` / `walkFormField` in // `packages/cli/src/utils/i18n-extract.ts`): every section's `label` and // `description`, then every field it declares, recursively, with the same -// dot-path accumulation and the same section-name normalisation. 45 `en` string -// leaves — 8 from the four sections, 37 from the 26 field rows — and the +// dot-path accumulation and the same section-name normalisation. 44 `en` string +// leaves — 8 from the four sections, 36 from the 25 field rows — and the // catalog and the form are asserted equal in BOTH directions, so a field added // to the form tomorrow joins this population without anybody editing a list. // // ⭐ THIS IS THE FIRST LEDGER OF THIS CARD WHOSE POPULATION REACHES SECTION // LEAVES. Round 8's walked `object.fields` only. The card's own body records // `report.sections.*` as a population NOTHING covers — "⚠️ A population NOTHING -// covers … 承接者:无". This walk covers it: 8 of the 45 leaves are section +// covers … 承接者:无". This walk covers it: 8 of the 44 leaves are section // leaves, asserted by count and by name. // // ## The controls — one outward, two inward, one dark @@ -62,9 +62,9 @@ // the sibling semantic-layer panel, 37 leaves, 0 echoing — is excluded // too, and that exclusion is asserted rather than trusted precisely // because a wrong sweep of it would NOT go red. -// (2) INWARD, AUTHORED. 39 of this population's own 45 leaves come back +// (2) INWARD, AUTHORED. 38 of this population's own 44 leaves come back // NON-echoing in all three locales, in the same walk. A hand-list of six -// echoes can only ever produce positives; this derivation produces 39 +// echoes can only ever produce positives; this derivation produces 38 // negatives from the same predicate in the same run. // (3) ⭐⭐ INWARD, THE TWIN. `report.fields['blocks.runtimeFilter']` is IN the // population, carries the IDENTICAL English string as @@ -72,7 +72,13 @@ // locales. One schema key, one rendering — the word is COPIED and the // copy is asserted, so the two positions can only move together. // (4) DARK. Fed the `en` catalog in place of a translated one, the same walk -// must flag all 45; and the echo predicate must flag all 6 rows. +// must flag all 44; and the echo predicate must flag all 6 rows. +// +// (#20161 took one field row out of `reportForm` — the joined-block `chart` +// input, whose key left `JoinedReportBlockSchema` because nothing ever drew a +// block chart. That row was an AUTHORED, non-echoing `label` leaf in all three +// locales, so the population went 45 → 44, the field leaves 37 → 36 and the +// inward negatives 39 → 38, and none of the six decided rows moved.) // // ## ⚠️⚠️ The phantom-translation trap — and why this family is the SHARP case // @@ -835,9 +841,9 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { // `report.sections.*` is written into #19403's body as a population NOTHING // covers — 「承接者:无」. This walk covers it, and the count is asserted so // a walk that quietly stopped at `fields` would go red. - expect(PANEL_LEAVES.length).toBe(45); + expect(PANEL_LEAVES.length).toBe(44); expect(PANEL_LEAVES.filter((l) => l.kind === 'section').length).toBe(8); - expect(PANEL_LEAVES.filter((l) => l.kind === 'field').length).toBe(37); + expect(PANEL_LEAVES.filter((l) => l.kind === 'field').length).toBe(36); expect( PANEL_LEAVES.some((l) => l.kind === 'section' && l.path === 'dataset_binding' && l.prop === 'description'), 'the section description this round decides is not in the population', @@ -894,13 +900,13 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { ); }); - it('⭐ DARK, INWARD — 39 of this population\'s own 45 leaves come back NON-ECHOING', () => { + it('⭐ DARK, INWARD — 38 of this population\'s own 44 leaves come back NON-ECHOING', () => { // A hand-list of six echoes can only ever produce positives. This derivation - // produces 39 negatives from the same predicate in the same run, including + // produces 38 negatives from the same predicate in the same run, including // the twin `blocks.runtimeFilter` the runtimeFilter row is copied from. const decided = new Set(DECISIONS.map(idOf)); const negatives = PANEL_LEAVES.filter((l) => !decided.has(idOf(l))); - expect(negatives.length).toBe(39); + expect(negatives.length).toBe(38); for (const leaf of negatives) { for (const [locale, forms] of TRANSLATED_LOCALES) { expect(catalogLeaf(forms, leaf), `${locale} ${idOf(leaf)} is an echo, not a control`).not.toBe(leaf.en); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 1b20fc41134..ddcee1dcc7a 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -914,7 +914,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | live + one planned; dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked | | view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead as a BODY key because the live one is the `sys_metadata` row column the door supplies, and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the platform's own writers send `name` (artifact-shipped containers, the metadata-validation sweep), so tombstoning it would reject shapes we write ourselves. `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) | -| report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | +| report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) — live on non-joined reports only: a `joined` report's container `chart` is refused and `blocks[].chart` was removed (#20161, 2026-09-27; nothing drew either) | | dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority | | query | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate resolves 1 experimental at the depth this ledger drills; the 7 marker-experimental search affordances sit one level deeper, below what this ledger declares (the walk recurses since #17424, but only where a `children` map is written, and none is written here) — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) | | datasource | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) | diff --git a/packages/spec/liveness/report.json b/packages/spec/liveness/report.json index 32d88130b7e..60d025fd82a 100644 --- a/packages/spec/liveness/report.json +++ b/packages/spec/liveness/report.json @@ -50,11 +50,13 @@ }, "chart": { "status": "live", - "note": "objectui: DatasetReportRenderer.tsx:776 + DatasetReportChart plots `chart.xAxis` (dataset dimension) × `chart.yAxis` (measure) via useDatasetRows, honoring `type`/`title` (framework#1890/#3441). Supersedes the 2026-06 audit's DEAD finding. Sub-key `groupBy` stays experimental (its own describe marker) — the dataset renderer plots a single series and never reads it." + "verifiedAt": "2026-09-27", + "note": "objectui: DatasetReportChart plots `chart.xAxis` (dataset dimension) × `chart.yAxis` (measure) via useDatasetRows, honoring `type`/`title` (framework#1890/#3441). Supersedes the 2026-06 audit's DEAD finding. Sub-key `groupBy` stays experimental (its own describe marker) — the dataset renderer plots a single series and never reads it. Live on NON-joined reports only (#20161, re-read at the `.objectui-sha` pin f8a9d0fb): the one `report.chart` read (DatasetReportRenderer.tsx:1557) sits after the joined branch's return (:1462-1524), so on a `type: 'joined'` report the schema's refinement REFUSES `chart` rather than accepting a key nothing reads." }, "blocks": { "status": "live", - "note": "objectui: joined-report sub-reports, each dataset-bound (dataset/rows/columns/values/runtimeFilter/chart) — DatasetReportRenderer joined branch." + "verifiedAt": "2026-09-27", + "note": "objectui: joined-report sub-reports, each dataset-bound — DatasetReportRenderer joined branch (:1462-1524 at the `.objectui-sha` pin f8a9d0fb) reads a block's dataset/rows/columns/values/runtimeFilter/order/type/label/description/name and draws it as a table. A block has NO chart: `chart` was removed from JoinedReportBlockSchema in #20161 because no renderer ever read it (`block.chart`: zero hits over objectui packages/ at the pin, against a lit `block.runtimeFilter` control)." } } } diff --git a/packages/spec/src/ui/report.test.ts b/packages/spec/src/ui/report.test.ts index ef403e4b8f6..6372178298c 100644 --- a/packages/spec/src/ui/report.test.ts +++ b/packages/spec/src/ui/report.test.ts @@ -219,6 +219,112 @@ describe('Joined reports refuse top-level selection keys', () => { }); }); +/** + * #20161 — a `joined` report draws no chart. Its renderer draws each block as + * a table and returns before the one container `chart` read, and nothing ever + * read a block's `chart` — so both parsed and plotted nothing. The block key is + * REMOVED from the closed block shape (answered by its `guidance` entry); the + * container key stays declared for every non-joined report and is refused by + * the joined arm of the refinement. + * + * Each refusal asserts what a schema door owes: the issue `code`, its `path`, + * and the first sentence of the prescription. + */ +describe('A joined report draws no chart — block `chart` removed, container `chart` refused (#20161)', () => { + const BLOCK = { name: 'open_block', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'] } as const; + const JOINED = { name: 'overview', label: 'Overview', type: 'joined', blocks: [BLOCK] } as const; + const CHART = { type: 'bar', xAxis: 'status', yAxis: 'task_count' } as const; + const BLOCK_FIRST_SENTENCE = '`report.blocks[].chart` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove)'; + const CONTAINER_FIRST_SENTENCE = 'a `joined` report draws no chart — it draws each block as a table and never reads `chart`, on the container or on a block.'; + const MIGRATE = 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + + const issuesOf = (r: { success: boolean; error?: { issues: ReadonlyArray<{ code: string; path: PropertyKey[]; message: string }> } }) => + (r.error?.issues ?? []).map((i) => ({ code: i.code, path: i.path, message: i.message })); + + it('a block `chart` is refused as a removed key, carrying the prescription — not a silent strip', () => { + const r = JoinedReportBlockSchema.safeParse({ ...BLOCK, chart: CHART }); + expect(r.success, 'a block `chart` parsed green — it would plot nothing').toBe(false); + const issues = issuesOf(r); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual([]); + expect(issues[0]!.message).toContain(BLOCK_FIRST_SENTENCE); + expect(issues[0]!.message).toContain('Delete the key.'); + expect(issues[0]!.message).toContain(MIGRATE); + }); + + it('…and the same refusal is located under the report, at the block that carries it', () => { + const r = ReportSchema.safeParse({ ...JOINED, blocks: [BLOCK, { ...BLOCK, name: 'done_block', chart: CHART }] }); + expect(r.success).toBe(false); + const issues = issuesOf(r); + expect(issues.map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', ['blocks', 1]]]); + expect(issues[0]!.message).toContain(BLOCK_FIRST_SENTENCE); + }); + + it('a container `chart` on a joined report is refused at its own path, with no pointer onto `blocks[]`', () => { + const r = ReportSchema.safeParse({ ...JOINED, chart: CHART }); + expect(r.success, 'a joined report carrying `chart` parsed green — it would plot nothing').toBe(false); + const issues = issuesOf(r); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['chart']]]); + expect(issues[0]!.message.startsWith(CONTAINER_FIRST_SENTENCE), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain('Delete `chart`'); + expect(issues[0]!.message).not.toContain('onto `blocks[]`'); + expect(issues[0]!.message).toContain(MIGRATE); + }); + + it('it joins the selection refusals rather than replacing them — one issue per key, `chart` last', () => { + const r = ReportSchema.safeParse({ ...JOINED, dataset: 'tasks', order: [{ by: 'task_count' }], chart: CHART }); + expect(issuesOf(r).map((i) => [i.code, i.path])).toEqual([ + ['custom', ['dataset']], + ['custom', ['order']], + ['custom', ['chart']], + ]); + }); + + it('the authoring factory refuses both — `defineReport` throws each prescription', () => { + expect(() => defineReport({ ...JOINED, chart: CHART } as never)).toThrow(CONTAINER_FIRST_SENTENCE); + expect(() => defineReport({ ...JOINED, blocks: [{ ...BLOCK, chart: CHART }] } as never)).toThrow(BLOCK_FIRST_SENTENCE); + }); + + it('the metadata save door\'s schema refuses both — the registry\'s `report` schema is the same schema', () => { + const saveDoor = getMetadataTypeSchema('report'); + expect(saveDoor, 'the `report` metadata type must resolve a schema').toBeDefined(); + const container = saveDoor!.safeParse({ ...JOINED, chart: CHART }); + expect(container.success).toBe(false); + expect(issuesOf(container as never).map((i) => [i.code, i.path])).toEqual([['custom', ['chart']]]); + const block = saveDoor!.safeParse({ ...JOINED, blocks: [{ ...BLOCK, chart: CHART }] }); + expect(block.success).toBe(false); + expect(issuesOf(block as never).map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', ['blocks', 0]]]); + }); + + it('a block on a NON-joined container is the same closed shape — its `chart` is refused there too', () => { + const r = ReportSchema.safeParse({ + name: 'hours', label: 'Hours', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['task_count'], + blocks: [{ ...BLOCK, chart: CHART }], + }); + expect(issuesOf(r).map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', ['blocks', 0]]]); + }); + + it('a non-joined report keeps its live `chart` — every non-joined type parses it and round-trips it', () => { + for (const type of ['tabular', 'summary', 'matrix'] as const) { + const r = ReportSchema.safeParse({ + name: 'hours', label: 'Hours', type, dataset: 'tasks', rows: ['status'], + ...(type === 'matrix' ? { columns: ['priority'] } : {}), + values: ['task_count'], chart: CHART, + }); + expect(r.success, `${type}: ${JSON.stringify(r.error?.issues ?? [])}`).toBe(true); + expect(r.data!.chart).toMatchObject(CHART); + } + }); + + it('a joined report without a chart parses exactly as before', () => { + const r = ReportSchema.safeParse({ ...JOINED, runtimeFilter: { done: false }, drilldown: false }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + expect(r.data!.chart).toBeUndefined(); + expect(r.data!.blocks![0]).not.toHaveProperty('chart'); + }); +}); + /** * #3916 — reports can declare an ordering. Before this the report schema had no * sort field at all: `DatasetSelection.order` existed but was unreachable for From eb04c6f4a95bfc685347999036e609ca087704f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:00:04 +0000 Subject: [PATCH 03/11] wip(spec): let the removed block chart's baseline line go (check (c) proof 4) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index b434cfdbc2b..5228c0ba78d 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -610,7 +610,6 @@ "ui/InterfacePageConfig:sourceView", "ui/InterfacePageConfig:userActions", "ui/InterfacePageConfig:userFilters", - "ui/JoinedReportBlock:chart", "ui/JoinedReportBlock:columns", "ui/JoinedReportBlock:dataset", "ui/JoinedReportBlock:description", From b8bae251363efc8b889a36642df1ad0d3f9457b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:12:47 +0000 Subject: [PATCH 04/11] wip(docs): regenerate the report reference page Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- content/docs/references/ui/report.mdx | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index 64f50f9363b..c8e8356ec0b 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -31,7 +31,6 @@ const result = JoinedReportBlockSchema.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | | -| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | | | **dataset** | `string` | optional | Dataset name to bind (ADR-0021) | | **rows** | `string[]` | optional | Dimension names down (dataset-bound) | | **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) | @@ -39,25 +38,6 @@ const result = JoinedReportBlockSchema.parse(data); | **runtimeFilter** | `any` | optional | Render-time scope filter (dataset-bound) | | **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first | -### Nested Shape: `JoinedReportBlock.chart` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>` | ✅ | | -| **title** | `string \| Record` | optional | Chart title | -| **subtitle** | `string \| Record` | optional | Chart subtitle | -| **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | -| **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) | -| **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | -| **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | -| **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | -| **showLegend** | `boolean` | optional (default: `true`) | Display legend | -| **showDataLabels** | `boolean` | optional (default: `false`) | Display data labels | -| **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? }` | -| **interaction** | `{ tooltips: boolean; brush: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` | -| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | - ### Nested Shape: `JoinedReportBlock.order[number]` | Property | Type | Required | Description | @@ -85,7 +65,7 @@ const result = JoinedReportBlockSchema.parse(data); | **runtimeFilter** | `any` | optional | Render-time scope filter | | **order** | `{ by: string; direction: Enum<'asc' \| 'desc'> }[]` | optional | Result ordering, most significant key first | | **drilldown** | `boolean` | optional (default: `true`) | Click-through to underlying records | -| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | Embedded chart configuration | +| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | Embedded chart configuration (refused on a joined report, which draws tables only) | | **blocks** | `{ name: string; label?: string \| Record; description?: string \| Record; type: Enum<'tabular' \| 'summary' \| 'matrix'>; … }[]` | optional | Sub-reports for type=joined | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this report. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -130,7 +110,6 @@ const result = JoinedReportBlockSchema.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | | -| **chart** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | | | **dataset** | `string` | optional | Dataset name to bind (ADR-0021) | | **rows** | `string[]` | optional | Dimension names down (dataset-bound) | | **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) | From 46386877cd1f487be202545ff80a1123f40527d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:20:16 +0000 Subject: [PATCH 05/11] wip(platform-objects): regenerate metadata-form bundles without blocks.chart Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 3 --- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 3 --- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 3 --- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 3 --- 4 files changed, 12 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index d8245effd9f..f867b693420 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1666,9 +1666,6 @@ export const enMetadataForms: NonNullable = { "blocks.type": { label: "Block Type" }, - "blocks.chart": { - label: "Chart" - }, "blocks.dataset": { label: "Dataset" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 03ececb5625..2cea4709f35 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1666,9 +1666,6 @@ export const esESMetadataForms: NonNullable = "blocks.type": { label: "Tipo de bloque" }, - "blocks.chart": { - label: "Gráfico" - }, "blocks.dataset": { label: "Conjunto de datos" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index b0fe0305a02..a5538f580d4 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1666,9 +1666,6 @@ export const jaJPMetadataForms: NonNullable = "blocks.type": { label: "ブロックタイプ" }, - "blocks.chart": { - label: "チャート" - }, "blocks.dataset": { label: "データセット" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index fbbd8440702..d244098baf3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1666,9 +1666,6 @@ export const zhCNMetadataForms: NonNullable = "blocks.type": { label: "分块类型" }, - "blocks.chart": { - label: "图表" - }, "blocks.dataset": { label: "数据集" }, From 5e04ec427f0cda04f668848dc230bdb3e1b86b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:21:49 +0000 Subject: [PATCH 06/11] wip(metadata-protocol): pin the report door's refusal of a joined report's chart Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- ...nvalid-metadata-422-face-inventory.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts index f7b230b80e8..3bac7b55996 100644 --- a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts @@ -369,3 +369,62 @@ describe('[#19620] a `translation` item carrying `settings` is refused at the me expect([...rows.values()].map((r) => r.type)).toEqual(['translation']); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. #20161 — the `report` door refuses a `joined` report's `chart` +// ═══════════════════════════════════════════════════════════════════════════ +// +// A joined report draws each block as a table and nothing ever drew its chart, +// on the container or on a block. `chart` left the closed block shape and the +// joined arm of `ReportSchema`'s refinement refuses it on the container, so a +// report carrying either is refused at THIS gate — the door the Studio report +// form saves through, which offered a block `chart` input until that change — +// with the ADR-0112 envelope (`code` + `status`) and the refusal located at the +// key. Rides this file's pinned engine double, as section 4 does. + +async function saveReport(protocol: any, item: Record): Promise { + try { + return await protocol.saveMetaItem({ + type: 'report', + name: 'task_overview', + item, + writeFace: 'meta-envelope', + }); + } catch (e: any) { + return e; + } +} + +describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door', () => { + // No `dataset` on the block: this door also runs the author-time lints, and + // `chart-dataset-unknown` refuses a dataset this stub engine cannot resolve — + // a second refusal the CONTROL below would otherwise be reading instead. + const block = { name: 'open_block', type: 'summary', rows: ['status'], values: ['task_count'] }; + const joined = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [block] }; + const chart = { type: 'bar', xAxis: 'status', yAxis: 'task_count' }; + + it.each([ + // The door flattens an issue's path to the dotted spelling it serves. + ['the container', { ...joined, chart }, 'custom', 'chart', 'a `joined` report draws no chart'], + ['a block', { ...joined, blocks: [{ ...block, chart }] }, 'unrecognized_keys', 'blocks.0', '`report.blocks[].chart` was removed'], + ] as const)('`chart` on %s — 422 INVALID_METADATA, located at the key, nothing stored', async (_where, item, code, path, prescription) => { + const { protocol, rows } = makeProtocol(); + const err = await saveReport(protocol, item); + + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + const issues = err.issues as Array<{ code?: string; path?: string; message: string }>; + expect(issues.map((i) => [i.code, i.path])).toEqual([[code, path]]); + expect(issues[0]!.message).toContain(prescription); + expect(rows.size).toBe(0); + }); + + it('CONTROL — the same joined report without a `chart` is stored (the refusal is the key, not the report)', async () => { + const { protocol, rows } = makeProtocol(); + const result = await saveReport(protocol, joined); + + expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored'); + expect([...rows.values()].map((r) => r.type)).toEqual(['report']); + }); +}); From b0f620b36889ff0af115bc37ab54ff6f0fe58169 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:34:52 +0000 Subject: [PATCH 07/11] wip(lint): the shipped-predicate census counts the new report chart gate Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../lint/src/validate-predicate-path-refs.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 9c033ff226d..52468070f71 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -591,7 +591,12 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `page :: source`, `action :: openIn`, `action :: newTabUrl` and // `action :: undoable`. The other twenty-six rows that card landed carry no // predicate at all, so they do not enter this census. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(72); + // It is 73 today, an ADDITION of ONE: #20161 gated `report :: chart` on + // `data.type != 'joined'` — a joined report draws no chart and its schema + // refuses one, so the form offers the control only where it draws. The same + // card REMOVED the joined-block `chart` repeater column, which carried no + // predicate, so it leaves this census untouched. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(73); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -680,7 +685,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // deliberately outside this rule (see the anchor note above), and // `action :: newTabUrl` compares against the bare `true`, which is not a // quoted literal and is not rewritten. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(52); + // It is 53 today: #20161's `report :: chart` gate is `data.type != 'joined'`, + // a `!=` against a single-quoted literal. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(53); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); From cb20843c40fcf49f0c1dbe0cb9c577c801a452c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:39:45 +0000 Subject: [PATCH 08/11] wip(platform-objects): the catalog's translated-label control drops the block chart row Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../object-lifecycle-panel-echo-decisions.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index eb380076ec0..49f5018b1b6 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1113,8 +1113,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // #19814: the view form's new `pagination` section, its label authored // in all three locales. 609 since #19955: the 25 row labels of the view // form's `columns` / `sort` / `tabs` repeaters, authored in all three - // locales. - expect(translated.length, `${locale} positive control`).toBe(609); + // locales. 608 since #20161: the report form's joined-block `chart` row + // left with its key (nothing ever drew a block chart), taking its label + // — authored in all three locales — out of the catalog. + expect(translated.length, `${locale} positive control`).toBe(608); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From 94a2c634d613fd07d6fe5daf205c51db34abf9e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:12:31 +0000 Subject: [PATCH 09/11] test(lint): the joined-container chart pin binds a dataset, so it can fail Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/lint/src/validate-chart-bindings.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/lint/src/validate-chart-bindings.test.ts b/packages/lint/src/validate-chart-bindings.test.ts index 2931d67b301..29e7fa389dc 100644 --- a/packages/lint/src/validate-chart-bindings.test.ts +++ b/packages/lint/src/validate-chart-bindings.test.ts @@ -227,12 +227,19 @@ describe('validateChartBindings — report charts', () => { }); it('does NOT check a joined container\'s chart either — the joined renderer never reads it', () => { + // The container carries a RESOLVABLE `dataset` here on purpose (the schema + // refuses one on a joined report; this rule reads the raw stack). Without + // it the container would bind nothing and return before the chart question + // is ever asked, so the silence below would hold with or without the + // joined guard — a pin that could not fail. const findings = validateChartBindings({ ...baseStack(), reports: [ { name: 'joined', type: 'joined', + dataset: 'task_metrics', + values: ['task_count'], chart: { type: 'bar', xAxis: 'ghost_dim', yAxis: 'ghost_measure' }, blocks: [{ name: 'b1', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }], }, From fdf804d6b8bac770e203038bdf2aa212d26792cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:38:39 +0000 Subject: [PATCH 10/11] feat(spec): the joined-report chart family's D3 semantic entry (ruling B) ui-report-joined-chart-retired: the judgement half of report-joined-chart-removed. The D2 strip is lossless; what it cannot decide is whether the author meant a chart, which must then move to a non-joined report of its own. The changeset's ADR-0087 marker names both registrations. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../20161-joined-report-chart-retired.md | 6 +- .../18.ui-report-joined-chart-retired.ts | 66 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 62 +++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts diff --git a/.changeset/20161-joined-report-chart-retired.md b/.changeset/20161-joined-report-chart-retired.md index 7025fa522d1..37ceb9ce53e 100644 --- a/.changeset/20161-joined-report-chart-retired.md +++ b/.changeset/20161-joined-report-chart-retired.md @@ -54,7 +54,9 @@ untouched: it is that report's live embedded chart. A joined report with no load path) strips a block's `chart` and a joined container's `chart` from old sources and stored `sys_metadata` rows as a lossless delete. Stored rows can carry them: the Studio report form offered a block `chart` input until this - change. + change. The family's D3 semantic entry, `ui-report-joined-chart-retired`, states + what the strip cannot decide: whether the chart was wanted. If it was, it moves + to a non-joined report of its own, because a joined report has no chart channel. - **Form.** `reportForm` drops the block `chart` input and shows the container `chart` only when `type` is not `joined`; the `platform-objects` metadata-form translation bundles drop the `blocks.chart` label in all four locales. @@ -66,4 +68,4 @@ untouched: it is that report's live embedded chart. A joined report with no non-joined reports and drops `chart` from the `blocks` row; `content/docs/ui/reports.mdx` lists what a joined container refuses. - + diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts new file mode 100644 index 00000000000..6548cc97e02 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20161 — the judgement half of `report-joined-chart-removed`, owed under +// #17152 ruling B (one D3 entry per retirement family, even when a lossless D2 +// exists). The D2 conversion strips a joined report's `chart` mechanically, at +// both coordinates, and that strip changes nothing that renders. What it cannot +// do is decide whether the author MEANT a chart: that intent has no place to go +// on a joined report, so moving it to a report that draws one is the author's +// call. Its own family, not an extension of +// `ui-report-joined-container-selection-refused`: that entry is the enforce arm +// for four selection keys that stay declared and has no D2 at all, while this one +// retires a declared block key, refuses a container key, and pairs with a D2. +export const entry: SemanticMigration = { + id: 'ui-report-joined-chart-retired', + surface: + '`report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on ' + + 'a report whose `type` is `joined` (REFUSED by `ReportSchema`\'s refinement) — a chart ' + + 'anywhere on a joined report', + replacement: + 'nothing on the joined report: a joined report draws each block as a table and has no chart ' + + 'channel at either level. Delete the `chart`. If the chart was wanted, give the slice it ' + + 'was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding ' + + 'the same `dataset` the block bound, selecting the dimension and measure the chart names ' + + 'in its `rows` and `values` — carry the `chart` over to that report\'s top level, where ' + + '`xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it ' + + 'from the app navigation beside the joined report.', + reason: + 'ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer\'s ' + + 'joined branch draws each block as a table and returns before its one read of the ' + + 'report\'s `chart`, and no renderer reads a block\'s `chart` at all — measured at this ' + + 'repo\'s `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` ' + + '(`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at ' + + '1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which ' + + 'resolved their axes as if they would plot), and showed tables only. The D2 conversion ' + + '`report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored ' + + 'sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless ' + + 'delete because neither value ever rendered. What it cannot repair is intent. Deleting ' + + 'the key leaves the report looking exactly as it always did — which is the problem when ' + + 'the author believed a chart was there: they were reading a chart that never existed, ' + + 'and only they know whether they wanted one. A walker cannot move it anywhere either: a ' + + 'joined report has no chart channel, and creating a new report, choosing its type and ' + + 'placing it in navigation are authoring decisions, not rewrites. The Studio report form ' + + 'offered a block chart input until this change, so a stored row carrying one is a real ' + + 'shape, not a hypothetical. Ships at once, no deprecation window: there is no window in ' + + 'which a key the renderer never reads does anything. `chart` on every non-joined report ' + + 'is unchanged — it is that report\'s live embedded chart.', + acceptanceCriteria: + 'WHICH DOOR: the refusal is the spec schema\'s, so it lands wherever a report is parsed ' + + 'through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata ' + + 'save door (the `report` entry of the metadata type registry, answered as ' + + '`INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key ' + + 'on that block with the upgrade prescription; a container `chart` on a joined report is ' + + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: ' + + 'run `os migrate meta --from 17` to list the mechanical strips for existing sources, and ' + + 'the stored-row seams replay the same strip for rows at rest. (2) For every joined report ' + + 'that carried one, decide whether the chart was wanted; if it was, a non-joined report ' + + 'now binds that slice\'s dataset and carries the chart, and its `xAxis` / `yAxis` resolve ' + + '(`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it ' + + 'renders exactly as before, because the chart was never drawn. A joined report with no ' + + '`chart` parses byte-identically to before, and every non-joined report is untouched. ' + + 'Census at the time of the change: zero joined reports with a chart in this repo\'s ' + + 'example apps and in the hotcrm reference app, against a lit control (non-joined reports ' + + 'carrying a chart: one and five).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9f13503716f..77cea609d76 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14704,6 +14704,68 @@ const step18: MigrationStep = { + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' + 'rail.', }, + // #20161 — the judgement half of `report-joined-chart-removed`, owed under + // #17152 ruling B (one D3 entry per retirement family, even when a lossless D2 + // exists). The D2 conversion strips a joined report's `chart` mechanically, at + // both coordinates, and that strip changes nothing that renders. What it cannot + // do is decide whether the author MEANT a chart: that intent has no place to go + // on a joined report, so moving it to a report that draws one is the author's + // call. Its own family, not an extension of + // `ui-report-joined-container-selection-refused`: that entry is the enforce arm + // for four selection keys that stay declared and has no D2 at all, while this one + // retires a declared block key, refuses a container key, and pairs with a D2. + { + id: 'ui-report-joined-chart-retired', + surface: + '`report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on ' + + 'a report whose `type` is `joined` (REFUSED by `ReportSchema`\'s refinement) — a chart ' + + 'anywhere on a joined report', + replacement: + 'nothing on the joined report: a joined report draws each block as a table and has no chart ' + + 'channel at either level. Delete the `chart`. If the chart was wanted, give the slice it ' + + 'was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding ' + + 'the same `dataset` the block bound, selecting the dimension and measure the chart names ' + + 'in its `rows` and `values` — carry the `chart` over to that report\'s top level, where ' + + '`xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it ' + + 'from the app navigation beside the joined report.', + reason: + 'ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer\'s ' + + 'joined branch draws each block as a table and returns before its one read of the ' + + 'report\'s `chart`, and no renderer reads a block\'s `chart` at all — measured at this ' + + 'repo\'s `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` ' + + '(`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at ' + + '1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which ' + + 'resolved their axes as if they would plot), and showed tables only. The D2 conversion ' + + '`report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored ' + + 'sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless ' + + 'delete because neither value ever rendered. What it cannot repair is intent. Deleting ' + + 'the key leaves the report looking exactly as it always did — which is the problem when ' + + 'the author believed a chart was there: they were reading a chart that never existed, ' + + 'and only they know whether they wanted one. A walker cannot move it anywhere either: a ' + + 'joined report has no chart channel, and creating a new report, choosing its type and ' + + 'placing it in navigation are authoring decisions, not rewrites. The Studio report form ' + + 'offered a block chart input until this change, so a stored row carrying one is a real ' + + 'shape, not a hypothetical. Ships at once, no deprecation window: there is no window in ' + + 'which a key the renderer never reads does anything. `chart` on every non-joined report ' + + 'is unchanged — it is that report\'s live embedded chart.', + acceptanceCriteria: + 'WHICH DOOR: the refusal is the spec schema\'s, so it lands wherever a report is parsed ' + + 'through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata ' + + 'save door (the `report` entry of the metadata type registry, answered as ' + + '`INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key ' + + 'on that block with the upgrade prescription; a container `chart` on a joined report is ' + + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: ' + + 'run `os migrate meta --from 17` to list the mechanical strips for existing sources, and ' + + 'the stored-row seams replay the same strip for rows at rest. (2) For every joined report ' + + 'that carried one, decide whether the chart was wanted; if it was, a non-joined report ' + + 'now binds that slice\'s dataset and carries the chart, and its `xAxis` / `yAxis` resolve ' + + '(`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it ' + + 'renders exactly as before, because the chart was never drawn. A joined report with no ' + + '`chart` parses byte-identically to before, and every non-joined report is untouched. ' + + 'Census at the time of the change: zero joined reports with a chart in this repo\'s ' + + 'example apps and in the hotcrm reference app, against a lit control (non-joined reports ' + + 'carrying a chart: one and five).', + }, { id: 'ui-report-joined-container-selection-refused', surface: 'report selection keys on a `joined` container — a top-level `dataset`, or a ' From 4aecf01d2f147e51001ff708889ff99112466473 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:13:55 +0000 Subject: [PATCH 11/11] fix(spec): the joined-chart D3 entry ends on the house migrate sentence The migrate-sentence class pin (repo project) judges semantic entries too: the sentence must be the house form and close the string. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../semantic/18.ui-report-joined-chart-retired.ts | 9 +++++---- packages/spec/src/migrations/registry.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts index 6548cc97e02..cf990528ea3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-report-joined-chart-retired.ts @@ -52,9 +52,9 @@ export const entry: SemanticMigration = { + 'save door (the `report` entry of the metadata type registry, answered as ' + '`INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key ' + 'on that block with the upgrade prescription; a container `chart` on a joined report is ' - + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: ' - + 'run `os migrate meta --from 17` to list the mechanical strips for existing sources, and ' - + 'the stored-row seams replay the same strip for rows at rest. (2) For every joined report ' + + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: the ' + + 'D2 strip covers existing sources on a chain replay, and the stored-row seams replay it for ' + + 'rows already at rest. (2) For every joined report ' + 'that carried one, decide whether the chart was wanted; if it was, a non-joined report ' + 'now binds that slice\'s dataset and carries the chart, and its `xAxis` / `yAxis` resolve ' + '(`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it ' @@ -62,5 +62,6 @@ export const entry: SemanticMigration = { + '`chart` parses byte-identically to before, and every non-joined report is untouched. ' + 'Census at the time of the change: zero joined reports with a chart in this repo\'s ' + 'example apps and in the hotcrm reference app, against a lit control (non-joined reports ' - + 'carrying a chart: one and five).', + + 'carrying a chart: one and five). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 77cea609d76..56056ed75cd 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14754,9 +14754,9 @@ const step18: MigrationStep = { + 'save door (the `report` entry of the metadata type registry, answered as ' + '`INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key ' + 'on that block with the upgrade prescription; a container `chart` on a joined report is ' - + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: ' - + 'run `os migrate meta --from 17` to list the mechanical strips for existing sources, and ' - + 'the stored-row seams replay the same strip for rows at rest. (2) For every joined report ' + + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: the ' + + 'D2 strip covers existing sources on a chain replay, and the stored-row seams replay it for ' + + 'rows already at rest. (2) For every joined report ' + 'that carried one, decide whether the chart was wanted; if it was, a non-joined report ' + 'now binds that slice\'s dataset and carries the chart, and its `xAxis` / `yAxis` resolve ' + '(`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it ' @@ -14764,7 +14764,8 @@ const step18: MigrationStep = { + '`chart` parses byte-identically to before, and every non-joined report is untouched. ' + 'Census at the time of the change: zero joined reports with a chart in this repo\'s ' + 'example apps and in the hotcrm reference app, against a lit control (non-joined reports ' - + 'carrying a chart: one and five).', + + 'carrying a chart: one and five). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }, { id: 'ui-report-joined-container-selection-refused',