Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/29592-app-scoped-record-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/react': patch
'@object-ui/app-shell': patch
'@objectstack/spec': patch
---

fix(console): a record opens the record page scoped to the app it is opened in (`PageSchema.apps`)

Clause-②: no
Metadata-protocol: none

A record page that names apps in `apps` now opens for its object inside those apps: in the Duly app a user record opens Duly's own `sys_user` page, while Setup → Users still opens the platform's `sys_user_detail`. Everywhere else, and wherever no page names `apps`, the object's `isDefault` record page opens exactly as before.

- `usePageAssignment` (`@object-ui/react`) now reads its existing `app` option, which names the owning app by its name (`AppSchema.name`): the record page whose `apps` lists that app opens before the object's default record page. The option's type is unchanged; it was reserved and read by nothing until now. Pass the app's name, never a route segment.
- The console's record view resolves the route's app segment to its app (package id first, then name) and passes the app's name, on the record route and in the record drawer. Where a package ships two apps, its package-id segment opens the first app, so the second app's scoped page opens under that app's name segment.
- The record route's object scope picks the same page, so the route reads the objects of the page it renders.
- `@objectstack/spec`: the `PageSchema.apps` description drops its "planned, not yet rendered" marker, and the key's liveness verdict is now live, citing the console reader.
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
| **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. |
| **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. |
| **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. |
| **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. |
| **apps** | `string[]` | optional | 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. |
| **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. |
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
Expand Down
7 changes: 4 additions & 3 deletions packages/spec/liveness/page.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,11 @@
"note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault. LOAD PATH (P0 after #23610): an installed or stored page still carrying the flag replays the retired D2 entry page-is-default-off-record-page-removed on read — stored rows, the artifact doors, and the kernel's package registration door for a body that declares its protocol range (ObjectQL.convertPackageBodyForward) — so the kernel builds with an OS_METADATA_CONVERTED notice; the authoring, save and publish doors keep refusing."
},
"apps": {
"status": "planned",
"status": "live",
"verifiedAt": "2026-11-02",
"evidence": "packages/spec/src/ui/default-record-page.ts#defaultRecordPageObject (with the app passed: the object a record page is the page of inside that app — the one reading defineStack, the registry's package door and the metadata save door ask); packages/spec/src/ui/page.zod.ts#checkPageDefaultPlacement (refuses `apps` on a page that is not a record page bound to an object)",
"note": "PLANNED, the renderer half pending — ruling A on #28979 (record 6573557633): inside an app `apps` names, the object's record view opens this page; elsewhere, Setup included, the object's one isDefault page still opens. The refusal half is enforced now: two record pages for one object naming the same app are refused by defineStack / composeStacks, the objectql registry's package door and item seam, and the metadata save door (DEFAULT_RECORD_PAGE_CONFLICT, naming both pages). Measured on objectstack main @2f7977cd: the console's page assignment (ui usePageAssignment / appObjectScope.declaredRecordPage) reads no app, so every app opens the default record page, as before the key existed. Flip to `live` when the ui follow-up reads the route's app first, citing the read point."
"evidenceScope": "in-repo",
"evidence": "ui/packages/react/src/hooks/usePageAssignment.ts#usePageAssignment (the `app` option, the app's name: the first record page of the object for which defaultRecordPageObject(page, app) answers it opens before the isDefault page); ui/packages/app-shell/src/views/RecordDetailView.tsx#RecordDetailView (resolves the route's `:appName` segment with matchAppBySegment and passes the app's name, on the record route and in the record drawer); ui/packages/app-shell/src/console/appObjectScope.ts#declaredRecordPage (the record route's object scope picks the same page); refusal half: packages/spec/src/ui/default-record-page.ts#defaultRecordPageObject and packages/spec/src/ui/page.zod.ts#checkPageDefaultPlacement",
"note": "LIVE — ruling A on #28979 (record 6573557633): inside an app `apps` names, the object's record view opens this page; elsewhere, Setup included, the object's one isDefault page still opens. The console resolves the route segment to its app first (the segment carries the package id when the app has one) and compares the app's NAME, never the segment; where a package ships two apps, its package-id segment resolves to the first, so the second app's scoped page opens under its name segment only. Enforced on write: two record pages for one object naming the same app are refused by defineStack / composeStacks, the objectql registry's package door and item seam, and the metadata save door (DEFAULT_RECORD_PAGE_CONFLICT, naming both pages). Not offered in the Studio page form: see the page.apps omit row in packages/spec/src/system/metadata-form-zod-reconciliation.test.ts."
},
"interfaceConfig": {
"status": "live",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `page` | 34 | 0 | 0 | 1 | 10 | 45 |
| `page` | 35 | 0 | 0 | 1 | 9 | 45 |
5 changes: 3 additions & 2 deletions packages/spec/src/stack-app-record-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ describe('PageSchema.apps — the key', () => {
expect(issues.map((i) => i.path)).toEqual(['isDefault', 'apps']);
});

it('the describe carries the planned marker and no tracker number', () => {
it('the describe carries no planned marker (the console reads the key) and no tracker number', () => {
const description = PageSchema.shape.apps.description ?? '';
expect(description).toMatch(/^\[PLANNED — not yet rendered\] /);
expect(description).toMatch(/^The apps whose routes open this record page /);
expect(description).not.toMatch(/PLANNED|not yet rendered|until the console reads it/i);
expect(description).not.toMatch(/#\d{3,}/);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,16 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
key: 'record',
why: "planned, not yet rendered — the binding's one-row query is validated at parse and refused on every page that is not `type: 'app'`, but no renderer mounts it as the page's `record` until the console's page-root binding lands. A form control offered today would let an author bind a row no predicate can read yet",
},
// #28979 — a record page's app scope lands spec-first the same way:
// `planned` in the liveness ledger, read by no console until the ui card
// that makes the page assignment read the route's app.
// #28979 — a record page's app scope landed spec-first the same way and is
// `live` since the console's page assignment reads the route's app. The
// form offer was decided with the enforcement: not offered yet, for the
// reason below.
{
kind: 'omit',
type: 'page',
path: ROOT_PATH,
key: 'apps',
why: "planned, not yet rendered — the app scope is validated at parse, refused off a record page bound to an object, and a second page for one object in one app is refused at every door, but no console opens the scoped page in its app until the page assignment reads the route's app. A form control offered today would let an author scope a page no route opens yet",
why: "live, not offered in this form — the console opens the scoped page inside each app the list names (the page assignment reads the route's app), but no registered form widget spells a list of app names: there is no app picker, and a free-text list would let an author type a package id or a label where an app name is required, which the parse refuses only after the fact. Authored in source or the page JSON until an app-name picker exists",
},
// #28022 — an app's per-audience landing list lands spec-first the same way:
// `planned` in the liveness ledger, read by no console until the landing
Expand Down
10 changes: 5 additions & 5 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2072,13 +2072,13 @@ export const PageSchema = lazySchema(() => strictObject({
* bound to an object ({@link checkPageDefaultPlacement}). Entries are app
* names (`AppSchema.name`), non-empty and unique.
*
* Declared ahead of its renderer (ADR-0049): the console's page assignment
* reads the route's app in the ui follow-up; until it does, the console
* opens the default record page in every app, as before the key existed.
* Renderer: the console's page assignment (`usePageAssignment` in
* `@object-ui/react`, and the record route's object scope, which picks the
* same page) resolves the route's app segment to its app and reads that
* app's NAME here, never the segment itself.
*/
// Renderer half: the console's usePageAssignment (ui lane follow-up of #28979).
apps: z.array(SnakeCaseIdentifierSchema).min(1).superRefine(checkPageAppsUnique).optional()
.describe("[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."),
.describe("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."),
// `assignedProfiles` REMOVED (ADR-0090 D2 / ADR-0049) — it named the concept
// D2 deleted and nothing anywhere enforced it, so a page that "assigned
// profiles" was open to everyone who could reach it. Page audience is the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* A record route reads the objects of the record page it RENDERS
* (objectui#13046), so the route's object scope must pick the page the way
* `RecordDetailView` does through `usePageAssignment`. With app-scoped record
* pages (`PageSchema.apps`, maintainer ruling A on objectstack#28979) that
* pick depends on the route's app: inside an app a page names, that page;
* elsewhere the object's default record page.
*
* Harness: `readAppObjectScope` with the app `AppContent` resolved from the
* route segment, a settled page list, and a scope reader that records the
* names it is asked. Each page binds one marker object, so the names asked
* say which page was picked. The same matrix is asked of `usePageAssignment`
* with the same app's name, and the two answers must agree.
*/

import React from 'react';
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { MetadataCtx, usePageAssignment, type MetadataContextValue } from '@object-ui/react';
import { readAppObjectScope } from '../appObjectScope';

type Page = Record<string, unknown>;

const OBJECT = 'sys_user';

/** A full record page of the user object binding one marker object. */
const userPage = (name: string, marker: string, extra: Page = {}): Page => ({
name,
label: name,
type: 'record',
object: OBJECT,
...extra,
regions: [{ name: 'main', components: [{ type: 'object-grid', objectName: marker }] }],
});

const SYS_USER_DETAIL = userPage('sys_user_detail', 'marker_default', { isDefault: true });
const DULY_MEMBER = userPage('duly_member', 'marker_duly', { apps: ['duly'] });
const MARKER_OF: Record<string, string> = { sys_user_detail: 'marker_default', duly_member: 'marker_duly' };

const SETUP_APP = { name: 'setup', _packageId: 'com.objectstack.setup', navigation: [] };
const DULY_APP = { name: 'duly', _packageId: 'com.duly', navigation: [] };

/** The page `readAppObjectScope` picked, read off the marker names it asked for. */
function scopePick(app: Record<string, unknown>, pages: Page[]): string | undefined {
const asked = new Set<string>();
const readObjectScope = ((q: { names?: readonly string[] }) => {
for (const n of q.names ?? []) asked.add(n);
return { objects: [], served: [], missing: [], settled: true };
}) as never;
readAppObjectScope(readObjectScope, app, `${OBJECT}/record/r1`, '', [], undefined, () => ({ pages, settled: true }));
const picked = Object.entries(MARKER_OF).filter(([, marker]) => asked.has(marker)).map(([name]) => name);
expect(picked.length, `exactly one page's objects are read, got ${picked.join(', ') || 'none'}`).toBe(1);
return picked[0];
}

/** The page `usePageAssignment` picks for the same app's name. */
async function assignmentPick(appName: string, pages: Page[]): Promise<string | undefined> {
const ctx = {
apps: [], objects: [], dashboards: [], reports: [], pages, loading: false, error: null,
refresh: async () => {}, invalidate: () => {}, ensureType: async () => pages,
getItem: async () => null, getItemsByType: () => [], getTypeStatus: () => 'ready' as const,
} as unknown as MetadataContextValue;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<MetadataCtx.Provider value={ctx}>{children}</MetadataCtx.Provider>
);
const { result } = renderHook(() => usePageAssignment(OBJECT, { app: appName }), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
return result.current.page?.name;
}

describe('appObjectScope — a record route reads the objects of the app-scoped page it renders (ruling A on objectstack#28979)', () => {
it('inside the Duly app the route reads duly_member\'s objects; inside Setup, sys_user_detail\'s', () => {
expect(scopePick(DULY_APP, [SYS_USER_DETAIL, DULY_MEMBER])).toBe('duly_member');
expect(scopePick(SETUP_APP, [DULY_MEMBER, SYS_USER_DETAIL])).toBe('sys_user_detail');
});

it('the scope picker and usePageAssignment agree on every app and order', async () => {
for (const app of [DULY_APP, SETUP_APP]) {
for (const pages of [[SYS_USER_DETAIL, DULY_MEMBER], [DULY_MEMBER, SYS_USER_DETAIL]]) {
expect(scopePick(app, pages)).toBe(await assignmentPick(app.name, pages));
}
}
});

it('CONTROL: with no `apps` on any page, every app reads the default page\'s objects', () => {
const plain = [{ ...DULY_MEMBER, apps: undefined }, SYS_USER_DETAIL];
expect(scopePick(DULY_APP, plain)).toBe('sys_user_detail');
expect(scopePick(SETUP_APP, plain)).toBe('sys_user_detail');
});
});
Loading
Loading