Repository navigation
Commit 5f37d36
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
File tree
- .changeset
- content/docs
- references
- api
- ui
- ui
- packages
- metadata-protocol/src
- objectql/src
- platform-objects/src/apps/translations
- spec
- api-surface
- export-origins
- src
- api
- migrations/entries/semantic
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
| 137 | + | |
137 | 138 | | |
138 | 139 | | |
139 | 140 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
| 304 | + | |
304 | 305 | | |
305 | 306 | | |
306 | 307 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
209 | 209 | | |
210 | 210 | | |
211 | 211 | | |
212 | | - | |
| 212 | + | |
213 | 213 | | |
214 | 214 | | |
215 | 215 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
| 69 | + | |
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| |||
0 commit comments