You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b16ee8b
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): a record page can name the apps whose routes open it (PageSchema.apps) (#29437)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95mUhXWSnPGxpNDeSMsZmpe)_Fixes#28979
Clause-②: yes (widening)
Metadata-protocol: added
## 维护者速读
- **改了什么**:记录页新增可选键 `apps: string[]`。在列出的应用里,该对象的记录视图打开这张页;其它地方(含
Setup)仍打开唯一的 `isDefault` 默认页。同一对象在同一应用里只能有一张:第二张在 defineStack /
composeStacks、包注册、元数据保存三道门被拒,点名两张页。
- **为什么改**:裁决 A(#28979,记录 6573557633)。duly 这类应用无法让自己的 `sys_user`
记录页在自己的路由下打开,而改成全局默认会让 Setup → Users 丢掉管理页。
- **风险与回滚**:纯新增键,无旧输入被拒;默认页规则不变(无 `apps` 时行为完全不变)。三道门共用一个读法
`defaultRecordPageObject(page, app)`,错误码沿用
`DEFAULT_RECORD_PAGE_CONFLICT`,无新公开名。回滚即 revert 本 PR。控制台尚未读 `apps`(ui
后续卡),所以合入后界面暂无变化。
-
**先看**:`packages/spec/src/ui/default-record-page.ts`(唯一读法)、`packages/objectql/src/registry.ts`(注册门按"槽位"计账,默认槽键不变)、`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(保存门逐槽判)。
- **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run
src/stack-app-record-page.test.ts`,应见 27 个用例全绿(在 main 上 22 红)。
- **需要你做的**:无,席位落地(落地前需一次契约复审档复核)。
## What changes
**The key.** `PageSchema` gains an optional `apps: string[]` (app names:
lowercase snake_case, at least one, each once; a repeat is refused at
the repeat). On a record page bound to an `object`, it names the apps
whose routes open this page for that object
(`/apps/APP/OBJECT/record/ID`). Everywhere else, Setup included, the
object's one `isDefault` record page still opens. Ruling A on #23015
(one default per object per deployment) is unchanged and no precedence
ladder is added: the deployment default and an app's page are two slots,
and one page may hold both. The describe carries `[PLANNED — not yet
rendered]` until the console reads it.
**Placement, the way `isDefault` is gated.** `apps` is refused on any
page that is not a record page bound to an object. The one sentence is
`misplacedDefaultPageRefusal`'s (it now covers `apps` after
`isDefault`), so the page parse (`checkPageDefaultPlacement`, which now
reports each key at its own path) and the registry's package door print
the same refusal.
**One reading, no new public name.** `defaultRecordPageObject(page,
app?)` gains an optional second argument: with an app, it answers the
object a record page is the page of inside that app (`apps` lists it).
With one argument it is byte-for-byte the old reading. Every door asks
it once with no app and once per string entry of the page's `apps`.
**The three refusal doors, one level down.** Each door keeps its
existing mechanism and keys it by slot (an object's default, or an
object inside one app) instead of by object alone:
- `defineStack` / `composeStacks` (`stack.zod.ts`,
`duplicateDefaultRecordPageFindings`): `Object 'X' has two record pages
for app 'A' in pages: … Remove 'A' from apps on all but one of them.`
(`STACK_CROSS_REFERENCE_INVALID`).
- Package registration (`objectql` `registry.ts` +
`default-record-page.ts`): the claims map, the holders reading, the
package door and the item seam are keyed by `recordPageSlotKey` (the
object alone for the default, so default keys are unchanged).
`DefaultRecordPageConflictError` keeps its code
(`DEFAULT_RECORD_PAGE_CONFLICT`, 422); a conflict carries `app` when the
slot is an app's, and the message names the app with its own remedy.
`defaultRecordPageHoldersOf(object, app?)` answers an app's slot.
- Metadata save (`metadata-protocol` `defaultRecordPageSaveRefusal`):
judges each slot the saved page holds against the stored rows and the
registry's holders; the refusal names both pages and the app and carries
`app`. Saving a page under its own name stays an overlay, as for the
default.
## Why
An app could not make its own record page over a platform object the
page its routes open: plugin-auth's `sys_user_detail` holds `sys_user`'s
one default, a second default is refused, and making the app's page the
default would take Setup → Users' admin page away from the whole
deployment (#28979). Ruling A (director 6573557633, maintainer 「同意」)
chose a page-level app scope.
## Tests
- `packages/spec/src/stack-app-record-page.test.ts` (new, 27): the key
(accepted alone and beside `isDefault`; empty, repeated and non-snake
entries refused; refused on a home page, an app page and an unbound
record page in the registry door's sentence; both misplaced keys
reported); `defaultRecordPageObject(page, app)`; `defineStack` (same app
refused naming both; different apps accepted; a page scoped to two apps;
default + app page coexist; one page both default and scoped; a second
default still reported as a default); `composeStacks` (merged and
`preserve`, with controls).
- `packages/objectql/src/registry-app-record-page.test.ts` (new, 10):
package door across packages and within one manifest, two-app page,
install claim and uninstall release, item seam, environment holder,
default and app page side by side, second default still the default's
refusal, misplaced `apps` at the package door; each refusal with a
control.
- `packages/objectql/src/default-record-page-save-door.test.ts` (+6 per
topology, 12): refused over a shipped page and over a stored page for
the same app, controls for another app, beside the package default and
an overlay by own name, and `apps` on a home page refused by the parse.
- **Forced test edits** (named):
`packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts`
(`page` serves 27 → 28 top-level properties: `apps`);
`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts` (a
`planned` omit row for `page.apps`, the `record` / `print` precedent).
## Acceptance notes
- **Follow-ups (not in this PR):** ui: `usePageAssignment` /
`appObjectScope.declaredRecordPage` read the route's app first, then the
default, then declaration order, with the ruling's two pins
(`/apps/THAT_APP/sys_user/record/ID` opens the scoped page; the Setup
app's route still opens `sys_user_detail`); the liveness row for
`page.apps` flips to `live` there. duly#113: delete the URL-action
stopgap once the ui half ships.
- `apps` entries are app **names** (`AppSchema.name`). The Setup app's
name is `setup`; `com.objectstack.setup` is its package id. The ui card
maps the route's app segment to the app name.
- No cross-reference that a named app exists: a page may scope itself to
another package's app, which a one-stack check cannot see. Noted, not
filed.
- Generated: `authorable-surface/ui.json`,
`content/docs/references/ui/page.mdx`, `liveness/state-counts/page.md`;
ledgers: `dropped-refinements.baseline.json` (+5 sites for the array
refine, 988 → 993), `liveness/page.json` (`apps`: `planned`).
## Verification
<details><summary>Red on main, green after</summary>
The six implementation files plus the two ledgers restored to main
(`2f7977cd`), proven by blob hash; spec and metadata-protocol rebuilt;
restored afterwards with an empty `git diff HEAD`.
- spec `src/stack-app-record-page.test.ts`: main `Tests 22 failed | 5
passed (27)`; branch `27 passed (27)`.
- objectql `registry-app-record-page.test.ts` +
`default-record-page-save-door.test.ts`: main `Tests 20 failed | 38
passed (58)`; branch `58 passed`.
</details>
<details><summary>Package suites</summary>
All on the merged head `0b4d7827` (main `dfbe34d8` merged with
`os-regen-merge.sh`; the authorable surface regenerated on the merged
tree; branch delta vs main unchanged, 17 files), each under
`os-verify-lock`, `VERDICT command-exit 0`:
- `pnpm --filter @objectstack/spec test`: `Test Files 792 passed (792)`,
`Tests 23478 passed | 1 todo`. `typecheck`: exit 0. `check:generated`:
exit 0.
- `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 304
passed | 4 skipped (308)`, `Tests 22155 passed | 26 skipped`.
`typecheck`: exit 0.
- `@objectstack/objectql`: the four default-record-page files `Tests 93
passed (93)`; `typecheck`: exit 0. The full suite ran on the pre-merge
head `4d11203c`: `Test Files 531 passed (531)`, `Tests 10300 passed`
(the merge moved no objectql file).
- Other readers of `defaultRecordPageObject` (core
`stack-organization-record-page.ts`, service-print, plugin-auth's test)
call it with one argument, which reads exactly as before; they compile
in the workspace build (`turbo run build --filter=./packages/**`, exit
0).
- Dogfood: n/a, no `!` in the title and no `(narrowing)` arm.
</details>
<details><summary>Gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `0b4d7827`: 110 families, all run after
the last commit, after a workspace build.
- 110 exit 0 (each command with stdin closed).
- `dispatch-gates --ran`: `110 derived famil(ies) accounted for — 110
run, 0 NOT-MEASURED (a DERIVED zero)`.
- The same 110 also ran on the pre-merge head `4d11203c`: 110 exit 0.
- `check-changeset-no-major.mjs --base origin/main --event` with this
body's two lines: `LEVEL AXIS: … clause-② yes (widening) and
metadata-protocol added`, exit 0.
- Local-only: `check:skill-frame-freshness` exit 0; `check:dev-prereqs`
exit 1 on machine state (35 `ui/` packages have no dist in this
container), not on this diff.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [ ] 3. No formatter ran; `pnpm lint` (repo-wide, CI-owned) not run
locally.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): a record page can name the apps whose routes open it for its object (`PageSchema.apps`)
8
+
9
+
Clause-②: yes (widening)
10
+
Metadata-protocol: added
11
+
12
+
An app could not make its own record page over a platform object the page its routes open: the deployment's one `isDefault` record page (for `sys_user`, plugin-auth's `sys_user_detail`) opened everywhere, and declaring a second default is refused. A record page now takes an optional app scope:
13
+
14
+
```ts
15
+
definePage({
16
+
name: 'duly_member',
17
+
label: 'Member',
18
+
type: 'record',
19
+
object: 'sys_user',
20
+
apps: ['duly'],
21
+
regions: [/* … */],
22
+
});
23
+
```
24
+
25
+
-**What it means.** Inside an app `apps` names, the object's record view (`/apps/APP/OBJECT/record/ID`) opens this page. Everywhere else, Setup included, the object's one `isDefault` record page still opens. One default per object per deployment is unchanged, and there is no precedence ladder: the default and an app's page are two slots, and one page may hold both.
26
+
-**One page per object per app.** Two record pages for the same object that both name the same app are refused at the doors that refuse a second default, naming both pages and the app: `defineStack` (and so `os validate` / `os build`) and `composeStacks` with `STACK_CROSS_REFERENCE_INVALID`, and package registration and the metadata save door with `DEFAULT_RECORD_PAGE_CONFLICT` (422). Saving a page under its own name stays an overlay of that page, as for the default.
27
+
-**Where it is accepted.** Only on a record page bound to an `object`, where `isDefault` is: on any other page `apps` is refused, by the page parse and at package registration, in one sentence. Entries are app names (lowercase snake_case), at least one, each once.
28
+
-**Declared ahead of the console.** The console's page assignment reads the route's app in a follow-up; until then every app still opens the default record page, exactly as before the key existed.
29
+
-**Readers.**`defaultRecordPageObject(page, app)` (`@objectstack/spec/ui`) answers the object a page is the record page of inside `app`; `SchemaRegistry.defaultRecordPageHoldersOf(object, app)` answers the holders of that slot; a `DefaultRecordPageConflictError` conflict and the save door's refusal carry `app` when the slot is an app's.
Copy file name to clipboardExpand all lines: content/docs/references/ui/page.mdx
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -227,6 +227,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
227
227
|**template**|`Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>`| optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. |
228
228
|**regions**|`{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]`| optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. |
229
229
|**isDefault**|`boolean`| optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. |
230
+
|**apps**|`string[]`| optional |[PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page. |
230
231
|**assignedProfiles**|`never`| optional |[REMOVED]`page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
0 commit comments