Skip to content

Commit 928bee8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22485-delete-committed-projections
2 parents 538bec7 + e223152 commit 928bee8

10 files changed

Lines changed: 557 additions & 46 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-②: yes (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 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.
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+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,26 @@ import type { Page } from '@objectstack/spec/ui';
1717
*
1818
* Strategy
1919
* --------
20-
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
21-
* `details`, `tabs` and `discussion`. Header / actions fall through
20+
* - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`,
21+
* `highlights`, `tabs` and `discussion`. Header / actions fall through
2222
* to the synthesizer so the object's declared actions
2323
* (`update_my_profile / change_my_password / resend_verification_email
2424
* / ban_user / impersonate_user / …`) still appear
2525
* in the header overflow menu automatically.
2626
* - `highlights` promotes the four signals worth scanning at the top:
2727
* email, verification state, 2FA, platform role. Highlight fields
2828
* are auto-dropped from the details grid below.
29-
* - `details` re-groups remaining fields into sections and hides
30-
* admin-internal audit columns. Banned / ban metadata is still
31-
* editable from the header actions — we just don't show it in
32-
* every user's body.
33-
* - `tabs` is **explicitly curated** to the 5 related lists that matter
34-
* on a user profile (Positions / Sessions / Linked Accounts / Organizations /
35-
* Personal OAuth Apps). Without this override, the synthesizer
29+
* - The FIRST `tabs` item is the details grid: a `record:details` that
30+
* re-groups remaining fields into sections and hides admin-internal
31+
* audit columns. Banned / ban metadata is still editable from the
32+
* header actions — we just don't show it in every user's body. It is
33+
* a tab item and not a `details` slot because the `tabs` slot replaces
34+
* the whole tab strip the Details tab lives in: a `details` slot beside
35+
* `tabs` never rendered, and `PageSchema` refuses the pair.
36+
* - The other `tabs` items are **explicitly curated** to the related lists
37+
* that matter on a user profile (Positions / Permission Sets / Business
38+
* Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API
39+
* Keys) plus a Security tab. Without this override, the synthesizer
3640
* auto-generates a tab per object that has a FK to sys_user
3741
* (sys_position.created_by, sys_email.updated_by, sys_user_preference,
3842
* sys_email_template.created_by, …) producing dozens of noisy
@@ -125,47 +129,61 @@ export const SysUserDetailPage: Page = {
125129
},
126130
},
127131

128-
// ── Body / details grid ───────────────────────────────────────
129-
details: {
130-
type: 'record:details',
131-
properties: {
132-
hideFields: [
133-
'id',
134-
'banned',
135-
'ban_reason',
136-
'ban_expires',
137-
// already promoted to highlights:
138-
'email',
139-
'phone_number',
140-
'email_verified',
141-
'two_factor_enabled',
142-
'role',
143-
],
144-
sections: [
145-
{
146-
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
147-
fields: ['name', 'image'],
148-
},
149-
{
150-
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
151-
fields: ['created_at', 'updated_at'],
152-
},
153-
],
154-
},
155-
},
156-
157-
// ── Tabs: curated related lists ───────────────────────────────
158-
// Only the 4 lists that are semantically about THIS user account.
132+
// ── Tabs: the details grid, then curated related lists ────────
133+
// Only the lists that are semantically about THIS user account.
159134
// Everything else (sys_position created_by, sys_email_template
160135
// updated_by, …) is incidental authorship metadata and would only
161136
// create noise.
137+
//
138+
// There is no `details` slot: the `tabs` slot replaces the whole tab
139+
// strip the synthesized Details tab lives in, so a `details` slot beside
140+
// it never rendered and `PageSchema` refuses the pair (#22568). The
141+
// details grid is therefore the FIRST tab's body here.
162142
tabs: {
163143
type: 'page:tabs',
164144
properties: {
165145
// `tabStyle`, not `type` (#6776) — see sys-organization.page.ts.
166146
tabStyle: 'line',
167147
position: 'top',
168148
items: [
149+
{
150+
// ── Body / details grid ───────────────────────────────
151+
// Re-groups the remaining fields into sections and hides
152+
// admin-internal columns. Banned / ban metadata is still
153+
// editable from the header actions — it is just not shown in
154+
// every user's body.
155+
label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' },
156+
icon: 'file-text',
157+
children: [
158+
{
159+
type: 'record:details',
160+
properties: {
161+
hideFields: [
162+
'id',
163+
'banned',
164+
'ban_reason',
165+
'ban_expires',
166+
// already promoted to highlights:
167+
'email',
168+
'phone_number',
169+
'email_verified',
170+
'two_factor_enabled',
171+
'role',
172+
],
173+
sections: [
174+
{
175+
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
176+
fields: ['name', 'image'],
177+
},
178+
{
179+
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
180+
fields: ['created_at', 'updated_at'],
181+
},
182+
],
183+
},
184+
},
185+
],
186+
},
169187
{
170188
label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
171189
icon: 'shield-check',

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@
495495
"checkListViewChartBinding (function)",
496496
"checkPagePrintComposition (function)",
497497
"checkPageRequiresKind (function)",
498+
"checkPageSlotPair (function)",
498499
"checkPageSourceCompleteness (function)",
499500
"columnSummaryAlias (function)",
500501
"compileListViewGroupQuery (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@
480480
"checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)",
481481
"checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)",
482482
"checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)",
483+
"checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)",
483484
"checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)",
484485
"columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)",
485486
"compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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` 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 at load (no conversion '
45+
+ 'touches it), so it renders as before — the authored tabs, without the details body — while '
46+
+ 'its read diagnostics name the pair and saving it again is refused until the details body '
47+
+ 'moves. ADR-0087.',
48+
acceptanceCriteria:
49+
'Grep every page in `defineStack` pages sources, exported stacks and every page row in '
50+
+ '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the '
51+
+ '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by '
52+
+ 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or '
53+
+ 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing '
54+
+ 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API '
55+
+ 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the '
56+
+ 'authored details body (its sections, without its hidden fields) in the tab that now carries it. '
57+
+ 'A page authoring only one of the two slots parses and renders byte-identically to before.',
58+
};

0 commit comments

Comments
 (0)