From b56888e361a8754404c66e380257adbb2d8ec0f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:28:12 +0000 Subject: [PATCH 1/5] feat(spec)!: PageSchema refuses a page that authors both slots.details and slots.tabs The tabs slot replaces the whole tab strip the Details tab lives in, so a details slot beside it never rendered. checkPageSlotPair refuses the pair at slots.details, naming both slots and the fix: the record:details component as a child of a tabs item. sys_user_detail moves its record:details, with its sections and hideFields unchanged, in as the first tabs item. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/pages/sys-user.page.ts | 98 +++++++++++-------- .../object-refinement-check-exports.test.ts | 36 ++++++- packages/spec/src/ui/page.zod.ts | 69 ++++++++++++- 3 files changed, 157 insertions(+), 46 deletions(-) diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 31e2b504a48..7c8b12e1a79 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -17,8 +17,8 @@ import type { Page } from '@objectstack/spec/ui'; * * Strategy * -------- - * - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`, - * `details`, `tabs` and `discussion`. Header / actions fall through + * - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`, + * `highlights`, `tabs` and `discussion`. Header / actions fall through * to the synthesizer so the object's declared actions * (`update_my_profile / change_my_password / resend_verification_email * / ban_user / impersonate_user / …`) still appear @@ -26,13 +26,17 @@ import type { Page } from '@objectstack/spec/ui'; * - `highlights` promotes the four signals worth scanning at the top: * email, verification state, 2FA, platform role. Highlight fields * are auto-dropped from the details grid below. - * - `details` re-groups remaining fields into sections and hides - * admin-internal audit columns. Banned / ban metadata is still - * editable from the header actions — we just don't show it in - * every user's body. - * - `tabs` is **explicitly curated** to the 5 related lists that matter - * on a user profile (Positions / Sessions / Linked Accounts / Organizations / - * Personal OAuth Apps). Without this override, the synthesizer + * - The FIRST `tabs` item is the details grid: a `record:details` that + * re-groups remaining fields into sections and hides admin-internal + * audit columns. Banned / ban metadata is still editable from the + * header actions — we just don't show it in every user's body. It is + * a tab item and not a `details` slot because the `tabs` slot replaces + * the whole tab strip the Details tab lives in: a `details` slot beside + * `tabs` never rendered, and `PageSchema` refuses the pair. + * - The other `tabs` items are **explicitly curated** to the related lists + * that matter on a user profile (Positions / Permission Sets / Business + * Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API + * Keys) plus a Security tab. Without this override, the synthesizer * auto-generates a tab per object that has a FK to sys_user * (sys_position.created_by, sys_email.updated_by, sys_user_preference, * sys_email_template.created_by, …) producing dozens of noisy @@ -125,40 +129,16 @@ export const SysUserDetailPage: Page = { }, }, - // ── Body / details grid ─────────────────────────────────────── - details: { - type: 'record:details', - properties: { - hideFields: [ - 'id', - 'banned', - 'ban_reason', - 'ban_expires', - // already promoted to highlights: - 'email', - 'phone_number', - 'email_verified', - 'two_factor_enabled', - 'role', - ], - sections: [ - { - label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, - fields: ['name', 'image'], - }, - { - label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, - fields: ['created_at', 'updated_at'], - }, - ], - }, - }, - - // ── Tabs: curated related lists ─────────────────────────────── - // Only the 4 lists that are semantically about THIS user account. + // ── Tabs: the details grid, then curated related lists ──────── + // Only the lists that are semantically about THIS user account. // Everything else (sys_position created_by, sys_email_template // updated_by, …) is incidental authorship metadata and would only // create noise. + // + // There is no `details` slot: the `tabs` slot replaces the whole tab + // strip the synthesized Details tab lives in, so a `details` slot beside + // it never rendered and `PageSchema` refuses the pair (#22568). The + // details grid is therefore the FIRST tab's body here. tabs: { type: 'page:tabs', properties: { @@ -166,6 +146,44 @@ export const SysUserDetailPage: Page = { tabStyle: 'line', position: 'top', items: [ + { + // ── Body / details grid ─────────────────────────────── + // Re-groups the remaining fields into sections and hides + // admin-internal columns. Banned / ban metadata is still + // editable from the header actions — it is just not shown in + // every user's body. + label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, + icon: 'file-text', + children: [ + { + type: 'record:details', + properties: { + hideFields: [ + 'id', + 'banned', + 'ban_reason', + 'ban_expires', + // already promoted to highlights: + 'email', + 'phone_number', + 'email_verified', + 'two_factor_enabled', + 'role', + ], + sections: [ + { + label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, + fields: ['name', 'image'], + }, + { + label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, + fields: ['created_at', 'updated_at'], + }, + ], + }, + }, + ], + }, { label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' }, icon: 'shield-check', diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index f8536ec5078..bae76fa5c25 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -56,7 +56,13 @@ import { checkListViewCalendarVisualization, checkListViewChartBinding, } from './view.zod'; -import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition } from './page.zod'; +import { + PageSchema, + checkPageSourceCompleteness, + checkPageRequiresKind, + checkPagePrintComposition, + checkPageSlotPair, +} from './page.zod'; import { GlobalFilterSchema, checkGlobalFilterDateDefaultValue, @@ -271,6 +277,28 @@ const pagePrintFixtures: Fixture[] = [ { label: 'a `slotted` page with no `print`', value: { ...PAGE_BASE, kind: 'slotted' }, refusesAt: [] }, ]; +// [#22568] `slots.details` beside `slots.tabs` is refused at `slots.details`: +// the `tabs` slot replaces the strip the Details tab lives in, so the +// `details` body never rendered. Every fixture is a `record` page with no +// `print`, `source` or `requires`, so the three siblings stay silent and each +// row exercises THIS check alone; the kind-less row is the one whose direct +// call sees no `kind` while the parse sees the applied `full` default. +const SLOT_DETAILS = { type: 'record:details', properties: { hideFields: ['id'] } } as const; +const SLOT_TABS = { + type: 'page:tabs', + properties: { items: [{ label: 'Related', children: [{ type: 'record:related_list' }] }] }, +} as const; +const SLOT_PAGE = { ...PAGE_BASE, type: 'record', object: 'account', kind: 'slotted' } as const; +const pageSlotPairFixtures: Fixture[] = [ + { label: '`details` beside `tabs`, one component each', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, + { label: '`details` beside `tabs`, each an array', value: { ...SLOT_PAGE, slots: { details: [SLOT_DETAILS], tabs: [SLOT_TABS] } }, refusesAt: ['slots.details'] }, + { label: 'an EMPTY `details: []` beside `tabs` — a present slot is refused, not its contents', value: { ...SLOT_PAGE, slots: { details: [], tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, + { label: '`details` beside `tabs` on a page with no `kind` (the `full` default)', value: { ...PAGE_BASE, type: 'record', object: 'account', slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] }, + { label: '`details` alone', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS } }, refusesAt: [] }, + { label: '`tabs` alone, carrying the `record:details` in its first item', value: { ...SLOT_PAGE, slots: { tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [SLOT_DETAILS] }] } } } }, refusesAt: [] }, + { label: 'a slot map with neither', value: { ...SLOT_PAGE, slots: { discussion: [] } }, refusesAt: [] }, +]; + const DATE_FILTER = { field: 'created_at', type: 'date' } as const; const dateDefaultFixtures: Fixture[] = [ @@ -485,6 +513,7 @@ const MIRRORED: MirroredSchema[] = [ { name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures }, { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, + { name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures }, ], cleanFixtures: [{ ...PAGE_BASE }], }, @@ -640,9 +669,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares all three exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all four exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); @@ -676,6 +705,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], + ['checkPageSlotPair', checkPageSlotPair], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], ] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => { expect((ui as Record)[name]).toBe(fn); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e3221dff63c..dc9d9046ae3 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -744,6 +744,55 @@ export function checkPageRequiresKind( }); } +/** + * The slot-pair check attached to {@link PageSchema}: a page that authors + * both `slots.details` and `slots.tabs` is refused at `slots.details`, naming + * the pair and the fix. + * + * The two slots are not independent, whatever the slot menu's layout + * suggests. On a slotted record page the `details` slot is the BODY of the + * Details tab, and that tab lives inside the synthesized `page:tabs` strip; + * the `tabs` slot replaces that whole strip. So a `details` authored beside a + * `tabs` has nowhere to render: the console's default-page synthesizer reads + * `tabs` and never reads `details` (its own doc states the precedence — "wins + * over `details` when both are provided"). Before this check the pair parsed, + * validated and saved, and the authored details body — its `sections`, its + * `hideFields` — silently never applied. The fix is the shape the platform's + * own `sys_user_detail` page uses: the `record:details` component as a child + * of a `tabs` item (the first one, by convention), and no `details` slot. + * + * Judged on the slot map whatever the page's `kind`: the pair is never a + * coherent declaration, and a page that is not slotted reads no slot at all. + * A slot is present when its value is not `undefined` — the synthesizer's own + * test — so an empty `details: []` beside `tabs` is refused like a full one. + * + * Exported for the reason {@link checkPageSourceCompleteness} is: a downstream + * mirror that derives its schema from `PageSchema.shape` drops every + * object-level check, and re-attaches this one with + * `.superRefine(checkPageSlotPair)`. `PageSchema` attaches this same binding — + * pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPageSlotPair( + page: { slots?: unknown }, + ctx: z.RefinementCtx, +): void { + const slots = page.slots; + if (slots === undefined || slots === null || typeof slots !== 'object') return; + const { details, tabs } = slots as { details?: unknown; tabs?: unknown }; + if (details === undefined || tabs === undefined) return; + ctx.addIssue({ + code: 'custom', + path: ['slots', 'details'], + message: + '`slots.details` and `slots.tabs` are refused together: the `details` slot is the body of the ' + + 'Details tab inside the synthesized `page:tabs` strip, and an authored `tabs` slot replaces that ' + + 'whole strip, so the `details` beside it is never rendered. Move the `record:details` component ' + + 'into the `tabs` items — as the `children` of the first item, e.g. ' + + "`{ label: 'Details', children: [{ type: 'record:details', properties: { … } }] }` — and delete " + + '`slots.details`. To keep the synthesized tabs instead, delete `slots.tabs`.', + }); +} + /** * Page Schema * Defines a composition of components for a specific context. @@ -1285,6 +1334,15 @@ export const PageSchema = lazySchema(() => strictObject({ * custom, call the corresponding `buildDefault*` sub-builder from the * renderer runtime (e.g. @object-ui/plugin-detail). * + * Precedence — `details` and `tabs` are NOT independent. `details` + * replaces the body of the Details tab, and that tab lives inside the + * synthesized `page:tabs` strip; `tabs` replaces the whole strip, so it + * wins and a `details` beside it is never rendered. A page authoring both + * is therefore refused ({@link checkPageSlotPair}): to replace the tabs + * AND the details body, put the `record:details` component in the `tabs` + * items (the first item's `children`, by convention) and author no + * `details` slot. + * * Only honored when `kind === 'slotted'`. */ slots: strictObject({ @@ -1355,10 +1413,15 @@ export const PageSchema = lazySchema(() => strictObject({ .superRefine(checkPageRequiresKind) // Ruling B′ on #8346 (#22158): a print page prints exactly the blocks it // authors. Attached by identifier for the same reason as the checks above. - .superRefine(checkPagePrintComposition)); -// PageSchema's cross-field rules are the three checks above: the + .superRefine(checkPagePrintComposition) + // #22568: `slots.details` beside `slots.tabs` never renders — the `tabs` + // slot replaces the strip the Details tab lives in. Attached by identifier + // for the same reason as the checks above. + .superRefine(checkPageSlotPair)); +// PageSchema's cross-field rules are the four checks above: the // ADR-0080/0081 source completeness check, the compiled-kind `requires` -// check and the print-page composition check. It once also required +// check, the print-page composition check and the `details` + `tabs` slot +// pair check. It once also required // `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / // "required-but-unauthorable" Studio traps). From 3e741bfa0069e6aca2136fd6067a773c251c0284 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:39:17 +0000 Subject: [PATCH 2/5] feat(spec)!: register the slot-pair narrowing (D3 entry, step-18 fragment) and pin it The D3 semantic entry page-slots-details-beside-tabs-refused and its step-18 rationale fragment, registry.ts regenerated; the refusal and its controls pinned in page-slot-pair.test.ts; sys_user_detail's first-tab details body pinned in platform-objects; the changeset. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- ...-page-slots-details-beside-tabs-refused.md | 35 ++++ .../src/pages/sys-user-details-tab.test.ts | 84 ++++++++++ ....page-slots-details-beside-tabs-refused.ts | 57 +++++++ packages/spec/src/migrations/registry.ts | 68 ++++++++ packages/spec/src/ui/page-slot-pair.test.ts | 152 ++++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 .changeset/22568-page-slots-details-beside-tabs-refused.md create mode 100644 packages/platform-objects/src/pages/sys-user-details-tab.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts create mode 100644 packages/spec/src/ui/page-slot-pair.test.ts diff --git a/.changeset/22568-page-slots-details-beside-tabs-refused.md b/.changeset/22568-page-slots-details-beside-tabs-refused.md new file mode 100644 index 00000000000..087ed02a765 --- /dev/null +++ b/.changeset/22568-page-slots-details-beside-tabs-refused.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +**Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. + +**What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. + +**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | +| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | + +**The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. + +**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is served as before (the authored tabs, without the details body); saving it again is refused until the details body moves. + +### The kit + +- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. +- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. +- **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. diff --git a/packages/platform-objects/src/pages/sys-user-details-tab.test.ts b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts new file mode 100644 index 00000000000..2bf76ec41df --- /dev/null +++ b/packages/platform-objects/src/pages/sys-user-details-tab.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22568 — the user page's details body is the FIRST `tabs` item, not a + * `details` slot. + * + * The `tabs` slot replaces the whole tab strip the synthesized Details tab + * lives in, so a `details` slot authored beside it never rendered: the page's + * Identity and Audit sections never showed, and the admin-internal columns it + * hides (`ban_reason`, `ban_expires`, …) were never hidden by it. + * `PageSchema` now refuses the pair, and the page carries its `record:details` + * inside the strip it authors. + * + * Read from the shipped page metadata, through the spec's own page contract, + * so the pin fails if the page stops parsing and if the details body moves + * out of the first tab or loses a section or a hidden field. + */ + +import { describe, expect, it } from 'vitest'; +import { PageSchema } from '@objectstack/spec/ui'; +import { SysUserDetailPage } from './sys-user.page.js'; + +type Rec = Record; + +/** The details body as the page authored it before the move — sections and hidden fields unchanged. */ +const HIDE_FIELDS = [ + 'id', + 'banned', + 'ban_reason', + 'ban_expires', + 'email', + 'phone_number', + 'email_verified', + 'two_factor_enabled', + 'role', +]; +const SECTIONS = [ + { + label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' }, + fields: ['name', 'image'], + }, + { + label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' }, + fields: ['created_at', 'updated_at'], + }, +]; + +describe('sys_user_detail — the details body lives in the first tab', () => { + const slots = SysUserDetailPage.slots as Rec; + const tabs = slots.tabs as Rec; + const items = (tabs.properties as Rec).items as Rec[]; + + it('parses through PageSchema — the slot-pair refusal does not fire on the shipped page', () => { + const parsed = PageSchema.safeParse(SysUserDetailPage); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('authors no `details` slot beside its `tabs` slot', () => { + expect(slots).not.toHaveProperty('details'); + expect(tabs.type).toBe('page:tabs'); + }); + + it('carries the `record:details` as the only child of the FIRST tab item, sections and hidden fields intact', () => { + const first = items[0]!; + expect((first.label as Rec).en).toBe('Details'); + const children = first.children as Rec[]; + expect(children.map((c) => c.type)).toEqual(['record:details']); + const props = children[0]!.properties as Rec; + expect(props.hideFields).toEqual(HIDE_FIELDS); + expect(props.sections).toEqual(SECTIONS); + }); + + it('is the one `record:details` on the page — no second copy anywhere in the slot map', () => { + const found: unknown[] = []; + const walk = (n: unknown): void => { + if (Array.isArray(n)) { n.forEach(walk); return; } + if (!n || typeof n !== 'object') return; + if ((n as Rec).type === 'record:details') found.push(n); + Object.values(n as Rec).forEach(walk); + }; + walk(slots); + expect(found).toHaveLength(1); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts new file mode 100644 index 00000000000..0edd1a5c5b5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of +// the slot map, not a key removal: both slots stay live on their own, so there +// is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the +// pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where +// the details body goes inside an authored tab strip (which item, under which +// label) is the author's call, and the move makes visible a body that never +// rendered. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'page-slots-details-beside-tabs-refused', + surface: + 'page.slots.details authored beside page.slots.tabs on one page — either slot a single component ' + + 'or an array, an empty details array included, whatever the page kind', + replacement: + 'One `tabs` slot whose items carry the details body: the `record:details` component (its ' + + '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by ' + + 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` ' + + '— and no `details` slot. To keep the synthesized tab strip with the authored details body in ' + + 'its Details tab instead, delete `slots.tabs`.', + reason: + 'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: ' + + 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside ' + + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s ' + + 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are ' + + 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save ' + + 'door while the authored details body — its sections, its hidden fields — silently never ' + + 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit ' + + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' + + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' + + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' + + '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). ' + + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' + + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' + + '`discussion` only. ' + + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' + + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' + + 'page, not a respelling. ' + + '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it ' + + 'renders the authored tabs, without the details body), its read diagnostics name the pair, and ' + + 'saving it again is refused until the details body moves. ADR-0087.', + acceptanceCriteria: + 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' + + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' + + '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by ' + + 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or ' + + 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing ' + + 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API ' + + 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the ' + + 'authored details body (its sections, without its hidden fields) in the tab that now carries it. ' + + 'A page authoring only one of the two slots parses and renders byte-identically to before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4be6aa6a5ad..42defe42ddc 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6189,6 +6189,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'replay clean while authored sources are refused until edited; the delete is lossless. Its D3 ' + 'record is the semantic entry `page-requires-non-compiled-kind-refused`.', }, + { + id: 'page-slots-details-beside-tabs-refused', + order: 92, + text: + 'It also refuses a page that authors both `slots.details` and `slots.tabs`. The two slots read ' + + 'as independent and are not: `details` is the body of the Details tab, that tab lives inside ' + + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip, so the console\'s ' + + 'synthesizer never read a `details` authored beside it and its sections and hidden fields ' + + 'silently never applied. `PageSchema` now refuses the pair at `slots.details`, naming both ' + + 'slots and the fix — the `record:details` component as the `children` of a `tabs` item — so ' + + '`objectstack validate` and the metadata save door agree; the platform\'s own `sys_user_detail` ' + + 'page moved its details body into its first tab. No key is removed, so there is no tombstone, ' + + 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 ' + + 'record is the semantic entry `page-slots-details-beside-tabs-refused`.', + }, { id: 'permission-restore-purge-bits-retired', order: 18, @@ -17348,6 +17363,59 @@ const step18: MigrationStep = { + 'upgrade.', conversionIds: ['page-requires-non-compiled-kind-removed'], }, + // #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of + // the slot map, not a key removal: both slots stay live on their own, so there + // is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the + // pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where + // the details body goes inside an authored tab strip (which item, under which + // label) is the author's call, and the move makes visible a body that never + // rendered. + // + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + { + id: 'page-slots-details-beside-tabs-refused', + surface: + 'page.slots.details authored beside page.slots.tabs on one page — either slot a single component ' + + 'or an array, an empty details array included, whatever the page kind', + replacement: + 'One `tabs` slot whose items carry the details body: the `record:details` component (its ' + + '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by ' + + 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` ' + + '— and no `details` slot. To keep the synthesized tab strip with the authored details body in ' + + 'its Details tab instead, delete `slots.tabs`.', + reason: + 'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: ' + + 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside ' + + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s ' + + 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are ' + + 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save ' + + 'door while the authored details body — its sections, its hidden fields — silently never ' + + 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit ' + + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' + + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' + + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' + + '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). ' + + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' + + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' + + '`discussion` only. ' + + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' + + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' + + 'page, not a respelling. ' + + '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it ' + + 'renders the authored tabs, without the details body), its read diagnostics name the pair, and ' + + 'saving it again is refused until the details body moves. ADR-0087.', + acceptanceCriteria: + 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' + + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' + + '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by ' + + 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or ' + + 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing ' + + 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API ' + + 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the ' + + 'authored details body (its sections, without its hidden fields) in the tab that now carries it. ' + + 'A page authoring only one of the two slots parses and renders byte-identically to before.', + }, // #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the // D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). The diff --git a/packages/spec/src/ui/page-slot-pair.test.ts b/packages/spec/src/ui/page-slot-pair.test.ts new file mode 100644 index 00000000000..4fc4816ea81 --- /dev/null +++ b/packages/spec/src/ui/page-slot-pair.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `slots.details` beside `slots.tabs` is refused (#22568, triage direction b). + * + * The slot menu reads as seven independent slots, and two of them are not: + * `details` is the BODY of the Details tab, which lives inside the synthesized + * `page:tabs` strip, and `tabs` replaces that whole strip. The console's + * default-page synthesizer therefore reads `tabs` and never reads `details` + * when both are authored, so the pair parsed, validated and saved while the + * authored details body — its `sections`, its `hideFields` — never applied. + * The platform's own `sys_user_detail` page was the measured instance. + * + * Bookkeeping shapes, pinned below: + * + * 1. `checkPageSlotPair`, an exported object-level check attached to + * `PageSchema` beside the three page checks before it, refuses the pair + * at `slots.details`, naming both slots and the fix. Its parity with the + * schema is pinned in `object-refinement-check-exports.test.ts`. + * 2. No D2 conversion: where the details body goes inside an authored strip + * is the author's decision (which item, which label), and moving it makes + * visible a body that never rendered. A stored row replays unchanged. + * 3. The D3 entry is `page-slots-details-beside-tabs-refused`. + * + * On the assertion set: a bare `PageSchema` parse raises zod issues, pinned by + * `code`, `path` and the message's named subjects; the stack door wraps them + * in the ADR-0112 envelope, pinned by `code` and `status`. + */ + +import { describe, expect, it } from 'vitest'; + +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { PageSchema, definePage } from './page.zod'; + +const D3_ID = 'page-slots-details-beside-tabs-refused'; + +const DETAILS = { + type: 'record:details', + properties: { + hideFields: ['id', 'internal_notes'], + sections: [{ label: 'Identity', fields: ['name'] }], + }, +}; +const TABS = { + type: 'page:tabs', + properties: { + items: [{ label: 'Related', children: [{ type: 'record:related_list', properties: { objectName: 'contact', relationshipField: 'account_id' } }] }], + }, +}; +const page = (slots: Record, extra: Record = {}) => ({ + name: 'account_detail', + label: 'Account', + type: 'record', + object: 'account', + kind: 'slotted', + regions: [], + slots, + ...extra, +}); + +function refusalOf(value: unknown) { + const r = PageSchema.safeParse(value); + expect(r.success, 'expected a refusal').toBe(false); + return r.success ? [] : r.error.issues; +} + +describe('`slots.details` beside `slots.tabs` — the parse refuses the pair', () => { + it.each([ + ['one component each', { details: DETAILS, tabs: TABS }], + ['each an array', { details: [DETAILS], tabs: [TABS] }], + ['an EMPTY `details: []` — a present slot is refused, not its contents', { details: [], tabs: TABS }], + ])('refuses the pair (%s), at `slots.details`, naming both slots and the fix', (_label, slots) => { + const issues = refusalOf(page(slots)); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue!.code).toBe('custom'); + expect(issue!.path).toEqual(['slots', 'details']); + expect(issue!.message.startsWith('`slots.details` and `slots.tabs` are refused together')).toBe(true); + // The remedy names the component to move and where it goes. + expect(issue!.message).toContain('`record:details`'); + expect(issue!.message).toContain('`tabs` items'); + // Runtime prose carries no tracker number (`check:doc-authoring`). + expect(issue!.message).not.toMatch(/#\d/); + }); + + it('refuses the pair whatever the page\'s `kind` — a page that omits it (the `full` default) too', () => { + const { kind: _kind, ...kindless } = page({ details: DETAILS, tabs: TABS }); + expect(PageSchema.parse({ ...kindless, slots: {} }).kind).toBe('full'); + const issues = refusalOf(kindless); + expect(issues.map((i) => i.path.join('.'))).toEqual(['slots.details']); + }); + + it('`definePage` — the authoring helper — throws on the pair', () => { + expect(() => definePage(page({ details: DETAILS, tabs: TABS }) as never)).toThrow(/`slots\.details` and `slots\.tabs`/); + }); + + it('the stack door wraps the same refusal in its ADR-0112 envelope, at `pages.N.slots.details`', () => { + const stack = { + manifest: { id: 'com.example.pages', name: 'pages', version: '1.0.0', type: 'app', namespace: 'pgs' }, + pages: [ + page({ tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [DETAILS] }] } } }, { name: 'pgs_moved' }), + page({ details: DETAILS, tabs: TABS }, { name: 'pgs_pair' }), + ], + }; + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stack as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the pair page').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'pages.1.slots.details', code: 'custom' }, + ]); + }); +}); + +describe('CONTROL — a page authoring only one of the two slots is unchanged', () => { + it.each([ + ['only `details`', { details: DETAILS }], + ['only `tabs`', { tabs: TABS }], + ['only `tabs`, carrying the `record:details` in its first item (the fix)', { + tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [DETAILS] }, ...TABS.properties.items] } }, + }], + ['neither', { discussion: [] }], + ])('%s — parses, and the slot map comes back as authored', (_label, slots) => { + const r = PageSchema.safeParse(page(slots)); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + if (r.success) expect(Object.keys(r.data.slots ?? {}).sort()).toEqual(Object.keys(slots).sort()); + }); +}); + +describe('the ledger — a D3 entry and no D2 conversion', () => { + it('step 18 carries the D3 entry, judging no mechanical edit', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((e) => e.id === D3_ID); + expect(entry, D3_ID).toBeDefined(); + expect(entry!.conversionIds ?? []).toEqual([]); + }); + + it('a STORED pair row replays unchanged — no conversion moves the details body — and the parse still refuses it', () => { + const notices: ConversionNotice[] = []; + const row = page({ details: DETAILS, tabs: TABS }); + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(rehydrated).toEqual(row); + expect(notices.map((n) => n.conversionId)).toEqual([]); + expect(refusalOf(rehydrated).map((i) => i.path.join('.'))).toEqual(['slots.details']); + }); +}); From e92b99226e1b2dea8c8d383a77f0da87cc4c9e83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 02:43:25 +0000 Subject: [PATCH 3/5] chore(spec): regenerate api-surface and export-origins for the checkPageSlotPair export Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a210d9ec772..6f5d5a83a20 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -495,6 +495,7 @@ "checkListViewChartBinding (function)", "checkPagePrintComposition (function)", "checkPageRequiresKind (function)", + "checkPageSlotPair (function)", "checkPageSourceCompleteness (function)", "columnSummaryAlias (function)", "compileListViewGroupQuery (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index bd42aa4c820..367adcb9917 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -480,6 +480,7 @@ "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", + "checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", "columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)", "compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)", From 792ca2a520743a504c8346c27a3a89fd24fc0973 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:12:42 +0000 Subject: [PATCH 4/5] docs(spec): state only the measured doors and the stored-row disposition in the D3 entry and changeset Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../22568-page-slots-details-beside-tabs-refused.md | 2 +- .../18.page-slots-details-beside-tabs-refused.ts | 9 +++++---- packages/spec/src/migrations/registry.ts | 9 +++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/22568-page-slots-details-beside-tabs-refused.md b/.changeset/22568-page-slots-details-beside-tabs-refused.md index 087ed02a765..1362e21c37b 100644 --- a/.changeset/22568-page-slots-details-beside-tabs-refused.md +++ b/.changeset/22568-page-slots-details-beside-tabs-refused.md @@ -26,7 +26,7 @@ Clause-②: no (narrowing) **The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. -**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is served as before (the authored tabs, without the details body); saving it again is refused until the details body moves. +**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. ### The kit diff --git a/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts index 0edd1a5c5b5..853be1894b3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-slots-details-beside-tabs-refused.ts @@ -34,16 +34,17 @@ export const entry: SemanticMigration = { + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' - + '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). ' + + '`objectstack validate` and the metadata save door (`422 INVALID_METADATA`). ' + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' + '`discussion` only. ' + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' + 'page, not a respelling. ' - + '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it ' - + 'renders the authored tabs, without the details body), its read diagnostics name the pair, and ' - + 'saving it again is refused until the details body moves. ADR-0087.', + + '⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion ' + + 'touches it), so it renders as before — the authored tabs, without the details body — while ' + + 'its read diagnostics name the pair and saving it again is refused until the details body ' + + 'moves. ADR-0087.', acceptanceCriteria: 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 42defe42ddc..96adf76eff1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -17395,16 +17395,17 @@ const step18: MigrationStep = { + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' - + '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). ' + + '`objectstack validate` and the metadata save door (`422 INVALID_METADATA`). ' + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' + '`discussion` only. ' + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' + 'page, not a respelling. ' - + '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it ' - + 'renders the authored tabs, without the details body), its read diagnostics name the pair, and ' - + 'saving it again is refused until the details body moves. ADR-0087.', + + '⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion ' + + 'touches it), so it renders as before — the authored tabs, without the details body — while ' + + 'its read diagnostics name the pair and saving it again is refused until the details body ' + + 'moves. ADR-0087.', acceptanceCriteria: 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' From c963de955d981777acec283de95af30efe4d9d1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 03:53:20 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(changeset):=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes=20(narrowing)=20for=20the=20checkPageSlotPair?= =?UTF-8?q?=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff exports checkPageSlotPair from the spec ui entry, a public-surface widening beside the accept-set narrowing; widen-plus-narrow is spelled yes (narrowing). Levels unchanged (spec minor, platform-objects patch). Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .changeset/22568-page-slots-details-beside-tabs-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22568-page-slots-details-beside-tabs-refused.md b/.changeset/22568-page-slots-details-beside-tabs-refused.md index 1362e21c37b..588eb69a814 100644 --- a/.changeset/22568-page-slots-details-beside-tabs-refused.md +++ b/.changeset/22568-page-slots-details-beside-tabs-refused.md @@ -5,7 +5,7 @@ feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab -Clause-②: no (narrowing) +Clause-②: yes (narrowing)