Measured on @objectstack/spec 17.2.0 + objectui at the current checkout, authoring a record page over sys_user (objectstack-ai/duly#13). Two instances of one defect class, filed together because the fix is the same shape on both sides.
PageSchema is a strictObject. A key it does not declare is a hard parse error, not a dropped key. So a renderer that reads a page key the spec does not declare has a behaviour no author can reach — and in both cases below the behaviour is the one the author wants.
1. priority — usePageAssignment
packages/react/src/hooks/usePageAssignment.ts:
// Stable ordering: respect explicit `priority` if present (higher wins),// otherwise fall back to declaration order.candidates.sort((a,b)=>(b?.priority??0)-(a?.priority??0));returncandidates[0];
priority is not on PageSchema. Writing it fails the parse; omitting it leaves every candidate at 0.
This is not hypothetical on a stock install. @objectstack/platform-objects ships sys_user_detail (type: 'record', object: 'sys_user', isDefault: true), so any app that authors its own sys_user record page has two candidates. Measured against a running pnpm demo, GET /api/v1/meta/page returns both, the app's page first, and it wins — by list position and a stable sort, not by anything authored. Note isDefault is not consulted here either, so the flag on the platform's page is inert for this decision while looking decisive.
2. disableDiscussion — RecordDetailView
packages/app-shell/src/views/RecordDetailView.tsx:2046:
constdisableDiscussion=(effectivePageasany)?.disableDiscussion===true;…constshowAutoDiscussion=!disableDiscussion&&!hasDiscussion&&feedsEnabled;
and its own comment at :2418 — "Hard opt-out via assignedPage.disableDiscussion = true". That escape hatch cannot be written.
It matters because the auto-appended panel is hard-coded open for writes:
feed: {enableReactions: true,enableThreading: true,showCommentInput: true}and feedsEnabled is opt-out (objectDef?.enable?.feeds !== false). For a record page over a platform object the object-side switch is not available either — sys_user is protection: { lock: 'full' }. So a deliberately read-only page over sys_user gets a comment box, reactions and threaded replies appended to it, with no authorable way to decline.
There is a workaround, and it is backwards. Placing an explicit record:discussion suppresses the append (hasExplicitDiscussion) and hands the config to the author, where feed.showCommentInput: falseis honoured (RecordActivityTimeline.tsx:215, config?.showCommentInput !== false). Verified in a browser: composer gone. But "declare the component that opens three write surfaces, in order to close them" is a strange thing to have to know, and an author who simply omits it gets the writes.
Suggested fix
Declare both on PageSchema (priority: z.number().optional(), disableDiscussion: z.boolean().optional()), or drop the renderer reads. Either is fine; the current state is the one that cannot be right, because the renderer is documenting an affordance the schema refuses.
If it helps, a gate that walks the renderers' page-key reads against PageSchema's declared keys would catch the next one — this is the same "declared-≠-read" asymmetry ComponentPropsMap already has machinery for, one level up at the page.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Measured on
@objectstack/spec17.2.0 + objectui at the current checkout, authoring a record page oversys_user(objectstack-ai/duly#13). Two instances of one defect class, filed together because the fix is the same shape on both sides.PageSchemais astrictObject. A key it does not declare is a hard parse error, not a dropped key. So a renderer that reads a page key the spec does not declare has a behaviour no author can reach — and in both cases below the behaviour is the one the author wants.1.
priority—usePageAssignmentpackages/react/src/hooks/usePageAssignment.ts:priorityis not onPageSchema. Writing it fails the parse; omitting it leaves every candidate at0.This is not hypothetical on a stock install.
@objectstack/platform-objectsshipssys_user_detail(type: 'record',object: 'sys_user',isDefault: true), so any app that authors its ownsys_userrecord page has two candidates. Measured against a runningpnpm demo,GET /api/v1/meta/pagereturns both, the app's page first, and it wins — by list position and a stable sort, not by anything authored. NoteisDefaultis not consulted here either, so the flag on the platform's page is inert for this decision while looking decisive.2.
disableDiscussion—RecordDetailViewpackages/app-shell/src/views/RecordDetailView.tsx:2046:and its own comment at :2418 — "Hard opt-out via
assignedPage.disableDiscussion = true". That escape hatch cannot be written.It matters because the auto-appended panel is hard-coded open for writes:
and
feedsEnabledis opt-out (objectDef?.enable?.feeds !== false). For a record page over a platform object the object-side switch is not available either —sys_userisprotection: { lock: 'full' }. So a deliberately read-only page oversys_usergets a comment box, reactions and threaded replies appended to it, with no authorable way to decline.There is a workaround, and it is backwards. Placing an explicit
record:discussionsuppresses the append (hasExplicitDiscussion) and hands the config to the author, wherefeed.showCommentInput: falseis honoured (RecordActivityTimeline.tsx:215,config?.showCommentInput !== false). Verified in a browser: composer gone. But "declare the component that opens three write surfaces, in order to close them" is a strange thing to have to know, and an author who simply omits it gets the writes.Suggested fix
Declare both on
PageSchema(priority: z.number().optional(),disableDiscussion: z.boolean().optional()), or drop the renderer reads. Either is fine; the current state is the one that cannot be right, because the renderer is documenting an affordance the schema refuses.If it helps, a gate that walks the renderers' page-key reads against
PageSchema's declared keys would catch the next one — this is the same "declared-≠-read" asymmetryComponentPropsMapalready has machinery for, one level up at the page.🤖 Generated with Claude Code
https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p