Repository navigation
feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both - #22197
Conversation
…use a second holder of a position, permission set or capability name The package door (SchemaRegistry.installPackage, ahead of every mutation) refuses a package whose declared positions, permission sets or capabilities name something an installed package, the environment catalog or a built-in already holds; the item seam (registerItem with a package id) refuses the same for direct package-bound registrations. ADR-0112 envelope: the namespace gate's code, NAMESPACE_CONFLICT, status 422; the message names both holders. Same-package reload stays allowed; no collisionPolicy downgrade; bare-slot (environment) registrations are not judged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…red-name catalog pin to the refusal Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…e one-holder refusal Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…ckage door; changeset; ADR anchor Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…curity-catalog-one-holder
Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…curity-catalog-one-holder
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 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 c95e6bd80f9ae5ab88a91c701d1d419667bcc13c && git checkout c95e6bd80f9ae5ab88a91c701d1d419667bcc13c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e4111ca054fe995b5a08a07d273cad18a749ef7 99fba80b6490f9984eb6cead8b08d21b4d044360 && git checkout -B drift-repro 4e4111ca054fe995b5a08a07d273cad18a749ef7 && git merge --no-ff 99fba80b6490f9984eb6cead8b08d21b4d044360
node scripts/docs-audit/affected-docs.mjs --json 4e4111ca054fe995b5a08a07d273cad18a749ef7
|
…s set is not also handed to plugin-security The dogfood fixtures declared one permission set under two packages: the app's own `permissions`, and the same set handed to SecurityPlugin's `defaultPermissionSets`, which plugin-security declares on its own manifest. Under one-name-one-holder that boot is refused (NAMESPACE_CONFLICT, both holders named). `os serve` never composes it: it hands the plugin the default's NAME only (`appSecurityPluginOptions`), and the app registers the set. The fixtures now wire it the same way. A runtime pin holds the refused composition. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22135 (body and all six comments: triage grade 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929 and 6054287813); PR #22197 (body, the 15-file list, the net diff against its merge base with ① Derived judgmentsCheck-runs on the head: 42, all Accept-set and public-surface changes the diff implies, each judged:
Kept as the ruling keeps them: same-package reload; every other metadata type ( ② Semver level
③ Boundary flagsFrom os-dev-report 6053492929, patch-round report 6054287813 and the PR's acceptance notes:
Implemented-by: VERDICT: PASS |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37746963251 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue kick-out: new signature, not re-queued · 2026-10-08T08:35Z
|
…curity-catalog-one-holder
…holder's own Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…curity-catalog-one-holder
…ngine's collection loop With positions registered by ObjectQL.registerApp's collection loop (now on main), three comments this change added said the loop never registers them. The claims doc and the installPackage comment now say what the claims are for on either tree: installPackage itself registers no items, so the claim is how the package door remembers every declared name. The declared-names reader's note and the runtime pin's header no longer say positions reach no engine slot. Comments only; no behaviour change. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-08T14:09Z. Inputs, and nothing else: card #22135 (body and all eleven comments: triage 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929, 6054287813, 6058641140 and 6061349995, the ACCEPT 6054723926, the kick-out note 6056029522, the landing record 6056178904); the ruling record 6050490870 on #15196 and Q4's option A as the card quotes it; PR #22197 (body, the 15-file list, the net diff from merge base ① Derived judgmentsCheck-runs on the head: 42, all Accept-set and public-surface changes the diff implies, each judged:
Kept as the ruling keeps them: every other metadata type's §3.4 coexistence (pinned on Comment truth on the merged tree. The four comments round 3 rewrote read true: the ② Semver level
③ Boundary flagsFrom os-dev-reports 6058641140 (round 2) and 6061349995 (round 3), the earlier record, and the PR's acceptance notes:
Implemented-by: VERDICT: FAIL What turns this to PASS, in one push: the changeset re-graded as the card graded it ( |
…e cold-boot boundary is open on #22307 The changeset graded objectql minor on the premise that Changesets pre mode was not yet on main. It is: .changeset/pre.json is in next pre mode, so the accept-set narrowing ships as a major on the v18 pre-release line. One sentence now states the cold-boot boundary: a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot (the existing collision warning fires), a hot install of it is, and whether a cold boot should refuse too is open on #22307. The runtime pin's comment no longer says no registry slot holds the position: the first package holds all three conflicting names. Comment only. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
… which lands separately #22307 was ruled while the previous text was in review, so the changeset no longer calls the cold-boot question open. The measured clauses before it are unchanged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-08T16:32Z. Inputs, and nothing else: card #22135 (body and all thirteen comments: triage 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929, 6054287813, 6058641140, 6061349995, 6063717789 and 6063894392, the ACCEPT 6054723926, the kick-out note 6056029522, the landing record 6056178904); the ruling records 6050490870 on #15196 (Q4 = A, as the card quotes it) and 6063176077 on #22307 (letter A, the cold-boot remainder), with #22307's body and its triage 6063800858; PR #22197 (body, the 15-file list, all five comments — the previous records 6054682234 PASS on ① Derived judgmentsWhat moved since the FAIL.
The three FAIL items, on this head:
Comment truth on this head: the four round-3 rewrites and the round-4 one read true, so the PR body's Patch round 3 sentence "each now reads true on
The changeset's cold-boot sentence, clause by clause — on this PR's merge, and after #22307 lands:
Check-runs on the head: 42, all Governed surfaces: none in the file list. ② Semver level
③ Boundary flagsFrom os-dev-reports 6063717789 (round 4) and 6063894392 (round 5), the previous records, and the PR's Acceptance notes:
Implemented-by: VERDICT: PASS |
…pabilities hold one name per deployment (objectstack-ai#22198) Part of objectstack-ai#22135 Records the maintainer's ruling Q4 = A on objectstack-ai#15196 (ruling record 6050490870, 「15196 Q3 A Q4 A」) in ADR-0048. The security catalog (positions, permission sets, capabilities) is taken out of §3.4's cross-package coexistence: each of the three types holds one name per deployment. This is the Tier H half of objectstack-ai#22135, split from the code PR (objectstack-ai#22197) so the code can land on its own record. It closes nothing: the card is closed by the code PR. objectstack-ai#22135 is not addressed by this PR alone. ## What changed — `docs/adr/0048-cross-package-metadata-collision.md` only Additive. The original text is untouched; no existing line is edited. - **A dated note directly beneath §3.4:** "Narrowed (2026-10-08) — the security catalog is out of §3.4", with a link to the addendum. - **A new addendum at the end:** "Addendum (2026-10-08): the security catalog holds one name per deployment — §3.4 narrowed". It carries: - the ruled option's text, verbatim, and the options not taken (B, C); - N.1, why §3.4's premise (every caller carries its package id) does not hold for bare-name assignments, with the measurement that motivated the ruling; - N.2, what is refused and who the holders are; - N.3, what stays as §3.4 has it: same-package reload, every other type, an environment save, no `OS_METADATA_COLLISION=warn` downgrade; - N.4, where it is implemented. The header's `**Addenda**:` index line is deliberately not edited, to keep the original text untouched. The note under §3.4 carries the link. ## Gates (at c383221) `dispatch-gates --commands` derived 19 commands; all 19 were run with exit codes recorded, and `--ran` reconciles 19/19 with 0 NOT MEASURED. `check:doc-formula-expressions` first answered PREREQUISITE NOT MET (exit 3); it passed after `@objectstack/formula` and `@objectstack/lint` were built. ## 维护者速读(草稿) ### 改了什么 只改了一份架构决策记录 ADR-0048 的文字,没动任何代码。在 §3.4「跨包同名不再报错」那一节下面加了一段带日期的说明,并在文末加了一个附录。内容是把您在 objectstack-ai#15196 上的裁决(Q4 选 A)写进去:职位、权限集、能力这三类安全目录,一个部署里一个名字只能有一个持有者。原文一个字都没改。 ### 为什么改 ADR-0048 §3.4 当初允许两个包用同一个名字,是因为界面类元数据被调用时总带着「我是哪个包」,系统能分清。但给用户分配职位、给职位挂权限集时,只记名字、不记包。两个包都带同名的「销售经理」,用户到底拿到哪一份权限,就取决于加载顺序。实测确实如此:同一套系统里,职位取后注册的那个包,权限集和能力取先注册的那个。一个应用自带一个叫 `admin_full_access` 的权限集,按名字查到的就是应用自己那份,而不是平台的管理员权限集。您裁定这三类单独收紧,这份 ADR 要跟着记下来,否则 ADR 写着「允许同名」,代码却在拒绝,两边对不上。 ### 风险与代价(含回滚) - 这份 PR 本身只是文档,没有运行时风险。 - 真正的行为变化在配套的代码 PR(objectstack-ai#22197):装包或启动时遇到同名会直接报错。仓库里四个示例应用加平台内置名,一共 50 个声明,实测没有一处同名,所以现有示例都能照常启动。已部署环境和应用市场里的包没有测过。 - 回滚:撤销这份 PR 即可,ADR 回到原文;代码 PR 可以分开回滚。 ### 席位意见 ### 你要做的 请审阅附录的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ Co-authored-by: Claude <noreply@anthropic.com>
… the Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) (objectstack-ai#22186) Fixes objectstack-ai#15195 Clause-②: yes (narrowing) ADR-0131 C1: the Default Organization is load-bearing under `single`. Scope as triage ruled it: 6040634630 (Q1 → B) for the implementation, 6053354661 (Q1 → A, Q2 → A) for landing it. This is one atomic, cross-lane PR. It carries the `@objectstack/verify` `bootStack` re-pin and the dogfood re-pins with the implementation. `packages/qa/dogfood` and `packages/verify` (`domain:cli`) join it, declared on objectstack-ai#6024. ## What this does - **The boot invariant (D3).** Under the `single` posture, `AuthPlugin.start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. A failed read or insert throws and fails the boot. - `AppPlugin` declares `com.objectstack.auth` as an order-if-present dependency, so the kernel starts the auth plugin first wherever a host registered it. - The organization therefore exists before the inline seed and before `kernel:listening`. - **The seed-exemption withdrawal (D3/D9).** The seed loader stamps the install's organization on every row of an object that carries an `organization_id` column, `sys_` / `cloud_` / `ai_` seeds included. - Suppose no organization is pinned and none can be derived (zero organizations, or several), on an install that registers the organization object. Such a row is refused, counted and named. - An object with no organization column is never stamped. - **The owner pin (D3 / ADR-0093 D7).** The reconciler binds the first admin as `member` before the owner bind learns who they are. - While the once-only bind is undecided and the Default Organization has no owner, the bootstrap promotes that row to `owner` in place. - It records the decision as `promoted`. - **The derivation rule for the objects already in scope (D9).** `resolveSystemInsertOrganization` derives at exactly one organization. It refuses at zero (new: `reason: 'no-organization'`), at several, and under a wall. - The refusal reuses `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500). - No `packages/spec` edit. - **`bootStack` boots the production `single` shape (D3 / D11).** The harness no longer pins the owner bind off (`autoDefaultOrganization: !!opts.orgContext` is gone). Every boot has the Default Organization, every sign-up is its member, and the harness admin is its owner, as `objectstack dev` / `serve` boot it. - `orgContext` is now only the vacuity guard: it asserts that the admin's session carries an organization, and refuses the boot otherwise. It still refuses to compose with `multiTenant: 'posture-only'`. - There is no test-only org-less mode and no escape hatch. - **Unchanged by ruling.** - The 49 gated platform objects keep `isPlatformObjectOutOfTenantAuditScope` until C8. - The lean-install branch is unchanged, with a pin. That is `probeInstallOrganizations` answering `[]` when no organization object is registered. - No C3, C5 or C6 surface, no seeder and no `applyTenantScope` is touched. ## Boot order: fresh `single`, `examples/app-showcase` through `bootStack` (measured) Base `51290bca2c`, implementation head `c49f46bab1`. Seeds: 132 rows over 19 objects. The last two rows were measured with the harness at its old pin. Since this PR, `bootStack` always boots the owner-bind-on row. | step | base | head | | :--- | :--- | :--- | | `AuthPlugin.start()` | no organization | Default Organization inserted: the first insert of the boot (seq 0 of 98) | | `AppPlugin.start()` inline seed | 132 rows, all `organization_id` NULL (`sys_business_unit` 5 of 5 NULL) | 132 rows, 0 NULL, `sys_business_unit` included | | `kernel:ready` | 0 organizations | 1 | | `kernel:listening` | 0 organizations (old harness default). 1 only when a dev admin existed at `kernel:ready`; even then the organization was the 49th insert, after every seed, and 130 of 132 seed rows stayed NULL | 1 | | dev admin, owner bind on (`bootStack` now, always) | org created and admin bound `owner` directly | reconciler binds `member`; the bootstrap logs "promoted the platform admin to owner"; `isPlatformAdmin: true`, positions `platform_admin`, `org_owner` | | dev admin, owner bind off (the old harness default, removed) | no membership, no active organization | `member` of the Default Organization, the session's active organization | ## The derivation rule: `resolveSystemInsertOrganization`, objects in scope | posture | organizations | organization object registered | write carries one | base | head | | :--- | :--- | :--- | :--- | :--- | :--- | | `single` | 1 | yes | no | derived | derived | | `single` | 0 | yes | no | lands NULL (measured, probe) | refused `no-organization` (measured, pins) | | `single` | 2 or more | yes | no | refused `ambiguous-organization` | unchanged | | `single` | 0 | no (lean composition) | no | lands unstamped | unchanged, pinned | | `isolated` / `group` | any | yes | no | refused `walled-posture` | unchanged (measured on a `posture-only` isolated showcase boot: code `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500; the same insert carrying `tenantId` lands stamped) | | any | any | any | yes | stamped with it | unchanged | | the 49 gated platform objects | any | any | no | exempt | unchanged (C8) | ## Acceptance pins (implementation head `4fc2472fd0`, base `ec8f37c890`) `runtime/src/default-organization-boot-invariant.pin.test.ts` boots a real kernel twice: - **Kernel A:** a fresh deployment nobody signed up to. - **Kernel B:** `objectstack dev`, where the dev admin is created after the organization exists. `AppPlugin` is registered before `AuthPlugin` on purpose. | pin | where | base | head | control | | :--- | :--- | :--- | :--- | :--- | | (a) organization before the listener and before the first seed row; an `isSystem` insert with no organization lands stamped | kernel A, 3 cases | red | green | an object declaring `tenancy: { enabled: false }` takes none | | (b) `isolated` / `group` refuse, with the code and status; the insert carrying `tenantId` lands | `objectql` `system-write-organization.test.ts` and the measured isolated boot above | green (since objectstack-ai#8844) | green | the carrying insert | | (c) the first admin is the owner, by promotion | kernel B | red | green | `isPlatformAdmin` read back unchanged | | (d) every seed row stamped, `sys_` seeds included | kernel A | red | green | the tenancy-off seed lands with no organization | | (e) lean-install branch unchanged | `objectql` and `metadata-protocol` `[ADR-0131 Q2, held as-is]` cases | green | green | the registered-but-empty case refuses | **Reverse verification of the implementation** (from committed `4fc2472fd0`): 1. The eight implementation source paths were restored to `ec8f37c890` in the tree only. 2. The four packages were rebuilt, and `scripts/ablation-dist-preflight.mjs` confirmed each head marker absent from `dist/`. 3. The pin run: 5 failed and 2 passed, as tabled. 4. A trap restore, proven by `git diff HEAD` empty and per-path blob hashes equal to HEAD. 5. A rebuild with the markers back. **Ablations** (`scripts/ablation-replace.mjs`, each restored and rebuilt): - **The promotion call disabled:** (c) turns red (`member`, not `owner`). - **`com.objectstack.auth` removed from `AppPlugin.optionalDependencies`:** every seed row is refused, and (a)'s ordering case and (d) turn red. ## M5: the existing tests C1 moved, judged one by one Each flip was judged against the ruling and none was restored. There are two judgements: - **flipped:** the old expectation was the defect C1 fixes, and the pin now holds the new answer. - **re-pinned:** the fixture changed and the subject did not. | file | package | judgement | what changed | | :--- | :--- | :--- | :--- | | `seed-loader-sole-organization-read-failure.test.ts` | metadata-protocol | flipped | no organization, several, or an unprovisioned table no longer write the seed row NULL | | `seed-loader-org-fallback.test.ts` | objectql | flipped | `sys_` seeds take the organization; ambiguity refuses | | `engine-organization-probe-outage.test.ts` (cause 2) | objectql | flipped | the empty probe still is not an outage, but zero organizations now refuses | | `system-write-tenancy-autonumber-split.integration.test.ts` | runtime | flipped | the first-boot write is refused at zero organizations, with the stamped control | | `auth-plugin.test.ts`, "`autoDefaultOrganization: false` opts out" | plugin-auth | flipped | the organization exists anyway | | `seed-loader-engine-schema-fallback.test.ts` | metadata-protocol | re-pinned | the one new engine read is the organization-object registration read | | `seed-loader-existing-records-read-failure.test.ts` | metadata-protocol | re-pinned | the metadata double no longer claims an organization object | | `protocol-publish-package-drafts.test.ts` | objectql | re-pinned | the install holds its Default Organization | | `packages-seed-apply-disclosure.test.ts`, `packages-seed-apply-read-decorations.test.ts`, `http-dispatcher.test.ts` | runtime | re-pinned | each install holds its Default Organization | | `seed-tenancy-autonumber-split.integration.test.ts` | runtime | re-pinned | the split is reproduced from pre-C1 residue written at the driver, since the loader can no longer produce it; a new case pins the refusal | | `auth-plugin.test.ts`, the two `app:seeded` cases | plugin-auth | re-pinned | they delete the organization to reach "no target" | | `status-mirror-cascade.integration.test.ts` | plugin-approvals | re-pinned | The rig registered `sys_organization` but left it unprovisioned and empty, then system-inserted `opportunity`, so CI refused it (`no-organization`). It now provisions `sys_organization` and seeds the Default Organization before the first insert. `sys_organization` leaves the expected-absent probe list, by that channel's own contract (a table that started resolving is provisioned now). The subject, an approval decision cascading as the deciding user, is unchanged. The delegation channel still fires (`afterAll` green). | | `claim-seed-ownership-warm-boot.test.ts` | plugin-security | re-pinned | The rig registered the real identity objects with no auth plugin, so CI refused the `crm_case` seed inserts in four cases. Every boot of the rig now finds or creates the Default Organization, as the invariant does on every boot. The subject, the warm-boot seed-ownership claim and its target, is unchanged: all 5 cases pass with the same expectations. | | `runas-system-stamping.integration.test.ts`, the SecurityPlugin flip block | service-automation | re-pinned | The rig composed the identity objects with no auth plugin, so the user-less run's `create_record` was refused (found by the sweep below; CI never reached this suite). The rig now seeds the Default Organization as `org_1`, the member's organization. The NULL-born case also pins the row's derived organization (`org_1`), so the `403` is decided by the stamp columns alone, which is the subject. The lean block above it registers no organization object and is unchanged. | None of these weakens the `no-organization` refusal, adds tolerance in the engine, or skips a case. ## `bootStack` and the dogfood: the 24 re-pinned files At this PR's implementation alone, 24 dogfood files failed; at base they pass. Each was re-pinned on its own cause, to the production `single` shape. Pins that C1 flips flip to the new answer and none is deleted. The shared helper is `leaveOrganization` (new, `test/armed.ts`): it deletes the user's `sys_member` rows in system context, signs in again, and throws if a membership survives. A user removed from their organization is an ordinary production state, not a test mode. | file | cause | what changed | why it keeps the original intent at the production shape | | :--- | :--- | :--- | :--- | | `analytics-adhoc-query-isolation` | its `memory` leg: `driver-memory` refuses a tenant-scoped read (503), and every session now carries the Default Organization | the `memory` leg became an `objectql-strategy` leg on `sqlite-wasm`, with the analytics plugin's `queryCapabilities` withholding native SQL. `Restart-when: objectstack-ai#15212 closed` | the file pins isolation under both analytics strategies; the memory driver was only the route to the ObjectQL strategy, and the capability switch reaches it on a driver that answers | | `analytics-contains-membership` | as above | as above | as above | | `analytics-inline-dataset-admission` | as above | as above | as above | | `analytics-inline-dataset-isolation` | as above | as above | as above | | `armed` | the "outside" class was an org-less sign-up; every sign-up is now a member | `orgless`/`orgbound` renamed `outside`/`inside`; the outside principal leaves the organization; the write-floor disarm text names "Keep the principal a member of the organization" | the floor is still measured on a principal with no active organization against one inside it | | `delegated-admin-invite` | it minted its own `slug: 'default'` organization (`DuplicateRecordError`) | reads the boot's Default Organization | same subject inside the deployment's one organization | | `delegation-of-duty` | the delegator is now a member, so the gate reads the delegator's organization's positions (ADR-0091 D3 rule 5), and the fixture's positions had none | `sys_position` / `sys_user_position` rows carry the Default Organization; the session double carries `activeOrganizationId` | same delegation rules, on rows held the way an org-bound deployment holds them | | `invitation-ledger-row-scope` | it minted a second organization (`acme-8095`) while the reconciler binds every sign-up to the Default Organization | uses the Default Organization | the ledger's row scope is measured inside the deployment's one organization | | `membership-actor-attribution` | minted its own `default` organization | reads the boot's | unchanged subject | | `membership-ended-session-revoke` | minted its own `default` organization | reads the boot's | unchanged subject | | `membership-reconciler` | minted its own `default` organization | reads the boot's | unchanged subject: the reconciler binding through the real sign-up pipeline | | `membership-role-vocabulary` | minted its own `default` organization | reads the boot's | unchanged subject | | `org-admin-affordance-reach` | it minted `reach-org` while sign-ups bind to the Default Organization | uses the Default Organization | grades measured in the organization the members are in | | `organization-delete-federated-fixture` | deleting the Default Organization now runs the delete behaviour of every row the deployment owns, the seed included | deletes a second organization (`org-21910`) that the admin owns and no row belongs to | the cascade scan probes every reference on any organization's delete, so the federated anchor is still reached, without a different question attached | | `parent-derived-write-refusal-not-visible` | an org-less principal was the precondition | `boot(inside)`: the outside principal leaves the organization; the inside boot keeps `orgContext: true` | same refusal shape, for a principal outside the organization | | `permission-set-lock-row-provenance` | the harness admin was a `member` | no edit: the harness owner bind | the admin is the deployment's administrator, the Default Organization's owner as `objectstack dev` boots it | | `permission-set-write-through-package-binding` | as above | no edit | as above | | `predicate-write-unreadable-not-matched` | org-less precondition | as `parent-derived-write-refusal-not-visible` | as above | | `sharing-rule-org-less-caller` | the org-less personas, and the harness admin as the org-less platform operator | the org-less personas leave the organization. The operator is a new user holding `admin_full_access` globally who leaves it. The control persona's Default membership is removed before its tenant-A one. Preconditions judge live sessions only, because leaving revokes the sign-up session (objectstack-ai#15784) | a sharing rule still must not widen reads for a caller with no organization; each caller is now a production shape | | `showcase-permission-projection` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `single-tenant-identity-create` | the old expectation, a `sys_business_unit` with no organization, is the defect C1 fixes | flipped: `organization_id` equals the Default Organization's id | ADR-0057's property (creatable single-tenant, no `VALIDATION_FAILED`) is still the pin | | `sys-file-metadata-write-refusal` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `two-doors-permission` | the harness admin was a `member` | no edit | as `permission-set-lock-row-provenance` | | `write-door-unreadable-is-not-found` | org-less precondition | as `parent-derived-write-refusal-not-visible` | as above | **Files beyond the declared set** (`packages/verify/src/harness.ts` and the 24 files): - `packages/qa/dogfood/test/armed.ts`, which holds the `leaveOrganization` helper. - `packages/verify/src/harness.org-context.test.ts`. The default-boot case now pins the production shape, including the admin's `owner` membership. - `showcase-external-autoconnect.dogfood.test.ts` and `showcase-scope-depth.dogfood.test.ts`: comments only, correcting the description of the removed org-less boot. - `.changeset/15195-verify-bootstack-production-single.md`. - Cross-lane (`domain:services`) test fixtures, round 3, tabled under M5: `packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts`, `packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts` and `packages/services/service-automation/src/runas-system-stamping.integration.test.ts`. - The tenant-audit census (`content/docs/permissions/tenant-audit-census.mdx`, `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`). After each `main` merge it was regenerated with the gate's own write mode, outside the MERGE state, and the prose figures the gate holds were moved with it: 234 → 236. **Reverse verification of the harness change** (committed `05dedeea79`, and again on `9aee4d05f1` with identical results; round 3 changes no file it reads): 1. `scripts/ablation-replace.mjs` (WRAP mode) restored the old pin in `harness.ts`, with a string-literal marker (`REVERSE-15195-OLD-PIN`) the bundler keeps. 2. `@objectstack/verify` was rebuilt, and `ablation-dist-preflight` confirmed the marker present in `dist/`. 3. The mutated leg turned red: - dogfood, the 24 files: 5 files and 14 tests failed, 204 tests passed. The five are exactly the "no edit" rows above. - `harness.org-context.test.ts`: 1 of 5 failed (`expected [ 'member' ] to deeply equal [ 'owner' ]`). 4. The file was restored: blob equal to HEAD and `git diff HEAD` empty. `@objectstack/verify` was rebuilt, and the preflight `--absent` passed. The same leg on `c2f7e36d6e` left the harness unit test green, because its two assertions hold without the owner bind. `05dedeea79` adds the `owner` assertion so the unit test reads the line itself. The first attempt was void and is not counted: the tool refused it because the replacement contained the anchor, and nothing ran. ## The in-memory driver until C8 (triage Q2 → A) `@objectstack/driver-memory` refuses tenant-scoped reads. Under `single`, every session now carries the Default Organization, so on that driver signed-in reads answer `503` until C8 (objectstack-ai#15212, ADR-0131 D8) gives `single` no read predicate. The `plugin-auth` changeset states this. This is not new in production terms. At base, a showcase boot with the owner bind on (the shape `objectstack dev` boots) already answered the platform admin's `GET /data/showcase_category` with `503 SERVICE_UNAVAILABLE` on the memory driver. Only the harness's org-less pin kept the four analytics `memory` variants green. Those variants now run on the SQL in-memory driver, with `Restart-when: objectstack-ai#15212 closed` in each file. ## Clause-②: the built entry declarations, base `ec8f37c890` against head - **`@objectstack/objectql`:** - `SystemWriteOrganizationDecision`'s `'no-organization-yet'` becomes `'no-organization-object'`. - `SystemWriteRefusalReason` gains `'no-organization'`. - `resolveSystemWriteOrganization` takes a required `organizationObjectRegistered`. - **`@objectstack/plugin-auth`:** - `EnsureDefaultOrganizationOnceOptions` gains an optional `organizationCreatedByThisProcess`. - `EnsureDefaultOrganizationResult` gains an optional `ownerPromoted` and the `'owner_promotion_failed'` reason. - **`@objectstack/verify`:** no declaration change. `bootStack` now boots an org-bound admin for every caller, so a fixture that relied on an org-less one breaks. - **`@objectstack/metadata-protocol`, `@objectstack/runtime`:** no public declaration changes. The accept sets narrow, so the answer is `yes (narrowing)`. The objectql, plugin-auth, metadata-protocol and verify changesets are `minor`, with the BREAKING banner and an ADR-0087 disposition. The runtime changeset is `patch`. Driven offline with this body as the `pull_request` payload (`--event`), `check-changeset-no-major` reads `Clause-②: yes (narrowing)` and passes the level axis. `check-adr-0087-registration` also passes. ## Verification (head `ede1fbd345`, merge base `9f0de32a03`) Every package that depends on `@objectstack/objectql` was run in full. That is 45 packages (`pnpm --filter '...@objectstack/objectql'`); 44 have a `test` script, and `metadata-protocol` was added. A package that does not register `sys_organization` cannot reach the `no-organization` refusal, but the sweep measured every package rather than relying on that argument. In each log, every `SystemWriteOrganizationRequiredError` / `no-organization` match was read; none remain after the fixes. - **Round 3, the three touched packages, at `ede1fbd345`** (plugin-approvals and plugin-security also ran at `b7d0b3469e`, which already carried their fixes): - `plugin-approvals`: 62 files, 905 passed. - `plugin-security`: 179 files, 3,775 passed, 45 skipped. - `service-automation`: 175 files, 2,120 passed. - **`runtime`, re-run at `ede1fbd345`:** 339 files, 5,495 passed, 19 skipped. - **`cli`:** unit tier 264 files, 3,915 passed. Integration tier in four slices: 93 files, 900 passed, 2 skipped. - **The rest of the sweep, at `b7d0b3469e` / `ede1fbd345`:** | package | files | tests | | :--- | ---: | ---: | | plugin-audit | 40 | 641 | | plugin-sharing | 40 | 1,002 | | trigger-record-change | 11 | 114 | | trigger-schedule | 8 | 174 | | service-analytics | 180 | 4,441 (262 skipped) | | service-datasource | 41 | 760 | | service-knowledge | 4 | 49 | | service-messaging | 48 | 534 | | service-settings | 39 | 707 | | service-storage | 43 | 717 | | service-queue | 5 | 77 | | service-sms | 5 | 74 | | rest | 262 | 4,938 (326 skipped) | | hono | 5 | 122 | | http-conformance | 8 | 102 | | downstream-contract | 3 | 31 | | client | 51 | 653 | | client-react | 3 | 34 | | cloud-connection | 41 | 505 | | connector-mcp / -openapi / -rest / -slack | 3 / 4 / 4 / 3 | 23 / 36 / 26 / 10 | | driver-mongodb | 31 (5 skipped) | 690 (182 skipped) | | knowledge-memory / knowledge-ragflow | 1 / 1 | 8 / 10 | | organizations | 11 | 151 | | plugin-dev | 9 | 86 | | plugin-email | 31 | 535 | | plugin-pinyin-search | 2 | 21 | | plugin-webhooks | 15 | 165 | | example-crm / -embed-objectql / -showcase / -todo | 5 / 1 / 33 / 7 | 45 / 2 / 408 / 238 | - **At `9aee4d05f1`.** Round 3 changes no file these read: - dogfood, all 8 shards (`OS_TEST_SHARD=k/8`): 222 files (1 skipped), 1,753 tests passed, 9 skipped. - `objectql`: 381 files, 7,527 passed. - `metadata-protocol`: 222 files (3 skipped), 28,283 passed. - `plugin-auth`: 128 files, 2,655 passed. - `verify`: 18 files, 133 passed. - **Typecheck:** green for plugin-approvals, plugin-security and service-automation at `ede1fbd345` (`check:test-typecheck` OK: the plugin-approvals debt ledger is held, the other two ledgers are empty). Earlier: objectql, runtime, metadata-protocol, plugin-auth, verify and dogfood. - **Gates:** `dispatch-gates --commands` derived 112 for this tree (two i18n families joined), all 112 ran with exit 0, and `--ran` reconciled 112 of 112 with recorded exit codes. The roster gates whose list lives in a touched directory all exit 0. `check-single-claim-paths` passed with this PR's context. - **Lint:** `eslint --no-inline-config` over the 51 script and TypeScript files this diff adds or modifies: 0 errors and 0 warnings, 51 files in the JSON report. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict. The full `pnpm lint` run is CI's. - **Serial:** objectstack-ai#22197 and objectstack-ai#22215 are still open. Neither shares a file with this PR; no dogfood showcase file and no `showcase-security.ts` is touched. - **`main` since the merge base:** 27 commits, not merged here. `git merge-tree` against it is clean. 16 test files they add or change name the organization object (for example `plugin-security/src/grant-holder-membership-refusal.test.ts`, `service-settings/src/settings-organization-isolation.pin.test.ts` and `dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`). They are NOT MEASURED against C1 here; CI's merge-ref run measures them. ## Acceptance notes - **The wall's sole-organization seed fallback.** The seed loader still derives under a walled posture when a load names no organization and the install holds exactly one organization. This was read, not measured. The walled inline seed is suppressed, and the per-organization replay always names one. `carrier:` the objectstack-ai#15195 claimant. - **The owner-bind ledger.** Its `admin-already-member` second insert (`DUPLICATE_RECORD` on two concurrent triggers, present at base too) is objectstack-ai#22099. - **Stale comments.** `packages/plugins/plugin-sharing/src/sharing-service.ts`, `sharing-rule-service.ts` and `sharing-service.test.ts` still describe the harness's `autoDefaultOrganization: false`. They are not edited here (outside the declared set). `carrier:` none named, so this is noted here only. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ssion-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (objectstack-ai#22365) Fixes objectstack-ai#22307 Clause-②: no Executes the maintainer's ruling letter A on objectstack-ai#22307 (ruling record 6063176077): the restart path refuses too. After `sys_metadata` hydration and before `kernel:ready`, the engine checks every package-held permission set and position name against the environment catalog, and a name the environment already holds fails the boot with the 422 `NAMESPACE_CONFLICT` envelope the package door uses, naming both holders. A cold boot, a hot install and an artifact boot now answer alike (Q4 = A, ruling record 6050490870). The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft PR, from branch `claude/issue-22307-adr-0048-n3-amendment`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/plugin.ts`.** `ObjectQLPlugin.start()` calls a new private `refuseEnvironmentHeldSecurityCatalogNames()` right after the hydration block (`restoreMetadataFromDb`, or the project-kernel skip line) and before Phase 3's schema sync. Any conflict throws `SecurityCatalogNameConflictError` with `door: 'cold-boot'`, which fails `start()` and with it the boot. It runs whether or not the kernel hydrated. - **`packages/objectql/src/registry.ts`.** - A private `SchemaRegistry.environmentHeldSecurityCatalogConflicts()` returns every package-held position and permission-set name that also has a bare-slot item. Built-in names are skipped. Results are sorted by type, then name. - A private `securityCatalogPackageHolders()` reads the package half of the holder reading: composite slots and install claims, never the bare slot. - A module-level `findEnvironmentHeldSecurityCatalogNames(registry)` is the plugin's handle on that reading. It is not re-exported from `index.ts` or `core.ts`, so the public surface does not grow. - `SecurityCatalogNameConflictError` takes an optional `{ door: 'cold-boot' }`, which changes only the message: which package declares each name, and a remedy stated for a restart. `code`, `status`, `httpStatus` and `conflicts[]` are unchanged. - **`packages/objectql/src/security-catalog-namespace.ts`.** `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` (`position`, `permission`: the two types the metadata-type registry declares `allowRuntimeCreate: true`), and a module-doc section, "The cold boot". - **`.changeset/22307-cold-boot-catalog-refusal.md`** (new). `'@objectstack/objectql': major`, the BREAKING banner, the ADR-0087 marker `not-required (no-migration-prescription)`, the upgrade shape and the remedy. - **`.changeset/22135-security-catalog-one-holder.md`** (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects". - **`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.** The invariant gains the cold-boot half. No new error code, no `packages/spec` change. ## Where each refusal sits (for the merge with objectstack-ai#22331, which landed first) `main` was merged at e3ae92a, after objectstack-ai#22331 landed. The merge was clean, and the order in `ObjectQLPlugin.start()` on this head is: 1. objectstack-ai#22331's `installDeploymentPlatformGlobalObjects(ctx)`, the first statement of `start()`. 2. `restoreMetadataFromDb(ctx)`: `sys_metadata` hydration. 3. **This PR's `refuseEnvironmentHeldSecurityCatalogNames()`**: right after the hydration `if`/`else` and before Phase 3's `installRegisteredSchemas`. It runs before any plugin that depends on the engine starts, and before `kernel:ready`. 4. objectstack-ai#22331's `assertDeploymentPlatformGlobalObjectsUnchanged(ctx)`, at the top of the `kernel:ready` hook. The two changes share no hunk. This PR's new method sits directly after `restoreMetadataFromDb`'s method body, and its import line comes after the `picklist-resolution` import block. ## Mechanism assumptions, measured - **M1, the admission today.** Reproduced through `bootStack` on one database file, on the untouched base 28bff18. Boot 1 saved a permission set and a position through `PUT /api/v1/meta/permission/NAME` and `PUT /api/v1/meta/position/NAME`. Both answered `200`; a new position name needs no `OS_METADATA_WRITABLE`. Boot 2, cold, added a package declaring both: it booted, with two `[Registry] Collision` warnings, and the by-name read answered the environment's definitions. Boot 3 hot-installed the same package: `422 NAMESPACE_CONFLICT`, both names held by `environment`. - **M2, where the check sits.** As above. Boot shapes: - standalone `os serve` / `os dev` / `bootStack`: `environmentId` unset, hydration runs, the check runs (measured, dogfood); - the artifact boot (`createStandaloneStack`): `environmentId: 'env_local'` with `hydrateMetadataFromDb: true`, hydration runs, the check runs (measured, runtime pin); - a project kernel with `environmentId` and no `hydrateMetadataFromDb`: hydration is skipped, and the check runs over whatever reached the bare slot, normally nothing (code reading); - a host with no `protocol` service, or one without `loadMetaFromDb`: nothing hydrates, and the check runs with nothing to judge (code reading). `loadMetadataFromService` at the top of `start()` syncs `object`, `view`, `app`, `flow` and `hook` only, so no other boot-time path writes these two types into the bare slot. - **M3, the holder reading. Partly falsified, route changed by the ruling's intent.** At a cold boot the hydrated environment row is NOT an unstamped bare-slot item. Hydration runs after the package registered, and the protocol's artifact-protection merge grafts the package's envelope onto the stored row. Measured on base: the bare slot `probe22307_set` carries `_packageId: com.probe.addon22307` and `_provenance: package`, so objectstack-ai#22197's stamp-based reading answers "the package itself" and finds no second holder. The check therefore reads every bare-slot item as the environment's, whatever stamp it wears: only a registration with no package writes the bare slot. A package holds a name through a composite slot or a claim, never through the bare slot. The envelope class, holder kinds and claims are objectstack-ai#22197's. - **M4, built-ins.** Built-in names are skipped. Through `bootStack`, with `OS_METADATA_WRITABLE=position`, environment saves under `org_admin` and `everyone` answered `200`, and the restart boots, with `GET /api/v1/meta/position/org_admin` answering the saved definition. S2b's pins are green: `builtin-positions.boot.test.ts` is in the plugin-security suite below. - **M5, the legacy shape.** The save door refuses it now (`PUT /api/v1/meta/permission/NAME` over a package-held set answers `403`, with or without `?package=`), so the rows were written at the driver. A row bound to no package refuses the restart, naming both holders (pinned). So does a row bound to the package itself (`package_id` = the package; objectql pin). A hot install refuses that bound row alike: measured, holder `environment`. A legacy row over one of the platform security plugin's own permission sets (`member_default`) refuses the restart, naming `com.objectstack.plugin-security`. On base, all three boot. - **M6, capabilities.** `PUT /api/v1/meta/capability/NAME` answers `403` ("code-only … allowRuntimeCreate=false"), so the environment catalog holds no capability. The check reads permission sets and positions only, and no capability path reaches it. ## Door table: base vs head "Base" is the untouched 28bff18, or a15b8af with the check ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes comments only). Boots go through `@objectstack/verify`'s `bootStack` on one database file unless the row says otherwise. | Door | Base | Head | |---|---|---| | Cold boot: environment-saved permission set and position, then a package declaring both | boots; two `[Registry] Collision` warnings; the by-name read answers the environment's definitions (28bff18 and ablated) | refused: `Plugin com.objectstack.engine.objectql failed to start`, cause `422 NAMESPACE_CONFLICT`, two conflicts, incoming the package, holder `environment` | | Hot install (post-boot `manifest.register`) of that package | refused, `422`, holder `environment`, both names | unchanged | | Artifact boot (`createStandaloneStack`, `file:` database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope | | Built-in shadow: environment saves under `org_admin` and `everyone`, restart | boots (ablated) | boots; the stored definition answers | | Legacy row (written at the driver, bound to no package) over a package-held set and position, restart | boots, one collision warning (28bff18) | refused, holder `environment`, both names | | Legacy row bound to the package itself, restart | boots (ablated) | refused, holder `environment` | | Legacy row over the platform's `member_default`, restart | boots (ablated) | refused, incoming `com.objectstack.plugin-security` | | Same-package restart; a package whose names the environment does not hold | boots | boots | | Remedy: boot without the package, `DELETE /api/v1/meta/permission/NAME` and `/position/NAME`, boot with it | (n/a) | both `200`, no row left, the boot with the package comes up | | Environment save of a capability | `403` code-only | unchanged | ## In-repo census The examples ship no `sys_metadata` rows, so the environment catalog holds no names on a fresh boot. Measured on a15b8af: a fresh boot of each example on a database file, then a restart. | Example | Package-held items | Environment rows (`permission`/`position`) after the boot | Restart | |---|---|---|---| | `app-crm` | 10 permission sets, 9 positions | 0 | boots | | `app-showcase` | 17 permission sets, 16 positions | 0 | boots | | `app-multi-package` | 8 permission sets, 6 positions | 0 | boots | The counts include the platform's own items (`plugin-security`'s 8 permission sets and 6 built-in positions). Names held twice: 0. `app-todo` declares no catalog name (objectstack-ai#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED. ## Tests The head is 3c160a2. Against 72dcb8e it changes comment lines only, in the new dogfood file (5 added, 3 removed, 0 outside a `//` comment). The runs below are at 72dcb8e or earlier, as each line says. - `@objectstack/objectql`, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, `protocol-boot-hydration-scoped.test.ts`: 16 passed (8 of them new). - `@objectstack/plugin-security`, whole suite at e3ae92a: 184 files / 3869 passed, 45 skipped. That includes S2b's `builtin-positions.boot.test.ts` and `bootstrap-declared-positions.test.ts`. - `@objectstack/runtime`, whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped. `standalone-stack-security-catalog-one-holder.test.ts` has 6, 1 of them new. - Dogfood, the CI split, at e3ae92a: - 1/3: 76 files / 567 passed; - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped); - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped). The one red was this PR's own built-in control: its `PUT /api/v1/meta/position/org_admin` answered `403` with the hatch set. The protocol memoises `OS_METADATA_WRITABLE` at its first read in a process, and the control set it only after the file's first case had already saved through the metadata door. It passed in isolation before the second merge and failed in the full shard after it; what made that difference is NOT MEASURED. At 72dcb8e the file opens the hatch before its first boot. The new file and the re-shaped Discard Overlay file then ran: 2 files / 11 passed. - Before the second merge, at bdfba35: dogfood 1/3 76 files passed; 2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since); 3/3 74 passed and 1 skipped. - `typecheck` at 72dcb8e: `objectql` (`tsc --noEmit` plus `check:test-typecheck`: 40 files, 234 errors, 65 pinned signatures, no new signature) and `dogfood`, exit 0. `runtime` at e3ae92a, exit 0; no runtime file changed after it. - `pnpm exec eslint --no-inline-config --format json` over the 7 touched TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This narrowed run is a measurement, not a skipped one, on three grounds: - the population comes from `eslint.config.mjs` itself (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`), and all 7 files are in it; - the count, 7, is read from the JSON output; - the config enables no type-aware linting (no `parserOptions.project`, as stated at `eslint.config.mjs:328`), so this diff cannot move any untouched file's verdict. The whole-repo `pnpm lint` is CI's. ## Ablation The call was neutralised through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit. In `plugin.ts`, `this.refuseEnvironmentHeldSecurityCatalogNames();` became the same call behind an always-false guard carrying the marker `ABLATION_22307_MARKER`, so the method stays referenced and the DTS build still runs. - **Landed on disk:** anchor 1 → 0, replacement 0 → 1, blob `399ddf47c127` → `2cf40c49e6e2`. `objectql` was rebuilt (exit 0), and `ablation-dist-preflight` found the marker in 2 built files. - **objectql pins (from `src`):** 5 failed / 11 passed of 16 in `protocol-boot-hydration-scoped.test.ts`. All 5 refusal pins went red: per type, the environment-held name and the row bound to the package, plus every conflict in one refusal. The controls stayed green: distinct names per type, and a built-in name the platform declares beside a stored definition. - **runtime pins (from `dist`):** 1 failed / 5 passed. The artifact-boot case went red; objectstack-ai#22197's five stayed green. - **dogfood pins (from `dist`):** 2 failed / 2 passed. The cold-boot case and the legacy-row case went red; the built-in shadow and distinct-name controls stayed green. - **Base readings under ablation** (an uncommitted probe): the cold boot booted with two collision warnings; the row bound to the package booted cold and was refused hot; the `member_default` overlay booted; S2b booted. - **Restore:** blob back to `399ddf47c127` == HEAD, `git diff HEAD` empty, `git status --porcelain` empty. After a rebuild, `ablation-dist-preflight --absent` is green: the marker is absent from all 14 built files and the tree is clean. The ablation ran at a15b8af. The second `main` merge (e3ae92a) brought objectstack-ai#22331's `plugin.ts` hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e. ## Clause-② (measured on the built entry declarations at 72dcb8e) `packages/objectql/dist/{index,core}.d.ts` and the shared chunk declare no new exported name. `findEnvironmentHeldSecurityCatalogNames`, `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` and `SecurityCatalogNameConflictError` are absent from the entries' export lists. The only new declaration text is three private member names (`SchemaRegistry.environmentHeldSecurityCatalogConflicts`, `SchemaRegistry.securityCatalogPackageHolders`, `ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) plus JSDoc. No widening was found, so `Clause-②: no` stands. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 81 commands at the head, 3c160a2. All 81 ran with exit codes recorded, and `--ran` reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The same 81 were derived and run at 72dcb8e, with the same answers. One exited 1, by design: `check-empty-changeset --base origin/main`. It is the deliberate correction of objectstack-ai#22135's pending note (see Acceptance notes), and the gate's own text says to confirm that class on the PR, not restore the note. On e3ae92a, `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) until eight packages outside this change were built: `studio`, `client-react`, `embedder-openai`, `knowledge-memory`, `knowledge-ragflow`, `organizations`, `service-cluster-redis` and `service-knowledge`. On 72dcb8e and 3c160a2 it exits 0. The changeset gates: `check-changeset-no-major --base` exits 0 (pre mode `next`), `check:adr-0087-registration` exits 0, and `check:changeset-gate-self-tests` exits 0. CI's own lanes are declared to CI and are NOT MEASURED here: the Test Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and the workspace type-check lanes. `origin/main` is 7 commits ahead of the head, among them objectstack-ai#22352 (`plugin-security` grant readers) and objectstack-ai#22353 (`metadata-protocol` seed loader); none touches a file of this PR. `git merge-tree` against it is clean, so `main` was not merged again. ## Acceptance notes - **A pending release note this PR corrects (`check-empty-changeset` stays red by design).** `.changeset/22135-security-catalog-one-holder.md` is objectstack-ai#22135's pending note, not yet consumed by a release (`packages/objectql` is at `17.7.0`). Its "What is NOT refused" paragraph said a package added at cold boot over an environment-held name "is not refused at cold boot". On this PR's merge that sentence is false, and both notes would publish in the same release. That one sentence now says the door cannot see the name at cold boot, and that the engine checks it right after the environment catalog loads and refuses the boot. Nothing else in the note changed. The gate's own text names this shape a DELIBERATE CORRECTION, to be confirmed on the PR, not restored. If a release consumes the note before this PR lands, the edit no longer reaches a published CHANGELOG, and the correct move then is an erratum PR against that CHANGELOG entry. - **The 2026-08-24 legacy-overlay remedies lose their boot-time population for code-package-declared sets.** The overlay detection reading and the drift pass's `overlay_shadow` run in `plugin-security`'s `kernel:ready`. A boot carrying an environment overlay of a package-declared set is now refused before `kernel:ready`, so on a deployment that boots, those branches see no such overlay. The same holds for the Discard Overlay action's discard path for such a set. The ruling names this cost ("including rows saved before the packaged locks"). The upgrade route is in the changeset: rename, or remove the row. A deployment can also run Discard Overlay on the release it runs now, before upgrading. `permission-set-discard-overlay-eligibility.dogfood.test.ts` (objectstack-ai#21860's pin) wrote its legacy overlay before a cold boot, which is now refused. It now writes the overlay into the running deployment and runs the two passes the boot ran for it, by the functions the security plugin's boot calls (`reconcilePermissionSetProjection`, then the drift pass), so its preconditions and its control still hold. - **The refusal leaves `start()`, so the kernel wraps it.** `bootstrap()` rejects with `Plugin com.objectstack.engine.objectql failed to start - rollback complete: …`, and the envelope is the wrapper's `cause`, as with any `start()`-time refusal (objectstack-ai#22197's item-seam refusal from `plugin-security.start` included). The pins read `cause`. - **Org-scoped rows are not judged.** Boot hydration loads env-wide rows only (`organization_id IS NULL`), and org-scoped rows never reach the registry, so the check judges the env-wide catalog. That is the population hydration serves. - **A refused boot over a `sqlite-wasm` file can still flush after the refusal.** In a probe, removing the database directory right after the refused `bootStack` raised `ENOENT` from the driver's atomic write. The committed dogfood file keeps its database files in the test file's working directory, which the dogfood run removes at its end, and never boots a file again after it was refused. Noted, not filed: a boot that failed has no process left to serve. - **Files outside the engine lane:** - `packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts` (new) and `packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts` (re-shaped, above): `domain:cli`. - `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (one case added, and the artifact-stack helper takes a `databaseUrl`): `domain:cli`. - `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`. - `.changeset/22135-security-catalog-one-holder.md` (above). ## Patch round 1 — the release note's remedy, completed Both contract reviews passed: 6070947709 on this PR, which also confirms the correction of objectstack-ai#22135's pending note, and 6070955792 on the ADR PR. This round changes text only. The code, the pins and `.changeset/22135-security-catalog-one-holder.md` are unchanged. The head is cf1a9dd. - **`.changeset/22307-cold-boot-catalog-refusal.md`.** "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts: - **Before upgrading, for a permission set.** The `kernel:ready` overlay reading names the sets this release refuses. The audited Discard Overlay action, or `DELETE /api/v1/meta/permission/NAME`, removes each overlay without touching the database, including on the platform's own sets. - **After upgrading, for a package that can be left out.** Boot without it, then delete through the metadata API. - **After upgrading, for a name the platform security plugin declares.** The SQL delete of the active, environment-wide rows under the type or its legacy plural. The changeset also says that no `os` command deletes a `sys_metadata` row offline. - **`content/docs/permissions/permission-sets.mdx`.** One clause under "Declared ≠ enforced", on the Discard Overlay remedy: discard such an overlay before you upgrade, because a deployment that still holds one does not boot. **Measured, clause by clause:** - **The current release.** This branch with the check ablated through `scripts/ablation-replace.mjs` (blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`), a legacy `member_default` overlay written at the driver, then a restart: - The boot logged one `kernel:ready` warning, "[security] 1 package-declared permission set(s) are being shadowed by an environment overlay — … use the audited "Discard Overlay" action on it …", naming `member_default`. - The record read `drift_status: overlay_shadow`, and Discard Overlay answered `200` and left no active row. - On the same release, `DELETE /api/v1/meta/permission/viewer_readonly` over a legacy overlay of that platform set answered `200` ("Customization overlay deleted — permission/viewer_readonly reset to artifact default") and left no active row. So Discard Overlay is not the only database-free remedy before the upgrade; the changeset names both. - **The restore.** `ablation-replace` put the blob back (== HEAD, `git diff HEAD` empty). After the rebuild, `ablation-dist-preflight --absent` was green on `dist/` at once. It was green on the tree once this round's doc edit, the one dirty path at that moment, was committed (cf1a9dd). - **The head, check live:** - The database on which the current release ran Discard Overlay on `member_default` boots. - Rows of type `permissions` and `positions` (the legacy plurals) over package-held names refuse the restart, both named. - A `draft` row over a third package-held name is not loaded and not named. - `loadMetaFromDb` selects `state: 'active'` and `organization_id: null`, and folds the type through `PLURAL_TO_SINGULAR`, which maps `permissions` to `permission` and `positions` to `position` on `main`. It sets no `package_id` condition: a row bound to the package itself refuses too, measured in the first round. - **The SQL.** The changeset's `DELETE` statements, run through Python's `sqlite3` against the refused database files (one per type, and one for `member_default`), deleted 1 row each. Each restart then booted. - **The CLI.** `os meta delete` and `os data delete` build an API client and require a token (`createApiClient`, `requireAuth`), and no command under `packages/cli/src/commands` deletes a `sys_metadata` row. - **The action.** `discard_permission_set_overlay`, labelled "Discard Overlay", on `sys_permission_set`, in the list-item and record-header locations, visible while `drift_status` is `overlay_shadow`. It is documented on `content/docs/permissions/permission-sets.mdx` under "Declared ≠ enforced — diagnosing a frozen package set". Positions have no overlay reading (it reads the `permission` / `permissions` types) and no such action. - **NOT MEASURED:** the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading. **Gates at cf1a9dd.** `dispatch-gates --commands` derived 107 commands; the doc page added the docs families. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0, including `check-changeset-no-major --base`, `check-adr-0087-registration --base`, `check:doc-authoring`, `check:docs-*`, `check-doc-frontmatter`, `@objectstack/spec`'s `check:docs` and `check:doc-formula-expressions`. One exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135 correction. `origin/main` is 12 commits ahead; `git merge-tree` against it is clean, so `main` was not merged. **One more file outside the engine lane:** `content/docs/permissions/permission-sets.mdx` (`domain:devx`). ## Patch round 2 — the metadata-API delete reaches singular-typed rows only The at-tier contract review on cf1a9dd (6071828819) failed two remedy sentences, and judged everything else right: the code, the objectstack-ai#22135 correction (confirmed on that head), case 3's SQL, the CLI sentence, the docs clause and the semver. The two sentences are case 1's "So does `DELETE /api/v1/meta/permission/NAME`" and case 2's metadata-API delete. Both are false for a row stored under the legacy plural `permissions` / `positions`, a shape the changeset's own "upgrade shape" paragraph names. This round changes `.changeset/22307-cold-boot-catalog-refusal.md` only. No code, pin, docs page or `.changeset/22135-security-catalog-one-holder.md` change. The head is 39ef237. **Measured first; the review's reading holds.** - **The current release** (this branch with the check ablated through `scripts/ablation-replace.mjs`, blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`): - A legacy overlay of `viewer_readonly` stored under `permissions`: `DELETE /api/v1/meta/permission/viewer_readonly` answered `200` with `{"success":true,"reset":false,"message":"No customization overlay found for permission/viewer_readonly — already at artifact default."}`, and the `permissions` row stayed active. Discard Overlay on the same set answered `200` and left no active row. - `mcp_agent_restricted` with two active rows, one bound to no package and one bound to `com.objectstack.plugin-security`: the first `DELETE` answered `200` "Customization overlay deleted — … reset to artifact default" and removed one row, leaving the bound one. A second `DELETE` removed it. - **The head, check live, case 2.** A package's permission set and position stored under `permissions` / `positions`. Booted without the package, `DELETE /api/v1/meta/permission/pr2_set` answered `200` "No permission 'pr2_set' found — nothing to delete.", and `DELETE /api/v1/meta/position/pr2_pos` answered "No position 'pr2_pos' found — nothing to delete." Both rows stayed active, and the boot with the package added back was refused, both names held by `environment`. - **The restore.** Blob == HEAD and `git diff HEAD` empty. After the rebuild, `ablation-dist-preflight --absent` is green on `dist/` and on the tree. **The text fix, as the record names it:** - **Case 1:** "neither touches the database" now reads "neither needs direct database access". - **Case 3's heading** now reads "for a name the platform security plugin declares, or for any row the metadata API does not reach". - **One paragraph after the three cases**, before the CLI sentence: - the two `DELETE` routes reach a row stored under `permission` or `position` only, one row per call; - a plural-typed row is not reached: `200`, nothing found, nothing removed; - where a name has two active rows, each call removes one; - a plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name. This also corrects round 1's summary above: the metadata-API delete is a database-free remedy before the upgrade only for a row stored under the singular type. - `content/docs/permissions/permission-sets.mdx`'s clause does not name the metadata-API delete, so the page is unchanged. **Gates at 39ef237.** `dispatch-gates --commands` derived 107 commands. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135 correction. `origin/main` is 22 commits ahead. `git merge-tree` against it is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22135
Clause-②: no
Executes the maintainer's ruling Q4 = A on #15196 (ruling record 6050490870): positions, permission sets and capabilities each hold one name per deployment. A package registering a name that an installed package, the environment catalog or a built-in already holds is refused, and the error names both holders. ADR-0048 §3.4's coexistence stands for every other metadata type.
The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on
main. This PR carries nodocs/adr/**file.What changed
packages/objectql/src/security-catalog-namespace.ts(new). The rule in one place: the three types, the built-in names, the holder vocabulary (package/environment/built-in), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's ownpositions/permissions/capabilitiesand each nestedplugins[]entry's, arrays only. A manifest-stagepermissionsgrant block is never read as permission sets.SchemaRegistry.installPackage— the package door. It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released byuninstallPackage. The claims are what the door reads for names no registered item records, andinstallPackageitself registers no items. ThroughObjectQL.registerApp(every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 landed onmain. There the claims agree with the items. With the claims ablated, theregisterAppdoors still refuse and the directinstallPackagedoor does not (Patch round 3), so the claims stay.SchemaRegistry.registerItem— the item seam. A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now onmain), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what everysys_metadatahydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling.code: 'NAMESPACE_CONFLICT'(NAMESPACE_CONFLICT_CODE, already exported),status: 422,httpStatus: 422. The condition is the same one, a name in a deployment-wide namespace already taken, and so is the remedy: rename, or uninstall the other holder. The class (SecurityCatalogNameConflictError) stays unexported, as the registry's other refusal classes are. It is not the namespace gate's class, whose message names amanifest.namespaceand offers theOS_METADATA_COLLISION=warndowngrade. Neither is true here, andcollisionPolicy: 'warn'does not downgrade this refusal (pinned).packages/specchange.Where the doors are, measured
The card names three doors. What this PR measured is that all three are reached through ONE:
ObjectQL.registerApp→SchemaRegistry.installPackage, which every package registration hits in the kernel's Phase 1, before anystart().AppPlugin's security registrar (registerInMemory, the'app-plugin'registrar) and the artifact door (MetadataPlugin._registerArtifactBodyCollections, the'artifact-door'registrar) both run in Phase 2.AppPlugin.initregisters every package of its bundle through themanifestservice first, a multi-package artifact package by package.packages/metadata/src/plugin.tsandpackages/runtime/src/app-plugin.tsare unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs.Door table: base vs head
"Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through
@objectstack/verify'sbootStack; the artifact rows go throughcreateStandaloneStack.new AppPlugin(stack)): two stacks sharing a position, a permission set and a capability name422 NAMESPACE_CONFLICT, 3 conflicts, second stack vs first stackeveryone/manage_users/admin_full_accessadmin_full_accessanswers the APP's setbuilt-infor the first two. Foradmin_full_accessthe app registers beforeplugin-securityinbootStack, so the platform's registration is the one stopped, naming the appeveryonemanifest.registerover a held namePOST /api/v1/marketplace/install-local, inline manifest200, installed422 PLUGIN_REGISTER_FAILED, the route's own code, with this refusal's message inerror.message; no recordPOST /api/v1/packages400: the strict body refusespositions, the retiredcapabilitiesand a flatpermissionslistenvironmentPUT /api/v1/meta/permission/NAME, with or without?package=) — not covered by the ruling403 NOT_OVERRIDABLE(the packaged permission-set lock)PUT /api/v1/meta/position/NAME) — not covered by the ruling200, and the saved position then answers the by-name read ahead of the package'smain:403 NOT_OVERRIDABLE(see Acceptance notes)Named but not measured:
MetadataPlugin._reloadAndAnnounce). It re-registers into the metadata service withoutregisterApp, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it.install-local's cloud-sourced install. Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal aterrorand skips the package. That is code reading only (it needs a control plane).In-repo collision census (M2)
Instrument. A tsx census over
examples/app-crm,examples/app-showcase,examples/app-todoandexamples/app-multi-package: each config's top level, itspackages[]bodies and its nestedplugins[]. Against those it reads the built-ins:BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS,PLATFORM_CAPABILITY_NAMES, andplugin-security'ssecurityDefaultPermissionSets.Result at 1604e09: 50 declarations — crm 3 positions / 2 sets; showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6 positions / 10 capabilities / 8 sets. Names with more than one holder: 0. Same-holder repeats: 0.
The guard, measured with the gate in place at 8ad6385:
crm,showcaseandmulti-packageboot throughbootStack, andsecurity-catalog-showcase.dogfood.test.ts(3 postures) andmulti-package-artifact.dogfood.test.tsare green.app-tododeclares no catalog name. Deployed and marketplace packages are NOT MEASURED.The P1.2 pin, flipped
S1's shared-name pin is the
security catalog read — a name two packages shipdescribe inpackages/objectql/src/protocol-boot-hydration-scoped.test.ts. It added noP1.2label, which is why agit grepmisses it. It now pins the ruled answer at the same seams:core'ssecurity-catalog.test.tspointed at a non-existentsecurity-catalog-shared-name.test.ts. It now names that describe and the new door pins.security-catalog.ts's module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer.engine-capability-provenance.test.tspinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal.Tests (at 8ad6385)
@objectstack/objectql:registry-security-catalog-namespace.test.ts(new, 28 cases),protocol-boot-hydration-scoped.test.ts,engine-capability-provenance.test.ts,registry-collision-order.test.tsandregistry-artifact-co-ownership.test.ts: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip.@objectstack/runtime:standalone-stack-security-catalog-one-holder.test.ts(new, 4) andstandalone-stack-security-registrar.test.ts: 2 files, 6 passed.@objectstack/coresecurity-catalog.test.ts: 14 passed.@objectstack/plugin-securitybuiltin-positions.boot.test.ts+builtin-positions.test.ts(S2's): 18 passed.security-catalog-showcase,multi-package-artifact, plus a local door probe that is not committed: 3 files, 44 passed.objectqldist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green.typecheckfor objectql, core and runtime (withcheck:test-typecheck): exit 0. No new test-typecheck debt.Ablation
Both gates were ablated together through
scripts/ablation-replace.mjs, which wraps the run and restores on exit:globalThismarker write;Both mutations landed on disk: anchor 1 → 0, blob
b96099a12688→12c018d406ab.objectqlwas rebuilt andablation-dist-preflightfound both markers indist/. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted.src): 22 failed / 21 passed of 43. Every refusal pin went red, including both flipped P1.2 cases and the flipped capability pin. The controls stayed green: same-package reload, uninstall releases the name, the environment-registration carve-out, non-catalog coexistence, the grant-block reader, and the platform's own built-in registration.dist): 3 failed / 1 passed. The control stayed green.Restore: the blob is back to
b96099a12688andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green for both markers (dist and whole tree).Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 78 commands on 8ad6385; all 78 were run, and--ranreconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first:check-engine-split-ratiorefused the shallow clone (deepened withgit fetch --shallow-since=2026-07-03), andcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run,origin/mainmoved 6 commits, none of which touches a file in this PR.Acceptance notes
200), and the saved position then won the by-name read; permission sets were protected on the same door by the packaged permission-set lock (403). Since fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 landed onmain, a package's positions are registered items under the package, and the same save answers403 NOT_OVERRIDABLE("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 withPUT /api/v1/meta/position/shared_pos?package=w. Outside this ruling either way; this PR changes nothing there.ObjectQL.registerAppin Phase 1, andsys_metadatahydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. TheCONTROLcase inregistry-security-catalog-namespace.test.tspins that the bare slot is not judged. A plugin's ownstart()is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holderenvironment). The exception is a built-in name, which the platform declares there itself (Patch round 2). Agit grepfor literal catalog-typeregisterItemcalls in production source finds one such registration:plugin-security's built-in positions. Carrier: [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 (ruled A: the cold boot refuses too; it lands separately).plugin-securitydeclares the platform's sets on its own manifest (configurable throughdefaultPermissionSets), so they are package-held. When an app registers before it, asbootStackcomposes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named.install-localinline import answers its ownPLUGIN_REGISTER_FAILEDfor any register refusal (this one and the namespace gate's alike), soerror.codedoes not carryNAMESPACE_CONFLICTthere. The refusal's text is inerror.message. Not changed here.NAMESPACE_CONFLICTinpackages/spec/src/api/error-code-ledger.zod.tsdescribes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here.packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test;runtimeisdomain:cli's package);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(new ADR anchor); and, from patch round 1, fivedomain:clidogfood files:packages/qa/dogfood/test/showcase-security.ts,showcase-d7-default-profile.dogfood.test.ts,authored-row-write-scope.dogfood.test.ts,bulk-widener-probe.dogfood.test.tsandowd-public-read-write-write-floor.dogfood.test.ts(each: theSecurityPluginconstruction, with its comment and imports).Patch round 1 — the dogfood fixtures declared one permission set twice
The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot,
plugin-securityregistered a permission set that the app package already held.The fixtures handed an app-declared set to
SecurityPlugin'sdefaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:showcase_member_default, throughshowcaseAppDefaultSecurity()and the D7 test;wscope_*,probe_widenerandowdw_*, in three fixtures.Measured:
os serve/objectstack devnever composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A realobjectstack dev --freshboot ofexamples/app-showcasecame up with the gate in place: health 200.Fixed at the producer: each fixture declares the set once, as the app's, and wires the default by name, as the CLI does. A runtime pin holds the refused composition.
All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.
Patch round 2 — the platform's built-in positions met an environment row at boot
The merge queue removed this PR (record 6056019838).
plugin-security'sregisterBuiltinPositionswas refused at the item seam: positionorg_admin, incomingcom.objectstack.plugin-security, holderenvironment. That refusal failedSecurityPlugin.start, and with it the boot. S2b's pins went red:builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), andbootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".Measured on 19c86b7 (this branch with
mainmerged, before the fix):SecurityPlugindepends on the engine. SoObjectQLPlugin.starthydratessys_metadatainto the bare slot BEFORESecurityPlugin.startdeclares the built-in positions. Through a real door: withOS_METADATA_WRITABLE=position,PUT /api/v1/meta/position/org_adminanswered200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers403 NOT_OVERRIDABLE.registerBuiltinPositionsregisters exactly the six static built-in names (BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.Fixed at the producer, the item seam in
SchemaRegistry.registerItem: for a built-in name, it no longer asks the environment holder. An environment item under a built-in name exists only because an environment save went over the platform's name, which is outside the ruling. Unchanged:built-in);environment).No same-definition exception, no
collisionPolicychange, and S2b's pins are untouched.registry-security-catalog-namespace.test.tsgained three cases, one per behaviour above (the third is aCONTROL).Reverse verification. The new condition was mutated through
scripts/ablation-replace.mjsto ask the environment holder again (blobc60d9bad21bc→eeca074e4f80).objectqlwas rebuilt, andablation-dist-preflightfound the marker indist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red withSecurityCatalogNameConflictError(org_adminheld by the environment catalog), and so did the new admit case. Restored: blob == HEAD, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green.All suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI.
Patch round 3 — #22262 landed on
mainfirst#22262 (squash 0b997ea) adds
positionsto the engine'sMETADATA_ARRAY_KEYS, soObjectQL.registerAppnow registers a package's positions under the package. This branch mergedmainat fbcbcf1. The merge touched none of this PR's files, andregistry.ts's logic is unchanged.Comments only. Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true on
main: thesecurityCatalogClaimsdoc and theinstallPackagecomment inregistry.ts, the declared-names reader's note insecurity-catalog-namespace.ts, and the header ofstandalone-stack-security-catalog-one-holder.test.ts. No behaviour changed, so no reverse leg was re-run.Measured with #22262 in the tree (f16fcd0, this branch with
mainmerged; throughbootStack; local probes, not committed):com.example.showcaseand 6 undercom.objectstack.plugin-security, and 10 claims. Re-registering the showcase is not refused. A second package declaringcontributoris refused, holdercom.example.showcase.org_adminandeveryone(OS_METADATA_WRITABLE=position), the cold restart boots, and both names resolve to the environment's saved definitions.PUT /api/v1/meta/position/shared_pos?package=wover a package-held position answers403 NOT_OVERRIDABLE.install-localare refused, each naming both holders; the same-package reload is accepted.The claims, ablated. This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (
scripts/ablation-replace.mjs),objectqlwas rebuilt, and the marker was proven indist/. The runtime boot pins stayed green (5 of 5): everyregisterAppdoor refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and thecollisionPolicy: 'warn'pin. Both reach the package door through a directinstallPackagecall, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild,ablation-dist-preflight --absentis green.Tests at f16fcd0, all under
os-verify-lock:@objectstack/objectql, whole suite: 383 files / 7572 passed.@objectstack/plugin-security, whole suite: 179 files / 3775 passed, 45 skipped.@objectstack/runtime, whole suite: 340 files / 5505 passed, 19 skipped.typecheckforobjectqlandruntime(tsc --noEmit+check:test-typecheck): exit 0.dispatch-gates --commandsderived 82 on f16fcd0. All 82 ran and exited 0, and--ranreconciles 82/82, 0 NOT MEASURED.CI on f16fcd0: 32 checks success, including Dogfood Regression Gate 1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs, Console Pin Gate, Packed-tarball smoke).
Patch round 4 — the changeset level, one comment, the cold-boot carrier
The contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only.
.changeset/22135-security-catalog-one-holder.mdgraded@objectstack/objectqlminor, on the premise that Changesets pre mode was not yet onmain. It is:.changeset/pre.json(modepre, tagnext) landed with chore(release): enter Changesets pre mode (next) with onemajormarker, so v18 opens at 18.0.0-next.0 #22084 (a87d8be), an ancestor of this branch's merge base. The changeset now gradesmajor, and its BREAKING sentence says the change ships asmajoron the v18 pre-release line. The ADR-0087 marker andClause-②: nostay.pnpm changeset statusresolves@objectstack/objectqlto18.0.0-next.0.sys_metadata, so a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot, and the registry's existing collision warning fires. A hot install of the same package is refused. [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 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two[Registry] Collisionwarnings, one for the permission set and one for the position. The hot install answered422 NAMESPACE_CONFLICT, both names held byenvironment, and left no package record. A capability cannot be saved in the environment (403, a code-only type), so the sentence names the two types an environment can hold.start()-time half of the round-2 question is settled as A (keep): the ruling names the environment catalog as a holder and does not distinguish phase. No change.Checks at 9e4ed5d:
check-changeset-no-major--self-testand--base, exit 0 (pre mode, tagnext, so the no-major guard stands aside; given this PR's body, the level axis readsClause-②: no);check-empty-changeset--self-testand--base, exit 0;check-changeset-fixed, exit 0;check:adr-0087-registration, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition);check:changeset-gate-self-tests, exit 0.pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-security-catalog-one-holder.test.ts: 1 file / 5 passed.pnpm --filter @objectstack/runtime typecheck: exit 0.dispatch-gates --commandsfor the two touched paths derived 61 commands. All 61 ran and exited 0, and--ranreconciles 61/61, 0 NOT MEASURED.git merge-treeagainstorigin/main4e4111c is clean, somainwas not merged.Patch round 5 — #22307 was ruled
The maintainer ruled #22307 A while round 4 ran: a cold boot is to refuse too. That work lands in its own PR, not in this one. The changeset's cold-boot sentence keeps its measured clauses and now ends "a cold-boot refusal is ruled and tracked on #22307, which lands separately", which is true on this PR's merge and stays true after #22307 lands. Nothing else changed.
Checks at 99fba80:
check-changeset-no-major --base, exit 0 (pre mode, tagnext; given this PR's body, the level axis readsClause-②: no);check-empty-changeset --base, exit 0;check:adr-0087-registration, exit 0.dispatch-gates --commandsfor the one touched path derived 20 commands; all 20 exited 0, and--ranreconciles 20/20, 0 NOT MEASURED.git merge-treeagainstorigin/main4e4111c is clean, somainwas not merged.Generated by Claude Code