Repository navigation
feat(spec,objectql,metadata-protocol)!: one default record page per object per deployment, a second refused at os validate, registration and save (#23015) - #23206
Conversation
… — stack, compose and registry doors Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…d pages Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
… one-default-per-object rule Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…e-default-record-page
…object rule Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…ault when it is published Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
… read Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…e-default-record-page
📓 Docs Drift CheckThis PR changes 4 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d4804794ade7011da75d246cb5d4ae802fd46394 && git checkout d4804794ade7011da75d246cb5d4ae802fd46394
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 150da61aa54c6a287bdc834db3b13399d18e50d9 566d19b85869c8a444873f701b06c629d86fd5f0 && git checkout -B drift-repro 150da61aa54c6a287bdc834db3b13399d18e50d9 && git merge --no-ff 566d19b85869c8a444873f701b06c629d86fd5f0
node scripts/docs-audit/affected-docs.mjs --json 150da61aa54c6a287bdc834db3b13399d18e50d9
|
Contract reviewServed-tier: Inputs read: card #23015 (body and all five comments: triage 6122995432, ruling A 6134056653, claim 6138406113, dev report 6141744005, ACCEPT 6141797380); the cross-lane declaration 6138419895 on #20966; ADR-0048's 2026-10-08 addendum with N.1 to N.4 as it stands on ① Derived judgmentsAccept-set narrowings the diff makes (each RIGHT, each judged with the one reading):
One reading, and it matches No precedence ladder, no registration-order rule, no silent downgrade. Whichever side arrives second is refused, in either order (pinned); The registry door's holder bookkeeping, read against
The save door. Judged after the schema fold and No value a real producer writes today is refused. Measured at the head: seven objects, each with exactly one default record page. Public-surface additions the diff makes (named here; their declaration is judged in ②): ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — one item, the ② Generated by Claude Code |
…) — the diff adds a ledger code and public exports Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The patch round after contract review ① Derived judgmentsThe round's diff is the one owed line and nothing else. Both carriers now read the corrected value. The changeset body carries the value The gate's reading of the arm holds. Net diff unchanged. The three-dot diff against Carry-forward across
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS The one owed item is discharged on this head; ① and ③ of Generated by Claude Code |
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_CONFLICTcode 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: truerecord 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 honoursisDefaultwith 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: truerecord page producerMeasured on objectstack
1a6e24c99(this branch after mergingorigin/maine540e7ffb; the producer set is unchanged atb091cd851) and objectui4baa8a67c. A producer is non-test source that ships a record page (type: 'record', or notype) bound to an object and declaredisDefault: true.sys_organizationsys_organization_detail@objectstack/plugin-auth(SysOrganizationDetailPage)sys_usersys_user_detail@objectstack/plugin-auth(SysUserDetailPage)sys_positionsys_position_detail@objectstack/plugin-security(SysPositionDetailPage)sys_approval_requestsys_approval_request_detail@objectstack/plugin-approvalsshowcase_projectshowcase_project_detailexamples/app-showcaseshowcase_taskshowcase_task_detailexamples/app-showcaseshowcase_preferenceshowcase_settingsexamples/app-showcaseisDefault: truehits 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: trueon a page that is not a record page bound to an object is outside the rule. The one case of that isshowcase_capability_map, ahomepage. It is unchanged.The carried producer is the cloud leg. cloud's
cloud_organization_detail(com.objectstack.service-tenant) and plugin-auth'ssys_organization_detailare both defaults forsys_organization. A cloud boot on an engine that carries this PR will be refused withDEFAULT_RECORD_PAGE_CONFLICTand 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_OVERRIDABLE403:pageisallowEnvironmentOverlay: false, which is the ADR-0126 Regime C rollback. Thedefault-record-page-save-door.test.tscontrol pins this on both topologies, unchanged. So "overlaysys_organization_detailby 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) thatPageSchema.typeis now built from.defineStack, and soos validate,os buildand a source bootpagesaccepted. The boot kept both, and registration order picked the one that opened.STACK_CROSS_REFERENCE_INVALID422. 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. …`composeStacksmanifest: 'preserve', one page name in two stacks counts as two packages' pages.SchemaRegistry.installPackage, ahead of every mutation, and theregisterItemseam)manifest.registershipping a default for an object another package holds registered silentlyDEFAULT_RECORD_PAGE_CONFLICT422 (new ledger code, exportedDEFAULT_RECORD_PAGE_CONFLICT_CODE), withobject,incoming,existingandconflicts: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. NocollisionPolicydowngrade.saveMetaItem, soPUT /api/v1/meta/page/:name)isDefault: truefor an object that already had a default stored or shipped was acceptedDEFAULT_RECORD_PAGE_CONFLICT422, 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 plusSchemaRegistry.defaultRecordPageHoldersOf(object), so an environment-scoped kernel (no write-through) is judged too.What is not a second default, pinned at each door:
isDefaultpage that is not a record page.Also in this diff:
isDefaulthelp used to say "Set as default page for this page type", which is a rule nothing enforced. It now states this rule. The source ispage.form.ts; theenbundle leaf was hand-edited to the source's string, and thezh-CN,ja-JPandes-ESleaves were hand-translated.content/docs/ui/pages.mdxrow said the same thing and is corrected too.PageSchema.isDefaultgets its.describe(). The regenerated reference docs,api-surface/ui.jsonandexport-origins/ui.jsoneach add one row.18.page-default-record-page-one-per-object-refused(norelevantWhen, because the surface reaches plugin code and stored rows).DEFAULT_RECORD_PAGE_CONFLICTis registered under@objectstack/objectqland@objectstack/metadata-protocol. It has its own code rather thanNAMESPACE_CONFLICT. No name is taken: what is held is an object's one default slot, and renaming fixes nothing.Verification record
Final head
b091cd851is this branch plusorigin/main8323cad97. Unless a row names another head, every number below was read atb091cd851. Heavy runs went throughscripts/pm/os-verify-lock.sh. The lock's wall-clock figures come from a shared box.spec/src/stack-default-record-page.test.ts26/26 ·objectql/src/registry-default-record-page.test.ts+default-record-page-save-door.test.ts37/37 (save door on both topologies, environment-scoped and host-config)@objectstack/specfull local suited7a8f7f34(this branch before the last merge; spec source is unchanged since then except what main brought)@objectstack/objectqlfull local suite1a6e24c99(after the first merge). The two door files were re-run atb091cd851.@objectstack/metadata-protocolfull suite@objectstack/platform-objectssuite498957f10(its only change, the four help-text leaves, was already in)metadata-protocolandobjectqlgreen atb091cd851.specandplatform-objectsgreen atd7a8f7f34. Each includescheck:test-typecheck.check:generated(spec)b091cd851dispatch-gates --commands, 124 families)check:dual-build-cjs-loads, because it needs every package'sdist/, i.e. a full workspace build, so CI runs it. UNRUN by dispatch order:check:type-check-debt.--ranreconciliation: 124 derived, 122 run, 1 NOT-MEASURED, 1 UNRUN.check:i18nandcheck:skill-examplesfirst 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.check-adr-0087-registration: 1 declared-breaking changeset with its disposition,registered page-default-record-page-one-per-object-refused.check-changeset-no-majorandcheck-empty-changesetgreen.--no-inline-config --format jsonover the 18 changed.tsfiles: 18 files, 0 errors, 0 warnings, and none ignored. This narrowing is a measurement. The config enables no type-aware linting (eslint.config.mjs: noparserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.turbo ls --affectednames 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 lsreads 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 withgit diff HEADempty), and the tests ran while the mutation was on disk.defineStackcollectDuplicateDefaultRecordPageErrorspushcomposeStackscollectComposedDefaultRecordPageErrorsspreadthis.refuseDefaultRecordPageConflicts(manifest, selfId)if (false && packageId && type === 'page')undefined. The objectql suite reads@objectstack/metadata-protocolthroughdist/, so the package was rebuilt, andablation-dist-preflightconfirmed the marker present before, absent after, and present again on restore, with the tree clean.Acceptance notes
sys_metadatahydration 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 ([decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307), so it is not invented here. Carrier: none.isDefaultonhome/app/utility/listpages keeps whatever objectui does with it today. The ruling's words are "record page for an object", and only those are judged.promoteDraftForPublish, which covers bothpublishMetaItemandpublishPackageDrafts). Another page may have taken the default in between; a test pins that case.Generated by Claude Code