…(objectui#12195)
The record-page pick returned the first matching page in served order and
never read the declared `PageSchema.isDefault`. The spec now allows one
default record page per object per deployment and refuses a second at every
door (ruling A on objectstack#23015, in @objectstack/spec 18.0.0-next.1), so
the hook returns the default through the spec's `defaultRecordPageObject`
with no tie-break, and keeps declaration order for the pages that are not
the default. The cold-boot boundary the platform states is named in the
hook's comment; no precedence rung is added for it.
Three objectui#7298 pins that said the flag was inert now pin the ruled
behaviour; a new suite carries the rule's pins and controls.
Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #12195
Clause-②: no
What changes
usePageAssignment(@object-ui/react, the record-page pick behindRecordDetailView) now opens the object's default record page. Among the served pages withtype: 'record'and the requestedobject, the one the spec reads as the object's default wins; with no default, declaration order decides as before; an explicitpageNamestill wins outright.6134056653), landed as objectstack5f37d36ce3and installed here through@objectstack/spec18.0.0-next.1: at most oneisDefault: truerecord page may exist for an object in a deployment, and a second is refused, naming both pages, bydefineStack/composeStacks(soos validateand a source boot), at package registration and at metadata save. The ruling's renderer half, verbatim: "The renderer then honoursisDefaultwith no tie-break." This hook adds no tie-break, option, key or precedence rung.defaultRecordPageObjectfrom@objectstack/spec/ui, the function every refusing door asks. Inside the candidate set (alreadytype: 'record'and the requestedobject), that answer is exactlyisDefault === true.@objectstack/specis already a runtime dependency of the package, andsrc/SchemaRenderer.tsxalready imports values from@objectstack/spec/ui.typediscriminator, theobjectmatch andmatchesAssignmentkeep their meaning (pinned: another object's default, and alistpage flagged default for the same object, do not displace the record page).not_planned; the objectql module doc ofdefault-record-page.tsstates it). The hook's comment names this case. For that pair the hook invents no rung: served order stays its only order, so the first default served wins. A pin holds that, so a precedence rule cannot be added here unannounced.Who sees a different page
An app or an environment that authors a record page without
isDefault: true, for an object that also has a served default record page, where that page came first in the served list. It used to open; the default opens now. Ruling A keeps two ways to replace a platform record page: an ADR-0005 overlay saved under the platform page's own name (the same page, not a second default), and host composition (composing the stack without the platform's page). Shipping a second default is not one: the platform refuses it.Census (dispatch assumption A4), objectstack
origin/mainf3fd209b3, objectui7c13d30527:isDefault: trueand the only record page for its object:sys_user_detailandsys_organization_detail(packages/platform-objects),sys_approval_request_detail(plugin-approvals), and the showcase'sshowcase_project_detail,showcase_task_detailandshowcase_settings(showcase_preference).sys_position_detailretired with thesys_positionobject. Shipped apps and examples authoring a non-default record page for any of those objects: 0. objectstack's own server tests that author record pages do not feed this hook.RecordDetailView*suites, 16 serve atype: 'record'page, each from a single record-page literal; one of them (recordOpenRequests-11699) flags itisDefault: true, alone. Non-default competitors: 0.Measurements of the dispatch's assumptions
7c13d30527(main moved pastfc528c3eb; the hook was unchanged) the hook filtered totype === 'record', the matchingobjectandmatchesAssignment, then returnedcandidates[0], and its comment recorded thatisDefaultwas not consulted. The change sits after that filter; thepageNamebranch is untouched.isDefaulton a later candidate does NOT promote it", "the app page wins when it is declared first" and "isDefault: trueon the platform page does not make it win from second place" are rewritten to the ruled behaviour, each naming ruling A, none deleted. The third now also carries a control (same two pages, same order, the flag removed: the app page wins again), so the flag is the variable. The two pure-order pins stay as they were. Correction: "the platform page wins when IT is declared first" is not an order-among-non-defaults pin, since that page is the default; its assertion stays true, but its old name ("nothing authored breaks the tie") became false and is renamed.default-record-page.tsin@objectstack/spec/ui) says the renderer opens the default "with no tie-break between two defaults: registration order, package order and declaration order decide nothing", and the platform refuses the pair at every door except the cold-boot boundary above. No ordinary path delivering two defaults was found, so no open question; the triage pin "twoisDefaultrecord pages resolve the same way whatever the registry order" is read as superseded by ruling A, as the dispatch directs.recordPageEnvelopeinruntime-metadata-persistence.tsstill mints a record page with noisDefaultand still has no non-test caller (its callers are its own test file and the ADR-0034 prose). Not in this diff; see Acceptance notes.Tests (all at
e72618f400)pnpm --filter @object-ui/react type-check(tsc --noEmit && tsc -p tsconfig.test.json): exit 0.tsc -p tsconfig.test.json --listFileslists all threeusePageAssignment.*.test.tsxfiles, so the new suite is type-checked.usePageAssignmentsuites,pnpm exec vitest run --maxWorkers=2 ...:Test Files 3 passed (3),Tests 27 passed (27).pnpm exec vitest run --maxWorkers=2 packages/react/:Test Files 111 passed (111),Tests 1420 passed (1420).RecordDetailView*suites inpackages/app-shell(the hook's only caller), in two batches of 29:Test Files 29 passed (29)/Tests 210 passed (210)andTest Files 29 passed (29)/Tests 168 passed (168).vitest.config.mtsaliases@object-ui/reacttopackages/react/src, so they ran the edited hook, not a staledist/. The 16 that serve a record page:authoredActivityThreading-12249,commentWriteFailure-10899,discussionExplicitComposition-7298,discussionFallbackPipeline-8983,feedCommentEditDelete-12268,feedGrant-12082,feedLoading,feedRecordScope,oneProvenanceFooter-12307,printAction-11958,reactionKeepIds-11019,reactionRecords-12078,reactionWriteFailure-10899,reactionsCommentRowsOnly-11035,recordOpenRequests-11699,replyCountOnRead-12144.pnpm --workspace-concurrency=2 --filter '@object-ui/react^...' build: exit 0;pnpm --filter @object-ui/react build: exit 0.Ablation (one-time, not kept). The fix was committed first.
scripts/ablation-replace.mjs(objectstack's copy, run from this worktree) replaced thecandidates.find(...defaultRecordPageObject...) ?? candidates[0]return withreturn candidates[0];(anchor 1 to 0, marker 0 to 1 on disk, blob685e6ad301c1tofff84703902a) and ran the 7298 and 12195 suites:Tests 7 failed | 13 passed (20), exit 1. Red, the predicted direction: the three rewritten 7298 pins and four new rule pins (default declared second, default from any depth, slotted default, two defaults in served order). The controls, thepageNamepin and the after-the-filter pin stayed green, as they should under that mutation. Restore proven, not assumed: blob after restore685e6ad301c1equals the HEAD blob,git diff HEADempty, marker count 0, anchor count 1. The suites import the hook by relative source path, so nodist/sits in the path.Gates
Local subset for these paths (derived by hand from objectui's
package.jsoncheck:*scripts and.github/workflows/lint.yml,ci.yml,changeset-guard.yml,changeset-presence.yml; exit codes captured before any pipe), ate72618f400:node scripts/check-changeset-presence.mjs0 ("3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)");check-changeset-no-major.mjs0;check-changeset-fixed.mjs0;check-changeset-overwrite.mjs0;pnpm check:changeset-claims0;pnpm check:pending-changeset-literals0.pnpm check:control-bytes0;pnpm check:new-line-citations0 ("0 new citation(s)").pnpm check:spec-symbols0;pnpm check:test-path-roots0;pnpm check:vi-mock-specifiers0;node scripts/check-vi-mock-override-shape.mjs0;pnpm check:phantom-deps0;pnpm check:esm-specifiers0;pnpm check:unreferenced-sources0.pnpm exec eslinton the three changed TypeScript files: exit 0, 0 errors; its 10 warnings all sit on lines this diff does not add.pnpm check:spec-floors: exit 1, every findingno-artifact(an unbuilt workspace, the gate's prerequisite). After building@object-ui/react, its row left the findings (the package that importsdefaultRecordPageObject, judged against the declared floor^18.0.0-next.1); the other 18 packages remain unbuilt: NOT MEASURED locally, left to CI'sspec-range-floors.yml.node scripts/check-governed-queue-guard.mjs --teston the four paths: NOT GOVERNED.Everything else (repo-wide
pnpm lint, the test shards, the performance budget) is CI's.Acceptance notes
recordPageEnvelope(A5). It mints akind: 'full'record page with noisDefault. Now that the flag decides, a page it mints for an object that also has a served default would not open. It has no non-test caller, so nothing reaches that today; the first caller decides whether it writesisDefault.scripts/page-key-read-census.mjs, boundary 4 of its header, says "isDefaultis declared and nothing consults it". That stops being true with this change. It is outside this card's file surface (a script edit adds that script's own suite), so it is not touched here.packages/plugin-gantt/README.mdalready told authors to "letisDefaultpick the one that opens". Before this change nothing did.type === 'record'literally, while the spec's reading counts a page with notypeas a record page (PAGE_TYPE_DEFAULT, whichPageSchema'stypedefault also reads). WhetherGET /meta/pagecan serve a page with notypewas not measured. This diff leaves the filter alone.Session:
https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB(dispatched by thedomain:uiseat 1, claim6184497895).Generated by Claude Code