From 3365088dede9fae9b985c142e5773818706d2f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:04:46 +0000 Subject: [PATCH 01/13] feat(spec)!: a chart list view whose effective binding names no dataset is refused at every list-view door Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/src/ui/view.zod.ts | 128 +++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 15 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 26426fc83fd..0915637f93b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2554,6 +2554,97 @@ export function checkListViewCalendarVisualization( } } +/** The remedy both chart-binding refusals close with — the top-level block, spelled out. */ +const LIST_VIEW_CHART_BINDING_REMEDY = + "Declare `chart: { dataset: '', values: [''] }`: `dataset` names the " + + 'ADR-0021 dataset to plot, `values` at least one of its measures, and `dimensions` (the X / group ' + + 'axis) is optional. There is no default binding to fall back on: the renderer plots only the names ' + + 'the author wrote.'; + +/** [#22491] `type: 'chart'` with no binding at all — refused at `chart`. */ +const LIST_VIEW_CHART_NEEDS_BINDING = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is " + + `nothing to plot. ${LIST_VIEW_CHART_BINDING_REMEDY} A view that is not meant to be a chart takes another \`type\`.`; + +/** + * [#22491] `type: 'chart'` whose only binding is the legacy `options.chart` + * bag, missing a key the `chart` block requires — refused at that key. + */ +const listViewChartBagMissing = (key: (typeof LIST_CHART_BINDING_KEYS)[number]): string => + "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " + + `which names no ${key === 'dataset' ? '`dataset`' : 'measure in `values`'}, so there is nothing to plot. ` + + 'With no top-level `chart` block the bag IS the binding, so it must carry what that block requires. ' + + `${LIST_VIEW_CHART_BINDING_REMEDY} The top-level block replaces the bag whole; prefer it to completing the bag.`; + +/** + * [#22491] The keys that make a chart block a binding: the required keys of + * {@link ListChartConfigSchema} (`chartType` defaults, `dimensions` is + * optional). Hand-listed for the messages above; `view-chart-binding.test.ts` + * derives the block's required keys from the schema and fails if the two part. + */ +const LIST_CHART_BINDING_KEYS = ['dataset', 'values'] as const; + +/** + * [#22491] A `type: 'chart'` list view must bind a dataset — the view's + * EFFECTIVE chart binding names a `dataset` and at least one measure. + * + * The effective binding is read the way the renderer reads it: the top-level + * `chart` block, else the legacy `options.chart` bag, and the block replaces + * the bag WHOLE — objectui `plugin-list/src/ListView.tsx` + * `resolveListChartBinding`, `schema.chart || schema.options?.chart || {}` + * (`:207` at this repo's `.objectui-sha` pin `f0268ad7`, `:260` at objectui + * `2a48bd40`). So, unlike the merged per-key underlays + * {@link listViewKindBlocks} describes, the bag is not one layer of the chart + * block: when no block is declared it is the whole of it, and it owes what + * the block requires. + * + * - No block and no bag ⇒ one issue at `chart`. + * - No block, a bag missing `dataset` / `values` ⇒ one issue per missing key, + * at `options.chart.KEY` (the bag lives on the flattened overlay only; the + * two authoring doors refuse `options` by name). + * - A declared `chart` block ⇒ nothing here: its own strict schema already + * requires both keys, at `chart.dataset` / `chart.values`. + * + * What such a view renders is a dead screen either way: at the pin the + * renderer fabricates a binding nobody wrote (an aggregate over `'name'` / + * `'value'`); from objectui#6152 round 15 (objectui `0253416`) that floor is + * retired and `ObjectChart` refuses on screen (`chart-missing-category-axis`). + * ⛔ Never fabricate a default here either — only the author knows the dataset. + * + * Reads `type` as the door hands it: the authoring shapes apply the `grid` + * default first; the overlay member reads the input side (no default), so a + * PATCH that names no `type` is not judged — it shadows a view whose own + * binding decides. + * + * ⚠️ Scope: `type: 'chart'` only. A view of another type that merely OFFERS a + * chart (`appearance.allowedVisualizations`) is not judged: objectui's + * `availableViews` gate asks the same resolver and never offers an unbound + * chart, so that view degrades to its own type rather than rendering dead. + * + * Attached at the same three list-view doors as + * {@link checkListViewCalendarVisualization}, and exported for the same + * reason: a mirror built from `ListViewSchema.shape` re-attaches it. + */ +export function checkListViewChartBinding( + view: { type?: unknown; chart?: unknown; options?: unknown }, + ctx: z.RefinementCtx, +): void { + if (view.type !== 'chart' || view.chart !== undefined) return; + const bag = view.options !== null && typeof view.options === 'object' + ? (view.options as { chart?: unknown }).chart + : undefined; + if (bag === undefined) { + ctx.addIssue({ code: 'custom', path: ['chart'], message: LIST_VIEW_CHART_NEEDS_BINDING }); + return; + } + // A bag that is not an object is refused by the bag's own schema. + if (bag === null || typeof bag !== 'object') return; + for (const key of LIST_CHART_BINDING_KEYS) { + if ((bag as Record)[key] !== undefined) continue; + ctx.addIssue({ code: 'custom', path: ['options', 'chart', key], message: listViewChartBagMissing(key) }); + } +} + /** * List View Schema (Expanded) * Defines how a collection of records is displayed to the user. @@ -2589,8 +2680,9 @@ export function checkListViewCalendarVisualization( * containing refinements"`, thrown at construction), and * {@link ObjectListViewSchema} is built by omitting `userFilters` from this * shape. So the shape stays refinement-free and BOTH terminals attach - * {@link checkListViewCalendarVisualization} themselves — one check function, - * two attachment points, no second copy of the rule. + * {@link checkListViewCalendarVisualization} and {@link checkListViewChartBinding} + * themselves — one function per check, attached at each door, no second copy + * of either rule. * * ⛔ Not exported, deliberately: a top-level EXPORTED schema binding mints a * new protocol def in `json-schema.manifest/` and a full set of ratcheted @@ -2753,7 +2845,8 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ gantt: GanttConfigSchema.optional().describe('Gantt-timeline configuration — applies when the view renders as a gantt layout'), gallery: GalleryConfigSchema.optional(), timeline: TimelineConfigSchema.optional(), - chart: ListChartConfigSchema.optional(), + chart: ListChartConfigSchema.optional() + .describe('Chart binding — applies when the view renders as a chart. A `type: \'chart\'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding'), map: ListMapConfigSchema.optional().describe('Map configuration — applies when the view renders as a map layout'), tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), @@ -2998,16 +3091,17 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ })); /** - * List View Schema (Expanded) — {@link ListViewShapeSchema} plus the - * `allowedVisualizations` ⇄ `calendar` binding check. See that shape for why - * shape and checks are separate bindings, and - * {@link checkListViewCalendarVisualization} for what the check refuses. - * (#17063 removed the `type: 'page'` ⇄ `pageName` binding check with the mount - * it policed.) + * List View Schema (Expanded) — {@link ListViewShapeSchema} plus two binding + * checks: `allowedVisualizations` ⇄ `calendar` + * ({@link checkListViewCalendarVisualization}) and `type: 'chart'` ⇄ a dataset + * binding ({@link checkListViewChartBinding}). See that shape for why shape and + * checks are separate bindings. (#17063 removed the `type: 'page'` ⇄ + * `pageName` binding check with the mount it policed.) */ export const ListViewSchema = lazySchema(() => ListViewShapeSchema - .superRefine(checkListViewCalendarVisualization)); + .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding)); /** * [commit c459da6bc] Form-view select option — {@link SelectOptionSchema} minus the @@ -4747,11 +4841,12 @@ export const ObjectListViewSchema = lazySchema(() => ListViewShapeSchema.omit({ userFilters: true }) .extend({ userFilters: ObjectUserFiltersSchema.optional() }) // Derived from the UNREFINED shape (zod 4 refuses `.omit()` on a refined - // object), so the binding check is re-attached here rather than inherited. - // Dropping this line would leave `objects[].listViews.*` — the ADR-0047 + // object), so the binding checks are re-attached here rather than inherited. + // Dropping either line would leave `objects[].listViews.*` — the ADR-0047 // authoring surface — as the one door where a calendar-enabled view with - // no `calendar:` block parses clean. - .superRefine(checkListViewCalendarVisualization)); + // no `calendar:` block, or a chart view binding no dataset, parses clean. + .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding)); /** * [#4001/#7741] The wrap remedy, ONE prose source for two doors: the container's @@ -6229,8 +6324,10 @@ function formOverlayColumnsField(): z.ZodOptional { * `'form'` only), and it judges a column-less PATCH as well as a full inline * config — see {@link listOverlayPatchFields}. A column-less body that names a * `type` is a full config missing its columns and is refused at `columns` - * ({@link checkListOverlayTypeNeedsColumns}). The three attached checks run in + * ({@link checkListOverlayTypeNeedsColumns}). The attached checks run in * order: that refusal reads the input side, the calendar check is unchanged, + * the chart-binding check ({@link checkListViewChartBinding}, #22491) reads the + * input side too and is the one door check that sees the `options.chart` bag, * and {@link applyListOverlayTypeDefault} restores the `grid` default last. */ const ListViewOverlayWireSchema = lazySchema(() => @@ -6252,6 +6349,7 @@ const ListViewOverlayWireSchema = lazySchema(() => }).strip() .superRefine(checkListOverlayTypeNeedsColumns) .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding) .overwrite(applyListOverlayTypeDefault), ); From 6760927e3a50fc3d8fdc93e3ca08c2732e907493 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:11:10 +0000 Subject: [PATCH 02/13] test(spec): pin the chart-binding refusal at every list-view door and catalogue its export Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../object-refinement-check-exports.test.ts | 29 +++- .../spec/src/ui/view-chart-binding.test.ts | 163 ++++++++++++++++++ packages/spec/src/ui/view.test.ts | 7 +- 3 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/ui/view-chart-binding.test.ts diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 0ae3d47c8c6..f8536ec5078 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -54,6 +54,7 @@ import { ListViewSchema, ObjectListViewSchema, checkListViewCalendarVisualization, + checkListViewChartBinding, } from './view.zod'; import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition } from './page.zod'; import { @@ -210,6 +211,20 @@ const calendarFixtures: Fixture[] = [ { label: 'no `appearance` at all', value: { type: 'grid', columns: ['name'] }, refusesAt: [] }, ]; +// [#22491] Shape-valid on BOTH authoring doors, so no `options` bag here (the +// two refuse it by name); the bag's paths are pinned on the overlay door in +// `view-chart-binding.test.ts`. A block missing a key is a SHAPE failure, so +// it is not a fixture of this check either. +const chartBindingFixtures: Fixture[] = [ + { label: "`type: 'chart'` with no `chart` block", value: { type: 'chart', columns: ['stage'] }, refusesAt: ['chart'] }, + { + label: "`type: 'chart'` with a block naming a dataset and a measure", + value: { type: 'chart', columns: ['stage'], chart: { dataset: 'lead_metrics', values: ['amount_sum'] } }, + refusesAt: [], + }, + { label: 'a block-less view of another type', value: { type: 'kanban', columns: ['stage'] }, refusesAt: [] }, +]; + const PAGE_BASE = { name: 'home_page', label: 'Home', type: 'home' } as const; const pageSourceFixtures: Fixture[] = [ @@ -454,6 +469,7 @@ const chartMeasureArityFixtures: Fixture[] = [ const listViewExports: ExportUnderTest[] = [ { name: 'checkListViewCalendarVisualization', check: checkListViewCalendarVisualization, fixtures: calendarFixtures }, + { name: 'checkListViewChartBinding', check: checkListViewChartBinding, fixtures: chartBindingFixtures }, ]; const MIRRORED: MirroredSchema[] = [ @@ -593,7 +609,7 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () const declarations = (src: string, name: string): number => src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; - it('view.zod.ts declares the export and chains it onto ListViewShapeSchema for ListViewSchema', () => { + it('view.zod.ts declares the exports and chains them onto ListViewShapeSchema for ListViewSchema', () => { const src = read('view.zod.ts'); expect(src).toContain('export function checkListViewCalendarVisualization('); // Exactly one declaration — the count below keys on this name. @@ -607,6 +623,14 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () // view.test.ts pins the behaviour; this pins that every attachment is the // export, by name, and none is an inline copy. expect(attachments(src, 'checkListViewCalendarVisualization')).toBe(3); + // [#22491] The chart-binding check: declared once, chained after the + // calendar check at the same three doors. + expect(src).toContain('export function checkListViewChartBinding('); + expect(declarations(src, 'checkListViewChartBinding')).toBe(1); + expect(src).toMatch( + /ListViewShapeSchema\s*\.superRefine\(checkListViewCalendarVisualization\)\s*\.superRefine\(checkListViewChartBinding\)/, + ); + expect(attachments(src, 'checkListViewChartBinding')).toBe(3); // [#17063] `checkListViewPageMount` was retired with the `type: 'page'` // mount it policed, so neither a declaration nor an attachment of it may // return: a re-attachment would be a check with no rule left to enforce. @@ -648,6 +672,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // an enumeration of every exported refinement. it.each([ ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], + ['checkListViewChartBinding', checkListViewChartBinding], ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], @@ -661,7 +686,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi // [#17063] The retired member, from the same surface, in the same leg. A // downstream mirror re-attaching a check it imports from here is the whole // point of this file, so the barrel is where a relapse would first become - // reachable — the runtime namespace answers it, with the four survivors + // reachable — the runtime namespace answers it, with the survivors // above as the lit control that the namespace is really populated. it('no longer exports `checkListViewPageMount` — retired with the mount it policed', () => { expect('checkListViewPageMount' in (ui as Record)).toBe(false); diff --git a/packages/spec/src/ui/view-chart-binding.test.ts b/packages/spec/src/ui/view-chart-binding.test.ts new file mode 100644 index 00000000000..e5886840fd6 --- /dev/null +++ b/packages/spec/src/ui/view-chart-binding.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22491] A `type: 'chart'` list view binds a dataset — at every list-view + * door, judged on the view's EFFECTIVE binding. + * + * Before (measured on `origin/main` @ `e148ca98`): the flattened overlay member + * (`PUT /api/v1/meta/view`, the Studio / MCP / AI write door) accepted + * `type: 'chart'` with no `chart` block, and accepted an `options.chart` bag + * holding only `chartType`; the two authoring doors (`ListViewSchema`, + * `ObjectListViewSchema` — what `defineStack` / `os validate` judge) accepted + * the block-less view too. Only a DECLARED `chart` block was held to its + * required `dataset` and `values`. + * + * The effective binding is the renderer's: the `chart` block, else the + * `options.chart` bag, the block replacing the bag whole — see + * `checkListViewChartBinding`'s docblock for the objectui line it mirrors. + * + * Refusal pins assert the issue `code`, its path and the message's FIRST + * sentence (the verdict); the HTTP envelope (`422 INVALID_METADATA`) is pinned + * at the real door in `packages/rest/src/meta-view-chart-binding.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + ListViewSchema, + ObjectListViewSchema, + ListChartConfigSchema, + ViewMetadataSchema, +} from './view.zod'; + +type Parse = (body: Record) => z.ZodSafeParseResult; + +const OVERLAY_IDENTITY = { name: 'crm_lead.revenue_chart', object: 'crm_lead', viewKind: 'list' } as const; + +/** The three list-view doors, the overlay through the union the write door runs. */ +const doors: ReadonlyArray = [ + ['ListViewSchema', (body) => ListViewSchema.safeParse(body)], + ['ObjectListViewSchema', (body) => ObjectListViewSchema.safeParse(body)], + ['flattened overlay (PUT /api/v1/meta/view)', (body) => ViewMetadataSchema.safeParse({ ...OVERLAY_IDENTITY, ...body })], +]; + +const overlay: Parse = (body) => ViewMetadataSchema.safeParse({ ...OVERLAY_IDENTITY, ...body }); + +const BINDING = { chartType: 'bar', dataset: 'lead_metrics', dimensions: ['stage'], values: ['amount_sum'] } as const; + +const NO_BLOCK_VERDICT = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot."; +const bagVerdict = (missing: string): string => + "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " + + `which names no ${missing}, so there is nothing to plot.`; + +/** Every issue, nested union arms included — a shape failure on the overlay is wrapped one level down. */ +const flatten = (issues: readonly z.core.$ZodIssue[]): z.core.$ZodIssue[] => + issues.flatMap((i) => { + const nested = (i as unknown as { errors?: z.core.$ZodIssue[][] }).errors; + return i.code === 'invalid_union' && Array.isArray(nested) ? [i, ...flatten(nested.flat())] : [i]; + }); + +const issuesOf = (r: z.ZodSafeParseResult): z.core.$ZodIssue[] => + (r.success ? [] : flatten(r.error.issues)); + +const at = (r: z.ZodSafeParseResult, path: string) => + issuesOf(r).filter((i) => i.path.map(String).join('.') === path); + +const firstSentence = (message: string): string => message.slice(0, message.indexOf('. ') + 1); + +describe.each(doors)("%s — a `type: 'chart'` list view binds a dataset", (_door, parse) => { + it("REFUSES `type: 'chart'` with no `chart` block, at `chart`, naming the block's required keys", () => { + const r = parse({ type: 'chart', columns: ['stage', 'amount'] }); + expect(r.success).toBe(false); + const hits = at(r, 'chart'); + expect(hits, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(hits[0]!.code).toBe('custom'); + expect(firstSentence(hits[0]!.message)).toBe(NO_BLOCK_VERDICT); + // The remedy is the top-level block, spelled with both required keys. + expect(hits[0]!.message).toContain("`chart: { dataset: '', values: [''] }`"); + }); + + it('ACCEPTS a chart view whose `chart` block names a dataset and a measure — the lit control', () => { + const r = parse({ type: 'chart', columns: ['stage', 'amount'], chart: BINDING }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r as { data: { chart?: unknown } }).data.chart).toEqual(BINDING); + }); + + it("leaves a declared `chart` block to its own schema — `chart.dataset` / `chart.values`, and no second issue at `chart`", () => { + const r = parse({ type: 'chart', columns: ['stage'], chart: { chartType: 'line' } }); + expect(r.success).toBe(false); + expect(at(r, 'chart.dataset'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'chart.values'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'chart')).toEqual([]); + }); + + it('does not judge a view of another type — a block-less grid still parses', () => { + expect(parse({ type: 'grid', columns: ['name'] }).success).toBe(true); + }); + + // ⚠️ Scope: a view that only OFFERS a chart. objectui's switcher gate asks + // the same binding resolver and never offers an unbound chart, so this view + // renders as the grid it is — a degrade, not a dead screen. + it("does not judge a grid that lists 'chart' in `allowedVisualizations` without a block", () => { + const r = parse({ type: 'grid', columns: ['name'], appearance: { allowedVisualizations: ['grid', 'chart'] } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); +}); + +describe("the overlay's legacy `options.chart` bag — the binding when no `chart` block replaces it", () => { + it('REFUSES a bag holding only `chartType`, at `options.chart.dataset` and `options.chart.values`', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { chartType: 'bar' } } }); + expect(r.success).toBe(false); + const dataset = at(r, 'options.chart.dataset'); + const values = at(r, 'options.chart.values'); + expect(dataset, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(values, JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(dataset[0]!.code).toBe('custom'); + expect(values[0]!.code).toBe('custom'); + expect(firstSentence(dataset[0]!.message)).toBe(bagVerdict('`dataset`')); + expect(firstSentence(values[0]!.message)).toBe(bagVerdict('measure in `values`')); + expect(dataset[0]!.message).toContain("`chart: { dataset: '', values: [''] }`"); + }); + + it('REFUSES a bag naming a dataset but no measure, at `options.chart.values` only', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { dataset: 'lead_metrics' } } }); + expect(r.success).toBe(false); + expect(at(r, 'options.chart.values'), JSON.stringify(issuesOf(r))).toHaveLength(1); + expect(at(r, 'options.chart.dataset')).toEqual([]); + }); + + it('ACCEPTS a bag that carries the whole binding, and keeps it', () => { + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: BINDING } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect((r as { data: { options?: unknown } }).data.options).toEqual({ chart: BINDING }); + }); + + it('ACCEPTS an incomplete bag under a complete `chart` block — the block replaces the bag whole', () => { + const r = overlay({ type: 'chart', columns: ['stage'], chart: BINDING, options: { chart: { chartType: 'line' } } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + // A body that names no `type` is a PATCH on the view it shadows (the console's + // toolbar save), whose own binding decides — the overlay member reads `type` + // on the input side for exactly this line. + it('does not judge a patch that names no `type`', () => { + const r = overlay({ options: { chart: { chartType: 'line' } } }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + // The check hand-lists the binding keys for its messages. Derive the block's + // REQUIRED keys from the schema itself, so a key the block starts requiring + // that the check does not ask of the bag goes red here. + it("asks of the bag exactly what `ListChartConfigSchema` requires of the block", () => { + const shape = (ListChartConfigSchema as unknown as { shape: Record }).shape; + const required = Object.keys(shape).filter((key) => !shape[key]!.safeParse(undefined).success); + expect(required.sort()).toEqual(['dataset', 'values']); + for (const key of required) { + const bag: Record = { ...BINDING }; + delete bag[key]; + const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: bag } }); + expect(at(r, `options.chart.${key}`), `${key}: ${JSON.stringify(issuesOf(r))}`).toHaveLength(1); + } + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 31fcc5e34e0..cbcafbe7ca6 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -4396,10 +4396,15 @@ describe("ListViewSchema — the RETIRED `page` view type", () => { }); it('keeps every surviving view type accepting exactly as before', () => { - const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; + const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'tree'] as const; for (const type of types) { expect(ListViewSchema.safeParse({ type, columns: ['name'] }).success, type).toBe(true); } + // [#22491] `chart` survives too, but a chart view binds a dataset: the + // block-less body is refused (`view-chart-binding.test.ts`), so the type + // is pinned here with the binding it now requires. + const chart = { dataset: 'lead_metrics', values: ['amount_sum'] }; + expect(ListViewSchema.safeParse({ type: 'chart', columns: ['name'], chart }).success, 'chart').toBe(true); }); describe.each(viewDoorsCarryingObjectLevelChecks)('%s', (_label, parse) => { From d5f6af57e327b8da44d2e2eaa7cf07e3e68be6d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:13:21 +0000 Subject: [PATCH 03/13] feat(spec): register the chart-binding narrowing in the step-18 ledger, its changeset and the REST door pins Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22491-chart-list-view-binds-a-dataset.md | 33 ++++ .../rest/src/meta-view-chart-binding.test.ts | 185 ++++++++++++++++++ .../18.view-chart-binding-dataset-required.ts | 57 ++++++ packages/spec/src/migrations/registry.ts | 53 +++++ 4 files changed, 328 insertions(+) create mode 100644 .changeset/22491-chart-list-view-binds-a-dataset.md create mode 100644 packages/rest/src/meta-view-chart-binding.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts diff --git a/.changeset/22491-chart-list-view-binds-a-dataset.md b/.changeset/22491-chart-list-view-binds-a-dataset.md new file mode 100644 index 00000000000..4f3b4c81868 --- /dev/null +++ b/.changeset/22491-chart-list-view-binds-a-dataset.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': major +--- + +A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface and on the view write door, graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes. + +**Why.** A chart list view plots only the ADR-0021 `dataset` its binding names. The renderer reads that binding as the top-level `chart` block, else the legacy `options.chart` bag, the block replacing the bag whole. The `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema: the view write door (`PUT /api/v1/meta/view/:name`) saved `type: 'chart'` with no `chart` block, and an `options.chart` bag holding only `chartType`, and `defineStack` / `os validate` accepted the block-less view. Such a view renders a dead screen: the renderer either guessed a binding nobody wrote or, since objectui retired that guess, refuses on screen. + +**What is refused.** A list view whose `type` is `chart` and that: + +- declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; +- declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. + +It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. The check is exported as `checkListViewChartBinding` from `@objectstack/spec/ui`, for a mirror built from `ListViewSchema.shape` to re-attach. + +**What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. + +**What to do.** Bind the chart: declare a top-level `chart` block naming the dataset to plot and at least one of its measures, for example `chart: { dataset: 'lead_metrics', values: ['amount_sum'] }`, with `dimensions` (the X / group axis) optional and `chartType` defaulting to `bar`. A view that carries its binding in the legacy `options.chart` bag either completes the bag or, preferred, moves it to the top-level `chart` block. A view that is not meant to be a chart takes another `type`. No conversion can do this for you: only the author knows which dataset a chart plots. + +**Stored views.** A stored `view` row is neither rewritten nor refused on read: it is served as stored, carries the same issue in its read-side `_diagnostics`, and is refused on its next save. + +**Who is affected, measured.** No chart list view without a binding exists in this repository: the two chart list views in `examples/app-showcase` and the chart list views in the `@objectstack/lint` fixtures all bind a dataset and a measure. Deployed metadata was not measured. + +### The kit + +- **The refusal.** `checkListViewChartBinding` in `ui/view.zod.ts`, attached beside the calendar binding check at the three list-view doors: `ListViewSchema`, `ObjectListViewSchema` and the flattened list overlay member of the view write door. The `chart` slot's description now says a chart view must bind one, and the generated reference page carries it. +- **The ledger.** The D3 semantic entry `view-chart-binding-dataset-required` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/packages/rest/src/meta-view-chart-binding.test.ts b/packages/rest/src/meta-view-chart-binding.test.ts new file mode 100644 index 00000000000..b0086f4e0b3 --- /dev/null +++ b/packages/rest/src/meta-view-chart-binding.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22491 — `PUT /api/v1/meta/view/:name`, the door a Studio tenant or an MCP/AI + * author writes through, refuses a `type: 'chart'` list view whose effective + * binding names no dataset, on the real composition: the real `RestServer` + * route over the real `saveMetaItem`, backed by a real `ObjectQL` + SQLite + * `sys_metadata`. + * + * Before the change (measured on `origin/main` @ `e148ca98`): the flattened + * list overlay member accepted `type: 'chart'` with no `chart` block, and an + * `options.chart` bag holding only `chartType`. + * + * Refusal cases assert the ADR-0112 envelope — `code` AND `status` — and that + * no row reached `sys_metadata`. The last case measures the READ side of the + * narrowing on a row stored before it: served as stored, flagged in its + * `_diagnostics`, and refused on its next save. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadata, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server.js'; + +const META_ITEM = '/api/v1/meta/:type/:name'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { + _status: 200, + write: () => true, end: () => {}, send: () => res, setHeader: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.metadata-objects', + name: 'Metadata Platform Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject], + }); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + // The route demands `manage_metadata`; the caller holds it, so a refusal + // here is the spec door's and nothing else's. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + const routeFor = (method: string) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === META_ITEM); + if (!route) throw new Error(`${method} ${META_ITEM} is not registered`); + return route; + }; + const call = async (method: string, name: string, body?: unknown) => { + const res = makeRes(); + await routeFor(method).handler({ params: { type: 'view', name }, query: {}, headers: {}, body } as any, res); + return res; + }; + const storedRows = async (name: string) => engine.find('sys_metadata', { where: { type: 'view', name } }); + return { engine, put: (name: string, body: unknown) => call('PUT', name, body), get: (name: string) => call('GET', name), storedRows }; +} + +const BINDING = { chartType: 'bar', dataset: 'lead_metrics', dimensions: ['stage'], values: ['amount_sum'] }; +const chartView = (name: string, extra: Record) => ({ + name, + object: 'crm_lead', + viewKind: 'list', + label: 'Pipeline by stage', + type: 'chart', + columns: ['stage', 'amount'], + ...extra, +}); + +type Issue = { path?: string; code?: string; message?: string }; +const issuesOf = (res: any): Issue[] => (res._json?.issues ?? []) as Issue[]; + +const NO_BLOCK_VERDICT = + "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot."; + +describe("#22491 PUT /api/v1/meta/view refuses a `type: 'chart'` list view that binds no dataset", () => { + it('a chart view with no `chart` block: 422 INVALID_METADATA at `chart`, and nothing is stored', async () => { + const { put, storedRows } = await boot(); + const res = await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', {})); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows('crm_lead.pipeline_chart')).toEqual([]); + const hit = issuesOf(res).find((i) => i.path === 'chart'); + expect(hit, JSON.stringify(res._json)).toBeDefined(); + expect(hit!.message!.startsWith(NO_BLOCK_VERDICT)).toBe(true); + }); + + it('an `options.chart` bag holding only `chartType`: 422 INVALID_METADATA at the bag\'s missing keys, and nothing is stored', async () => { + const { put, storedRows } = await boot(); + const res = await put( + 'crm_lead.pipeline_chart', + chartView('crm_lead.pipeline_chart', { options: { chart: { chartType: 'bar' } } }), + ); + + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows('crm_lead.pipeline_chart')).toEqual([]); + const paths = issuesOf(res).map((i) => i.path); + expect(paths, JSON.stringify(res._json)).toContain('options.chart.dataset'); + expect(paths).toContain('options.chart.values'); + }); + + it('the control: a chart view that binds a dataset and a measure answers 200 and is stored with its binding', async () => { + const { put, storedRows } = await boot(); + const res = await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', { chart: BINDING })); + + expect(res._status, JSON.stringify(res._json)).toBe(200); + const rows = await storedRows('crm_lead.pipeline_chart'); + expect(rows).toHaveLength(1); + const stored = typeof rows[0].metadata === 'string' ? JSON.parse(rows[0].metadata) : rows[0].metadata; + expect(stored.chart).toEqual(BINDING); + }); + + it('a row stored before the narrowing is served as stored, flagged in `_diagnostics`, and refused on its next save', async () => { + const { engine, put, get, storedRows } = await boot(); + // Store a valid chart view through the door, then plant a copy of its + // row whose body lost the binding — the shape a row saved before this + // change can carry. The door can no longer write it, so the store is + // written directly. + expect((await put('crm_lead.pipeline_chart', chartView('crm_lead.pipeline_chart', { chart: BINDING })))._status).toBe(200); + const [row] = await storedRows('crm_lead.pipeline_chart'); + const body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + const { chart: _dropped, ...unbound } = { ...body, name: 'crm_lead.legacy_chart' }; + const { id: _id, ...rowData } = row; + await engine.insert('sys_metadata', { ...rowData, name: 'crm_lead.legacy_chart', metadata: JSON.stringify(unbound) }); + + const read = await get('crm_lead.legacy_chart'); + expect(read._status, JSON.stringify(read._json)).toBe(200); + const served = read._json?.item ?? read._json?.data ?? read._json; + expect(served.type).toBe('chart'); + expect(served).not.toHaveProperty('chart'); + expect(served._diagnostics?.valid, JSON.stringify(served._diagnostics)).toBe(false); + expect( + (served._diagnostics?.errors ?? []).some((e: Issue) => e.path === 'chart' && e.message?.startsWith(NO_BLOCK_VERDICT)), + JSON.stringify(served._diagnostics), + ).toBe(true); + + // Re-saving what was read is refused at the same path. + const { _diagnostics: _d, ...resave } = served; + const again = await put('crm_lead.legacy_chart', resave); + expect(again._status, JSON.stringify(again._json)).toBe(422); + expect(again._json?.code).toBe('INVALID_METADATA'); + expect(issuesOf(again).some((i) => i.path === 'chart')).toBe(true); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts new file mode 100644 index 00000000000..a9c5b2bbb24 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry for the list-view chart-binding check (#22491): the enforce arm +// of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list +// chart block already required. It narrows a list view's accept set; no key is +// removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is +// no D2 conversion either: which dataset a chart plots is the author's +// decision, and a fabricated binding is the defect this closes. +export const entry: SemanticMigration = { + id: 'view-chart-binding-dataset-required', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: + 'A list view whose type is chart and whose effective chart binding names no dataset: no chart block ' + + 'and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an ' + + 'options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every ' + + 'list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the ' + + 'flattened list overlay.', + replacement: + 'Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one ' + + 'of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults ' + + 'to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` ' + + 'and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag ' + + 'whole. A view that is not meant to be a chart takes another `type`.', + reason: + 'ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that ' + + 'renders nothing is refused rather than warned). A chart list view plots only the dataset its ' + + 'effective binding names, and the renderer reads that binding as the `chart` block, else the ' + + '`options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, ' + + '`resolveListChartBinding`, at this repo\'s `.objectui-sha` pin and at objectui main alike). The ' + + 'authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or ' + + 'with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened ' + + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' + + 'named name and a measure named value), and objectui#6152 round 15 retired that floor, after which ' + + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' + + 'only the author knows which dataset a chart was meant to show.', + acceptanceCriteria: + 'WHICH DOOR: the spec schema\'s refusal, so it lands wherever a list view is parsed through ' + + '`@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write ' + + 'door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at ' + + '`chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / ' + + '`options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by ' + + 'name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read ' + + 'door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next ' + + 'save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view ' + + 'that already declares a complete `chart` block parses byte-identically to before, and every view of ' + + 'another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. ' + + 'Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart ' + + 'list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that ' + + 'parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ed7eda14f48..eeac1dbe0d0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -22909,6 +22909,59 @@ const step18: MigrationStep = { + 'fixture and docs example at the pin above (13 occurrences); in the cloud repo none ' + 'exist.', }, + // The D3 entry for the list-view chart-binding check (#22491): the enforce arm + // of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list + // chart block already required. It narrows a list view's accept set; no key is + // removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is + // no D2 conversion either: which dataset a chart plots is the author's + // decision, and a fabricated binding is the defect this closes. + { + id: 'view-chart-binding-dataset-required', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span. + surface: + 'A list view whose type is chart and whose effective chart binding names no dataset: no chart block ' + + 'and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an ' + + 'options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every ' + + 'list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the ' + + 'flattened list overlay.', + replacement: + 'Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one ' + + 'of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults ' + + 'to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` ' + + 'and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag ' + + 'whole. A view that is not meant to be a chart takes another `type`.', + reason: + 'ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that ' + + 'renders nothing is refused rather than warned). A chart list view plots only the dataset its ' + + 'effective binding names, and the renderer reads that binding as the `chart` block, else the ' + + '`options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, ' + + '`resolveListChartBinding`, at this repo\'s `.objectui-sha` pin and at objectui main alike). The ' + + 'authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or ' + + 'with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened ' + + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' + + 'named name and a measure named value), and objectui#6152 round 15 retired that floor, after which ' + + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' + + 'only the author knows which dataset a chart was meant to show.', + acceptanceCriteria: + 'WHICH DOOR: the spec schema\'s refusal, so it lands wherever a list view is parsed through ' + + '`@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write ' + + 'door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at ' + + '`chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / ' + + '`options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by ' + + 'name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read ' + + 'door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next ' + + 'save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view ' + + 'that already declares a complete `chart` block parses byte-identically to before, and every view of ' + + 'another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. ' + + 'Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart ' + + 'list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that ' + + 'parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.', + }, // The absent-value half of the coupling #6227 declared, recorded beside its // array half (`view-filter-rule-scalar-operator-array-refused`) rather than // amended onto it: that entry's own replacement prose told an upgrading author From 5413aed93aca1498ec89fb45fce5fba628c078e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:25:19 +0000 Subject: [PATCH 04/13] chore(spec): regenerate api-surface, export-origins and reference docs for the chart-binding check Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 12 ++++++------ packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f05b0e279e8..4e6470a40f5 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1689,7 +1689,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1774,7 +1774,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5ee0bcffd7d..a8722beff67 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -382,7 +382,7 @@ const result = ApiMethod.parse(data); | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ad52bcbc453..50e263d2dbe 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -785,7 +785,7 @@ Map view configuration | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1177,7 +1177,7 @@ View filter rule | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1760,7 +1760,7 @@ Tab configuration for multi-tab view interface | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -1845,7 +1845,7 @@ Tab configuration for multi-tab view interface | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2086,7 +2086,7 @@ This schema accepts one of the following structures: | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -2264,7 +2264,7 @@ This schema accepts one of the following structures: | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | -| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | Chart binding — applies when the view renders as a chart. A `type: 'chart'` view must bind one: it names the ADR-0021 `dataset` and the measures (`values`) the chart plots, and there is no default binding | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0b28904473a..a210d9ec772 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -492,6 +492,7 @@ "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", + "checkListViewChartBinding (function)", "checkPagePrintComposition (function)", "checkPageRequiresKind (function)", "checkPageSourceCompleteness (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 26e94e5a860..bd42aa4c820 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -477,6 +477,7 @@ "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", + "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", From 7836b3266061821ba35ecad1e23c71c80c060a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:07:45 +0000 Subject: [PATCH 05/13] test(spec): the pagination door pin carries the binding a chart view now requires Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/src/ui/view-form-pagination.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/view-form-pagination.test.ts b/packages/spec/src/ui/view-form-pagination.test.ts index 808cfbd648e..41abdf52351 100644 --- a/packages/spec/src/ui/view-form-pagination.test.ts +++ b/packages/spec/src/ui/view-form-pagination.test.ts @@ -126,8 +126,13 @@ describe('view form — `pagination` is offered to every view type', () => { expect(offeredTo('pagination', undefined)).toHaveLength(1); }); + // A `chart` view binds a dataset (#22491), so it carries that binding here; + // every other type parses with no block of its own. + const bindingFor = (type: string) => + (type === 'chart' ? { chart: { dataset: 'lead_metrics', values: ['amount_sum'] } } : {}); + it.each(VIEW_TYPES.map((t) => [t]))("is backed by the door: type '%s' parses a pagination block and keeps it", (type) => { - const r = ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } }); + const r = ListViewSchema.safeParse({ type, columns: ['name'], ...bindingFor(type), pagination: { pageSize: 50 } }); expect(r.success, JSON.stringify(r.error?.issues ?? '')).toBe(true); expect((r.data as { pagination?: unknown }).pagination).toEqual({ pageSize: 50 }); }); From d39009050b83e66ed418fa49bd9b5787b735eb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:51:52 +0000 Subject: [PATCH 06/13] chore(spec): regenerate data/object reference docs from the merged tree Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/data/object.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a8722beff67..404eb28dd74 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -272,6 +272,8 @@ const result = ApiMethod.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -607,6 +609,8 @@ const result = ApiMethod.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | From 742960d6ee23df56111c8525c110b7d5f104ab56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:52:18 +0000 Subject: [PATCH 07/13] chore(spec): project view-chart-binding-dataset-required into spec-changes.json and the protocol upgrade guide (protocol 18) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f72946075bb..9e2bf7430d5 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1536,6 +1536,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. +- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. + - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. + - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index c8f42c57087..53b89f8d842 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -3501,6 +3501,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -7097,6 +7104,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", From b83e8aaf3ea3ea27f2fd73667c8413ba9896176a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:54:42 +0000 Subject: [PATCH 08/13] =?UTF-8?q?docs(changeset):=20Clause-=E2=91=A1=20yes?= =?UTF-8?q?=20(narrowing)=20=E2=80=94=20the=20fix=20also=20publishes=20che?= =?UTF-8?q?ckListViewChartBinding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22491-chart-list-view-binds-a-dataset.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22491-chart-list-view-binds-a-dataset.md b/.changeset/22491-chart-list-view-binds-a-dataset.md index 4f3b4c81868..e0ca0c962d5 100644 --- a/.changeset/22491-chart-list-view-binds-a-dataset.md +++ b/.changeset/22491-chart-list-view-binds-a-dataset.md @@ -4,7 +4,7 @@ A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. -Clause-②: no (narrowing) +Clause-②: yes (narrowing) @@ -17,7 +17,7 @@ Clause-②: no (narrowing) - declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; - declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. -It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. The check is exported as `checkListViewChartBinding` from `@objectstack/spec/ui`, for a mirror built from `ListViewSchema.shape` to re-attach. +It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. **New export:** `checkListViewChartBinding`, published from `@objectstack/spec/ui`, is this refinement check itself, a `(view, ctx) => void` function; objectui's `ListViewSchema` mirror, which is built from `ListViewSchema.shape` and so drops the schema's object-level checks, attaches it with `.superRefine(checkListViewChartBinding)`. **What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. From e295f361635be1016f17173a249eabc1cf132680 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:24:38 +0000 Subject: [PATCH 09/13] fix(spec): the view-chart-binding-dataset-required guidance names no tracker id; regenerate its projections Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 2 +- packages/spec/spec-changes.json | 4 ++-- .../semantic/18.view-chart-binding-dataset-required.ts | 2 +- packages/spec/src/migrations/registry.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 9e2bf7430d5..43de08c1dc4 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1537,7 +1537,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. - **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. - - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. + - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 53b89f8d842..43ddb429c66 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -3506,7 +3506,7 @@ "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", "migrationId": "view-chart-binding-dataset-required", "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", @@ -7109,7 +7109,7 @@ "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", "migrationId": "view-chart-binding-dataset-required", "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui#6152 round 15 retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", diff --git a/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts index a9c5b2bbb24..f304406fb7f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-chart-binding-dataset-required.ts @@ -35,7 +35,7 @@ export const entry: SemanticMigration = { + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' - + 'named name and a measure named value), and objectui#6152 round 15 retired that floor, after which ' + + 'named name and a measure named value), and objectui has since retired that floor, after which ' + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5fa49a71c10..69c8ffc8b33 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -22977,7 +22977,7 @@ const step18: MigrationStep = { + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' - + 'named name and a measure named value), and objectui#6152 round 15 retired that floor, after which ' + + 'named name and a measure named value), and objectui has since retired that floor, after which ' + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' From 1727d1ef1702dd324af37356acbdf4d07b547476 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 23:40:21 +0000 Subject: [PATCH 10/13] =?UTF-8?q?chore(spec):=20merge=20hand-off=20?= =?UTF-8?q?=E2=80=94=20regenerate=20the=20reference=20docs=20the=20merge?= =?UTF-8?q?=20could=20not=20text-merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++- docs/protocol-upgrade-guide.md | 8 +++--- packages/spec/spec-changes.json | 32 ++++++++++++------------ 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4e6470a40f5..e60ee7b49fa 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1624,7 +1624,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 404eb28dd74..3c6f1ce61a1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -152,7 +152,7 @@ const result = ApiMethod.parse(data); | **datasource** | `string` | optional (default: `"default"`) | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | -| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it): `record.` resolves, and `record..` resolves only to a leaf the block declares. | +| **attachedOnRead** | `Record>>` | optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → `{ leaf key → value type (number \| text \| boolean \| date) }`. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (`@objectstack/lint` over `@objectstack/formula`, as `os build` / `os validate` run it), and only where `record` is a row the service served: in an action's `visible` and `disabled` predicates `record.` resolves, and `record..` resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option `visibleWhen`, a sharing rule, a hook — and refuses `record.` as an unknown field. | | **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | @@ -274,6 +274,7 @@ const result = ApiMethod.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -611,6 +612,7 @@ const result = ApiMethod.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 43de08c1dc4..645bd0a066d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -976,7 +976,7 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). -- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal +- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. - **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. @@ -1272,6 +1272,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`screen-field-lookup-reference-required`** — `The `reference` key of a `type: 'lookup'` field on a `screen` node — `flows[].nodes[].config.fields[]` where the node `type` is `screen` and the field `type` is `lookup` (`ScreenFieldConfigSchema`). Nothing is renamed, retired or re-typed and the key set does not move: `reference` was already declared and already optional in the shape. What narrows is the ACCEPT SET for one value of the sibling `type` — a `lookup` field with no `reference`, or with a blank one, parsed before this major and is refused now. Every other widget hint is untouched, and a `lookup` field that already names its target parses byte-identically.` → Name the object whose records the picker offers, beside the type: `{ name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }`. The value is an object NAME (the canonical id — same string `FieldSchema.reference` carries), not a label and not a record id. ⚠️ There is deliberately no default and no inference: a picker pointed at the wrong object is worse than one that refuses to load, because it offers a human a plausible list of the wrong records and the flow stores the id it is given. Where the field genuinely has no target object — the author was using `lookup` to mean "type an id here" — the fix is the other direction: change `type` to `'text'`, which is what that field actually was, and keep the prose that asked for an id in `inlineHelpText`. - Why not automatic: Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for. - Done when: Every `type: 'lookup'` field on every `screen` node in the stack declares a non-empty `reference`, and the stack parses: `ScreenFieldConfigSchema` refuses the bare form with a message addressed to `reference` (`SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED`), so a full metadata parse — `os lint`, or any publish — reports one issue per unfixed site and names the FLOW and the FIELD in its path. Work the list to empty rather than sampling it: a flow whose screen never reaches that node in testing is refused at publish just the same. For each site, answer which object the picker was meant to offer — the declaration is the answer, and where there is no such object the field was never a lookup (retype it `'text'`). ⚠️ Runs SUSPENDED at a screen before the upgrade rehydrate their `ScreenSpec` from stored context, so an in-flight run parked on an unfixed screen carries the old shape: drain or re-drive those rather than assuming the fix reaches them retroactively. +- **`security-catalog-environment-overlay-refused`** — `the cold boot of a deployment whose sys_metadata holds an active, environment-wide row of type permission or position (or the legacy plural permissions or positions) under the name of a permission set or position a configured package declares, the platform security plugin's shipped sets included` → before the first boot on this major, run `os migrate security-catalog-overlays` with the flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean to `os serve`): it lists exactly those rows, each with the package that holds its name. Then run `os migrate security-catalog-overlays --apply` to delete them through the metadata write path (a history tombstone per row). Or rename the item in the package. Nothing is adopted: an item the environment needs under its own name is re-created under a name no package holds + - Why not automatic: Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot now refuses it too, right after the stored rows load: the stored row used to be served in place of the package's definition, with only a collision warning. Rows like this exist on deployments that saved over a package-held name before the packaged locks refused such saves, and over the security plugin's own sets, which it declares only where the boot composes it behind the auth gate (an auth secret set, or a development boot). The refused deployment cannot start, so no in-server action can clear the rows, and the metadata API reaches no legacy-plural row at all; the offline step can. No conversion applies: the rows are the environment's own work, and whether to drop or rename one is the operator's call, which the step's preview puts in front of them. ADR-0048, ADR-0087. + - Done when: On the deployment's database and configuration, with its boot flags and environment, `os migrate security-catalog-overlays` lists no row (exit 0). The listing covers permission sets and positions and the legacy plural spellings, and never an organization-scoped or a draft row. After `--apply`, the next boot of the same database and configuration comes up, where before it was refused with NAMESPACE_CONFLICT (422) naming the environment catalog as the holder. - **`send-template-input-org-retired`** — `contracts.emailService.sendTemplate input.org` → (removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution) - Why not automatic: ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as "Tenant id for org-overlay resolution (when supported)" and no implementation ever read it: `@objectstack/plugin-email` — the only IEmailService implementation — resolves templates on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no error; the "(when supported)" hedge was the declaration admitting the gap. After the delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped keys of which one did nothing — exactly the shape that invites an AI author to pick the wrong one. There is no behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a call-time input bag (the `data.engine.update options.upsert` precedent), which is why this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. Org-overlay template resolution, if it ever earns a measured business pull, is a new capability with its own ruling — not this key revived. - Done when: No caller passes `org` to `IEmailService.sendTemplate()`. The enforcement channel is the compiler: `SendTemplateInput` is a programmatic contracts interface with no Zod surface, so authoring `org` is an excess-property `tsc` error (pinned in `packages/spec/src/contracts/email-service.test.ts`). Runtime behaviour is deliberately UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a JavaScript caller still passing `org` keeps its exact pre-removal outcome (the key is carried inert and ignored). Template resolution still keys on `(name, locale)`, and `organizationId` still stamps `sys_email.organization_id` without acquiring any overlay semantics. @@ -1536,9 +1539,6 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. -- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. - - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. - - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 43ddb429c66..1803b2c3756 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2215,7 +2215,7 @@ }, { "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." @@ -2885,6 +2885,13 @@ "toMajor": 18, "rationale": "Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for." }, + { + "surface": "the cold boot of a deployment whose sys_metadata holds an active, environment-wide row of type permission or position (or the legacy plural permissions or positions) under the name of a permission set or position a configured package declares, the platform security plugin's shipped sets included", + "replacement": "before the first boot on this major, run `os migrate security-catalog-overlays` with the flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean to `os serve`): it lists exactly those rows, each with the package that holds its name. Then run `os migrate security-catalog-overlays --apply` to delete them through the metadata write path (a history tombstone per row). Or rename the item in the package. Nothing is adopted: an item the environment needs under its own name is re-created under a name no package holds", + "migrationId": "security-catalog-environment-overlay-refused", + "toMajor": 18, + "rationale": "Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot now refuses it too, right after the stored rows load: the stored row used to be served in place of the package's definition, with only a collision warning. Rows like this exist on deployments that saved over a package-held name before the packaged locks refused such saves, and over the security plugin's own sets, which it declares only where the boot composes it behind the auth gate (an auth secret set, or a development boot). The refused deployment cannot start, so no in-server action can clear the rows, and the metadata API reaches no legacy-plural row at all; the offline step can. No conversion applies: the rows are the environment's own work, and whether to drop or rename one is the operator's call, which the step's preview puts in front of them. ADR-0048, ADR-0087." + }, { "surface": "contracts.emailService.sendTemplate input.org", "replacement": "(removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution)", @@ -3501,13 +3508,6 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, - { - "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", - "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", - "migrationId": "view-chart-binding-dataset-required", - "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." - }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -5818,7 +5818,7 @@ }, { "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." @@ -6488,6 +6488,13 @@ "toMajor": 18, "rationale": "Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a `lookup` with no `reference`: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare `lookup` already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one `schedule-flow-acting-organization-required` gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so `objectstack migrate meta` can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for." }, + { + "surface": "the cold boot of a deployment whose sys_metadata holds an active, environment-wide row of type permission or position (or the legacy plural permissions or positions) under the name of a permission set or position a configured package declares, the platform security plugin's shipped sets included", + "replacement": "before the first boot on this major, run `os migrate security-catalog-overlays` with the flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean to `os serve`): it lists exactly those rows, each with the package that holds its name. Then run `os migrate security-catalog-overlays --apply` to delete them through the metadata write path (a history tombstone per row). Or rename the item in the package. Nothing is adopted: an item the environment needs under its own name is re-created under a name no package holds", + "migrationId": "security-catalog-environment-overlay-refused", + "toMajor": 18, + "rationale": "Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot now refuses it too, right after the stored rows load: the stored row used to be served in place of the package's definition, with only a collision warning. Rows like this exist on deployments that saved over a package-held name before the packaged locks refused such saves, and over the security plugin's own sets, which it declares only where the boot composes it behind the auth gate (an auth secret set, or a development boot). The refused deployment cannot start, so no in-server action can clear the rows, and the metadata API reaches no legacy-plural row at all; the offline step can. No conversion applies: the rows are the environment's own work, and whether to drop or rename one is the operator's call, which the step's preview puts in front of them. ADR-0048, ADR-0087." + }, { "surface": "contracts.emailService.sendTemplate input.org", "replacement": "(removed — never implemented; delete the key from the call. It is NOT replaced by `organizationId`: that member is the delivery row's tenant stamp (`sys_email.organization_id` pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution)", @@ -7104,13 +7111,6 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, - { - "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", - "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", - "migrationId": "view-chart-binding-dataset-required", - "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." - }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", From 1b5e73392e2ab092b9d24478d279f22c85826ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 23:42:30 +0000 Subject: [PATCH 11/13] =?UTF-8?q?chore(spec):=20regenerate=20the=20step-18?= =?UTF-8?q?=20chain=20on=20the=20merged=20tree=20=E2=80=94=20spec-changes.?= =?UTF-8?q?json=20and=20the=20upgrade=20guide=20carry=20view-chart-binding?= =?UTF-8?q?-dataset-required=20beside=20main's=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 645bd0a066d..92b5fb4528d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1539,6 +1539,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. +- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. + - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. + - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 1803b2c3756..708de826037 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -3508,6 +3508,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -7111,6 +7118,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", From e64b46d01c87c7c2c3fece3d156ae686f982309e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:21:03 +0000 Subject: [PATCH 12/13] merge origin/main (os-regen artifacts taken from main; regeneration follows) --- docs/protocol-upgrade-guide.md | 8 ++++---- packages/spec/spec-changes.json | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 92b5fb4528d..7bef8ddfa44 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -473,7 +473,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte ## Protocol 17 → 18 -Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. +Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. ### Mechanical (applied for you) @@ -973,6 +973,9 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }} - Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object. +- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} + - Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it. + - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and confirm the text carries the value, with no stray brace and no missing fragment. - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). @@ -1539,9 +1542,6 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. -- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. - - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. - - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 708de826037..3ac3991abc7 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2206,6 +2206,13 @@ "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, + { + "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", + "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "migrationId": "flow-text-slot-unbound-dollar-root-refused", + "toMajor": 18, + "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." + }, { "surface": "the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object", "replacement": "read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent", @@ -3508,13 +3515,6 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, - { - "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", - "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", - "migrationId": "view-chart-binding-dataset-required", - "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." - }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -5816,6 +5816,13 @@ "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, + { + "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", + "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "migrationId": "flow-text-slot-unbound-dollar-root-refused", + "toMajor": 18, + "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." + }, { "surface": "the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object", "replacement": "read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent", @@ -7118,13 +7125,6 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, - { - "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", - "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", - "migrationId": "view-chart-binding-dataset-required", - "toMajor": 18, - "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." - }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", From 2c0f7aa07f660e869af379fc18b76b9bb15782b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:23:48 +0000 Subject: [PATCH 13/13] =?UTF-8?q?chore(spec):=20regenerate=20the=20step-18?= =?UTF-8?q?=20chain=20on=20the=20merged=20tree=20=E2=80=94=20view-chart-bi?= =?UTF-8?q?nding-dataset-required=20beside=20main's=20flow-text-slot-unbou?= =?UTF-8?q?nd-dollar-root-refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 7bef8ddfa44..0d151cabc0d 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1542,6 +1542,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`ui-report-joined-container-selection-refused`** — `report selection keys on a `joined` container — a top-level `dataset`, or a NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is `joined` (`ReportSchema`'s refinement)` → the same key on the `blocks[]` entries that need it — each block binds its own `dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container `order` refusal beside it always has, and that `order` refusal is unchanged. - Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. - Done when: WHICH DOOR: this is the spec schema's refusal, 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) — as one `custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored `sys_metadata` report row is not rewritten: it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across `packages/lint` and `packages/platform-objects`; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist. +- **`view-chart-binding-dataset-required`** — `A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.` → Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`. + - Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show. + - Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through `@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at `chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / `options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete `chart` block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change. - **`view-filter-rule-absent-value-refused`** — `ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema` → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words - Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112. - Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 3ac3991abc7..710b2aeb7f1 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -3515,6 +3515,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words", @@ -7125,6 +7132,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A `joined` report selects nothing itself, and the refinement already said so for `order` alone — it refused a container `order` with a pointer onto `blocks[]` while the four selection keys beside it parsed green. Measured at this repo's `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: `DatasetReportRenderer`'s joined branch (`DatasetReportRenderer.tsx:1462`) reads `blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: `fields` / `measures` / `metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and `objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a report bound first and switched to `joined` second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container `order` refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything." }, + { + "surface": "A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay.", + "replacement": "Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag whole. A view that is not meant to be a chart takes another `type`.", + "migrationId": "view-chart-binding-dataset-required", + "toMajor": 18, + "rationale": "ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the `chart` block, else the `options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, `resolveListChartBinding`, at this repo's `.objectui-sha` pin and at objectui main alike). The authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened overlay member accepted `type: 'chart'` with no block and an `options.chart` bag holding only `chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, `chart`, or at `options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show." + }, { "surface": "ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema", "replacement": "the value the rule compares against — value: \"open\" on equals, value: \"2026-01-01\" on after. A rule that meant \"the field has no value\" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words",