Skip to content

Commit 3e741bf

Browse files
committed
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 <noreply@anthropic.com>
1 parent b56888e commit 3e741bf

5 files changed

Lines changed: 396 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered page-slots-details-beside-tabs-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
**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.
15+
16+
**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.
17+
18+
**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither.
19+
20+
## FROM → TO
21+
22+
| you wrote | write instead |
23+
|:--|:--|
24+
| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` |
25+
| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot |
26+
27+
**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.
28+
29+
**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.
30+
31+
### The kit
32+
33+
- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code.
34+
- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item.
35+
- **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.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22568 — the user page's details body is the FIRST `tabs` item, not a
5+
* `details` slot.
6+
*
7+
* The `tabs` slot replaces the whole tab strip the synthesized Details tab
8+
* lives in, so a `details` slot authored beside it never rendered: the page's
9+
* Identity and Audit sections never showed, and the admin-internal columns it
10+
* hides (`ban_reason`, `ban_expires`, …) were never hidden by it.
11+
* `PageSchema` now refuses the pair, and the page carries its `record:details`
12+
* inside the strip it authors.
13+
*
14+
* Read from the shipped page metadata, through the spec's own page contract,
15+
* so the pin fails if the page stops parsing and if the details body moves
16+
* out of the first tab or loses a section or a hidden field.
17+
*/
18+
19+
import { describe, expect, it } from 'vitest';
20+
import { PageSchema } from '@objectstack/spec/ui';
21+
import { SysUserDetailPage } from './sys-user.page.js';
22+
23+
type Rec = Record<string, unknown>;
24+
25+
/** The details body as the page authored it before the move — sections and hidden fields unchanged. */
26+
const HIDE_FIELDS = [
27+
'id',
28+
'banned',
29+
'ban_reason',
30+
'ban_expires',
31+
'email',
32+
'phone_number',
33+
'email_verified',
34+
'two_factor_enabled',
35+
'role',
36+
];
37+
const SECTIONS = [
38+
{
39+
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
40+
fields: ['name', 'image'],
41+
},
42+
{
43+
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
44+
fields: ['created_at', 'updated_at'],
45+
},
46+
];
47+
48+
describe('sys_user_detail — the details body lives in the first tab', () => {
49+
const slots = SysUserDetailPage.slots as Rec;
50+
const tabs = slots.tabs as Rec;
51+
const items = (tabs.properties as Rec).items as Rec[];
52+
53+
it('parses through PageSchema — the slot-pair refusal does not fire on the shipped page', () => {
54+
const parsed = PageSchema.safeParse(SysUserDetailPage);
55+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
56+
});
57+
58+
it('authors no `details` slot beside its `tabs` slot', () => {
59+
expect(slots).not.toHaveProperty('details');
60+
expect(tabs.type).toBe('page:tabs');
61+
});
62+
63+
it('carries the `record:details` as the only child of the FIRST tab item, sections and hidden fields intact', () => {
64+
const first = items[0]!;
65+
expect((first.label as Rec).en).toBe('Details');
66+
const children = first.children as Rec[];
67+
expect(children.map((c) => c.type)).toEqual(['record:details']);
68+
const props = children[0]!.properties as Rec;
69+
expect(props.hideFields).toEqual(HIDE_FIELDS);
70+
expect(props.sections).toEqual(SECTIONS);
71+
});
72+
73+
it('is the one `record:details` on the page — no second copy anywhere in the slot map', () => {
74+
const found: unknown[] = [];
75+
const walk = (n: unknown): void => {
76+
if (Array.isArray(n)) { n.forEach(walk); return; }
77+
if (!n || typeof n !== 'object') return;
78+
if ((n as Rec).type === 'record:details') found.push(n);
79+
Object.values(n as Rec).forEach(walk);
80+
};
81+
walk(slots);
82+
expect(found).toHaveLength(1);
83+
});
84+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of
6+
// the slot map, not a key removal: both slots stay live on their own, so there
7+
// is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the
8+
// pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where
9+
// the details body goes inside an authored tab strip (which item, under which
10+
// label) is the author's call, and the move makes visible a body that never
11+
// rendered.
12+
//
13+
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
14+
// inside a code span and a table cell.
15+
export const entry: SemanticMigration = {
16+
id: 'page-slots-details-beside-tabs-refused',
17+
surface:
18+
'page.slots.details authored beside page.slots.tabs on one page — either slot a single component '
19+
+ 'or an array, an empty details array included, whatever the page kind',
20+
replacement:
21+
'One `tabs` slot whose items carry the details body: the `record:details` component (its '
22+
+ '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by '
23+
+ 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` '
24+
+ '— and no `details` slot. To keep the synthesized tab strip with the authored details body in '
25+
+ 'its Details tab instead, delete `slots.tabs`.',
26+
reason:
27+
'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: '
28+
+ 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside '
29+
+ 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s '
30+
+ 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are '
31+
+ 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save '
32+
+ 'door while the authored details body — its sections, its hidden fields — silently never '
33+
+ 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit '
34+
+ 'sections never showed and the ban columns it hides were never hidden by it; it now carries its '
35+
+ '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, '
36+
+ 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), '
37+
+ '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). '
38+
+ 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the '
39+
+ 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and '
40+
+ '`discussion` only. '
41+
+ '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the '
42+
+ 'author\'s decision, and moving it makes visible a body that never rendered — a change to the '
43+
+ 'page, not a respelling. '
44+
+ '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it '
45+
+ 'renders the authored tabs, without the details body), its read diagnostics name the pair, and '
46+
+ 'saving it again is refused until the details body moves. ADR-0087.',
47+
acceptanceCriteria:
48+
'Grep every page in `defineStack` pages sources, exported stacks and every page row in '
49+
+ '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the '
50+
+ '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by '
51+
+ 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or '
52+
+ 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing '
53+
+ 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API '
54+
+ 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the '
55+
+ 'authored details body (its sections, without its hidden fields) in the tab that now carries it. '
56+
+ 'A page authoring only one of the two slots parses and renders byte-identically to before.',
57+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6189,6 +6189,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
61896189
+ 'replay clean while authored sources are refused until edited; the delete is lossless. Its D3 '
61906190
+ 'record is the semantic entry `page-requires-non-compiled-kind-refused`.',
61916191
},
6192+
{
6193+
id: 'page-slots-details-beside-tabs-refused',
6194+
order: 92,
6195+
text:
6196+
'It also refuses a page that authors both `slots.details` and `slots.tabs`. The two slots read '
6197+
+ 'as independent and are not: `details` is the body of the Details tab, that tab lives inside '
6198+
+ 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip, so the console\'s '
6199+
+ 'synthesizer never read a `details` authored beside it and its sections and hidden fields '
6200+
+ 'silently never applied. `PageSchema` now refuses the pair at `slots.details`, naming both '
6201+
+ 'slots and the fix — the `record:details` component as the `children` of a `tabs` item — so '
6202+
+ '`objectstack validate` and the metadata save door agree; the platform\'s own `sys_user_detail` '
6203+
+ 'page moved its details body into its first tab. No key is removed, so there is no tombstone, '
6204+
+ 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 '
6205+
+ 'record is the semantic entry `page-slots-details-beside-tabs-refused`.',
6206+
},
61926207
{
61936208
id: 'permission-restore-purge-bits-retired',
61946209
order: 18,
@@ -17348,6 +17363,59 @@ const step18: MigrationStep = {
1734817363
+ 'upgrade.',
1734917364
conversionIds: ['page-requires-non-compiled-kind-removed'],
1735017365
},
17366+
// #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of
17367+
// the slot map, not a key removal: both slots stay live on their own, so there
17368+
// is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the
17369+
// pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where
17370+
// the details body goes inside an authored tab strip (which item, under which
17371+
// label) is the author's call, and the move makes visible a body that never
17372+
// rendered.
17373+
//
17374+
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
17375+
// inside a code span and a table cell.
17376+
{
17377+
id: 'page-slots-details-beside-tabs-refused',
17378+
surface:
17379+
'page.slots.details authored beside page.slots.tabs on one page — either slot a single component '
17380+
+ 'or an array, an empty details array included, whatever the page kind',
17381+
replacement:
17382+
'One `tabs` slot whose items carry the details body: the `record:details` component (its '
17383+
+ '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by '
17384+
+ 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` '
17385+
+ '— and no `details` slot. To keep the synthesized tab strip with the authored details body in '
17386+
+ 'its Details tab instead, delete `slots.tabs`.',
17387+
reason:
17388+
'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: '
17389+
+ 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside '
17390+
+ 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s '
17391+
+ 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are '
17392+
+ 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save '
17393+
+ 'door while the authored details body — its sections, its hidden fields — silently never '
17394+
+ 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit '
17395+
+ 'sections never showed and the ban columns it hides were never hidden by it; it now carries its '
17396+
+ '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, '
17397+
+ 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), '
17398+
+ '`objectstack validate`, an artifact\'s parse and the metadata save door (`422 INVALID_METADATA`). '
17399+
+ 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the '
17400+
+ 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and '
17401+
+ '`discussion` only. '
17402+
+ '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the '
17403+
+ 'author\'s decision, and moving it makes visible a body that never rendered — a change to the '
17404+
+ 'page, not a respelling. '
17405+
+ '⚠️ A page row already stored with the pair is replayed unchanged and served as before (it '
17406+
+ 'renders the authored tabs, without the details body), its read diagnostics name the pair, and '
17407+
+ 'saving it again is refused until the details body moves. ADR-0087.',
17408+
acceptanceCriteria:
17409+
'Grep every page in `defineStack` pages sources, exported stacks and every page row in '
17410+
+ '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the '
17411+
+ '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by '
17412+
+ 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or '
17413+
+ 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing '
17414+
+ 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API '
17415+
+ 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the '
17416+
+ 'authored details body (its sections, without its hidden fields) in the tab that now carries it. '
17417+
+ 'A page authoring only one of the two slots parses and renders byte-identically to before.',
17418+
},
1735117419
// #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the
1735217420
// D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on
1735317421
// #17152: one D3 entry per retirement family, even when D2 is lossless). The

0 commit comments

Comments
 (0)