From 82b50682ad7cec37f75e7fb78417d2ed664a41bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:32:31 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(spec)!:=20retire=20record:json=20?= =?UTF-8?q?=E2=80=94=20refused=20by=20name,=20RecordJsonProps=20removed=20?= =?UTF-8?q?(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- .../src/validate-record-json-23745.test.ts | 110 +++---- .../retired-defs/18.ui__RecordJsonProps.ts | 14 + .../semantic/18.ui-record-json-retired.ts | 47 +++ .../spec/src/migrations/registry.ts.template | 14 + .../src/type-alias-convention.pin.test.ts | 11 +- ...ponent-record-block-field-security.test.ts | 11 +- .../component-record-json-retirement.test.ts | 276 ++++++++++++++++++ .../spec/src/ui/component-record-json.test.ts | 143 --------- .../src/ui/component-type-vocabulary.test.ts | 15 + packages/spec/src/ui/component.zod.ts | 99 ++----- packages/spec/src/ui/page.zod.ts | 36 ++- 11 files changed, 485 insertions(+), 291 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.ui__RecordJsonProps.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-record-json-retired.ts create mode 100644 packages/spec/src/ui/component-record-json-retirement.test.ts delete mode 100644 packages/spec/src/ui/component-record-json.test.ts diff --git a/packages/lint/src/validate-record-json-23745.test.ts b/packages/lint/src/validate-record-json-23745.test.ts index f792ee6f447..f8d551b64fd 100644 --- a/packages/lint/src/validate-record-json-23745.test.ts +++ b/packages/lint/src/validate-record-json-23745.test.ts @@ -1,33 +1,32 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #23745 — `record:json`, one key of the bound record drawn as indented JSON - * behind a REQUIRED, fail-closed `requiredPermissions` gate, through the - * authoring rules `os validate` / `os build` / `os lint` run. The spec-side - * pins (the row, the enum member, the print classification) live beside the - * row in `@objectstack/spec` (`component-record-json.test.ts`). + * #24162 — `record:json` (#23745) is RETIRED by name, so the authoring rules + * `os validate` / `os build` / `os lint` run now REFUSE it. This file was + * #23745's accept pin at this door; it is flipped, not deleted, because the + * lint door is its own layer: the spec-side pins + * (`component-record-json-retirement.test.ts`) judge the parse, and the + * map-driven `flags %s` case in `validate-component-types.test.ts` judges every + * retired name generically. This file holds the one this card retired, at + * both rules that dispatch on it, beside a live control on the same page. * - * Also pinned here: `field` is NOT judged against the object's fields — the - * key the block exists for (`payload` on a `sys_approval_request` row) is one - * the read route serves beside them, so `page-field-unknown` stays silent. + * The live control is `record:approvals`, the declared record-page member that + * sits beside it on the approval request page. */ +import { RETIRED_PAGE_COMPONENT_TYPES } from '@objectstack/spec/ui'; import { describe, expect, it } from 'vitest'; -import { COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; -import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js'; -import { PAGE_FIELD_UNKNOWN, validatePageFieldBindings } from './validate-page-field-bindings.js'; +import { runAuthoringRules } from './authoring-rules.js'; +import { COMPONENT_PROPS_INVALID, validateComponentProps } from './validate-component-props.js'; +import { + COMPONENT_TYPE_UNKNOWN, + retiredTypeHead, + retiredTypeRemedy, + validateComponentTypes, +} from './validate-component-types.js'; type AnyRec = Record; -const OBJECT = { - name: 'request', - label: 'Request', - fields: { - name: { type: 'text', label: 'Name' }, - payload_json: { type: 'textarea', label: 'Payload' }, - }, -}; - const pageWith = (node: AnyRec): AnyRec => ({ name: 'request_record', label: 'Request', @@ -37,46 +36,53 @@ const pageWith = (node: AnyRec): AnyRec => ({ }); const AT = 'pages[0].regions[0].components[0]'; -const VALID = { field: 'payload', requiredPermissions: ['studio.access'], label: 'Raw data (JSON)' }; +/** The bag #23745's accept pin authored — now refused like any other. */ +const FORMER_BAG = { field: 'payload', requiredPermissions: ['studio.access'], label: 'Raw data (JSON)' }; +const HEAD = '`record:json` was removed in @objectstack/spec 18 (ADR-0049)'; -describe('a `record:json` node passes the authoring doors', () => { - it('is a known type with an accepted bag', () => { - const page = pageWith({ type: 'record:json', properties: VALID }); - expect(validateComponentTypes({ pages: [page] })).toEqual([]); - expect(validateComponentProps({ pages: [page] })).toEqual([]); +describe('a `record:json` node is refused at the authoring doors', () => { + it('the spec map names it, and its head is the one this door quotes', () => { + const prescription = RETIRED_PAGE_COMPONENT_TYPES.get('record:json'); + expect(prescription).toBeTypeOf('string'); + expect(retiredTypeHead(prescription!)).toBe(HEAD); + expect(retiredTypeRemedy(prescription!)).toBe('Delete the `record:json` component node and put nothing in its place'); }); - it('`field` naming a route-served key is not a `page-field-unknown` finding', () => { - const page = pageWith({ type: 'record:json', properties: VALID }); - const findings = validatePageFieldBindings({ objects: [OBJECT], pages: [page] } as never); - expect(findings.filter((f) => f.rule === PAGE_FIELD_UNKNOWN)).toEqual([]); - // Control: the same rule fires on a sibling block naming the same key. - const control = pageWith({ type: 'record:highlights', properties: { fields: ['payload'] } }); - const fired = validatePageFieldBindings({ objects: [OBJECT], pages: [control] } as never); - expect(fired.map((f) => f.rule)).toContain(PAGE_FIELD_UNKNOWN); + it('`component-type-unknown` reports it by exact name at its `type` path, bare or populated', () => { + for (const node of [{ type: 'record:json' }, { type: 'record:json', properties: FORMER_BAG }]) { + const findings = validateComponentTypes({ pages: [pageWith(node)] }); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([[COMPONENT_TYPE_UNKNOWN, 'error', `${AT}.type`]]); + expect(findings[0].message.startsWith(HEAD)).toBe(true); + expect(findings[0].hint).toMatch(/^Delete the `record:json` component node/); + } + // Control: the live sibling on the same page draws no finding. + expect(validateComponentTypes({ pages: [pageWith({ type: 'record:approvals' })] })).toEqual([]); }); - it('control: a misspelled type in the reserved namespace offers the declared spelling', () => { - const findings = validateComponentTypes({ pages: [pageWith({ type: 'record:jsn', properties: VALID })] }); - expect(findings.map((f) => f.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]); - expect(findings[0].message).toContain("'record:json'"); + it('the props gate refuses its bag WHOLE, `{}` included, as an error at `properties`', () => { + for (const properties of [{}, FORMER_BAG]) { + const findings = validateComponentProps({ pages: [pageWith({ type: 'record:json', properties })] }); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [COMPONENT_PROPS_INVALID, 'error', `${AT}.properties`], + ]); + } + // Control: the live sibling's empty bag passes the same gate. + expect(validateComponentProps({ pages: [pageWith({ type: 'record:approvals', properties: {} })] })).toEqual([]); }); -}); -describe('the props gate refuses what the row does not declare', () => { - it('an undeclared key is `component-props-unknown-key`, naming it', () => { - const findings = validateComponentProps({ pages: [pageWith({ type: 'record:json', properties: { ...VALID, collapsed: false } })] }); - expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.collapsed`]]); - expect(findings[0].message).toContain('`collapsed`'); + it('a misspelling in the reserved namespace is no longer steered to the retired name', () => { + const findings = validateComponentTypes({ pages: [pageWith({ type: 'record:jsn' })] }); + expect(findings.map((f) => f.rule)).toEqual([COMPONENT_TYPE_UNKNOWN]); + expect(findings[0].message).not.toContain("'record:json'"); }); - it.each([ - ['no `requiredPermissions`', { field: 'payload' }], - ['an empty `requiredPermissions`', { field: 'payload', requiredPermissions: [] }], - ['no `field`', { requiredPermissions: ['studio.access'] }], - ])('%s is a `component-props-invalid` error', (_label, properties) => { - const findings = validateComponentProps({ pages: [pageWith({ type: 'record:json', properties })] }); - expect(findings.length).toBeGreaterThan(0); - expect(findings.every((f) => f.rule === COMPONENT_PROPS_INVALID && f.severity === 'error')).toBe(true); + it('reaches the shared pipeline as errors — the run `os validate` / `os build` / `os lint` refuse on', () => { + const findings = runAuthoringRules('validate', { + normalized: { pages: [pageWith({ type: 'record:json', properties: FORMER_BAG })] } as never, + }); + const mine = findings.filter((f) => f.path.startsWith(AT)); + expect(mine.map((f) => [f.rule, f.severity]).sort()).toEqual( + [[COMPONENT_PROPS_INVALID, 'error'], [COMPONENT_TYPE_UNKNOWN, 'error']].sort(), + ); }); }); diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__RecordJsonProps.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__RecordJsonProps.ts new file mode 100644 index 00000000000..6805cbc8380 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__RecordJsonProps.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24162 — `ui/RecordJsonProps` (`field`, `label`, `requiredPermissions`) leaves +// with the `record:json` page element it described (ADR-0049 +// enforce-or-remove), one prerelease after #23745 declared it: it was published +// in 18.0.0-next.3 only. The element was declared spec first for the approval +// request page's raw JSON panel, which the maintainer dropped, so no renderer +// was built and no page placed it, and none of the three keys was ever read. +// Its only carrier, the `ComponentPropsMap['record:json']` row, is kept as a +// whole-bag refusal (`retiredComponentProps`) that answers with the element's +// retirement prescription, and the node itself is refused by name at +// `PageComponentSchema.type`. Upgraders get the D3 semantic entry +// `ui-record-json-retired`. +export const entry = 'ui/RecordJsonProps'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-json-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-json-retired.ts new file mode 100644 index 00000000000..466116b3ae3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-json-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24162 — the D3 record of the `record:json` retirement (ADR-0049 +// enforce-or-remove; the maintainer dropped face 4 of the objectui#12438 +// ruling C, the approval request page's raw JSON panel `record:json` was +// declared for in #23745, and the element shipped in the 18.0.0-next.3 +// prerelease only). Refused BY NAME through `RETIRED_PAGE_COMPONENT_TYPES`, +// the `ai:chat_window` shape. There is no D2 half: the element's three keys +// left with its props def, so there is no key a walker could strip and leave a +// valid node behind, and deleting an authored page node is a layout decision a +// mechanical conversion must not make (the `element-filter-removed` docblock's +// rule). What the chain owes the upgrader is the delegation itself, in writing. +// +// The replacement is not new prose: it is the node-level refusal message in +// `RETIRED_PAGE_COMPONENT_TYPES` (ui/page.zod.ts), so the door that refuses and +// the chain that prescribes carry one instruction. No replacement panel is +// named, by ruling. +export const entry: SemanticMigration = { + id: 'ui-record-json-retired', + surface: + 'page.component.record:json — the component node, with every key its props bag ' + + 'declared (`field`, `label`, `requiredPermissions`), in regions, named slots and nested ' + + 'containers alike', + replacement: + 'Delete the component node and put nothing in its place: a record drawn as raw JSON is not ' + + 'a page element, and no replacement panel exists by ruling. `field`, `label` and ' + + '`requiredPermissions` have no counterpart: none of them was ever read', + reason: + '`record:json` was declared in the 18.0.0-next.3 prerelease for one face only, the approval ' + + 'request page\'s raw JSON panel, and the maintainer dropped that face: the person deciding ' + + 'an approval has no business need to read the request as JSON. So no renderer was built, ' + + 'no page placed it, and its one planned producer was closed unmerged — zero consumers were ' + + 'measured in objectstack and objectui. A declared element nothing draws is a capability ' + + 'claim nothing keeps, so the name is now refused at `PageComponentSchema.type`, its ' + + '`ComponentPropsMap` row refuses every props bag with the same prescription, and the enum ' + + 'no longer lists it. No conversion is registered, because the only edit is deleting the ' + + 'node, and which region closes up or holds something else is the author\'s judgment about ' + + 'a page they composed — this entry is that delegation', + acceptanceCriteria: + 'No `record:json` component remains in any page — regions, named slots and nested containers ' + + 'alike. `os validate` is clean: a remaining node is reported at its own `type` path with ' + + '`params.retiredComponentType` naming `record:json`, so each one is named individually ' + + 'rather than as one page-level failure. Replaying the 17 → 18 chain over the edited source ' + + 'then reports the migrated stack schema-valid — `schemaValid: true` in `--json`', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index a3e93b7a2ee..e56a6649c89 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3161,6 +3161,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`os migrate meta` replay it. Its D3 record is the semantic entry ' + '`ui-page-action-row-navigation-executor-refused`.', }, + { + id: 'ui-record-json-retired', + order: 100, + text: + 'It also retires the `record:json` page element (#24162, ADR-0049 enforce-or-remove), one ' + + 'prerelease after it was declared: it shipped in 18.0.0-next.3 only, for the approval request ' + + 'page\'s raw JSON panel, and the maintainer dropped that panel — the person deciding an ' + + 'approval has no business need to read the request as JSON — so no renderer was built and no ' + + 'page placed it. 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 `RecordJsonProps` 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-record-json-retired`.', + }, { id: 'ui-record-line-items-props-closed', order: 57, diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index fd7f3f4b42e..f8cbdb749b5 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1521,14 +1521,13 @@ export type Iso_ui_chart__ChartTypeSchema = Assert, z.infer< typeof M170.ElementDefinitionListPropsSchema > >>; export type Iso_ui_component__PageContainerProps = Assert, z.infer< typeof M170.PageContainerProps > >>; export type Iso_ui_component__RecordAlertActionSchema = Assert, z.infer< typeof M170.RecordAlertActionSchema > >>; export type Iso_ui_component__RecordHighlightsField = Assert, z.infer< typeof M170.RecordHighlightsField > >>; export type Iso_ui_component__RecordHistoryProps = Assert, z.infer< typeof M170.RecordHistoryProps > >>; -export type Iso_ui_component__RecordJsonProps = Assert, z.infer< typeof M170.RecordJsonProps > >>; export type Iso_ui_component__RecordPathProps = Assert, z.infer< typeof M170.RecordPathProps > >>; export type Iso_ui_component__RecordQuickActionsProps = Assert, z.infer< typeof M170.RecordQuickActionsProps > >>; export type Iso_ui_component__RecordReferenceRailProps = Assert, z.infer< typeof M170.RecordReferenceRailProps > >>; @@ -2542,7 +2541,11 @@ describe('ADR-0122 type-alias convention', () => { // origin — a plain string enum, so it is pinned here // (Iso_kernel_packageRegistry__InstalledPackageSourceSchema) rather than // given a `…Parsed` synonym. +1 added. - expect(pins).toHaveLength(789); + // + // 789 -> 788 is #24162: `RecordJsonProps` left with the retired + // `record:json` element, so its pin (Iso_ui_component__RecordJsonProps) + // left with it. -1. + expect(pins).toHaveLength(788); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/src/ui/component-record-block-field-security.test.ts b/packages/spec/src/ui/component-record-block-field-security.test.ts index cf36fcfb4be..f7e9a8d0b19 100644 --- a/packages/spec/src/ui/component-record-block-field-security.test.ts +++ b/packages/spec/src/ui/component-record-block-field-security.test.ts @@ -192,11 +192,12 @@ describe('`requiredPermissions` is declared on the three blocks as a capability expect(keys).toContain('requiredPermissions'); }); - it('B · the census: exactly the three blocks, `record:quick_actions` and `record:json` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { - // `record:json` (#23745) declares the same NAME with a different meaning on - // purpose — required and fail-closed — so it is outside the "one key, one - // text" pin below; its own pins live in `component-record-json.test.ts`. - expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:json', 'record:quick_actions'].sort()); + it('B · the census: exactly the three blocks and `record:quick_actions` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { + // `record:json` (#23745) declared the same NAME with a different meaning — + // required and fail-closed — until it was retired (#24162); its row now + // refuses every bag, so it declares no key at all + // (`component-record-json-retirement.test.ts`). + expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions'].sort()); // Controls: the census can find a key at all, and finds the blocks under // test when it should. `fields` is declared on two of the three (the related // list's column key is `columns`), which is itself a discriminating reading. diff --git a/packages/spec/src/ui/component-record-json-retirement.test.ts b/packages/spec/src/ui/component-record-json-retirement.test.ts new file mode 100644 index 00000000000..ee6aec5ba13 --- /dev/null +++ b/packages/spec/src/ui/component-record-json-retirement.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #24162 — `record:json` RETIRED by name (ADR-0049 enforce-or-remove), one + * prerelease after #23745 declared it. + * + * It was declared spec first for one face: the approval request page's raw + * JSON panel (face 4 of objectui#12438's ruling C). The maintainer dropped that + * face — the person deciding an approval has no business need to read the + * request as JSON — so the type has no renderer, no placement and no producer + * (objectui and objectstack measured zero; the one planned producer was closed + * unmerged). It shipped in the 18.0.0-next.3 prerelease only. + * + * The `ai:chat_window` shape, pinned below: + * 1. the name leaves `PageComponentType` and joins + * `RETIRED_PAGE_COMPONENT_TYPES`, whose text answers at every door; + * 2. `RecordJsonProps` is deleted, and the `ComponentPropsMap` row stays as + * `retiredComponentProps('record:json')`, refusing every bag; + * 3. the print classification row leaves with it (a retired type is refused + * at the parse, before any print rule reads it); + * 4. the D3 entry `ui-record-json-retired` and the `RETIRED_DEFS_BY_MAJOR[18]` + * entry `ui/RecordJsonProps`. No D2 conversion: deleting an authored page + * node is a layout decision a conversion must not make. + * + * Each refusal is pinned by `code`, `path` and the prescription's first + * sentence, never a bare `toThrow()`, which greens on any error. The live + * control at every door is `record:approvals`, the declared record-page member + * with a renderer that sits beside it on the same request page. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR } from '../migrations/registry'; +import * as UI from './component.zod'; +import { ComponentPropsMap } from './component.zod'; +import { + PageComponentSchema, + PageComponentType, + PageSchema, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + PRINTABLE_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from './page.zod'; + +const TYPE = 'record:json'; +const CONTROL = 'record:approvals'; +const D3_ID = 'ui-record-json-retired'; + +const FIRST_SENTENCE = + /^`record:json` was removed in @objectstack\/spec 18 \(ADR-0049\) — it shipped only in the 18\.0\.0-next\.3 prerelease, declared for one raw-JSON panel on the approval request page, and that panel was dropped: the person deciding an approval has no business need to read a record as raw JSON, so no renderer draws the type and no page places it, and its `field`, `label` and `requiredPermissions` props configure nothing\./; +const FIX = 'Delete the `record:json` component node and put nothing in its place.'; +// `check:doc-authoring`: a prescription printed at the customer carries no +// citation-shaped issue id. +const ISSUE_ID = /#\d{3,}/; + +/** The bag #23745's accept pin authored — now refused like any other. */ +const FORMER_BAG = { field: 'payload', requiredPermissions: ['studio.access'], label: 'Raw data (JSON)' }; + +const requestPage = (node: Record) => ({ + name: 'approval_request_detail', + label: 'Approval request', + regions: [{ name: 'main', components: [{ type: 'record:details' }, node] }], +}); + +describe('record:json is retired, refused by name', () => { + it('the map carries the prescription: the fix imperative, no replacement named, no tracker number', () => { + const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(TYPE); + expect(guidance).toBeTypeOf('string'); + expect(guidance!).toMatch(FIRST_SENTENCE); + expect(guidance!.endsWith(FIX)).toBe(true); + expect(guidance!).not.toMatch(ISSUE_ID); + // No `os migrate meta` sentence: there is no mechanical edit to list. + expect(guidance!).not.toContain('os migrate meta'); + }); + + it('the def is gone from the exports; the ComponentPropsMap row refuses even the empty bag', () => { + expect('RecordJsonProps' in UI).toBe(false); + for (const bag of [{}, FORMER_BAG, { field: 'payload' }]) { + const r = ComponentPropsMap[TYPE].safeParse(bag); + expect(r.success, JSON.stringify(bag)).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + // The `retiredKey` channel at element grain: `expected: 'never'`. + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([]); + expect(issue.message).toBe(RETIRED_PAGE_COMPONENT_TYPES.get(TYPE)); + } + // Control: the sibling row on the same page still accepts its empty bag. + expect(ComponentPropsMap[CONTROL].safeParse({}).success).toBe(true); + }); + + it('PageComponentSchema refuses the node by name at `type`, bare or populated', () => { + for (const properties of [undefined, {}, FORMER_BAG]) { + const r = PageComponentSchema.safeParse(properties === undefined ? { type: TYPE } : { type: TYPE, properties }); + expect(r.success, `properties=${JSON.stringify(properties)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['type']); + expect(issue.message).toMatch(FIRST_SENTENCE); + expect((issue as { params?: Record }).params).toEqual({ retiredComponentType: TYPE }); + } + expect(PageComponentSchema.safeParse({ type: CONTROL }).success).toBe(true); + }); + + it('PageSchema refuses an authored page at the element path — the door `os validate` parses', () => { + const r = PageSchema.safeParse(requestPage({ type: TYPE, properties: FORMER_BAG })); + expect(r.success).toBe(false); + if (r.success) return; + const located = r.error.issues.filter((i) => i.code === 'custom'); + expect(located).toHaveLength(1); + expect(located[0]!.path).toEqual(['regions', 0, 'components', 1, 'type']); + expect(located[0]!.message).toMatch(FIRST_SENTENCE); + // Control: the same page with the live sibling in that slot parses. + expect(PageSchema.safeParse(requestPage({ type: CONTROL })).success).toBe(true); + }); + + it('PageComponentType itself refuses the member with the prescription — the enum error map', () => { + expect(PageComponentType.options).not.toContain(TYPE); + const r = PageComponentType.safeParse(TYPE); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.code).toBe('invalid_value'); + expect(r.error.issues[0]!.message).toMatch(FIRST_SENTENCE); + // Only a value that USED to be legal gets the prescription. + const stranger = PageComponentType.safeParse('record:jsonl'); + expect(stranger.success).toBe(false); + if (!stranger.success) expect(stranger.error.issues[0]!.message).not.toContain('raw-JSON panel'); + expect(PageComponentType.safeParse(CONTROL).success).toBe(true); + }); + + it('the print classification row left with it — a retired type is refused before any print rule reads it', () => { + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(TYPE)).toBe(false); + // Control: the live sibling keeps its print refusal reason. + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(CONTROL)).toMatch(/\S/); + }); + + it('the open string arm stays open — only the retired NAME is refused', () => { + for (const type of ['custom.record_json', 'object-grid', 'record:details']) { + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + } + }); + + it('is registered with ADR-0087: the D3 entry and the retired def, at major 18', () => { + const entry = MIGRATIONS_BY_MAJOR[18]?.semantic.find((m) => m.id === D3_ID); + expect(entry).toBeDefined(); + expect(entry!.surface).toContain(TYPE); + expect(entry!.replacement.length).toBeGreaterThan(0); + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + expect(RETIRED_DEFS_BY_MAJOR[18]).toContain('ui/RecordJsonProps'); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps typed references to the deleted export, but a page component's +// `type` is a string inside an open bag, so `tsc` does not reach a node +// authored through `definePage`/`defineStack` or in JSON / YAML at all. This +// walk covers every text file under the five repo roots +// `scripts/cross-package-test-inputs.mjs` declares for `@objectstack/spec#test` +// (mirrored in `turbo.json`), plus the example apps' own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: a `type` key whose +// value is `record:json` (TS / JS / JSON, and YAML). Prose mentions are spelled +// in inline code in this repo, and inline code is stripped before judging. The +// bound, stated: a `type` computed at runtime, and `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a record:json node', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?type["']?[ \t]*:[ \t]*["']?record:json["']?(?![\w:-])/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * author the retired node. + */ + const EXCLUDED = new Set([ + // This pin authors the node to assert its refusal. + THIS_FILE, + // The lint-door pin authors the node to assert the refusal an author meets + // at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-record-json-23745.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // Release-owned prose records what shipped; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `page.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention (anti-vacuity)', () => { + // Offenders — the retired node, in each syntax the walk reads. + expect(judge("{ type: 'record:json', properties: { field: 'payload' } }")).not.toBeNull(); + expect(judge('{ "type": "record:json" }')).not.toBeNull(); + expect(judge(' - type: record:json\n properties: {}\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ type: \"record:json\" }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a node naming `type: record:json` is refused')).toBeNull(); + expect(judge("['record:json', '`record:json` was removed'],")).toBeNull(); + expect(judge("'record:json': retiredComponentProps('record:json'),")).toBeNull(); + expect(judge("{ type: 'record:jsonl' }")).toBeNull(); + expect(judge("{ type: 'record:details' }")).toBeNull(); + }); + + it('no record:json authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records what shipped + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a record:json authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component-record-json.test.ts b/packages/spec/src/ui/component-record-json.test.ts deleted file mode 100644 index 9845bf7cfbf..00000000000 --- a/packages/spec/src/ui/component-record-json.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #23745 — `record:json`, one key of the bound record drawn as indented JSON - * behind a REQUIRED, fail-closed `requiredPermissions` gate (the spec half of - * face 4 of objectui#12438's ruling, letter C). - * - * What these pins hold: - * - the type is a `PageComponentType` member with its own strict - * `ComponentPropsMap` row, so a page naming it parses and its props bag is - * judged rather than skipped; - * - `field` and `requiredPermissions` are required, and the gate refuses an - * empty list — the fail-closed meaning is declared, not left to a default; - * - the record blocks' shared fail-OPEN gate is untouched: this row's - * describe is its own text, and the four sibling rows keep theirs; - * - a print page refuses the block, with a reason. - * The authoring rule ids are pinned where they live, in `@objectstack/lint`. - */ -import { describe, expect, it } from 'vitest'; - -import { ComponentPropsMap, RecordDetailsProps, RecordJsonProps } from './component.zod'; -import { - PageComponentSchema, - PageComponentType, - PageSchema, - PRINTABLE_PAGE_COMPONENT_TYPES, - PRINT_REFUSED_PAGE_COMPONENT_TYPES, -} from './page.zod'; -import { isKnownComponentType } from './component-type-vocabulary'; - -const GATE = ['studio.access']; -const VALID = { field: 'payload', requiredPermissions: GATE }; - -const issueCodes = (r: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[] }> } }) => - (r.error?.issues ?? []).map((i) => `${i.code}@${i.path.join('.')}`); - -describe('record:json is a declared page component type with its own strict row', () => { - it('is a `PageComponentType` member and a known vocabulary type', () => { - expect(PageComponentType.options).toContain('record:json'); - expect(isKnownComponentType('record:json')).toBe(true); - }); - - it('its `ComponentPropsMap` row is `RecordJsonProps`', () => { - expect(ComponentPropsMap['record:json']).toBe(RecordJsonProps); - }); - - it('accepts the whole contract: `field`, `label` (string or locale map) and the gate', () => { - expect(RecordJsonProps.safeParse(VALID).success).toBe(true); - expect(RecordJsonProps.safeParse({ ...VALID, label: 'Raw data (JSON)' }).success).toBe(true); - const r = RecordJsonProps.safeParse({ ...VALID, label: { en: 'Raw data', 'zh-CN': '原始数据' } }); - expect(r.success).toBe(true); - expect(r.data).toEqual({ ...VALID, label: { en: 'Raw data', 'zh-CN': '原始数据' } }); - }); - - it('a page node naming it parses, `properties` included', () => { - const node = { type: 'record:json', properties: { ...VALID, label: 'Raw data' } }; - expect(PageComponentSchema.safeParse(node).success).toBe(true); - const page = { - name: 'request_record', - label: 'Request', - type: 'record', - object: 'sys_approval_request', - regions: [{ name: 'main', components: [node] }], - }; - const r = PageSchema.safeParse(page); - expect(issueCodes(r)).toEqual([]); - expect(r.success).toBe(true); - }); - - it('refuses an undeclared key as `unrecognized_keys`, beside a valid bag (lit control)', () => { - expect(RecordJsonProps.safeParse(VALID).success).toBe(true); - const r = RecordJsonProps.safeParse({ ...VALID, collapsed: false }); - expect(r.success).toBe(false); - expect(r.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); - }); - - it('names the declared key for a near spelling', () => { - const r = RecordJsonProps.safeParse({ requiredPermissions: GATE, key: 'payload' }); - expect(r.success).toBe(false); - expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('field'); - }); -}); - -describe('`field` is a required machine name, not judged against declared fields', () => { - it('is required', () => { - const r = RecordJsonProps.safeParse({ requiredPermissions: GATE }); - expect(r.success).toBe(false); - expect(issueCodes(r)).toContain('invalid_type@field'); - }); - - it('takes the snake_case grammar and refuses anything else as a value error', () => { - for (const ok of ['payload', 'payload_json', '_meta', 'a1']) { - expect(RecordJsonProps.safeParse({ ...VALID, field: ok }).success).toBe(true); - } - for (const bad of ['', 'Payload', 'payload.amount', '1st', 'pay-load']) { - const r = RecordJsonProps.safeParse({ ...VALID, field: bad }); - expect(r.success).toBe(false); - expect(issueCodes(r)).toEqual(['invalid_format@field']); - } - }); -}); - -describe('`requiredPermissions` is REQUIRED and fail-closed — its own declaration, not the shared one', () => { - it('is required: a bag without it is refused at the key, not defaulted', () => { - const r = RecordJsonProps.safeParse({ field: 'payload' }); - expect(r.success).toBe(false); - expect(issueCodes(r)).toContain('invalid_type@requiredPermissions'); - }); - - it('refuses an empty list and an empty name — a gate nobody can pass is never authored by accident', () => { - expect(issueCodes(RecordJsonProps.safeParse({ ...VALID, requiredPermissions: [] }))) - .toEqual(['too_small@requiredPermissions']); - expect(issueCodes(RecordJsonProps.safeParse({ ...VALID, requiredPermissions: [''] }))) - .toEqual(['too_small@requiredPermissions.0']); - }); - - it('accepts any non-empty capability names, all of them carried through', () => { - const r = RecordJsonProps.safeParse({ ...VALID, requiredPermissions: ['studio.access', 'read'] }); - expect(r.success).toBe(true); - expect(r.data!.requiredPermissions).toEqual(['studio.access', 'read']); - }); - - it('its describe states the fail-closed meaning and that it authorises nothing', () => { - const text = RecordJsonProps.shape.requiredPermissions.description ?? ''; - expect(text).toContain('fails CLOSED'); - expect(text).toContain('authorises nothing'); - }); - - it('the shared record-block gate stays fail-OPEN and optional — the two declarations differ', () => { - const shared = RecordDetailsProps.shape.requiredPermissions; - expect(shared.safeParse(undefined).success).toBe(true); - expect(shared.safeParse([]).success).toBe(true); - expect(shared.description).toContain('fails open'); - expect(shared.description).not.toBe(RecordJsonProps.shape.requiredPermissions.description); - }); -}); - -describe('a print page refuses the block', () => { - it('is not printable, and the refusal carries a reason', () => { - expect(PRINTABLE_PAGE_COMPONENT_TYPES.has('record:json')).toBe(false); - expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.get('record:json')).toMatch(/\S/); - }); -}); diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts index 2c0d39a60ee..4cd22336998 100644 --- a/packages/spec/src/ui/component-type-vocabulary.test.ts +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -143,6 +143,21 @@ describe('KNOWN_COMPONENT_TYPES covers every declared face', () => { // Lit control: the kept member of the same namespace is a candidate. expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain('ai:suggestion'); }); + + /** + * #24162 — `record:json` is retired BY NAME the `ai:chat_window` way: out of + * the enum, KNOWN through its kept `ComponentPropsMap` row, and out of the + * typo candidates, so the suggester no longer offers it for `record:jsn`. + * `record:` stays reserved — a dozen live members populate it. + */ + it('record:json is known through its kept row, not a candidate, and `record:` stays reserved', () => { + expect(PageComponentType.options).not.toContain('record:json'); + expect(isKnownComponentType('record:json')).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).not.toContain('record:json'); + expect(hasReservedComponentNamespace('record:json')).toBe(true); + // Lit control: a kept member of the same namespace is a candidate. + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain('record:details'); + }); }); describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 0be4166f059..fee472ca872 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -455,9 +455,10 @@ const emptyProps = (type: string) => /** * A component RETIRED at element grain whose props bag is refused WHOLE — - * `user:profile` (#14159), and `ai:chat_window` (#21504), whose four keys left - * with its props def. The `retiredKey` channel one grain wider: where a - * tombstoned KEY accepts absence and refuses any value, a retired ELEMENT has + * `user:profile` (#14159), `ai:chat_window` (#21504), whose four keys left + * with its props def, and `record:json` (#24162), whose three did. The + * `retiredKey` channel one grain wider: where a tombstoned KEY accepts absence + * and refuses any value, a retired ELEMENT has * nothing an author may write at all, so the row is `z.never` — `{}` is refused * exactly like a populated bag, `expected: 'never'` / `code: 'invalid_type'` is * the same issue shape a key tombstone raises, and the message is the element's @@ -2568,76 +2569,13 @@ const RecordApprovalsProps = strictObject({ }, }, {}); -/** - * `record:json` (#23745, the spec half of face 4 of objectui#12438's ruling, - * letter C): one key of the bound record, drawn as indented JSON in a panel - * that starts collapsed, with a copy control. The worked case is the approval - * request page's raw request snapshot (`payload` on a `sys_approval_request` - * row), which only a caller holding `studio.access` may see. - * - * A NEW block rather than a widened one, on the ruling's own test ("A NEW block - * is opened only where no existing block can carry the face"): no declared - * block draws a record value as JSON, `record:details` draws JSON only for a - * DECLARED `json` field, and the gate this face needs fails CLOSED, which the - * record blocks' gate of the same name does not - * ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}, clause 5). That - * shared gate is left exactly as it is. - * - * Declared spec first, like `record:approval_decision`: no renderer draws it - * and no page authors it on the day it lands. objectui registers the renderer, - * and plugin-approvals places it on the request page, after this declaration. - * - * Three keys, all of them the renderer's whole contract: - * - * - `field` — the key to draw. It is NOT judged against the object's - * declared fields (it has no entry in `@objectstack/lint`'s - * `COMPONENT_FIELD_SPECS`), because the key this block exists for is one - * the read route serves beside the declared fields: the request's - * `payload` is parsed from the declared column `payload_json`, which is - * never served under its own name. `attachedOnRead` on the object does not - * describe such a key either — it declares per-caller blocks with a fixed - * set of scalar leaves, and a snapshot's leaves are the source record's - * fields. So a misspelled `field` draws nothing, and nothing at publish - * says so. - * - `label` — the collapsed panel's summary line. - * - `requiredPermissions` — REQUIRED, and fail-CLOSED: see the describe. - * Presentation only, like every block-level gate: it authorises nothing. - */ -export const RecordJsonProps = strictObject({ - surface: 'this `record:json`', - history: PROPS_HISTORY, - guidanceSets: COMPONENT_LEVEL_GUIDANCE, - aliases: { - key: 'field', path: 'field', fieldName: 'field', - title: 'label', summary: 'label', - permissions: 'requiredPermissions', requiredCapabilities: 'requiredPermissions', - }, -}, { - field: z.string() - .regex(/^[a-z_][a-z0-9_]*$/, { - message: 'record:json `field` must be a lowercase snake_case key of the bound record (e.g. "payload").', - }) - .describe( - 'The key of the bound record whose value this block draws as indented JSON, in a panel that starts ' - + 'collapsed, with a copy control. A declared field, or a key the record\'s read route serves beside ' - + 'the declared fields (`payload` on a `sys_approval_request` row). It is NOT checked against the ' - + 'object\'s fields: a key with no value on the served record draws nothing, so a misspelled `field` ' - + 'draws nothing and no publish check reports it.', - ), - label: I18nLabelSchema.optional().describe( - 'The collapsed panel\'s summary line — a string or an inline locale map (renderer default: "Raw data (JSON)").', - ), - requiredPermissions: z.array(z.string().min(1)).min(1).describe( - '[ADR-0066] REQUIRED, at least one. Capabilities the user must ALL hold — names that permission sets grant ' - + 'through `systemPermissions`, not object actions. It fails CLOSED, unlike the record blocks\' gate of the ' - + 'same name: a client that cannot resolve the user\'s capabilities (no permission provider, or one that ' - + 'does not report `systemPermissions`) draws nothing, and so does a user missing any of them — no notice, ' - + 'no summary line, no empty panel. ' - + 'Presentation only: it authorises nothing, and the read route still serves the value to the same user. ' - + 'To keep a value from a user, gate the object, the field or the route.', - ), -}); -export type RecordJsonProps = z.input; +// `RecordJsonProps` REMOVED (#24162, ADR-0049) with the `record:json` element +// it described (#23745): `field` / `label` / `requiredPermissions` were the +// contract of a renderer that was never built, for the approval request page's +// raw JSON panel, which the maintainer dropped before anything drew or placed +// the type. Its row in `ComponentPropsMap` below now refuses the whole bag +// (`retiredComponentProps`), and the def is recorded in +// `migrations/entries/retired-defs/18.ui__RecordJsonProps.ts`. export const PageAccordionProps = strictObject({ surface: 'this `page:accordion`', @@ -8325,13 +8263,14 @@ export const ComponentPropsMap = { // attributes it reads are node-level, never `properties`. 'record:approvals': RecordApprovalsProps, 'record:attachments': emptyProps('record:attachments'), - // #23745 — one key of the bound record drawn as indented JSON, behind a - // REQUIRED, fail-closed `requiredPermissions` gate (objectui#12438 ruling, - // letter C, face 4). A `PageComponentType` member declared spec first, as - // `record:approval_decision` was: no renderer and no authored page yet, so - // the key set is the declared contract the renderer is built to, not a - // measurement of read points. See the schema's own header. - 'record:json': RecordJsonProps, + // RETIRED by name (#24162, ADR-0049; the maintainer dropped the approval + // request page's raw JSON panel, the one face `record:json` was declared + // for, #23745). The row STAYS, for the reason `user:profile`'s does: every + // reader that dispatches on the row keeps recognising the name and answers + // with the prescription instead of skipping it as an unregistered custom + // string. Its three keys (`field`, `label`, `requiredPermissions`) left with + // `RecordJsonProps`, so the WHOLE bag is refused, as `ai:chat_window`'s is. + 'record:json': retiredComponentProps('record:json'), // Navigation 'app:launcher': emptyProps('app:launcher'), diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 40d37b9f559..286af7f77f3 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -142,6 +142,26 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map + 'is the supported entry point. Delete the `ai:chat_window` component node and put nothing ' + "in its place; to choose which platform agent the overlay answers with, set the app's " + '`defaultAgent`.'], + // #24162, ADR-0049 enforce-or-remove, by the maintainer's ruling quoted on + // that card (triage record 6232511377): face 4 of the objectui#12438 ruling C — + // the approval request page's raw JSON panel — is dropped, because the person + // deciding an approval has no business need to read the request as JSON. + // `record:json` (#23745) was declared spec first for that one panel and + // shipped only in the 18.0.0-next.3 prerelease: no renderer registered it, + // no page placed it (objectui and objectstack measured zero), and its one + // planned producer was closed unmerged. The `ai:chat_window` shape: the + // row's three keys go with the element, so the kept `ComponentPropsMap` row + // refuses the WHOLE bag (`retiredComponentProps`) with this text. No + // `os migrate meta` sentence: there is no mechanical edit to list (a + // conversion does not delete authored page nodes), and the D3 entry + // `ui-record-json-retired` carries the delegated deletion. No replacement is + // named, by ruling. + ['record:json', '`record:json` was removed in @objectstack/spec 18 (ADR-0049) — it shipped ' + + 'only in the 18.0.0-next.3 prerelease, declared for one raw-JSON panel on the approval ' + + 'request page, and that panel was dropped: the person deciding an approval has no business ' + + 'need to read a record as raw JSON, so no renderer draws the type and no page places it, and ' + + 'its `field`, `label` and `requiredPermissions` props configure nothing. Delete the ' + + '`record:json` component node and put nothing in its place.'], ['element:form', '`element:form` was removed in @objectstack/spec 17 ' + '(ADR-0049) — the whole `element:form` element is retired: no renderer for it ' + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' @@ -174,6 +194,11 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map * kept as a whole-bag refusal. No renderer ever shipped and none is wanted — * the console's floating chat overlay is the supported AI chat entry point, so * an inline page-level chat window is not part of the authorable surface. + * + * `record:json` REMOVED (#24162, ADR-0049 enforce-or-remove) the same way, one + * prerelease after it was declared (#23745, published in 18.0.0-next.3 only): + * the approval request page's raw JSON panel it was declared for was dropped + * by the maintainer, so it has no renderer, no placement and no producer. */ export const PageComponentType = z.enum([ // Structure @@ -202,12 +227,10 @@ export const PageComponentType = z.enum([ // every record page of an `enable.files` object. Each has a // `ComponentPropsMap` row measured from its renderer's read points. 'record:approvals', 'record:attachments', - // #23745 (the spec half of face 4 of the objectui#12438 ruling, letter C): - // one key of the bound record drawn as indented JSON behind a REQUIRED, - // fail-closed `requiredPermissions` gate. Declared spec first, as - // `record:approval_decision` above was: objectui registers the renderer and - // plugin-approvals places it after this declaration exists. - 'record:json', + // `record:json` REMOVED (#24162, ADR-0049; the maintainer dropped face 4 of + // the objectui#12438 ruling, the approval request page's raw JSON panel it + // was declared for): refused by name through `RETIRED_PAGE_COMPONENT_TYPES` + // above, not merely de-advertised. // Navigation 'app:launcher', 'nav:menu', 'nav:breadcrumb', // Utility — `user:profile` REMOVED (#14159): shell chrome, refused by name @@ -1291,7 +1314,6 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['record:approval_decision', 'is the decision panel of an approval request: action controls that differ by viewer, beside a progress tally each decision changes, with nothing to print'], ['record:approvals', 'is the record\'s live approval timeline, with a remind control only the submitter sees, so what prints would depend on who prints it'], ['record:attachments', 'is a list of attached files with upload, download and delete controls, and the files themselves never reach paper'], - ['record:json', 'is a panel that starts collapsed behind a per-viewer capability gate, so what prints would depend on who prints it and whether they opened it'], ['element:button', 'is an action control, with nothing to print'], ['element:record_picker', 'is an input control, with nothing to print'], ['element:text_input', 'is an input control, with nothing to print'], From 9dce7800d20c22d3cfd5c7286948a4f9a056108e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:57:36 +0000 Subject: [PATCH 2/6] chore(spec): regenerate artifacts for the record:json retirement Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- content/docs/references/ui/component.mdx | 19 +++---------------- content/docs/references/ui/page.mdx | 5 ++--- .../ui.md | 10 +++++----- packages/spec/api-surface/ui.json | 2 -- packages/spec/authorable-surface/ui.json | 3 --- packages/spec/declaration-map/ui.json | 1 - packages/spec/export-origins/ui.json | 1 - packages/spec/json-schema.manifest/ui.json | 1 - packages/spec/src/ui/page.zod.ts | 2 +- 10 files changed, 16 insertions(+), 38 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 9589c7a954e..6e0cc998ed6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1538 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1539** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 168 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **197** | **1538** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 169 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 168 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -375,7 +375,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` | | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | -| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordJsonProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | +| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d60241b6179..f84ab2bb3da 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1,7 +1,7 @@ --- title: Component schema — UI Protocol reference navTitle: Component -description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProps and 44 more — each property with its type, default and a TypeScript example." +description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProps and 43 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Component schemas of the ObjectStack UI Protocol: ActionButtonProp ## TypeScript Usage ```typescript -import { ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, ElementButtonPropsSchema, ElementDefinitionListPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordJsonProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; -import type { ActionButtonProps, ActionGroupProps, ActionIconProps, ActionMenuProps, ElementDefinitionListProps, ElementNumberProps, ElementRecordPickerProps, ElementRepeaterProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordJsonProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; +import { ActionButtonPropsSchema, ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, ElementButtonPropsSchema, ElementDefinitionListPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, ObjectCalendarPropsSchema, ObjectFormPropsSchema, ObjectGanttPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, ObjectMapPropsSchema, ObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema, ObjectTimelinePropsSchema, ObjectTreePropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordAlertActionSchema, RecordAlertProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordHistoryProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, RecordRelatedListProps, ReferenceRailEntrySchema } from '@objectstack/spec/ui'; +import type { ActionButtonProps, ActionGroupProps, ActionIconProps, ActionMenuProps, ElementDefinitionListProps, ElementNumberProps, ElementRecordPickerProps, ElementRepeaterProps, ObjectCalendarProps, ObjectFormProps, ObjectGanttProps, ObjectGridProps, ObjectKanbanProps, ObjectMapProps, ObjectMasterDetailFormProps, ObjectMetricProps, ObjectTimelineProps, ObjectTreeProps, PageContainerProps, RecordAlertAction, RecordAlertProps, RecordHighlightsField, RecordHistoryProps, RecordLineItemsProps, RecordPathProps, RecordQuickActionsProps, RecordReferenceRailProps, ReferenceRailEntry } from '@objectstack/spec/ui'; // Validate data const result = ActionButtonPropsSchema.parse(data); @@ -1790,19 +1790,6 @@ Type: `string` | **unknownUserText** | `string` | optional | Copy substituted when an entry has no resolvable actor. Literal string, every locale (renderer default: "Unknown user"). | ---- - -## RecordJsonProps - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | The key of the bound record whose value this block draws as indented JSON, in a panel that starts collapsed, with a copy control. A declared field, or a key the record's read route serves beside the declared fields (`payload` on a `sys_approval_request` row). It is NOT checked against the object's fields: a key with no value on the served record draws nothing, so a misspelled `field` draws nothing and no publish check reports it. | -| **label** | `string \| Record` | optional | The collapsed panel's summary line — a string or an inline locale map (renderer default: "Raw data (JSON)"). | -| **requiredPermissions** | `string[]` | ✅ | [ADR-0066] REQUIRED, at least one. Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions. It fails CLOSED, unlike the record blocks' gate of the same name: a client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) draws nothing, and so does a user missing any of them — no notice, no summary line, no empty panel. Presentation only: it authorises nothing, and the read route still serves the value to the same user. To keep a value from a user, gate the object, the field or the route. | - - --- ## RecordLineItemsProps diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index dfc76954661..11c065d4c97 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -293,7 +293,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'record:json' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'record:approval_decision' \| 'record:approvals' \| 'record:attachments' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -372,7 +372,6 @@ View filter rule * `record:approval_decision` * `record:approvals` * `record:attachments` -* `record:json` * `app:launcher` * `nav:menu` * `nav:breadcrumb` @@ -433,7 +432,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 3a466662dad..a95f31caf2c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 214 | 203 | 4 | 0 | 7 | +| `ui/` | 213 | 202 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 81 | +| `component.zod.ts` | 80 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **214** | +| **total** | **213** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 214**, in 4 file(s). +**7 strip of 213**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **214** | +| **total** | **7** | **213** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index bd104844618..04c6a8e3b1b 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -376,8 +376,6 @@ "RecordHighlightsProps (const)", "RecordHistoryProps (const)", "RecordHistoryProps (type)", - "RecordJsonProps (const)", - "RecordJsonProps (type)", "RecordLineItemsProps (const)", "RecordLineItemsProps (type)", "RecordLineItemsPropsParsed (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 80e80102f2d..cf32666e900 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1196,9 +1196,6 @@ "ui/RecordHistoryProps:emptyText", "ui/RecordHistoryProps:limit", "ui/RecordHistoryProps:unknownUserText", - "ui/RecordJsonProps:field", - "ui/RecordJsonProps:label", - "ui/RecordJsonProps:requiredPermissions", "ui/RecordLineItemsProps:amountField", "ui/RecordLineItemsProps:childObject", "ui/RecordLineItemsProps:columns", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 78b9481f0c0..6c451181ba4 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -238,7 +238,6 @@ "RecordHighlightsField": "ui/RecordHighlightsField", "RecordHighlightsProps": "ui/RecordHighlightsProps", "RecordHistoryProps": "ui/RecordHistoryProps", - "RecordJsonProps": "ui/RecordJsonProps", "RecordLineItemsProps": "ui/RecordLineItemsProps", "RecordPathProps": "ui/RecordPathProps", "RecordQuickActionsProps": "ui/RecordQuickActionsProps", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 410381de552..c1dd7c14814 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -367,7 +367,6 @@ "RecordHighlightsField": "src/ui/component.zod.ts#RecordHighlightsField (type)", "RecordHighlightsProps": "src/ui/component.zod.ts#RecordHighlightsProps (const)", "RecordHistoryProps": "src/ui/component.zod.ts#RecordHistoryProps (type)", - "RecordJsonProps": "src/ui/component.zod.ts#RecordJsonProps (type)", "RecordLineItemsProps": "src/ui/component.zod.ts#RecordLineItemsProps (type)", "RecordLineItemsPropsParsed": "src/ui/component.zod.ts#RecordLineItemsPropsParsed (type)", "RecordPathProps": "src/ui/component.zod.ts#RecordPathProps (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index f1f4e2bd430..3e53599717e 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -134,7 +134,6 @@ "ui/RecordHighlightsField", "ui/RecordHighlightsProps", "ui/RecordHistoryProps", - "ui/RecordJsonProps", "ui/RecordLineItemsProps", "ui/RecordPathProps", "ui/RecordQuickActionsProps", diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 286af7f77f3..e3f3674b323 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -605,7 +605,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ if (guidance) { ctx.addIssue({ code: 'custom', message: guidance, params: { retiredComponentType: type } }); } - }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription.'), + }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `ai:chat_window` — no renderer by design, the floating chat overlay is the AI chat entry point; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped; `record:json` — the raw-JSON panel it was declared for was dropped) is refused at the parse itself, with the retirement prescription.'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */ From eb20f072e0fcac92215d0bd29abc175f8437241c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:58:13 +0000 Subject: [PATCH 3/6] chore: changeset for the record:json retirement Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- .changeset/24162-record-json-retired.md | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/24162-record-json-retired.md diff --git a/.changeset/24162-record-json-retired.md b/.changeset/24162-record-json-retired.md new file mode 100644 index 00000000000..5034714aa51 --- /dev/null +++ b/.changeset/24162-record-json-retired.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `record:json` is retired — refused by name at the schema door; the approval request page's raw JSON panel it was declared for was dropped (#24162, ADR-0049) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form` and `ai:chat_window` retirements shipped the same way). + +`record:json` was added to `PageComponentType` in `18.0.0-next.3`, with a strict `ComponentPropsMap` row, `RecordJsonProps` (`field`, `label`, `requiredPermissions`). It was declared spec first for one face: the approval request page's raw JSON panel. The maintainer then dropped that panel — the person deciding an approval has no business need to read the request as JSON — so no renderer was built and no page placed the type: zero consumers in objectstack and objectui, and its one planned producer was closed unmerged. A declared element that nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove, refused by name, the `ai:chat_window` way. The `18.0.0-next.3` entry below records what that prerelease shipped; this entry is its retirement. + +**What is refused:** an authored `record:json` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `PageComponentType`'s own error map refuses the name with the same text when the enum is parsed alone. `ComponentPropsMap['record:json']` stays as a row, so every reader that dispatches on it keeps recognising the name: the component-props gate, `check-yaml-examples` and the type vocabulary's known set. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at all three doors. The authoring rule `component-type-unknown` reports the node by name, and stops suggesting `record:json` for a misspelling in the `record:` namespace. + +**What is removed from the exports:** `RecordJsonProps` (`@objectstack/spec/ui`), the props schema the element no longer has. Its JSON Schema (`ui/RecordJsonProps`) is no longer published. Its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it: a retired type is refused at the parse before any print rule reads it. + +**What stays accepted:** every other member of `PageComponentType` and `ComponentPropsMap`, byte-identically, including the four record blocks' own `requiredPermissions` gate, which `record:json` never changed. The open string arm also stays open: custom and plugin-registered types keep parsing. The only string refused is the retired name itself. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| a `{ type: 'record:json' }` component node in a page region, slot or container | nothing: delete the node. No replacement panel exists, by ruling | +| `properties: { field, label, requiredPermissions }` on that node | nothing: none of them was ever read | + +The one-line fix: delete the `record:json` component node. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-record-json-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. + +## Who is affected, measured + +- **objectstack** at `eda6f14b7b`: zero authored `record:json` nodes in `examples/**`, `packages/apps/**`, `apps/**`, `skills/**` and `content/docs/**` code samples. The only hits were the spec's own type list, row and print row, its tests, the lint accept pin and the generated reference docs. A tree-scoped absence pin now holds that zero. +- **objectui** `main`: zero hits (the card's census); the `.objectui-sha` pin is not moved. +- **Deployed metadata** was not measured; the type was published in one prerelease only. + +The retirement kit: + +- the retired-type map entry and the enum value removed (`packages/spec/src/ui/page.zod.ts`), its print classification row removed, and the row turned into a whole-bag refusal, with `RecordJsonProps` removed (`packages/spec/src/ui/component.zod.ts`) +- the D3 semantic entry `ui-record-json-retired`, its step-18 rationale fragment, and the `RETIRED_DEFS_BY_MAJOR` entry `ui/RecordJsonProps` +- pin tests: `component-record-json-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and populated bags, `PageComponentSchema`, `PageSchema` at the element path, the enum error map), with `record:approvals` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known, out of the typo candidates, and `record:` still reserved; the lint accept pin `validate-record-json-23745.test.ts` is flipped into a refusal pin at both lint rules and the shared pipeline +- generated baselines and docs follow the schema: `api-surface/`, `export-origins/`, `declaration-map/`, `authorable-surface/`, `json-schema.manifest/`, the strictness-ledger counts, `spec-changes.json`, the upgrade guide and the reference docs From 3fa5d206092e329e5c585c97499d1f33deb020ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 11:00:06 +0000 Subject: [PATCH 4/6] test(spec): census pins follow the record:json retirement Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 4 ++-- .../src/ui/component-record-related-list-parent-23134.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f8cbdb749b5..903eb3aa19b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -277,7 +277,7 @@ import type * as M189 from './system/fate-supplement.zod.js'; import type * as M190 from './kernel/execution-context.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 788 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1702,7 +1702,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 788 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes diff --git a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts index 7e0de245642..9dafd54cf3f 100644 --- a/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts +++ b/packages/spec/src/ui/component-record-related-list-parent-23134.test.ts @@ -166,7 +166,7 @@ describe('C — every other component row refuses the key: the per-type door', ( expect(notRefused).toEqual([]); // The whole-bag set is the retired types only — a live row landing here // would be refusing for a reason this test does not mean to accept. - expect(wholeBag.sort()).toEqual(['ai:chat_window', 'user:profile']); + expect(wholeBag.sort()).toEqual(['ai:chat_window', 'record:json', 'user:profile']); }); it('CONTROL: the related list itself does not report the key as unknown', () => { From 26e52c407a47db174d857825fb12f7221e3596e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 11:59:50 +0000 Subject: [PATCH 5/6] test(spec): the record:json absence walk runs in the repo project Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- packages/spec/vitest.repo-tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index aabb3f273e2..82290f55be1 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -54,6 +54,7 @@ "src/system/email-template-category-is-system-retirement.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/component-record-json-retirement.test.ts", "src/ui/element-flat-binding-retirement.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", From a12ff6bd8835bf887ece378b577d235776e1133d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:39:04 +0000 Subject: [PATCH 6/6] fix(spec): the record:json step-18 rationale fragment carries no tracker id Claude-Session: https://claude.ai/code/session_01LKyb454ATwXXksfRnB8Xiy Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index e56a6649c89..0aaf83a9d81 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3165,7 +3165,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ id: 'ui-record-json-retired', order: 100, text: - 'It also retires the `record:json` page element (#24162, ADR-0049 enforce-or-remove), one ' + 'It also retires the `record:json` page element (ADR-0049 enforce-or-remove), one ' + 'prerelease after it was declared: it shipped in 18.0.0-next.3 only, for the approval request ' + 'page\'s raw JSON panel, and the maintainer dropped that panel — the person deciding an ' + 'approval has no business need to read the request as JSON — so no renderer was built and no '