Skip to content

Commit ed15448

Browse files
feat(spec)!: a joined report refuses a block that binds no dataset, at blocks[i].dataset, naming the block (#21702) (#21712)
Fixes #21702 Clause-②: yes (narrowing) Triage direction `5977861131` (enforce), claim `5977924610`. The joined arm of `ReportSchema`'s refinement now refuses each block of a `joined` report that binds no `dataset`, at `blocks[i].dataset`, naming the block, with the prescription to bind the block to a dataset. The refinement comment "each block dataset-bound" and the reports guide's type-table cell "(each block dataset-bound)" were declarations; they are now enforced. ## What changes - **The refusal** (`packages/spec/src/ui/report.zod.ts`). A per-block arm inside the joined branch of `ReportSchema`'s `superRefine`, right after the existing "needs `blocks`" check. For each block whose `dataset` is undefined it adds one `custom` issue at `['blocks', i, 'dataset']`. The message comes from a module-private builder, `joinedBlockDatasetRequired`, worded like the neighbouring joined refusals: ```text a `joined` report draws each block from that block's own `dataset`, and block `NAME` binds none, so nothing queries it and it draws no rows. Bind the block to a dataset: set its `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows. ``` `NAME` is the block's `name`. A block whose `name` is empty (its own `too_small` issue, which does not stop the refinement) is named by position instead, as `blocks[1]`. - **What stays accepted.** `JoinedReportBlockSchema.dataset` stays `.optional()`: `blocks` is read on a `joined` report only, so the requirement lives on the joined arm, and a block on a non-joined report is not judged (pinned). No type, export or JSON Schema key changes. The block's `dataset` `.describe()` now reads "Dataset name to bind (ADR-0021); a joined report refuses a block without one", and `content/docs/references/ui/report.mdx` is regenerated with `gen:docs` (two table rows). - **The ADR-0087 kit**, in #21687's shape: - the D3 semantic entry `entries/semantic/18.ui-report-joined-block-dataset-required.ts`; - its step-18 rationale fragment in `STEP18_RATIONALE` at **order 77**, inserted where its id sorts. 77 is the next free order: the highest on `main` is 76, re-read at `7e0066af7a` just before this PR. #21699 (74 to 76) and #21698 landed before this branch's base `16d241a6af`, so no merge was needed; - `registry.ts` regenerated with `gen:migration-registry`; - one BREAKING `@objectstack/spec` `minor` changeset, `.changeset/21702-joined-report-block-dataset-required.md`, carrying the `Clause-②` line, the `adr-0087: registered ui-report-joined-block-dataset-required` disposition marker and a FROM → TO table. The fragment and entry text state each decision in words and carry no tracker number. No tombstone (no key is removed) and no D2 conversion (only the author knows which dataset a block was meant to show). - **`content/docs/ui/reports.mdx`: no edit.** No sentence there became false; the type-table cell became true. See Acceptance notes. ## Census, at base `16d241a6af`, before any edit `git grep` for a `type: 'joined'` report across `examples/**`, `packages/**`, `skills/**`, `content/docs/**`, `docs/**`, `scripts/**` and `apps/**` gave 33 lines in 15 files: CHANGELOG quotations, the spec's own comments, and these reports: | where | joined reports | unbound blocks | |:--|:--|:--| | `examples/app-showcase` `TaskOverviewReport` | 1 | 0 | | `content/docs/ui/reports.mdx` example | 1 | 0 | | `packages/lint` `validate-chart-bindings.test.ts` (raw stacks, never parsed) | 4 | 0 | | `packages/platform-objects` `report-form-echo-decisions.test.ts` | 2 | 0 | | `packages/spec` tests (`report.test.ts`, `filter-save-door-face-parity.test.ts`, `joined-report-block-type.test.ts`) and the `report-joined-chart-removed` conversion fixture | 10 | 0 | | `packages/metadata-protocol` `protocol.invalid-metadata-422-face-inventory.test.ts` | 1 | **1**, unbound on purpose (below) | | `skills/**` | 0 | 0 | - Triage measured hotcrm `4054ec26` (1 joined report, 0 unbound blocks) and cloud `2205b530` (none). I took those readings as given. I read hotcrm's `src/sales/reports/churn.report.ts` once, to shape the preservation fixture. Every block binds a dataset: three bind `account_metrics` and the fourth, `recently_closed_lost`, binds `opportunity_metrics`. So triage's line "every block binds `account_metrics`" is slightly off; its conclusion, zero unbound blocks, stands. - **objectui (read only, at `2e818d0b51` and at this repo's pin `ab18797215`).** Studio's joined-report authoring **can** save a block with no `dataset`. That producer is this narrowing's reach: - `ReportDefaultInspector.tsx` renders the blocks through `SchemaForm` with the spec `reportForm` "Joined blocks" repeater; - `RepeaterField`'s `add()` seeds a blank row with every column `undefined`; - the row's `dataset` column is free text and not required (the block's derived JSON Schema `required` is `["name"]`, measured); - the bundled `ReportSchema` (`clientValidation.ts`) and the save door both accepted the result. Filed as objectstack-ai/objectui#11601: category ①, no labels, dedupe query and hit count in its body. After the spec bump, Studio's live validation and the save door both refuse such a block at `blocks.N.dataset`. - The renderers, at the pin: `DatasetReportRenderer`'s joined branch passes `String(block.dataset ?? '')` to each block's table, and that table's query hook goes idle on an empty name (`:443`). A report whose blocks all lack one fails `isDatasetReport` and falls through to the presentation bridge. `DrillDownDrawer`'s `isDatasetBoundReport` lists the records instead of drawing it. ## Fixture triage: one test-only file outside the claim's file surface `protocol.invalid-metadata-422-face-inventory.test.ts` section 5 (the joined-report `chart` door pins) left its block **unbound on purpose**: the door's author-time gate refuses an unresolvable dataset with `chart-dataset-unknown`, and the stub engine had no dataset universe. The new refusal turned 2 of its 23 tests red (`Tests 2 failed | 21 passed`): the container case got a second issue at `blocks.0.dataset`, and the CONTROL was refused at `blocks.0.dataset`. That second red is the metadata save door (`422 INVALID_METADATA`, `writeFace: 'meta-envelope'`) refusing the probe shape. - **Disposition: add the declaration.** The block binds `task_metrics`, and the harness gains an optional `makeProtocol({ datasets })`, which registers that dataset through `registry.listItems('dataset')`. Every other caller passes nothing, so the registry lists nothing, as before. Result: `Tests 23 passed (23)`. - **The registration is load-bearing.** I mutated the CONTROL to call `makeProtocol()` without datasets (`scripts/ablation-replace.mjs`, anchor 1 → 0, blob `f718099c2d` → `9dc51bd7b8`). Predicted 1 red / 22 green; observed `Tests 1 failed | 22 passed`. The failure was `chart-dataset-unknown` at `reports[0].blocks[0].dataset`, "Declared datasets: (none)". Restore: blob back to `f718099c2d`, which equals HEAD, and `git diff HEAD` is empty. - This file is outside the claim's declared file surface. It is reported in the dev report as a deviation. - Queue check: #21706, queued at this writing, appends a section to the same file at line 570 and later. A local `git merge-tree` of this head with that queue head exits 0. The file is not `merge=os-regen` routed, so that local answer is also GitHub's. ## Doors, tested and probed - **Pins** (`packages/spec/src/ui/report-joined-block-dataset.test.ts`, 16 tests): - the triage probe shape is refused once per block at `blocks.0.dataset` and `blocks.1.dataset`, each naming its block; a bound block beside an unbound one draws one issue, at the unbound index; an empty name is named by position; - the new issue joins the other joined-arm refusals rather than replacing them; - taking the advice parses; - CONTROLS: a block on a non-joined report, and `JoinedReportBlockSchema` parsed on its own, both still parse; - doors: `defineReport` throws; the registered `report` type schema refuses (and accepts the bound report); `ObjectStackDefinitionSchema`, the stack parse `objectstack validate` runs, refuses at `reports.1.blocks.{0,1}.dataset`; `defineStack` answers `STACK_SCHEMA_INVALID` / 422; - preservation: the showcase `TaskOverviewReport` (mirrored byte for byte) parses with output equal to its input, and a fixture shaped like hotcrm's `customer_churn_signals` parses, gaining only the `drilldown` default; - ledger: one D3 entry at protocol 18 with no conversion; the step-18 rationale names it; no `JoinedReportBlock:dataset` tombstone, with a control on a known tombstone. - **`objectstack validate`, the real CLI** (`packages/cli/bin/run.js`, built here). Fixtures were temporary, in the session scratchpad, outside the repo; `@objectstack/spec` resolved to this worktree's build. - The triage probe stack (two blocks, each only `name` + `label`): **exit 1**, `"code": "STACK_SCHEMA_INVALID"`, `defineStack validation failed (2 issues)` at `reports.0.blocks.0.dataset` and `reports.0.blocks.1.dataset`, each with the message above. - CONTROL, the same report with both blocks bound to a declared dataset: **exit 0**, `"valid": true`. - The showcase app (`examples/app-showcase/objectstack.config.ts`, four reports including `TaskOverviewReport`): **exit 0**, `"valid": true`. - **The CLI door's verdict is this arm's, proven through `dist`.** The CLI reads `@objectstack/spec` through `dist`, so the mutation was rebuilt: - mutated leg: the `ctx.addIssue` of the new arm became a `globalThis` marker assignment (anchor 1 → 0, blob `fadd536165` → `2cbfdb9feb`); `@objectstack/spec` rebuilt; `ablation-dist-preflight.mjs` found the marker in `dist` (exit 0); the probe then gave **exit 0**, `"valid": true`. That reproduces the card's reading at `main`; - restore leg: blob back to `fadd536165`, which equals HEAD, and `git diff HEAD` is empty; rebuilt; `--absent` found the marker in none of 228 built files (exit 0); the probe gave **exit 1** `STACK_SCHEMA_INVALID` again. ## Ablation of the refusal, at the pins (predicted first) `scripts/ablation-replace.mjs` replaced the arm's `ctx.addIssue(...)` with a no-op (anchor 1 → 0, blob `fadd536165` → `45f34370c5`). The pins import `./report.zod` relatively, which resolves to `src`, so no build was needed. Predicted: 8 red, the 4 refusal pins and the 4 door pins; 8 green, the advice, the 2 controls, the 2 preservation pins and the 3 ledger pins; `report.test.ts` untouched. Observed: `Tests 8 failed | 80 passed (88)` over the pin file and `report.test.ts`. The 8 were exactly the predicted ones. Restore: blob equals HEAD (`fadd536165`), and `git diff HEAD` is empty. ## Verification (all at head `c4e3ab9631` unless noted) - `@objectstack/spec` full suite (`vitest run --project local --maxWorkers=2`): `Test Files 613 passed (613)`, `Tests 18201 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, test layer: `check:test-typecheck: OK`) and `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `--listFiles` shows both edited test files in their programs. - Consumers that parse a joined report: `platform-objects` `report-form-echo-decisions.test.ts` 35 passed; `lint` `validate-chart-bindings.test.ts` 47 passed; `@objectstack/example-showcase` whole suite `32 files, 399 passed`; `metadata-protocol` face-inventory 23 passed. - `pnpm --filter @objectstack/spec check:generated`: one artifact stale before regeneration (`content/docs/references/**`), regenerated with `gen:docs`; then `check:generated` exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands. Every one was run, with its exit code recorded before any pipe. `--ran` reconciled **115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN**. - Two first exited 3 (prerequisite not met, packages with no `dist`): `check:skill-examples` and `check:dual-build-cjs-loads`. Both were re-run green after building those packages. All 115 exited 0. - Among them: `check:adr-0087-registration`, `check:changeset-no-major`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:authorable-surface`, `check:api-surface`, `check:docs`, `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check:cross-package-test-inputs`. - eslint, narrowed and proven: 1. population: `eslint.config.mjs`'s `**/*.{ts,…}` block covers all five changed `.ts` files; 2. `--format json`: 5 files, 0 errors, 0 warnings; 3. invariance: the config enables no type-aware linting (no `parserOptions.project`, as its own comment states), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - Declared to CI: the path-scheduled jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core), the whole-workspace type-check lanes and every downstream consumer suite of `@objectstack/spec` beyond the four above. ## Acceptance notes (observations, not filed) - `reports.mdx` says a `joined` report "must declare at least one block". That is still true, but it no longer names every requirement: each block must now also bind a `dataset`. Not edited, under the claim's rule that only a sentence this change makes false is edited. Carrier: none. - The block row in the spec's own `reportForm` (`packages/spec/src/ui/report.form.ts`, "Joined blocks" repeater) offers `dataset` with no `ref:dataset` widget and no `required`. objectstack-ai/objectui#11601 names this as a possible spec-side fix for the Studio producer, to be measured first. Carrier: objectstack-ai/objectui#11601's acting seat. - The requirement is a refinement, so the published JSON Schema does not carry it. `ui/Report` already sits in `dropped-refinements.baseline.json`, and the new arm adds no site there. The `.describe()` text is now the JSON-Schema-visible hint. - The joined arm, this check included, does not run while a block carries an unknown key or a wrong-typed member: that issue aborts the refinement. The dataset refusal then arrives after that first issue is fixed. This is existing behaviour shared by every arm of the refinement. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ced3e1a commit ed15448

7 files changed

Lines changed: 492 additions & 11 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Every block of a `joined` report must bind a `dataset`: a block with none is refused at `blocks[i].dataset`, by name, with the prescription to bind the block to a dataset.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-report-joined-block-dataset-required -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**Why.** A `joined` report carries its data on `blocks`, each an independent query over that block's own `dataset`, and the container selects nothing. `ReportSchema`'s refinement comment and the reports guide both said each block is dataset-bound, but the joined arm only required `blocks` to be non-empty, and a block's `dataset` is optional on its shape. So a block with no `dataset` parsed, `objectstack validate` exited 0 on it, the metadata save door stored it, and the report drew nothing for it: the joined renderer issues no query for an unbound block and draws it as an empty table, a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it.
14+
15+
**What is refused.** On a report whose `type` is `joined`, each block with no `dataset`. The issue's `code` is `custom`, at `blocks.N.dataset`, one per unbound block, and its message names the block: *a `joined` report draws each block from that block's own `dataset`, and block `NAME` binds none, so nothing queries it and it draws no rows. Bind the block to a dataset: set its `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows.* It is an arm of `ReportSchema`'s own refinement, so it reaches `defineReport`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `reports.N.blocks.M.dataset`), `os validate` / `os build`, and the metadata save door (`422 INVALID_METADATA`). A stored report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save.
16+
17+
**What stays accepted, byte for byte.** A `joined` report whose blocks all bind a `dataset`; every non-joined report, including one that carries `blocks` (they are read on a `joined` report only); and `JoinedReportBlockSchema` parsed on its own, where `dataset` stays optional.
18+
19+
## FROM → TO
20+
21+
| you wrote | write instead |
22+
|:--|:--|
23+
| a `joined` report block with no `dataset`, e.g. `{ name: 'open_block', label: 'Open' }` | the same block bound to the dataset it shows: `{ name: 'open_block', label: 'Open', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] }` |
24+
| a `joined` report whose blocks all bind a `dataset`, or any non-joined report | unchanged |
25+
26+
**The one-line fix: set each block's `dataset` to the dataset whose measures it shows, or delete a block that has nothing to show (a `joined` report keeps at least one block).** The renderer never drew an unbound block, so binding it is the first time it draws anything.
27+
28+
**Who is affected, measured.** No joined report with an unbound block exists in this repository's `examples/**`, `packages/**`, `skills/**` or `content/docs/**`: the showcase's one joined report, the reports guide's example and every test fixture bind each block, apart from one metadata-door test fixture that left its block unbound on purpose and is bound in this change. The hotcrm application's one joined report binds every block, and the cloud repository has no joined report. Deployed metadata was not measured. Studio's report inspector can still produce one: its `blocks` repeater adds a blank row and requires no column of it, so a block saved with only a name is now refused at save, at `blocks.N.dataset`, where it used to be stored and draw nothing.
29+
30+
### The kit
31+
32+
- **The refusal.** A per-block arm of the joined branch of `ReportSchema`'s refinement (`ui/report.zod.ts`), beside the container refusals for `dataset` / `rows` / `columns` / `values`, `order` and `chart`. The block's `dataset` description now says a joined report refuses a block without one, and the generated reference page carries it.
33+
- **The ledger.** The D3 semantic entry `ui-report-joined-block-dataset-required` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which dataset a block shows is the author's decision, and no rewrite can name it.

‎content/docs/references/ui/report.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const result = JoinedReportBlockSchema.parse(data);
3232
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
3333
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
3434
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
35-
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
35+
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021); a joined report refuses a block without one |
3636
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
3737
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
3838
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |
@@ -111,7 +111,7 @@ const result = JoinedReportBlockSchema.parse(data);
111111
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
112112
| **description** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
113113
| **type** | `Enum<'tabular' \| 'summary' \| 'matrix'>` | optional (default: `"tabular"`) | |
114-
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021) |
114+
| **dataset** | `string` | optional | Dataset name to bind (ADR-0021); a joined report refuses a block without one |
115115
| **rows** | `string[]` | optional | Dimension names down (dataset-bound) |
116116
| **columns** | `string[]` | optional | Dimension names across (matrix, dataset-bound) |
117117
| **values** | `string[]` | optional | Measure names to show (dataset-bound) |

‎packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ interface Row {
9797
const keyOf = (w: Record<string, unknown>) =>
9898
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;
9999

100-
function makeProtocol() {
100+
function makeProtocol(opts: { datasets?: readonly unknown[] } = {}) {
101101
// ⚠️ Keyed BY TABLE. `find`/`findOne` below answer nothing, so this harness
102102
// cannot serve a `sys_metadata_history` row as a `sys_metadata` row the way
103103
// #16223 measured — but one flat map still made `rows.size` the total of
@@ -131,7 +131,16 @@ function makeProtocol() {
131131
assertEngineDeleteDispatch(opts);
132132
return { deleted: 0 };
133133
},
134-
registry: { registerItem: () => {}, registerObject: () => {} },
134+
registry: {
135+
registerItem: () => {},
136+
registerObject: () => {},
137+
// `datasets` (section 5 only) is the registered dataset universe the
138+
// author-time gate resolves a report's bindings against; every other
139+
// caller passes none, so the registry lists nothing, as before.
140+
...(opts.datasets
141+
? { listItems: (type: string) => (type === 'dataset' ? [...opts.datasets!] : []) }
142+
: {}),
143+
},
135144
};
136145
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
137146
return { protocol, rows };
@@ -396,10 +405,18 @@ async function saveReport(protocol: any, item: Record<string, unknown>): Promise
396405
}
397406

398407
describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door', () => {
399-
// No `dataset` on the block: this door also runs the author-time lints, and
400-
// `chart-dataset-unknown` refuses a dataset this stub engine cannot resolve —
401-
// a second refusal the CONTROL below would otherwise be reading instead.
402-
const block = { name: 'open_block', type: 'summary', rows: ['status'], values: ['task_count'] };
408+
// The block binds a dataset the harness registers: a joined report's block
409+
// with no `dataset` is itself refused at `blocks.0.dataset` (#21702), and
410+
// this door also runs the author-time lints, where `chart-dataset-unknown`
411+
// refuses a dataset the stub engine cannot resolve — either would be a
412+
// second refusal the CONTROL below would otherwise be reading instead.
413+
const datasets = [{
414+
name: 'task_metrics',
415+
object: 'task',
416+
dimensions: [{ name: 'status', field: 'status' }],
417+
measures: [{ name: 'task_count', aggregate: 'count' }],
418+
}];
419+
const block = { name: 'open_block', type: 'summary', dataset: 'task_metrics', rows: ['status'], values: ['task_count'] };
403420
const joined = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [block] };
404421
const chart = { type: 'bar', xAxis: 'status', yAxis: 'task_count' };
405422

@@ -408,7 +425,7 @@ describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door'
408425
['the container', { ...joined, chart }, 'custom', 'chart', 'a `joined` report draws no chart'],
409426
['a block', { ...joined, blocks: [{ ...block, chart }] }, 'unrecognized_keys', 'blocks.0', '`report.blocks[].chart` was removed'],
410427
] as const)('`chart` on %s — 422 INVALID_METADATA, located at the key, nothing stored', async (_where, item, code, path, prescription) => {
411-
const { protocol, rows } = makeProtocol();
428+
const { protocol, rows } = makeProtocol({ datasets });
412429
const err = await saveReport(protocol, item);
413430

414431
expect(err).toBeInstanceOf(Error);
@@ -421,7 +438,7 @@ describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door'
421438
});
422439

423440
it('CONTROL — the same joined report without a `chart` is stored (the refusal is the key, not the report)', async () => {
424-
const { protocol, rows } = makeProtocol();
441+
const { protocol, rows } = makeProtocol({ datasets });
425442
const result = await saveReport(protocol, joined);
426443

427444
expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The D3 entry for the joined arm of `ReportSchema`'s refinement refusing a
6+
// block that binds no `dataset`: the enforce arm of ADR-0049 enforce-or-remove,
7+
// applied to the "each block dataset-bound" contract the schema comment and the
8+
// reports guide already stated. It narrows a report's accept set; no key is
9+
// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is
10+
// no D2 conversion either: which dataset a block shows is the author's
11+
// decision, and no rewrite can name it.
12+
export const entry: SemanticMigration = {
13+
id: 'ui-report-joined-block-dataset-required',
14+
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
15+
// inside a code span.
16+
surface: 'reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset '
17+
+ '(the joined arm of the ReportSchema refinement)',
18+
replacement: 'Bind the block to a dataset: set the block\'s `dataset` to the dataset whose measures '
19+
+ '(`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted '
20+
+ 'instead, as long as the report keeps at least one block.',
21+
reason:
22+
'ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report '
23+
+ 'carries its data on `blocks`, each an independent query over that block\'s own `dataset`, and the '
24+
+ 'container selects nothing: a container `dataset` is refused. `ReportSchema`\'s refinement comment '
25+
+ 'and the reports guide both said each block is dataset-bound, but the joined arm required only that '
26+
+ '`blocks` be non-empty, and a block\'s `dataset` is optional on its shape, so a block with no `dataset` '
27+
+ 'parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo\'s '
28+
+ '`.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each '
29+
+ 'block\'s `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block '
30+
+ 'draws an empty table and issues no query; a report whose blocks all lack one fails the '
31+
+ 'dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query '
32+
+ 'either, and a dashboard drill-down that opens that report lists the records instead of drawing '
33+
+ 'it. Studio\'s report inspector authors blocks through the spec form\'s `blocks` repeater, which '
34+
+ 'adds a blank row and requires no column of it, so a block saved with only a name reached the store '
35+
+ 'with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, '
36+
+ 'with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: '
37+
+ '`blocks` is read only on a `joined` report, and a block on any other report type is ignored, as '
38+
+ 'before. Ships at once, with no deprecation window: there is no window in which an unbound block '
39+
+ 'draws anything, and there is no mechanical rewrite, because only the author knows which dataset '
40+
+ 'the block was meant to show.',
41+
acceptanceCriteria:
42+
'WHICH DOOR: this is the spec schema\'s refusal, so it lands wherever a report is parsed through '
43+
+ '`@objectstack/spec` — `defineReport`, `defineStack`, `os validate` / `os build`, and the metadata '
44+
+ 'save door (the `report` entry of the metadata type registry) — as one `custom` issue per unbound '
45+
+ 'block at `blocks.N.dataset`, naming the block. A stored `sys_metadata` report row is not rewritten: '
46+
+ 'it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each '
47+
+ 'by binding the block to the dataset it is meant to show, or by deleting the block, then check the '
48+
+ 'rendered report: every block queries its dataset and draws its rows. A joined report whose blocks '
49+
+ 'all bind a `dataset` parses byte-identically to before, and every non-joined report is untouched. '
50+
+ 'Census at the time of the change: no joined report with an unbound block in this repository (one '
51+
+ 'example-app report, one docs example and the test fixtures in `packages/lint`, '
52+
+ '`packages/platform-objects` and `packages/spec` all bind every block; one metadata-door test '
53+
+ 'fixture that left its block unbound on purpose was bound in the same change), in the hotcrm '
54+
+ 'application (one joined report, every block bound) or in the cloud repository (no joined report '
55+
+ 'exists).',
56+
};

0 commit comments

Comments
 (0)