From 145686f946f0ca7c5ff2b288c672cae340782804 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:36:50 +0000 Subject: [PATCH 1/7] feat(spec)!: retire page-header-subtitle-alias and inline-action-api-params-to-body-extra from the load path at 18 (wip) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 27 +++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index bcbae2c6232..9ebdcf7d7d3 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -4930,11 +4930,24 @@ const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); * without those pages silently losing their second line, the exact failure * shape this entry exists to prevent. The fixture pins all three positions. * - * **Live window**; retires at 18. + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the old key, so an + * author meets `PageHeaderProps`' own refusal, which names the rename + * (`aliases: { description: 'subtitle' }`), at every door that judges page + * props: `os validate` / `os build` / `os lint` and the metadata save door + * alike, where before the first two converted silently and the save door + * refused. Stored rows keep healing: stored-row rehydration replays the full + * chain, this entry included (`applyConversionsToStoredItem`), and so do the + * artifact-ingestion door (inside its window) and `os migrate meta`. The kebab + * `page-header` type stays in its walk for those replays only — objectui no + * longer registers that type, so a source node of it renders the unknown + * component panel whatever its props say. */ const pageHeaderSubtitleAlias: MetadataConversion = { id: 'page-header-subtitle-alias', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'page.component.page-header.description', summary: "page-header component prop 'description' → 'subtitle' (the off-spec spelling a renderer tolerated through a bare `subtitle ?? description` fallback; `subtitle` is the declared key, and the fallback retires)", @@ -5575,11 +5588,21 @@ const pageCardBodyToChildren: MetadataConversion = { * anything the rewrite declines to touch: it refuses the object form with a * message naming `bodyExtra`, at any position. * - * **Live window**; retires at 18. + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the object form, + * so an author meets that array-only field's refusal naming `bodyExtra` at + * every door that judges page props — `os validate` / `os build` / `os lint` + * and the metadata save door alike, where before the first two converted + * silently and the save door refused. Stored rows keep healing: stored-row + * rehydration replays the full chain, this entry included + * (`applyConversionsToStoredItem`), and so do the artifact-ingestion door + * (inside its window) and `os migrate meta`. */ const inlineActionApiParamsToBodyExtra: MetadataConversion = { id: 'inline-action-api-params-to-body-extra', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'page.component.element:button.action.params', summary: "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (a static payload and a parameter definition are two things, so the payload gets its own key; `params` stays the ActionParam[] definition array)", From 976f9f12c63c7193d2ea10d5df2d7d18acab6d9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:39:25 +0000 Subject: [PATCH 2/7] test(spec): conversion pins run the two retired entries under includeRetired; the record-door vehicle moves to a live conversion Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../spec/src/conversions/conversions.test.ts | 90 +++++++++---- .../spec/src/stack-conversions-record.test.ts | 120 +++++++++--------- 2 files changed, 130 insertions(+), 80 deletions(-) diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index fad00ada93e..f8f37ce9e3f 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -332,12 +332,13 @@ describe('conversion layer (ADR-0087 D2)', () => { (stack.pages as Array<{ regions: Array<{ components: Array<{ properties: unknown }> }> }>)[0]! .regions[0]!.components[0]!.properties; - const equal = collectConversionNotices(header({ title: 'T', subtitle: 'One', description: 'One' })); + // A retired entry (#23421), so the data-at-rest posture replays it. + const equal = collectConversionNotices(header({ title: 'T', subtitle: 'One', description: 'One' }), { includeRetired: true }); expect(propsOf(equal.stack)).toEqual({ title: 'T', subtitle: 'One' }); expect(equal.notices).toHaveLength(1); const differs = header({ title: 'T', subtitle: 'One', description: 'Another' }); - const kept = collectConversionNotices(structuredClone(differs)); + const kept = collectConversionNotices(structuredClone(differs), { includeRetired: true }); expect(kept.stack).toEqual(differs); expect(kept.notices).toHaveLength(0); }); @@ -1107,9 +1108,12 @@ describe('conversion layer (ADR-0087 D2)', () => { * `page-header-subtitle-alias` (#4827, objectui#3226). * * The PD #12 retirement of objectui's `subtitle ?? description` fallback on - * the page header. Unlike the entries above this one is a LIVE window, so - * every case here runs the plain load posture (no `includeRetired`) — that - * the rewrite happens without it is the property objectui is waiting on. + * the page header. Its protocol-17 window has run: the entry is retired from + * the load path at 18 (#23421), so the authoring funnel no longer rewrites + * the key and an author meets `PageHeaderProps`' refusal naming the rename. + * The rewrite cases below therefore run the data-at-rest posture + * (`includeRetired`) — the replay stored rows, the artifact door and + * `os migrate meta` still perform — and one case pins the authoring posture. */ describe('page-header-subtitle-alias (the `subtitle ?? description` retirement)', () => { const pageWith = (...components: Record[]) => ({ @@ -1126,7 +1130,7 @@ describe('conversion layer (ADR-0087 D2)', () => { // looks like on the way in. expect(componentsOf(before)[0]!.properties).toEqual({ title: 'Leads', description: 'All open leads' }); - const { stack, notices } = collectConversionNotices(structuredClone(before)); + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); // …and this is what the runtime sees: the second line survives under the // canonical key, and the dialect spelling is gone rather than shadowing it. @@ -1138,6 +1142,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('rewrites it on the CANONICAL `page:header` node too — where it is dropped on the floor today', () => { const { stack, notices } = collectConversionNotices( pageWith({ type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }), + { includeRetired: true }, ); expect(componentsOf(stack)[0]!.properties).toEqual({ title: 'Lead', subtitle: 'One lead' }); expect(notices).toHaveLength(1); @@ -1146,6 +1151,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('does NOT rewrite the node TYPE — the kebab alias is objectui\'s to retire', () => { const { stack } = collectConversionNotices( pageWith({ type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }), + { includeRetired: true }, ); expect(componentsOf(stack)[0]!.type).toBe('page-header'); }); @@ -1158,7 +1164,7 @@ describe('conversion layer (ADR-0087 D2)', () => { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' }, }); - const { stack, notices } = collectConversionNotices(structuredClone(before)); + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); expect(stack).toEqual(before); expect(notices).toHaveLength(0); }); @@ -1170,17 +1176,18 @@ describe('conversion layer (ADR-0087 D2)', () => { { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, { type: 'record:details', properties: { description: 'not a subtitle' } }, ); - const { stack, notices } = collectConversionNotices(structuredClone(before)); + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); expect(stack).toEqual(before); expect(notices).toHaveLength(0); }); - it('emits a notice that names the surface, the site and the live window', () => { + it('emits a notice that names the surface, the site and the window it had', () => { const { notices } = collectConversionNotices( pageWith( { type: 'element:divider' }, { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, ), + { includeRetired: true }, ); expect(notices).toHaveLength(1); expect(notices[0]).toMatchObject({ @@ -1196,14 +1203,31 @@ describe('conversion layer (ADR-0087 D2)', () => { it('is idempotent — the canonical shape is not a match', () => { const before = pageWith({ type: 'page:header', properties: { title: 'Leads', subtitle: 'All open leads' } }); - const { stack, notices } = collectConversionNotices(structuredClone(before)); + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); expect(stack).toEqual(before); expect(notices).toHaveLength(0); }); it('copies on write — a page with no header passes through by reference', () => { const stack = pageWith({ type: 'record:details' }); - expect(applyConversions(stack)).toBe(stack); + expect(applyConversions(stack, { includeRetired: true })).toBe(stack); + }); + + it('is retired from the load path at 18 — the authoring posture rewrites nothing, on either node type', () => { + // Without `includeRetired` (the normalizeStackInput posture) the authored + // `description` reaches the props gate as written, where the rename is + // said to the author (the premise pin below) instead of absorbed. + expect(ALL_CONVERSIONS.find((c) => c.id === 'page-header-subtitle-alias')).toMatchObject({ + toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + }); + for (const type of ['page:header', 'page-header']) { + const before = pageWith({ type, properties: { title: 'Leads', description: 'All open leads' } }); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + } }); it('reaches a STORED page row, so data at rest canonicalizes on rehydration', () => { @@ -1235,11 +1259,12 @@ describe('conversion layer (ADR-0087 D2)', () => { * the conversion did not fire there, `PageSchema` stayed green, and the * props gate is CLI-only. * - * With the shape closed there are two lines of defence rather than one: the - * conversion still canonicalizes on every load and rehydration (the tests - * above, unchanged), and a `description` that reaches this parse - * unconverted is now rejected BY NAME carrying the rename. The alias entry - * is what makes the second one useful rather than merely loud. + * With the shape closed, a `description` that reaches this parse is + * rejected BY NAME carrying the rename — and since the conversion retired + * from the load path at 18 (#23421), every authored `description` reaches + * it: this refusal is now the authoring answer at every door that judges + * page props, while the conversion canonicalizes data at rest only. The + * alias entry is what makes the refusal teach rather than merely refuse. */ it('the canonical props schema declares `subtitle` and no `description`', () => { expect(PageHeaderProps.parse({ title: 'Leads', subtitle: 'All open leads' }).subtitle) @@ -1284,6 +1309,11 @@ describe('conversion layer (ADR-0087 D2)', () => { * a payload from the `${param.X}` interpolation scope a `url` action reads * out of the same key. Both are ways this rewrite could be lossy, and neither * is visible in a fixture that only carries the happy path. + * + * Retired from the load path at 18 (#23421): the rewrite cases run the + * data-at-rest posture (`includeRetired`), and one case pins that the + * authoring posture rewrites nothing, so the array-only field's refusal + * naming `bodyExtra` is what an author meets. */ describe('inline-action-api-params-to-body-extra', () => { const button = (action: Record) => ({ @@ -1300,7 +1330,7 @@ describe('conversion layer (ADR-0087 D2)', () => { const notices: ConversionNotice[] = []; const out = applyConversions( button({ type: 'api', target: '/api/v1/forms/contact-us/submit', params: { name: '{{page.inquiryName}}' } }), - { onNotice: (n) => notices.push(n) }, + { includeRetired: true, onNotice: (n) => notices.push(n) }, ); expect(actionOf(out)).toEqual({ type: 'api', @@ -1314,7 +1344,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('leaves an ActionParam[] definition array alone — that meaning of the key survives', () => { const params = [{ name: 'reason', label: 'Reason', type: 'text' }]; const stack = button({ type: 'api', target: '/x', params }); - const out = applyConversions(stack); + const out = applyConversions(stack, { includeRetired: true }); // Identity, not just equality: nothing converted, so copy-on-write shares. expect(out).toBe(stack); }); @@ -1325,24 +1355,40 @@ describe('conversion layer (ADR-0087 D2)', () => { // those into an api request body would be lossy, so the guard is not a // conservatism — it is the difference between lossless and not. const stack = button({ type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }); - expect(applyConversions(stack)).toBe(stack); + expect(applyConversions(stack, { includeRetired: true })).toBe(stack); }); it('keeps BOTH when `bodyExtra` already says something different', () => { const stack = button({ type: 'api', target: '/x', params: { a: 1 }, bodyExtra: { b: 2 } }); - const out = applyConversions(stack); + const out = applyConversions(stack, { includeRetired: true }); expect(out).toBe(stack); expect(actionOf(out)).toEqual({ type: 'api', target: '/x', params: { a: 1 }, bodyExtra: { b: 2 } }); }); it('is idempotent — the converted result replays to itself with no second notice', () => { - const once = applyConversions(button({ type: 'api', target: '/x', params: { a: 1 } })); + const once = applyConversions(button({ type: 'api', target: '/x', params: { a: 1 } }), { includeRetired: true }); const notices: ConversionNotice[] = []; - const twice = applyConversions(once, { onNotice: (n) => notices.push(n) }); + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); expect(twice).toBe(once); expect(notices).toEqual([]); }); + it('is retired from the load path at 18 — the authoring posture leaves the object form for the props gate', () => { + expect(ALL_CONVERSIONS.find((c) => c.id === 'inline-action-api-params-to-body-extra')).toMatchObject({ + toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + }); + const before = button({ type: 'api', target: '/api/v1/forms/contact-us/submit', params: { name: '{{page.n}}' } }); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + // …where the refusal names the rename the conversion used to perform. + const refused = ElementButtonPropsSchema.safeParse({ label: 'Go', action: actionOf(stack) }); + expect(refused.success).toBe(false); + expect(refused.error!.issues.map((i) => i.message).join('\n')).toContain('bodyExtra'); + }); + /** * Reachability, judged by what this rule guards — a VALUE verdict, not a * key one (#5046's distinction). The key `params` is declared either way; diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index a5af5107859..b532b5db59b 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -45,12 +45,15 @@ afterEach(() => { }); /** - * A stack with one `page:header` component whose second line is authored - * under `headerKey` — `description` is the alias the live conversion - * `page-header-subtitle-alias` rewrites to `subtitle`; `subtitle` is canonical. + * A stack with one `page:header` component whose visibility predicate is + * authored under `headerKey` — `visibility` is the alias the live conversion + * `page-component-visibility-to-visibleWhen` rewrites to `visibleWhen`; + * `visibleWhen` is canonical. (The vehicle used to be the header's + * `description` → `subtitle` alias, until that conversion retired from the + * load path at 18 — any live page conversion carries what is pinned here.) * No standalone actions, so the same built stack can be composed twice. */ -const source = (ns: string, headerKey: 'description' | 'subtitle', pageKind: 'jsx' | 'html' = 'html') => ({ +const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', pageKind: 'jsx' | 'html' = 'html') => ({ manifest: { id: `com.example.${ns}`, name: ns, version: '1.0.0', type: 'app' as const, namespace: ns }, objects: [{ name: `${ns}_thing`, label: 'Thing', fields: { title: { type: 'text' as const, label: 'Title' } } }], pages: [ @@ -60,7 +63,7 @@ const source = (ns: string, headerKey: 'description' | 'subtitle', pageKind: 'js regions: [ { name: 'main', - components: [{ type: 'page:header', properties: { title: 'Things', [headerKey]: 'All things' } }], + components: [{ type: 'page:header', properties: { title: 'Things' }, [headerKey]: "page.mode != 'edit'" }], }, ], }, @@ -68,7 +71,7 @@ const source = (ns: string, headerKey: 'description' | 'subtitle', pageKind: 'js ], }); -const HEADER_PATH = 'pages[0].regions[0].components[0].properties.subtitle'; +const HEADER_PATH = 'pages[0].regions[0].components[0].visibleWhen'; /** The substance a door publishes per entry: identity, site, direction, expiry. */ const substance = (n: ConversionNotice) => ({ @@ -82,11 +85,11 @@ const substance = (n: ConversionNotice) => ({ const HEADER_NOTICE = { code: CONVERSION_NOTICE_CODE, - conversionId: 'page-header-subtitle-alias', + conversionId: 'page-component-visibility-to-visibleWhen', path: HEADER_PATH, - from: 'description', - to: 'subtitle', - retiresIn: 18, + from: 'visibility', + to: 'visibleWhen', + retiresIn: 16, }; const KIND_NOTICE = { @@ -102,7 +105,7 @@ const quiet = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); describe('defineStack records the conversions it applied', () => { it('strict: the one conversion, whole, as the conversion layer emitted it', () => { quiet(); - const built = defineStack(source('aa', 'description') as never); + const built = defineStack(source('aa', 'visibility') as never); const record = stackConversionsOf(built); expect(record.map(substance)).toEqual([HEADER_NOTICE]); // The element IS the `ConversionNotice` the doors' `conversions` field @@ -111,21 +114,22 @@ describe('defineStack records the conversions it applied', () => { ['code', 'conversionId', 'from', 'message', 'path', 'retiresIn', 'surface', 'to', 'toMajor'].sort(), ); // …and the stack itself IS canonical: the record is of what was done to it. - const header = (built.pages as Array<{ regions: Array<{ components: Array<{ properties: Record }> }> }>)[0] - .regions[0].components[0].properties; - expect(header.subtitle).toBe('All things'); - expect('description' in header).toBe(false); + const header = (built.pages as Array<{ regions: Array<{ components: Array> }> }>)[0] + .regions[0].components[0]; + // The canonical key, as the strict parse lowers the predicate it carries. + expect(header.visibleWhen).toEqual({ dialect: 'cel', source: "page.mode != 'edit'" }); + expect('visibility' in header).toBe(false); }); it('strict: false records it too — the conversion runs in both modes', () => { quiet(); - const built = defineStack(source('aa', 'description') as never, { strict: false }); + const built = defineStack(source('aa', 'visibility') as never, { strict: false }); expect(stackConversionsOf(built).map(substance)).toEqual([HEADER_NOTICE]); }); it('two conversions in one source are recorded in the order they were applied', () => { quiet(); - const built = defineStack(source('aa', 'description', 'jsx') as never, { strict: false }); + const built = defineStack(source('aa', 'visibility', 'jsx') as never, { strict: false }); const record = stackConversionsOf(built).map(substance); expect(record).toHaveLength(2); expect(record).toContainEqual(HEADER_NOTICE); @@ -135,23 +139,23 @@ describe('defineStack records the conversions it applied', () => { it('⭐ control: the canonical spelling records nothing, on a stack that IS marked', () => { const warn = quiet(); for (const built of [ - defineStack(source('aa', 'subtitle') as never), - defineStack(source('aa', 'subtitle') as never, { strict: false }), + defineStack(source('aa', 'visibleWhen') as never), + defineStack(source('aa', 'visibleWhen') as never, { strict: false }), ]) { expect(hasStackProvenance(built), '`[]` here must not be the unmarked answer').toBe(true); expect(stackConversionsOf(built)).toEqual([]); } - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("conversion 'page-header-subtitle-alias'")); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("conversion 'page-component-visibility-to-visibleWhen'")); }); it('is not subject to the stderr warn-once: a second build of the same source carries the record too', () => { const warn = quiet(); const ns = 'warnonce'; - const first = defineStack(source(ns, 'description') as never); - const second = defineStack(source(ns, 'description') as never); + const first = defineStack(source(ns, 'visibility') as never); + const second = defineStack(source(ns, 'visibility') as never); const lines = warn.mock.calls .map((c) => String(c[0])) - .filter((l) => l.includes("conversion 'page-header-subtitle-alias'") && l.includes(HEADER_PATH)); + .filter((l) => l.includes("conversion 'page-component-visibility-to-visibleWhen'") && l.includes(HEADER_PATH)); // stderr says it at most once per process (an earlier test may have said it already)… expect(lines.length).toBeLessThanOrEqual(1); // …while each built stack keeps its own record. @@ -161,7 +165,7 @@ describe('defineStack records the conversions it applied', () => { it('a built stack handed straight back to defineStack keeps the record it was built with', () => { quiet(); - const inner = defineStack(source('aa', 'description') as never, { strict: false }); + const inner = defineStack(source('aa', 'visibility') as never, { strict: false }); const outer = defineStack(inner as never, { strict: false }); expect(outer).not.toBe(inner); expect(stackConversionsOf(outer).map(substance)).toEqual([HEADER_NOTICE]); @@ -171,7 +175,7 @@ describe('defineStack records the conversions it applied', () => { describe('the record is invisible to every data reader, and frozen', () => { const built = () => { quiet(); - return defineStack(source('aa', 'description') as never); + return defineStack(source('aa', 'visibility') as never); }; it('is not an own string key and is non-enumerable, non-writable, non-configurable', () => { @@ -195,7 +199,7 @@ describe('the record is invisible to every data reader, and frozen', () => { it('never reaches JSON, and the strict stack schema neither sees nor refuses it', () => { const stack = built(); expect(stackConversionsOf(stack), 'anti-vacuity: there IS a record to leak').toHaveLength(1); - expect(JSON.stringify(stack)).not.toContain('page-header-subtitle-alias'); + expect(JSON.stringify(stack)).not.toContain('page-component-visibility-to-visibleWhen'); expect(ObjectStackDefinitionSchema.safeParse(stack).success).toBe(true); }); }); @@ -203,12 +207,12 @@ describe('the record is invisible to every data reader, and frozen', () => { describe('stackConversionsOf answers [] wherever the mark is absent', () => { const built = () => { quiet(); - return defineStack(source('aa', 'description') as never); + return defineStack(source('aa', 'visibility') as never); }; it('a plain literal, a spread copy and a JSON copy of a built stack', () => { const stack = built(); - expect(stackConversionsOf(source('aa', 'description'))).toEqual([]); + expect(stackConversionsOf(source('aa', 'visibility'))).toEqual([]); expect(stackConversionsOf({ ...stack })).toEqual([]); expect(stackConversionsOf(JSON.parse(JSON.stringify(stack)))).toEqual([]); // positive control for the three: the built stack itself @@ -234,15 +238,15 @@ describe('stackConversionsOf answers [] wherever the mark is absent', () => { }); describe('composeStacks records its inputs’ records, in input order, one application once', () => { - const build = (ns: string, headerKey: 'description' | 'subtitle') => { + const build = (ns: string, headerKey: 'visibility' | 'visibleWhen') => { quiet(); return defineStack(source(ns, headerKey) as never, { strict: false }); }; it('two inputs: concatenated in input order, each path relative to its own defineStack call', () => { - const a = build('aa', 'description'); - const b = build('bb', 'subtitle'); - const c = build('cc', 'description'); + const a = build('aa', 'visibility'); + const b = build('bb', 'visibleWhen'); + const c = build('cc', 'visibility'); const composed = composeStacks([a, b, c] as ObjectStackDefinition[]); const record = stackConversionsOf(composed); expect(record).toEqual([...stackConversionsOf(a), ...stackConversionsOf(c)]); @@ -254,15 +258,15 @@ describe('composeStacks records its inputs’ records, in input order, one appli }); it('the same built stack passed twice is one application, counted once', () => { - const a = build('aa', 'description'); + const a = build('aa', 'visibility'); const composed = composeStacks([a, a] as ObjectStackDefinition[], { objectConflict: 'override' }); expect(stackConversionsOf(composed).map(substance)).toEqual([HEADER_NOTICE]); }); it('every arity, the preserve manifest and nesting', () => { - const a = build('aa', 'description'); - const b = build('bb', 'subtitle'); - const c = build('cc', 'description'); + const a = build('aa', 'visibility'); + const b = build('bb', 'visibleWhen'); + const c = build('cc', 'visibility'); expect(stackConversionsOf(a), 'anti-vacuity: the inputs carry records').toHaveLength(1); expect(stackConversionsOf(c)).toHaveLength(1); expect(stackConversionsOf(composeStacks([]))).toEqual([]); @@ -277,7 +281,7 @@ describe('composeStacks records its inputs’ records, in input order, one appli }); it('⭐ control: inputs that converted nothing compose to an empty record on a marked artifact', () => { - const composed = composeStacks([build('aa', 'subtitle'), build('bb', 'subtitle')] as ObjectStackDefinition[]); + const composed = composeStacks([build('aa', 'visibleWhen'), build('bb', 'visibleWhen')] as ObjectStackDefinition[]); expect(hasStackProvenance(composed)).toBe(true); expect(stackConversionsOf(composed)).toEqual([]); }); @@ -297,10 +301,10 @@ function thrown(run: () => unknown): Error & { code?: string; status?: number; i const ownRecord = (value: object) => Object.getOwnPropertyDescriptor(value, Symbol.for('objectstack.stack.conversions')); describe('a refusing producer carries the conversions it applied on its refusal', () => { - it("⭐ triage pin: a convert-then-refuse call's error answers page-header-subtitle-alias", () => { + it("⭐ triage pin: a convert-then-refuse call's error answers page-component-visibility-to-visibleWhen", () => { quiet(); const error = thrown(() => - defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never), ); expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); expect(error.status).toBe(422); @@ -317,7 +321,7 @@ describe('a refusing producer carries the conversions it applied on its refusal' it('⭐ triage pin: a refusal with no conversion answers an empty record — stamped, not merely absent', () => { quiet(); const error = thrown(() => - defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibleWhen'), requires: ['no-such-capability'] } as never), ); expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN'); expect(stackConversionsOf(error)).toEqual([]); @@ -332,10 +336,10 @@ describe('a refusing producer carries the conversions it applied on its refusal' it('the refusal is otherwise the same refusal: code, status, name, message and issues are unchanged', () => { quiet(); const converting = thrown(() => - defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never), ); const canonical = thrown(() => - defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibleWhen'), requires: ['no-such-capability'] } as never), ); for (const key of ['code', 'status', 'name', 'message'] as const) { expect(converting[key], key).toBe(canonical[key]); @@ -347,7 +351,7 @@ describe('a refusing producer carries the conversions it applied on its refusal' it('the record on a refusal is invisible to data readers and frozen, like the one on a stack', () => { quiet(); const error = thrown(() => - defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never), ); const desc = ownRecord(error); expect(desc?.enumerable).toBe(false); @@ -364,7 +368,7 @@ describe('a refusing producer carries the conversions it applied on its refusal' quiet(); // Built without validation, so the capability refusal is still owed. const inner = defineStack( - { ...source('rf', 'description'), requires: ['no-such-capability'] } as never, + { ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never, { strict: false }, ); expect(stackConversionsOf(inner), 'anti-vacuity: the inner build recorded one').toHaveLength(1); @@ -397,13 +401,13 @@ describe('a refusing producer carries the conversions it applied on its refusal' } }; const headerLines = (lines: string[]) => - lines.filter((l) => l.includes("conversion 'page-header-subtitle-alias'") && l.includes(HEADER_PATH)); + lines.filter((l) => l.includes("conversion 'page-component-visibility-to-visibleWhen'") && l.includes(HEADER_PATH)); // A and B author the same notice path, so their warn-once key is one key. const error = thrown(() => composeStacks([ - define(source('ca', 'description')), - define({ ...source('cb', 'description'), requires: ['no-such-capability'] }), + define(source('ca', 'visibility')), + define({ ...source('cb', 'visibility'), requires: ['no-such-capability'] }), ] as ObjectStackDefinition[]), ); @@ -449,8 +453,8 @@ describe('every refusal site in defineStack carries the record — census, both ], edges: [{ id: 'e1', source: 'start', target: 'end' }], }; - /** The converting page every row carries: `description` on a `page:header`. */ - const pages = source('probe', 'description').pages; + /** The converting page every row carries: `visibility` on a `page:header`. */ + const pages = source('probe', 'visibility').pages; const grant = { name: 'managers', label: 'Managers', objects: { [task.name]: { allowRead: true, readScope: 'unit_and_below' } } }; const rows: Array<{ site: string; code: string; config: Record; strict?: false }> = [ @@ -495,7 +499,7 @@ describe('every refusal site in defineStack carries the record — census, both }); describe('a composeStacks refusal carries its inputs’ records', () => { - const build = (ns: string, headerKey: 'description' | 'subtitle', objectNs = ns) => { + const build = (ns: string, headerKey: 'visibility' | 'visibleWhen', objectNs = ns) => { quiet(); const stack = source(ns, headerKey); return defineStack( @@ -505,9 +509,9 @@ describe('a composeStacks refusal carries its inputs’ records', () => { }; it('an object conflict: the inputs’ records, in input order — the record the artifact would have carried', () => { - const a = build('ka', 'description', 'kk'); - const b = build('kb', 'subtitle', 'kk'); - const c = build('kc', 'description', 'kk'); + const a = build('ka', 'visibility', 'kk'); + const b = build('kb', 'visibleWhen', 'kk'); + const c = build('kc', 'visibility', 'kk'); const error = thrown(() => composeStacks([a, b, c] as ObjectStackDefinition[])); expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT'); expect(error.status).toBe(422); @@ -516,7 +520,7 @@ describe('a composeStacks refusal carries its inputs’ records', () => { }); it('an unbuilt input: the built inputs’ records ride the provenance refusal', () => { - const a = build('pa', 'description'); + const a = build('pa', 'visibility'); const error = thrown(() => composeStacks([a, { ...a }] as ObjectStackDefinition[])); expect(error.code).toBe('STACK_PROVENANCE_MISSING'); expect(stackConversionsOf(error)).toEqual(stackConversionsOf(a)); @@ -525,7 +529,7 @@ describe('a composeStacks refusal carries its inputs’ records', () => { it('⭐ control: inputs that converted nothing refuse with an empty record — stamped', () => { const error = thrown(() => - composeStacks([build('za', 'subtitle', 'zz'), build('zb', 'subtitle', 'zz')] as ObjectStackDefinition[]), + composeStacks([build('za', 'visibleWhen', 'zz'), build('zb', 'visibleWhen', 'zz')] as ObjectStackDefinition[]), ); expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT'); expect(stackConversionsOf(error)).toEqual([]); @@ -533,8 +537,8 @@ describe('a composeStacks refusal carries its inputs’ records', () => { }); it('a non-refusal throw is not given a record: the options parse error carries none', () => { - const a = build('oa', 'description'); - const b = build('ob', 'subtitle'); + const a = build('oa', 'visibility'); + const b = build('ob', 'visibleWhen'); const error = thrown(() => composeStacks([a, b] as ObjectStackDefinition[], { objectConflict: 'nope' } as never), ); @@ -552,7 +556,7 @@ describe('stackConversionsOf reads a refusal’s record and nothing that merely it('a record inherited through the prototype, not stamped on the error itself, is not read', () => { quiet(); const refusal = thrown(() => - defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never), + defineStack({ ...source('rf', 'visibility'), requires: ['no-such-capability'] } as never), ); expect(stackConversionsOf(refusal), 'anti-vacuity: the stamped refusal is read').toHaveLength(1); const heir = Object.create(refusal) as Error; From 7fedbb7ca5236030209ce75e9762f57ec42d11d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:47:14 +0000 Subject: [PATCH 3/7] test(lint): the normalized tier refuses a retired conversion's old header key with the rename Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../lint/src/validate-component-props.test.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index a1880e00f16..b4992db317e 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -713,31 +713,35 @@ describe('validateComponentProps — object-* blocks are dispatched (#7751)', () }); /** - * Why the registry entry reads the NORMALIZED tier. + * The normalized tier, after `page-header-subtitle-alias` retired from the + * load path at 18 (#23421). * - * The props bag survives the Zod parse unchanged (`z.record(z.string(), - * z.unknown())`), so the two tiers carry identical props and the choice looks - * free. It is not: the ADR-0087 D2 conversion layer runs INSIDE - * `normalizeStackInput`, and `page-header-subtitle-alias` rewrites - * `properties.description` → `subtitle` on header nodes. Reading the raw - * authored input would report a key the conversion layer has already fixed — - * the rule contradicting a declared conversion, which is exactly the - * second-de-facto-contract shape Prime Directive #12 forbids. + * The registry entry reads the NORMALIZED tier, and while that conversion was + * live it rewrote `properties.description` → `subtitle` on header nodes inside + * `normalizeStackInput`, so `os validate` passed a page the metadata save door + * (which judges the raw page) refused. Retired, the conversion no longer runs + * in the authoring funnel: both tiers now carry the authored `description`, + * and both refuse it with the rename `PageHeaderProps` declares — the same + * answer every door gives. The conversion still canonicalizes data at rest. */ describe('validateComponentProps — reads the post-conversion (normalized) tier', () => { const authored = stackWith([ { type: 'page:header', properties: { title: 'Lead', description: 'All open leads' } }, ]); + const DESCRIPTION_PATH = 'pages[0].regions[0].components[0].properties.description'; - it('says nothing about a key the ADR-0087 conversion layer canonicalizes', () => { - expect(validateComponentProps(normalizeStackInput(authored))).toEqual([]); + it('refuses a retired conversion\'s old key at the normalized tier, naming the rename', () => { + const findings = validateComponentProps(normalizeStackInput(authored)); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [COMPONENT_PROPS_UNKNOWN_KEY, 'error', DESCRIPTION_PATH], + ]); + expect(findings[0].message).toContain('subtitle'); }); - it('WOULD report it pre-conversion — which is why the tier is not free', () => { + it('gives the raw tier the same answer — the two doors no longer disagree on this page', () => { const raw = validateComponentProps(authored); - expect(unknownKeys(raw).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.description', - ]); + expect(unknownKeys(raw).map((f) => f.path)).toEqual([DESCRIPTION_PATH]); + expect(unknownKeys(raw)[0].message).toContain('subtitle'); }); }); From 91995d2254640991e9987150dd75614eeeabdb95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:49:09 +0000 Subject: [PATCH 4/7] test(cli): the conversion-record and strict-exit fixtures ride the live page visibility conversion Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../validate-json-strict-exit.e2e.test.ts | 50 ++++++++++--------- .../test/stack-conversion-record-door.test.ts | 40 ++++++++------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index 715fc6e4781..8b33b6c13e9 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -57,12 +57,15 @@ * the original divergence for exactly these configs with all of them green. * * The two fixtures below are a MINIMAL PAIR from one template, differing in a - * single key on one page-header component: `description`, the alias - * `page-header-subtitle-alias` rewrites at load, against `subtitle`, the - * canonical spelling that converts nothing. So the pin discriminates on the + * single key on one page-header component: `visibility`, the alias + * `page-component-visibility-to-visibleWhen` rewrites at load, against + * `visibleWhen`, the canonical spelling that converts nothing. (The pair used + * to differ in the header's `description` against `subtitle`; that conversion, + * `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and + * the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the * CONVERSION and not on "a page is present" — measured on the pair before it - * was written: `description` → text `--strict` 1, `--json --strict` 1, `--json` - * 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices. + * was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json` + * 0, `warnings: []`, one notice; `visibleWhen` → 0 on every face, no notices. * * Under the one-authoring-shape ruling (#20367) every accepted config is * `defineStack` output, and `defineStack` applies the conversion itself at @@ -71,13 +74,13 @@ * reachable again (#20476) because the producer RECORDS the conversions it * applied on the stack it returns (`stackConversionsOf`) and the door folds * that record into the list `--strict` gates on and into `conversions`. The - * cell below is back to the measurement above: `description` → both `--strict` + * cell below is back to the measurement above: `visibility` → both `--strict` * faces 1, `--json` alone 0, `warnings: []`, the one notice. * * The live-notice assertion (the payload entry, and the producer's one * stderr line) is the anti-vacuity guard, and it is load-bearing rather than - * decorative. `page-header-subtitle-alias` is a LIVE - * window that retires from the load path at protocol 18; the day it retires, + * decorative. `page-component-visibility-to-visibleWhen` is a LIVE + * window; the day it retires from the load path, * this fixture raises nothing and, without that assertion, the file would keep * passing while pinning an empty cell — precisely the failure this test exists * to remove. It goes red instead, and whoever retires the entry re-points the @@ -172,14 +175,15 @@ export default defineStack({ /** * The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one - * `page:header` component, whose second line is authored under the key named. + * `page:header` component, whose visibility predicate is authored under the + * key named. * * Nothing else here raises an advisory: the unknown-key lints run on the - * POST-conversion `normalized`, so what they see is the canonical `subtitle` + * POST-conversion `normalized`, so what they see is the canonical `visibleWhen` * either way, and both members are pinned to `warnings: []` below rather than * assumed to be. */ -const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => ` +const headerPageSource = (headerVisibilityKey: 'visibility' | 'visibleWhen'): string => ` import { defineStack } from '@objectstack/spec'; export default defineStack({ @@ -195,7 +199,7 @@ export default defineStack({ name: 'strictexit_home', label: 'Home', regions: [{ name: 'main', components: [ - { type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } }, + { type: 'page:header', properties: { title: 'Tickets' }, ${headerVisibilityKey}: "page.mode != 'edit'" }, ] }], }], }, { strict: false }); @@ -237,10 +241,10 @@ beforeAll(() => { writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE); linkSpec(cleanDir); conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-')); - writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description')); + writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('visibility')); linkSpec(conversionsDir); conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-')); - writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle')); + writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('visibleWhen')); linkSpec(conversionsCanonDir); }); @@ -326,25 +330,25 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => 'the producer-applied conversion reaches the payload, once', ).toEqual([ { - conversionId: 'page-header-subtitle-alias', - path: 'pages[0].regions[0].components[0].properties.subtitle', - from: 'description', - to: 'subtitle', + conversionId: 'page-component-visibility-to-visibleWhen', + path: 'pages[0].regions[0].components[0].visibleWhen', + from: 'visibility', + to: 'visibleWhen', }, ]); expect(typeof payload.conversions?.[0]?.retiresIn, 'the expiry rides the entry').toBe('number'); // The text face names what failed it, in its `⚠` block. - expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'"); + expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'"); // Anti-vacuity AND the one-stderr-line property: the conversion is LIVE — // it fired, in the producer, once per run — and no door-side line repeats it - // on stderr. The day `page-header-subtitle-alias` retires this goes red; + // on stderr. The day `page-component-visibility-to-visibleWhen` retires this goes red; // re-point `headerPageSource` at a live entry in // `packages/spec/src/conversions/registry.ts`. for (const run of [text, json]) { expect( - run.stderr.split("conversion 'page-header-subtitle-alias'").length - 1, + run.stderr.split("conversion 'page-component-visibility-to-visibleWhen'").length - 1, 'defineStack reported the conversion at load, on exactly one stderr line', ).toBe(1); } @@ -356,7 +360,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => const json = await runCli(['validate', '--json'], conversionsDir); expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0); const payload = JSON.parse(json.stdout) as { conversions?: Array<{ conversionId?: unknown }> }; - expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-header-subtitle-alias']); + expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-component-visibility-to-visibleWhen']); }, 120_000); it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => { @@ -372,7 +376,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => expect(payload.warnings).toEqual([]); expect(payload.conversions).toEqual([]); // The discriminator's other half: the canonical key raises no notice anywhere. - expect(json.stderr).not.toContain("conversion 'page-header-subtitle-alias'"); + expect(json.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'"); }, 120_000); it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => { diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index ba8c850fbf1..db5ecb79d4c 100644 --- a/packages/cli/test/stack-conversion-record-door.test.ts +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -59,8 +59,10 @@ * needed no conversion answers `[]`, so an exit that reports the notice without * reading it off the refusal it caught is red there. * - * `page-header-subtitle-alias` is the live conversion driven here (`description` - * on a `page:header` component, canonical `subtitle`). The day it retires from + * `page-component-visibility-to-visibleWhen` is the live conversion driven here + * (`visibility` on a `page:header` component, canonical `visibleWhen`; it was + * the header's `description` → `subtitle` until that entry retired from the + * load path at protocol 18). The day it retires from * the load path the non-empty rows go red; re-point the fixture at a live entry * in `packages/spec/src/conversions/registry.ts`. * @@ -122,10 +124,10 @@ function payloadOf(run: Run, label: string): Payload { /** The one entry every non-empty row must carry, by identity, site and direction. */ const THE_NOTICE = { code: 'OS_METADATA_CONVERTED', - conversionId: 'page-header-subtitle-alias', - path: 'pages[0].regions[0].components[0].properties.subtitle', - from: 'description', - to: 'subtitle', + conversionId: 'page-component-visibility-to-visibleWhen', + path: 'pages[0].regions[0].components[0].visibleWhen', + from: 'visibility', + to: 'visibleWhen', }; function expectExactly(payload: Payload, expected: Array, label: string): void { @@ -141,15 +143,15 @@ function expectExactly(payload: Payload, expected: Array, lab const manifest = (ns: string) => `{ id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }`; -const page = (ns: string, headerKey: 'description' | 'subtitle') => `{ +const page = (ns: string, headerKey: 'visibility' | 'visibleWhen') => `{ name: '${ns}_home', label: 'Home', regions: [{ name: 'main', components: [ - { type: 'page:header', properties: { title: 'Things', ${headerKey}: 'All things' } }, + { type: 'page:header', properties: { title: 'Things' }, ${headerKey}: "page.mode != 'edit'" }, ] }], }`; -const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null, requires?: string) => `{ +const stackBody = (ns: string, headerKey: 'visibility' | 'visibleWhen' | null, requires?: string) => `{ manifest: ${manifest(ns)}, objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''} @@ -159,32 +161,32 @@ const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\ const FIXTURES: Record = { // The plain case: one `defineStack` default export, no named export. - plain: IMPORT + `export default defineStack(${stackBody('pln', 'description')});\n`, + plain: IMPORT + `export default defineStack(${stackBody('pln', 'visibility')});\n`, // The record, across `loadConfig`'s named-export spread. recordAcrossSpread: IMPORT + `export const onEnable = async () => {};\n\n` + - `export default defineStack(${stackBody('rec', 'description')});\n`, + `export default defineStack(${stackBody('rec', 'visibility')});\n`, // The record of a composed stack: its inputs' records. composed: IMPORT + - `const withIt = defineStack(${stackBody('cmpa', 'description')});\n` + + `const withIt = defineStack(${stackBody('cmpa', 'visibility')});\n` + `const without = defineStack({ manifest: ${manifest('cmpb')}, objects: [{ name: 'cmpb_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }] });\n\n` + `export default composeStacks([withIt, without]);\n`, // A key merged after the producer ran: only the door's own pass sees it. namedExportPass: IMPORT + - `export const pages = [${page('nep', 'description')}];\n\n` + + `export const pages = [${page('nep', 'visibility')}];\n\n` + `export default defineStack(${stackBody('nep', null)});\n`, // The control: the canonical spelling. - canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`, + canonical: IMPORT + `export default defineStack(${stackBody('can', 'visibleWhen')});\n`, // Convert, then refuse: the strict (default) `defineStack` converts the // retiring spelling, then refuses an unknown capability token. convertThenRefuse: - IMPORT + `export default defineStack(${stackBody('ctr', 'description', 'no-such-capability')});\n`, + IMPORT + `export default defineStack(${stackBody('ctr', 'visibility', 'no-such-capability')});\n`, // Its control: the same refusal from a source that needed no conversion. canonicalThenRefuse: - IMPORT + `export default defineStack(${stackBody('ctc', 'subtitle', 'no-such-capability')});\n`, + IMPORT + `export default defineStack(${stackBody('ctc', 'visibleWhen', 'no-such-capability')});\n`, }; let root = ''; @@ -232,7 +234,7 @@ for (const command of ['validate', 'build'] as const) { expect(run.code, run.stdout + run.stderr).toBe(0); expectExactly(p, [THE_NOTICE], `${command} namedExportPass`); // The producer never saw that key, so it printed nothing for it. - expect(run.stderr).not.toContain("conversion 'page-header-subtitle-alias'"); + expect(run.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'"); }, 180_000); it('control: the canonical spelling converts nothing — `[]`', async () => { @@ -251,7 +253,7 @@ describe("os lint --json — the producer's conversion record reaches `conversio // and still converts through the pass (`lint-conversion-notices.e2e.test.ts` // pins that half on a plain object-literal export). const lint = (label: string) => runCli(['lint', '--json'], dirs[label]); - const producerLine = "conversion 'page-header-subtitle-alias'"; + const producerLine = "conversion 'page-component-visibility-to-visibleWhen'"; it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => { const run = await lint('plain'); @@ -333,7 +335,7 @@ describe('os validate --strict — a conversion the producer applied fails it, o const json = await runCli(['validate', '--json', '--strict'], dirs.recordAcrossSpread); expect(text.code, text.stdout + text.stderr).toBe(1); expect(text.stdout).toContain('Strict mode: warnings treated as errors'); - expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'"); + expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'"); expect(json.code, json.stdout + json.stderr).toBe(1); const p = payloadOf(json, 'validate --json --strict'); expect(p.valid).toBe(true); From d7f47bae9647e4dd6527908c3c19315d32f23ae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:57:40 +0000 Subject: [PATCH 5/7] docs(spec): the two retired conversions' schema comments and the changeset Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/23421-retire-page-aliases-at-18.md | 20 +++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 9 ++++++--- packages/spec/src/ui/component.zod.ts | 8 +++++--- 3 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 .changeset/23421-retire-page-aliases-at-18.md diff --git a/.changeset/23421-retire-page-aliases-at-18.md b/.changeset/23421-retire-page-aliases-at-18.md new file mode 100644 index 00000000000..a05ead302cf --- /dev/null +++ b/.changeset/23421-retire-page-aliases-at-18.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: two page-property conversions leave the load path at 18 — a `page:header`'s `description` and an inline `api` action's object-form `params` are refused with their rename at every door that judges page props + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** the ADR-0087 D2 conversions `page-header-subtitle-alias` and `inline-action-api-params-to-body-extra` were declared for protocol 17's one-major window and are now retired from the load path. The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites either spelling, so an author who still writes one meets the page-props refusal, which names the rename. That refusal is the same answer the metadata save door gives, so `os validate` no longer passes a page that the save door then refuses. + +Migration: + +- `page:header` (and the legacy `page-header` node) `properties.description` → `properties.subtitle`. Rename the key; the value is unchanged. If both keys are present with different text, keep the one you mean under `subtitle` and delete `description`. +- An inline `type: 'api'` action's object-form `params` (a request payload map such as `params: { name: '{{page.inquiryName}}' }`) → `bodyExtra`. Rename the key; the value is unchanged. `params` stays the `ActionParam[]` definition array (the fields a dialog collects). + +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. + +Stored metadata needs no edit. Stored-row rehydration still replays the whole conversion chain, these two entries included, so `sys_metadata` page rows written under either old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. + + diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 9defb530cc8..2a4c90a02d7 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1255,9 +1255,12 @@ const actionObject = () => strictObject({ * The maintainer's 2026-08-06 ruling on #5777 took direction A (a separate * key, no same-name union), so this key keeps ONE meaning and the object form * is REFUSED here — with a message that names `bodyExtra` rather than the - * bare "expected array, received object" an author cannot act on. Sources - * still carrying the object form are rewritten at load by the - * `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2). + * bare "expected array, received object" an author cannot act on. The + * `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2) rewrote + * the object form at load for protocol 17's window; retired from the load + * path at 18, it now rewrites only data at rest (stored rows, artifacts + * inside the ingestion window, `os migrate meta`), so an authored object + * form meets this refusal. * * **The api prescription is not universal, which #6828 measured and the * maintainer's 2026-08-10 ruling closed.** On a `type:'url'` action the diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index fa2fcbf2ecf..e08db314495 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -591,9 +591,11 @@ export const PageHeaderProps = strictObject({ aliases: { /** * The ADR-0087 D2 conversion `page-header-subtitle-alias` (#4827, - * objectui#3226) renames this on load and on stored-row rehydration, so the - * canonical paths never reach here. What DOES reach here is the source an - * author is typing right now — and until this shape closed, that was the + * objectui#3226) renamed this on load for protocol 17's window; retired + * from the load path at 18, it renames only data at rest (stored-row + * rehydration, artifacts inside the ingestion window, `os migrate meta`). + * What reaches here is the source an author is typing right now — every + * authored `description`, since the retirement — and until this shape closed, that was the * one path with no diagnostic at all: `conversions/walk.ts` records the * hole in as many words, that `description` "is tombstoned nowhere * (`description` is a live declared prop on other components), got no From ba27f6b76f9fec596afc1d9c66c854128a86ad07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:43:03 +0000 Subject: [PATCH 6/7] feat(spec): the two retired spellings' refusals name the release that refuses them, joined in the removal-version pin Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../retired-key-removal-version.pin.test.ts | 14 +++++++++++++ packages/spec/src/ui/action.zod.ts | 6 ++++-- packages/spec/src/ui/component.zod.ts | 21 +++++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index a29e4a763db..3f33b6e7166 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -88,6 +88,7 @@ import { ActionMenuPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, + ElementButtonPropsSchema, ElementRepeaterPropsSchema, ObjectGridPropsSchema, ObjectKanbanPropsSchema, @@ -239,6 +240,19 @@ const JOIN: Readonly> = { 'sharingModel', ), ], + // Two protocol-17 live-window conversions retired from the load path at 18 + // (`toMajor: 17`, unlike every other row): the prescription is the refusal + // the surface answers once the loader stops rewriting the old spelling. + 'page-header-subtitle-alias': [ + refusal("page:header property 'description'", () => PageHeaderProps.safeParse({ title: 'Leads', description: 'All open leads' })), + ], + 'inline-action-api-params-to-body-extra': [ + refusal( + "an inline type:'api' action's object-form params", + () => ElementButtonPropsSchema.safeParse({ label: 'Go', action: { type: 'api', target: '/x', params: { a: 1 } } }), + 'action', + ), + ], // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 2a4c90a02d7..32602c361ab 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1295,11 +1295,13 @@ const actionObject = () => strictObject({ && typeof iss.input === 'object' && !Array.isArray(iss.input) ? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not a values map. " - + "For a `type:'api'` action's static request body — including `{{page.}}` tokens — use `bodyExtra: { … }` instead. " + + "For a `type:'api'` action's static request body — including `{{page.}}` tokens — use `bodyExtra: { … }` instead; " + + "the loader rewrote that object form to `bodyExtra` on an inline page button until @objectstack/spec 18, which refuses it (ADR-0087 D2). " + "For a `type:'url'` action there is nowhere to move it to, by decision: put static values straight into the `target` string " + "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with " + "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed. " - + 'Expected an array of ActionParam, received an object.' + + 'Expected an array of ActionParam, received an object. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for the inline `element:button` action case; `--write` applies the ones it can prove, and a registered action keeps its object form until you move it by hand.' : undefined ), }).optional().describe('Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`).'), diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e08db314495..edb4366b232 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -588,7 +588,7 @@ export const PageHeaderProps = strictObject({ surface: 'this `page:header`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, - aliases: { + guidance: { /** * The ADR-0087 D2 conversion `page-header-subtitle-alias` (#4827, * objectui#3226) renamed this on load for protocol 17's window; retired @@ -601,12 +601,21 @@ export const PageHeaderProps = strictObject({ * (`description` is a live declared prop on other components), got no * diagnostic at a nested site from any layer". * - * An alias rather than a `retiredKey` tombstone precisely because of that - * parenthesis: `description` is a live prop elsewhere in this file - * (`element:text_input`), so the answer is a rename on THIS surface, not a - * removal notice. Grounded in the conversion registry rather than guessed. + * A guidance row rather than a `retiredKey` tombstone: `PageHeaderProps` + * never declared `description`, so there is no property for a tombstone to + * replace, and `description` is a live prop elsewhere in this file + * (`element:text_input`), so the answer is a rename on THIS surface. It was + * an `aliases` row (a bare "did you mean") while the conversion was live; + * retired, it is the upgrade prescription, naming the release that refuses + * the spelling (#23421). Grounded in the conversion registry rather than + * guessed. */ - description: 'subtitle', + description: + '`page:header` property `description` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was an ' + + 'off-spec second spelling of the header\'s second line, which the loader rewrote to `subtitle` until ' + + 'this release; `subtitle` is the declared key. Use `subtitle`. Rename the key; the value ' + + '(the second line\'s text) 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.', }, }, { /** From b873d113111007b5386be42536b3a90cc0d251f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:48:59 +0000 Subject: [PATCH 7/7] docs(changeset): the refusals now name the release Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/23421-retire-page-aliases-at-18.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23421-retire-page-aliases-at-18.md b/.changeset/23421-retire-page-aliases-at-18.md index a05ead302cf..09c99890848 100644 --- a/.changeset/23421-retire-page-aliases-at-18.md +++ b/.changeset/23421-retire-page-aliases-at-18.md @@ -6,7 +6,7 @@ feat(spec)!: two page-property conversions leave the load path at 18 — a `page Clause-②: yes (narrowing) -**BREAKING (authoring):** the ADR-0087 D2 conversions `page-header-subtitle-alias` and `inline-action-api-params-to-body-extra` were declared for protocol 17's one-major window and are now retired from the load path. The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites either spelling, so an author who still writes one meets the page-props refusal, which names the rename. That refusal is the same answer the metadata save door gives, so `os validate` no longer passes a page that the save door then refuses. +**BREAKING (authoring):** the ADR-0087 D2 conversions `page-header-subtitle-alias` and `inline-action-api-params-to-body-extra` were declared for protocol 17's one-major window and are now retired from the load path. The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites either spelling, so an author who still writes one meets the page-props refusal, which names the rename. That refusal is the same answer the metadata save door gives, so `os validate` no longer passes a page that the save door then refuses. Both refusals now also name the release that refuses the old spelling and the `os migrate meta` command: the `page:header` `description` refusal is an upgrade prescription naming `subtitle` (it was a bare "did you mean"), and the object-form `params` refusal adds the release and the command after its `bodyExtra` instruction. Migration: @@ -17,4 +17,4 @@ Run `os migrate meta --from 16` to list the mechanical edits for existing source Stored metadata needs no edit. Stored-row rehydration still replays the whole conversion chain, these two entries included, so `sys_metadata` page rows written under either old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. - +