Skip to content

Commit 5f37d36

Browse files
feat(spec,objectql,metadata-protocol)!: one default record page per object per deployment, a second refused at os validate, registration and save (#23015) (#23206)
Fixes #23015 Clause-②: yes (narrowing: a second default record page for one object, accepted today, is refused; the public surface also grows by the new `DEFAULT_RECORD_PAGE_CONFLICT` code and the new exports, per contract review 6142059579) Ruling A (record 6134056653) on #23015, quoted from the card: "**A — one default record page per object per deployment, refused loudly.** The spec states that at most one `isDefault: true` record page may exist for an object in a deployment. A second is refused at registration and at save, naming both holders, the way ADR-0048's 2026-10-08 addendum refuses a second holder of a position, permission-set or capability name. The renderer then honours `isDefault` with no tie-break." This PR is the objectstack half. It changes nothing in objectui or cloud. Their seats file those legs (objectstack-ai/objectui#12195, and the cloud card the ruling asks for). ## Census: every `isDefault: true` record page producer Measured on objectstack `1a6e24c99` (this branch after merging `origin/main` `e540e7ffb`; the producer set is unchanged at `b091cd851`) and objectui `4baa8a67c`. A producer is non-test source that ships a record page (`type: 'record'`, or no `type`) bound to an object and declared `isDefault: true`. | repo | object | page | producer | |:--|:--|:--|:--| | objectstack | `sys_organization` | `sys_organization_detail` | `@objectstack/plugin-auth` (`SysOrganizationDetailPage`) | | objectstack | `sys_user` | `sys_user_detail` | `@objectstack/plugin-auth` (`SysUserDetailPage`) | | objectstack | `sys_position` | `sys_position_detail` | `@objectstack/plugin-security` (`SysPositionDetailPage`) | | objectstack | `sys_approval_request` | `sys_approval_request_detail` | `@objectstack/plugin-approvals` | | objectstack | `showcase_project` | `showcase_project_detail` | `examples/app-showcase` | | objectstack | `showcase_task` | `showcase_task_detail` | `examples/app-showcase` | | objectstack | `showcase_preference` | `showcase_settings` | `examples/app-showcase` | | objectui | none | none | 0 record-page producers. The `isDefault: true` hits are apps, flow edges and list views. | Every object has exactly one default, so no producer in either repository trips the refusal. The fork clause does not fire here. `isDefault: true` on a page that is not a record page bound to an object is outside the rule. The one case of that is `showcase_capability_map`, a `home` page. It is unchanged. **The carried producer is the cloud leg.** cloud's `cloud_organization_detail` (`com.objectstack.service-tenant`) and plugin-auth's `sys_organization_detail` are both defaults for `sys_organization`. A cloud boot on an engine that carries this PR will be refused with `DEFAULT_RECORD_PAGE_CONFLICT` and both pages named. The ruling orders the cloud leg to land before cloud bumps to that engine, and the cloud seat resolves it. cloud is not in this session, so this run did not re-measure it. The pair is the card's own measurement. **The cloud seat needs one measured fact.** An environment save under a page that a **managed package ships** is refused before this rule is reached, with `NOT_OVERRIDABLE` 403: `page` is `allowEnvironmentOverlay: false`, which is the ADR-0126 Regime C rollback. The `default-record-page-save-door.test.ts` control pins this on both topologies, unchanged. So "overlay `sys_organization_detail` by name" is not available as an environment save. For cloud, the paths that remain are host composition (compose plugin-auth without its organization page) and not shipping a second default. The ruling's fork clause routes a lost-behaviour finding to the cloud seat, so this is reported and the ruling is not touched. ## FROM → TO, per door All three doors judge with one reading: `defaultRecordPageObject(page)` (`@objectstack/spec/ui`, `default-record-page.ts`). It holds the type default (`PAGE_TYPE_DEFAULT`) that `PageSchema.type` is now built from. | door | FROM | TO | |:--|:--|:--| | `defineStack`, and so `os validate`, `os build` and a source boot | two default record pages for one object in `pages` accepted. The boot kept both, and registration order picked the one that opened. | refused, `STACK_CROSS_REFERENCE_INVALID` 422. The finding names the object and every page with its position: `Object 'X' has two default record pages in \`pages\`: 'a' (stack.pages[0]) and 'b' (stack.pages[1]). Set \`isDefault: false\` on all but one of them. …` | | `composeStacks` | each input legal on its own, so the pair composed clean | refused in the same envelope, naming each stack's page. Under `manifest: 'preserve'`, one page name in two stacks counts as two packages' pages. | | package registration (`SchemaRegistry.installPackage`, ahead of every mutation, and the `registerItem` seam) | a second package, a nested plugin or a plugin's `manifest.register` shipping a default for an object another package holds registered silently | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422 (new ledger code, exported `DEFAULT_RECORD_PAGE_CONFLICT_CODE`), with `object`, `incoming`, `existing` and `conflicts`: `Default record page conflict: page "b" of package "p2" cannot be the default record page of object "X": page "a" of package "p1" already is. …` The other holder can be another package's page (two packages' pages under one name are two pages), the environment's stored page, or a second page of the same manifest. No `collisionPolicy` downgrade. | | metadata save (`saveMetaItem`, so `PUT /api/v1/meta/page/:name`) | a page saved `isDefault: true` for an object that already had a default stored or shipped was accepted | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, in draft and publish mode, before anything is written. The message names both pages and gives the remedy that applies: demote the stored default, or for a shipped page, compose without the package. The holders are the environment-wide stored rows plus `SchemaRegistry.defaultRecordPageHoldersOf(object)`, so an environment-scoped kernel (no write-through) is judged too. | **What is not a second default**, pinned at each door: - a save under the existing default's own name (an ADR-0005 overlay by name); - an environment item under the registered page's own name; - the same package again: a reload, or an upgrade that moves its default to another of its own pages; - a non-default record page; - defaults for two different objects; - an `isDefault` page that is not a record page. **Also in this diff:** - The page form's `isDefault` help used to say "Set as default page for this page type", which is a rule nothing enforced. It now states this rule. The source is `page.form.ts`; the `en` bundle leaf was hand-edited to the source's string, and the `zh-CN`, `ja-JP` and `es-ES` leaves were hand-translated. - The hand-written `content/docs/ui/pages.mdx` row said the same thing and is corrected too. - `PageSchema.isDefault` gets its `.describe()`. The regenerated reference docs, `api-surface/ui.json` and `export-origins/ui.json` each add one row. - The D3 entry is `18.page-default-record-page-one-per-object-refused` (no `relevantWhen`, because the surface reaches plugin code and stored rows). - `DEFAULT_RECORD_PAGE_CONFLICT` is registered under `@objectstack/objectql` and `@objectstack/metadata-protocol`. It has its own code rather than `NAMESPACE_CONFLICT`. No name is taken: what is held is an object's one default slot, and renaming fixes nothing. ## Verification record Final head `b091cd851` is this branch plus `origin/main` `8323cad97`. Unless a row names another head, every number below was read at `b091cd851`. Heavy runs went through `scripts/pm/os-verify-lock.sh`. The lock's wall-clock figures come from a shared box. | what | result | |:--|:--| | new door pins | `spec/src/stack-default-record-page.test.ts` 26/26 · `objectql/src/registry-default-record-page.test.ts` + `default-record-page-save-door.test.ts` 37/37 (save door on both topologies, environment-scoped and host-config) | | `@objectstack/spec` full local suite | 662 files, 20120 passed, 1 todo, at `d7a8f7f34` (this branch before the last merge; spec source is unchanged since then except what main brought) | | `@objectstack/objectql` full local suite | 411 files, 8033 passed, at `1a6e24c99` (after the first merge). The two door files were re-run at `b091cd851`. | | `@objectstack/metadata-protocol` full suite | 232 files passed, 3 skipped; 28278 passed, 19 skipped | | `@objectstack/platform-objects` suite | 70 files, 1096 passed, at `498957f10` (its only change, the four help-text leaves, was already in) | | typecheck | `metadata-protocol` and `objectql` green at `b091cd851`. `spec` and `platform-objects` green at `d7a8f7f34`. Each includes `check:test-typecheck`. | | `check:generated` (spec) | all 14 artifacts up to date, after rebuilding the spec closure at `b091cd851` | | derived gate union (`dispatch-gates --commands`, 124 families) | 122 run, exit 0. NOT MEASURED: `check:dual-build-cjs-loads`, because it needs every package's `dist/`, i.e. a full workspace build, so CI runs it. UNRUN by dispatch order: `check:type-check-debt`. `--ran` reconciliation: 124 derived, 122 run, 1 NOT-MEASURED, 1 UNRUN. `check:i18n` and `check:skill-examples` first answered PREREQUISITE NOT MET. I built their prescribed closures and both then passed: i18n reported 10 packages in sync, and skill-examples reported 263 prose examples type-checked. | | changeset gates | `check-adr-0087-registration`: 1 declared-breaking changeset with its disposition, `registered page-default-record-page-one-per-object-refused`. `check-changeset-no-major` and `check-empty-changeset` green. | | lint (narrowed) | eslint `--no-inline-config --format json` over the 18 changed `.ts` files: 18 files, 0 errors, 0 warnings, and none ignored. This narrowing is a measurement. The config enables no type-aware linting (`eslint.config.mjs`: no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. | | affected closure | `turbo ls --affected` names 82 packages, because a spec change reaches nearly all of them. The four above were measured locally and the rest are declared to CI. `turbo ls` reads the package graph, so it is blind to tests that read another package's source from disk, and the root package is not on that graph. | **Ablations** were done one per door, each red then green. The mutation went through `scripts/ablation-replace.mjs` (anchor hit 1→0, blob changed, restore proven to the HEAD blob with `git diff HEAD` empty), and the tests ran while the mutation was on disk. | door | mutation | red | green after restore | |:--|:--|:--|:--| | `defineStack` | delete the `collectDuplicateDefaultRecordPageErrors` push | 4 failed / 26 | 26/26 | | `composeStacks` | delete the `collectComposedDefaultRecordPageErrors` spread | 3 failed / 26 | 26/26 | | package door | delete `this.refuseDefaultRecordPageConflicts(manifest, selfId)` | 3 failed / 17 | 17/17 | | item seam | `if (false && packageId && type === 'page')` | 1 failed / 17 | 17/17 | | metadata save | the save call replaced with `undefined`. The objectql suite reads `@objectstack/metadata-protocol` through `dist/`, so the package was rebuilt, and `ablation-dist-preflight` confirmed the marker present before, absent after, and present again on restore, with the tree clean. | 6 failed / 20 | 20/20 | ## Acceptance notes - **A boundary of the registry door, stated in the module doc and not closed here:** `sys_metadata` hydration is not judged. That is the bare slot, with no package, which is the same thing ADR-0048 N.3 says for the security catalog. The save door refuses a second default before it is stored. But at a cold boot every package registers before stored pages hydrate. So a stored default saved while no package held that object's default, together with a package upgrade that later adds one, reaches the registry unrefused on restart. A hot install of that package is refused. Closing the cold-boot order would mean refusing a boot after hydration. The security catalog got that only by its own ruling (#22307), so it is not invented here. Carrier: none. - `isDefault` on `home` / `app` / `utility` / `list` pages keeps whatever objectui does with it today. The ruling's words are "record page for an object", and only those are judged. - The save door judges environment-wide rows, per ADR-0131 D6, because an organization-scoped write is refused for every type. A draft is judged twice: at its own save, and again when it is published (`promoteDraftForPublish`, which covers both `publishMetaItem` and `publishPackageDrafts`). Another page may have taken the default in between; a test pins that case. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8024e12 commit 5f37d36

25 files changed

Lines changed: 1648 additions & 16 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata-protocol': minor
5+
'@objectstack/platform-objects': patch
6+
---
7+
8+
feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015)
9+
10+
Clause-②: yes (narrowing)
11+
12+
<!-- adr-0087: registered page-default-record-page-one-per-object-refused -->
13+
14+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes.
15+
16+
`PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule.
17+
18+
**What is refused, and where.** Every door that can add a second default refuses it, naming both pages:
19+
20+
- **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages.
21+
- **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it.
22+
- **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written.
23+
24+
**Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again.
25+
26+
## FROM → TO
27+
28+
| door | FROM | TO |
29+
|:--|:--|:--|
30+
| `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one |
31+
| `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages |
32+
| package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other |
33+
| metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default |
34+
35+
**The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path.
36+
37+
No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18).
38+
39+
**`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`.
40+
41+
**`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant.
42+
43+
**`@objectstack/metadata-protocol`.** The save-door refusal.
44+
45+
**`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales.
46+
47+
**Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +326 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -134,6 +134,7 @@ const result = ApiErrorSchema.parse(data);
134134
* `CUBE_NOT_FOUND`
135135
* `DATASET_INVALID`
136136
* `DATASOURCE_ADMIN_ERROR`
137+
* `DEFAULT_RECORD_PAGE_CONFLICT`
137138
* `DELEGABLE_SCOPE_FAILED`
138139
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
139140
* `DELIVERY_NEVER_SENT`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ const result = ErrorCode.parse(data);
301301
* `CUBE_NOT_FOUND`
302302
* `DATASET_INVALID`
303303
* `DATASOURCE_ADMIN_ERROR`
304+
* `DEFAULT_RECORD_PAGE_CONFLICT`
304305
* `DELEGABLE_SCOPE_FAILED`
305306
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
306307
* `DELIVERY_NEVER_SENT`

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ View filter rule
209209
| **object** | `string` | optional | Bound object (for Record pages) |
210210
| **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") |
211211
| **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. |
212-
| **isDefault** | `boolean` | optional (default: `false`) | |
212+
| **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. Outside this rule for a page that is not a record page bound to an object. |
213213
| **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. |
214214
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
215215
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

‎content/docs/ui/pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const homePage = {
6666
| `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) |
6767
| `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) |
6868
| `variables` | `PageVariable[]` | optional | Local state variables |
69-
| `isDefault` | `boolean` | optional | Is default page for its type |
69+
| `isDefault` | `boolean` | optional | The default record page of its `object` — the page that object's record view opens. At most one `isDefault: true` record page per object per 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. Outside the rule on a page that is not a record page bound to an object |
7070

7171
### Page Types
7272

0 commit comments

Comments
 (0)