Repository navigation
feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) - #22365
Conversation
…mission-set name the environment catalog already holds After sys_metadata hydration and before any plugin that depends on the engine starts, ObjectQLPlugin.start asks the registry for every package-held position and permission-set name the environment catalog also holds, and one such name refuses the boot with the package door's envelope (422 NAMESPACE_CONFLICT, every conflict listed, both holders named). The hydration write itself stays unjudged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…eld catalog name objectql: a real kernel boot (ObjectQLPlugin, the package door in Phase 1, the real protocol hydrating stored rows) refuses a package-held position or permission-set name the environment catalog holds, lists every conflict, treats a row bound to the package itself as the environment's, and boots the controls (distinct names, a built-in name the platform declares beside a stored definition). runtime: the artifact boot over one database refuses the same shape. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…n one database Environment-saved names then a package declaring both: the hot install and the cold boot are refused alike, naming both holders. A row saved over a package-held name before the packaged locks refuses the restart. Controls: stored definitions under built-in position names boot, and a package whose names the environment does not hold boots and restarts. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…he one-holder entry's cold-boot sentence The new changeset grades @objectstack/objectql major on the v18 pre-release line and names the upgrade shape (an environment-wide stored position or permission set under a name a configured package declares, pre-lock rows included) and the remedy. The unreleased one-holder changeset said a cold boot was not refused; on this change's merge it is, so that sentence now says where the cold boot is judged. The ADR anchor gains the cold-boot half. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…o the running deployment A cold boot whose environment catalog holds a package-held permission-set name is now refused (ADR-0048 N.3), so the legacy overlay of the shipped set can no longer ride the restart into the deployment. It is written after the cold boot, and the two passes the security plugin's boot runs for it (projection reconciliation and the drift pass) are run on it, so the control still meets the field shape the action exists for. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…ld-boot-catalog-refusal
…s string column The overlay row's `metadata` column is a string; the cold-boot pins passed the body object, a type error the test layer's exact ratchet refuses. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…ld-boot-catalog-refusal
The inline plugin erased its context and the manifest lookup to `any`, which the service-lookup erasure rule refuses; it now takes `PluginContext` and names each slot's contract. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…before its first boot The protocol memoises OS_METADATA_WRITABLE at its first read in a process, and since the package door answers a save before the body checks, the first case's saves read it. Set inside the built-in control only, the hatch was already memoised closed there and the save answered 403 in the shard run. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
The base reading came from a probe with the same steps, not from this file; the file's own base reading is the ablation's. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 30b787a65d338075737e048a248150bcbeef313f && git checkout 30b787a65d338075737e048a248150bcbeef313f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 345d3f3d86305eb3b5614a1788ef46598648e4f5 39ef237d40b56c769ef4b917fa2227f207450594 && git checkout -B drift-repro 345d3f3d86305eb3b5614a1788ef46598648e4f5 && git merge --no-ff 39ef237d40b56c769ef4b917fa2227f207450594
node scripts/docs-audit/affected-docs.mjs --json 345d3f3d86305eb3b5614a1788ef46598648e4f5
|
Contract reviewServed-tier: Read at 2026-10-08T23:17Z. Inputs, and nothing else: card #22307 (body; the ruling 6063176077, triage 6063800858, the claim 6066505265, the os-dev-report 6070693740, which carries both PR bodies); card #22135 and PR #22197 (its records 6061710772 and 6064469150; ① Derived judgmentsCheck-runs on the head: 35, all Accept-set and public-surface changes the diff implies, each judged:
② Semver level
③ Boundary flagsFrom the os-dev-report 6070693740 (the deviations, the open question, the out-of-scope findings) and the PR's Acceptance notes:
Implemented-by: VERDICT: PASS Owed alongside, not blocking: the two changeset sentences in ③ item 2 (the legacy plural spelling; the pre-upgrade Discard Overlay path) and a carrier card for ③ item 3. Generated by Claude Code |
…nd after the upgrade The changeset now names what an operator can do before upgrading (the kernel:ready overlay reading lists the permission sets this release refuses; Discard Overlay or the metadata API delete removes each overlay without touching the database) and after it (boot without a package that can be left out and delete through the metadata API; for a name the platform security plugin declares, the SQL delete of the active, environment-wide rows under the type or its legacy plural). No CLI command deletes a sys_metadata row offline. The Permission Sets page tells the reader to discard such an overlay before upgrading. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-09T00:35Z. Patch round 1 of #22307's code PR. The same-head record 6070947709 (PASS on ① Derived judgmentsCheck-runs on the head: 42, all This round's scope — confirmed text-only. ⭐ The remedy, sentence by sentence. Judged against Case 1 — before upgrading, for a permission set.
Case 2 — after upgrading, for a package you can leave out. "Boot once without the package in the configuration, rename or delete the environment's item through the metadata API ( Case 3 — after upgrading, for a name the platform security plugin declares. "Back the database up, then delete the row in it. The rows that refuse the boot are the active, environment-wide ones of that name: "No Positions, every case. A Minimal text fix — one sentence after the three bullets, before "No The docs clause ( ② Semver level
③ Boundary flags1. The deliberate correction of 2. Dev flags, 6071587973. (a) "The order said Discard Overlay before upgrading is the only remedy that does not touch the database; measured false — 3. The ADR PR #22366 (Tier H). Merged on the maintainer's APPROVED review (merge 4. Carriers owed by the seat, not blocking: a card for the stale docs bullet (the dev's class-a finding); #22371 already carries the 2026-08-24 remedies' post-upgrade population, as the final 速读 says. 5. CI — the seven required contexts are 6. Local runs — none. Implemented-by: VERDICT: FAIL One text fix lifts it (① "Minimal text fix"): the |
…only, one per call DELETE /api/v1/meta/permission|position/NAME matches the stored type exactly, so a row stored under the legacy plural permissions / positions is not reached (200, nothing found) and stays to refuse the next boot; a name with two active rows needs two calls. The changeset says so, routes a plural-typed row to Discard Overlay before upgrading or to the SQL after it for any name, and says the remedies need no direct database access rather than that they do not touch it. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-09T01:20Z. Patch round 2 of #22307's code PR, text only, from the FAIL record 6071828819 on ① Derived judgmentsCheck-runs on the head (read once, at posting time): 42, all This round's scope — confirmed: the changeset alone.
⭐ The changeset's remedy, every sentence re-read on this head against The population the check refuses. Case 1 — before upgrading, for a permission set. (1) The Case 2 — after upgrading, for a package you can leave out. RIGHT for a row stored under Case 3 — after upgrading, for a name the platform security plugin declares, or for any row the metadata API does not reach. The widened heading — RIGHT, and it closes the gap 6071828819 named: a position stored under The added paragraph, clause by clause. (a) " The CLI sentence and "What is NOT refused" — unchanged and RIGHT: the two CLI deletes are untouched on Every refused shape has a true remedy. Permission set, singular, unbound or package-bound, declared by an app package or by the platform: Discard Overlay or Promises not measured. Two, both stated here as code readings and not proofs: ② Semver level
③ Boundary flags1. The deliberate correction of 2. Dev flags, 6072153086 (this round). 3. Earlier rounds' flags — each answered on record and unchanged by this round: the M3 route and the pre-lock bound row (6070947709 ① item 2); the reshaped Discard Overlay pin (① item 7); the position hatch, the 4. #22366 (Tier H) is merged on the maintainer's approval; its final 速读 claims nothing the metadata-API 5. CI — the seven required contexts are 6. Local runs — none. Implemented-by: VERDICT: PASS The two sentences 6071828819 failed are now bounded by the added paragraph, case 3's heading routes every row the metadata API does not reach to the SQL, and every shape the check refuses has a true remedy before or after upgrading. The code, the pins and the #22135 correction are unchanged and confirmed on this head. Owed alongside, not blocking: the carrier card for the stale docs bullet (③ item 3). Generated by Claude Code |
…tion rows the way an older release left them; the hatch no longer opens that save (ADR-0131 D6) #22365's control saved stored definitions under two built-in position names through OS_METADATA_WRITABLE=position. The positions are shipped by the platform's own package, so the seal now refuses that save with the hatch set too. The control pins the 403 NOT_OVERRIDABLE refusal, writes the rows at the driver as the file's legacy-row case already does, and keeps its assertions: the restart boots and the stored definition answers. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
declare the current protocol, ^18 main added two manifest fixtures that declare engines.protocol '^17' and stand for a valid current app, not for an old artifact: - packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts (#22365): at protocol 18 the load-seam handshake refuses it, and all four cases fail with ProtocolIncompatibleError before reaching their subject. - packages/cli/test/retry-policy-key-validate-door.test.ts (#22380): os validate only advises on the gap, so it stays green either way, but its subject is the retry-policy key door, not the protocol's age. Both now read '^18', like the other current-app fixtures this change moved. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…ge's claim against the environment catalog (ruling letter A on objectstack-ai#22307) (objectstack-ai#22366) Refs objectstack-ai#22307 Records the maintainer's ruling letter A on objectstack-ai#22307 (ruling record 6063176077) in ADR-0048: addendum N.3 gains one dated note. The hydration write stays unjudged as a write, and the post-hydration check judges the package's claim against it. This is the Tier H half of objectstack-ai#22307, split from the code PR (objectstack-ai#22365) so the code can land on its own record. objectstack-ai#22307 stays open after this PR; the code PR carries the card. ## What changed — `docs/adr/0048-cross-package-metadata-collision.md` only Additive: 7 lines, no existing line edited. - **A dated note directly under N.3's list:** "Amended (2026-10-08) — the cold boot". After `sys_metadata` hydration and before `kernel:ready`, every package-held permission set and position name is checked against the environment catalog, and a name the environment already holds fails the boot with the N.2 envelope, naming both holders. It cites the ruling record. - N.3's existing bullet ("A write with no package provenance … stays under ADR-0005 overlay precedence") is left as it is: the hydration write is still not judged as a write, which is what the note says first. - N.4 ("Where it is implemented") is not edited. The code PR leaves the ADR id in the code (`ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) and extends the module's ADR anchor (`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`). ## Gates (at f86b451) `node scripts/pm/dispatch-gates.mjs --commands` derived 19 commands at f86b451. All 19 ran with exit codes recorded, and `--ran` reconciles 19/19 with 0 NOT-MEASURED (a derived zero). All 19 exit 0. `check:doc-formula-expressions` first answered PREREQUISITE NOT MET (exit 3); it exited 0 after `@objectstack/formula` and `@objectstack/lint` were built. `origin/main` has moved since the branch point, and `git merge-tree` against it is clean. ## 维护者速读(草稿) ### 改了什么 只改 ADR-0048 的文字,没动代码。在附录 N.3 下面加了一段带日期的说明(7 行),原文一个字都没改。说明的内容就是您在 objectstack-ai#22307 上选的 A:重启时,环境里已经存着的权限集或职位名,如果某个包也声明了同名的,启动直接失败,报错点名双方。 ### 为什么改 N.3 原来写的是"环境自己保存的数据不受这条规则管"。重启时,包先注册,环境数据后加载,所以按原来的写法,重启这条路正好漏过去:同一个包热安装会被拒,重启加进来却能装上,还被环境里的同名定义悄悄盖住。您裁定重启也要拒。这段说明把两件事分开写清楚:环境数据的加载本身照旧不判;加载完以后,拿包的声明去对环境目录,重名就拒。不写进 ADR,ADR 和代码就对不上。 ### 风险与代价(含回滚) - 这份 PR 本身只是文档,没有运行时风险。 - 真正的行为变化在配套的代码 PR(objectstack-ai#22365):已经处在"环境里存着同名权限集或职位、配置里又有声明同名的包"这种状态的部署,升级后会起不来,要运维改名或删掉其中一个才行。包括锁上线前保存的旧覆盖行,也包括平台安全插件自带的权限集(例如 `member_default`)上的旧覆盖行。仓库里的示例应用实测没有这种状态,真实部署的数量测不到。 - 回滚:撤销这份 PR,ADR 回到原文;代码 PR 可以分开回滚。 ### 席位意见 ### 你要做的 请看这段说明的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ Co-authored-by: Claude <noreply@anthropic.com>
…authoritative store" (objectstack-ai#22391) Fixes objectstack-ai#22379 Clause-②: no ## What changed Two docs pages: four passages that still said a set shipped in code can be overlaid in the environment. +25 / -16 lines against `main`. ### `content/docs/permissions/permission-sets.mdx`, "One authoritative store — the record is a projection (ADR-0094)" - **First bullet.** It said a Setup edit of any declared set, packaged sets included, "becomes an environment overlay" that "genuinely takes effect", and that the Studio layered view diffs it and a reset removes it. Since the packaged lock, that edit answers `403 NOT_OVERRIDABLE` and stores nothing. The bullet now says: - which sets are locked: a set shipped in code, meaning a set an installed package declares (`*.permission.ts`, a stack's `permissionSets`) or a platform default such as `member_default`; - what the edit answers (`403 NOT_OVERRIDABLE`, nothing stored); - the remedy the refusal names: clone to a new name, with the **Clone** action or `POST /api/v1/data/sys_permission_set`; - which sets are still edited in place: one created in this environment, a clone, or one saved into a writable runtime package. - **Closing sentence.** "use an overlay where a packaged set must be narrowed" becomes: clone it, narrow the clone, and bind the clone in its place (restriction is done by not granting). Narrowing the packaged set itself is the package author's change (ADR-0086 two-doors). The position-binding half of the sentence did not change. - **Third bullet** ("Deleting through the data door … resets it"). It holds against `main`, so it is **not edited**. Evidence is under premise check 5. ### `content/docs/permissions/permission-sets.mdx`, "Provenance — package vs environment sets (ADR-0086)", `:311-312` - Before: "(two-doors separation, evolved by ADR-0094 — ordinary edits of a packaged set become environment overlays, see below)". - After: "(two-doors separation, evolved by ADR-0094 — an edit of a set shipped in code is refused; clone it instead, see below)". - Why: its "see below" pointed at the corrected bullet, which now says the opposite. - Backed by the same pins as the bullet: `permission-set-write-through-package-binding.dogfood.test.ts:167-173` (a data-door edit of `showcase_contributor` answers `403 NOT_OVERRIDABLE`, no row minted), and `permission-set-lock-row-provenance.dogfood.test.ts` (the shipped set is refused at both doors, while a clone takes an edit). The clone remedy is the `clone_permission_set` action in `packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:115-160`. ### `content/docs/permissions/administrator-guide.mdx`, Step 5 (`:162-164`) and the FAQ (`:194-196`) - **Step 5.** - Before: "Editing a set that shipped with an app creates an *environment overlay* — your change wins, survives upgrades, and can be reset back to the vendor baseline; the API name is immutable after creation." - After: "A set shipped in code — by an app you installed, or as a platform default such as `member_default` — can't be edited in the environment (a save answers `403 NOT_OVERRIDABLE`): clone it and adjust the clone, which is your own set while upgrades keep reaching the original. Sets created here, clones, and sets saved into a writable runtime package are edited in place. The API name is immutable after creation." - The page's order of preference is unchanged: bind an existing set to a position, then clone and adjust, then author a new set. - **FAQ, "Can I delete the built-in permission sets?"** - Before: "Shipped sets can be overlaid (Step 5) or simply left unassigned." - After: "Shipped sets can't be edited in place either, but they can be cloned and adjusted (Step 5) or simply left unassigned." - Backed by: - The installed-app half: `permission-set-write-through-package-binding.dogfood.test.ts:167-173` and `two-doors-permission.dogfood.test.ts` 块2 (`showcase_contributor` answers 403, and no overlay is minted). - The platform-default half: `two-doors-permission.dogfood.test.ts` (last 块2 case) and `showcase-permission-projection.dogfood.test.ts` §2 pin `403` on a `member_default` edit with no overlay minted. The status is pinned. The code is read from the producer (`refusePackagedBaseOverride`, `NOT_OVERRIDABLE`) and is not pinned. - The editable populations: `permission-set-lock-row-provenance.dogfood.test.ts` shapes 1-3 (runtime-package set, org set, clone). - The upgrade sentence: the lock's own `userMessage` in `packaged-permission-set-lock.ts` ("The clone is your organization's own permission set, and package upgrades keep reaching the original."). Not touched: the "Declared ≠ enforced — diagnosing a frozen package set" section. PR objectstack-ai#22365 (card objectstack-ai#22307) adds a clause there in a hunk starting at `:368`, and the decision card objectstack-ai#22371 may rewrite that section. `content/docs/releases/` is not touched either. ## Premise checks against `main` (base `117d34de3f`, now merged up to `191543456f`) 1. **The two passages were still pre-lock text.** At `117d34de3f`, `permission-sets.mdx:332-339` ("it genuinely takes effect") and `:350-352` ("use an overlay where a packaged set must be narrowed") read as the card quotes them. Holds. 2. **The Setup edit of a set a code package ships answers 403 and stores nothing.** Holds: - Pin: `packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts:167-173`. `PATCH /data/sys_permission_set/:id` on `showcase_contributor` answers `{ status: 403, code: 'NOT_OVERRIDABLE' }`, and the active `sys_metadata` rows are unchanged. - Producer: `PackagedPermissionSetLockedError` in `packages/plugins/plugin-security/src/packaged-permission-set-lock.ts`. The data door's insert and update legs throw it (`permission-set-projection.ts`, `createPermissionSetWriteThrough`), and so does the metadata door (`packaged-permission-set-lock-gate.ts`). 3. **Scope: which package-held sets does the lock cover?** The caution was right. "A packaged set cannot be edited" over-claims, so the text is written to a narrower group: sets shipped in code. - How the lock decides: `classifyPackagedPermissionSet` reads the engine SchemaRegistry. `declaredPackageIdOf` skips projection echoes, tenant-authored stored rows (`isTenantAuthored`, `_provenance: 'org'`), and `_packageId: 'sys_metadata'` shadows. - Editable shapes, pinned: a set saved into a writable runtime package (`permission-set-write-through-package-binding.dogfood.test.ts:152-160`, and `permission-set-lock-row-provenance.dogfood.test.ts` shape 1), an org-created set (shape 2), and a clone (shape 3). 4. **Both remedies exist and work on `main`.** Holds. - **Clone:** the `clone_permission_set` action (`sys-permission-set.object.ts:115-160`) POSTs to `/api/v1/data/sys_permission_set`. It carries every permission facet but deliberately not `admin_scope`. The lock's `userMessage` and the producer's regime sentence (`packaged-base-regime.ts:162-167`) both name it. - **Bind to a position:** `sys_position_permission_set` rows ("Assigning permission sets" on this page; `positions.mdx:7-10`). `showcase-permission-zoo.dogfood.test.ts:133-143` pins a tenant admin binding the package-shipped `showcase_contributor` to a position. - Binding only adds (union) and cannot narrow anything, so narrowing goes through clone-and-bind-in-its-place. 5. **Third bullet: holds.** Measured per kind of set: - *A set created in this environment is removed:* `showcase-permission-projection.dogfood.test.ts` ("deleting a runtime-only set retires both the definition and the record") and `permission-set-projection.test.ts:785`. - *A set shipped in code is not removed, and the row remains:* `two-doors-permission.dogfood.test.ts` 块2 delete case and `showcase-permission-projection.dogfood.test.ts` §3 both answer 2xx with `success: false`, and the record stays. - *"(the customization overlay is dropped)":* a pre-lock overlay is removed by `deleteMetaItem` (the 2026-08-10 maintainer ruling, the `mergesOverlayAtRead` carve-out in `refusePackagedBaseRemoval`). Pinned by `protocol.legacy-overlay-delete.test.ts` and `permission-set-projection.test.ts:926`. With no overlay present, the delete changes nothing. - NOT MEASURED: deleting a set saved into a writable runtime package. No pin covers it. See Acceptance notes. - CI's `Dogfood Regression Gate` concluded `success` on `117d34de3f`. Confirming that each cited file appears in the job logs is NOT MEASURED: the log host answered `Forbidden` from this container. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `fc3fdb1b69`: 43 commands, the same list as round 1. All 43 ran on `fc3fdb1b69` and exited 0. Exit codes were captured before any pipe. - Prerequisite builds: the spec, lint and client/client-react closures were built under the verify lock (`VERDICT command-exit 0`) before the run, so the dist-reading gates measured this tree. - Reconciliation (`--ran` with per-command exit codes): "43 derived famil(ies) accounted for — 43 run, 0 NOT-MEASURED (a DERIVED zero …)". - Sample verdict lines: - `check:skill-examples`: "262 prose examples type-check across 3 surface(s)". - `check:doc-anchors`: "468 internal #fragment link(s) across 417 source file(s) all resolve to a real heading". - `check:nul-bytes`: "OK (scanned 10373 text file(s) … no raw ASCII control bytes)". - `check:doc-authoring`: clean. - `check:docs`: "225 generated files in sync with packages/spec". - `origin/main` moved to `0ef9029da4` after this round's merge. That commit only retitles `packages/spec` test files and touches no docs, so it was not merged again; CI's merge ref covers it. - Outside the derived list, for CI: the path-scheduled `Build Docs` and `Test Core` jobs, and the type-check lanes. ## Changeset None. The diff is docs-only under `content/docs/`, which no package publishes, so the PR carries the `skip-changeset` label. ## Acceptance notes Accepted by the seat as notes, not filed: - **Stale code comments, not docs.** These are comment drift with no runtime effect. - In `packages/plugins/plugin-security/src/permission-set-projection.ts:559-568`, the comment says "objectstack-ai#6960 measures the ordinary delete path refusing to lift" a legacy overlay. The 2026-08-10 ruling since lets that delete go through (`protocol.legacy-overlay-delete.test.ts`). - The `createPermissionSetWriteThrough` doc comment at `:1055-1058` still says a package-owned row's update becomes an env-scope overlay. - **Unmeasured: deleting a runtime-package set.** This is a code reading only, not reproduced. - The data door's delete leg calls `deleteMetaItem` with no package. The repository delete matches any package (`sys-metadata-repository.ts`, `whereFor`). - `readDeclaredBody` (`permission-set-projection.ts:494`) skips `_packageId: 'sys_metadata'` shadows and projection echoes, but not the tenant-authored (`_provenance: 'org'`) rows the lock's classifier learned to skip. - A delete after a list read might therefore re-project instead of retiring. A booted-stack probe would settle it. --- _Generated by [Claude Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22307
Clause-②: no
Executes the maintainer's ruling letter A on #22307 (ruling record 6063176077): the restart path refuses too. After
sys_metadatahydration and beforekernel: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 422NAMESPACE_CONFLICTenvelope 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 nodocs/adr/**file.What changed
packages/objectql/src/plugin.ts.ObjectQLPlugin.start()calls a new privaterefuseEnvironmentHeldSecurityCatalogNames()right after the hydration block (restoreMetadataFromDb, or the project-kernel skip line) and before Phase 3's schema sync. Any conflict throwsSecurityCatalogNameConflictErrorwithdoor: 'cold-boot', which failsstart()and with it the boot. It runs whether or not the kernel hydrated.packages/objectql/src/registry.ts.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.securityCatalogPackageHolders()reads the package half of the holder reading: composite slots and install claims, never the bare slot.findEnvironmentHeldSecurityCatalogNames(registry)is the plugin's handle on that reading. It is not re-exported fromindex.tsorcore.ts, so the public surface does not grow.SecurityCatalogNameConflictErrortakes an optional{ door: 'cold-boot' }, which changes only the message: which package declares each name, and a remedy stated for a restart.code,status,httpStatusandconflicts[]are unchanged.packages/objectql/src/security-catalog-namespace.ts.ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES(position,permission: the two types the metadata-type registry declaresallowRuntimeCreate: 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 markernot-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/specchange.Where each refusal sits (for the merge with #22331, which landed first)
mainwas merged at e3ae92a, after #22331 landed. The merge was clean, and the order inObjectQLPlugin.start()on this head is:installDeploymentPlatformGlobalObjects(ctx), the first statement ofstart().restoreMetadataFromDb(ctx):sys_metadatahydration.refuseEnvironmentHeldSecurityCatalogNames(): right after the hydrationif/elseand before Phase 3'sinstallRegisteredSchemas. It runs before any plugin that depends on the engine starts, and beforekernel:ready.assertDeploymentPlatformGlobalObjectsUnchanged(ctx), at the top of thekernel:readyhook.The two changes share no hunk. This PR's new method sits directly after
restoreMetadataFromDb's method body, and its import line comes after thepicklist-resolutionimport block.Mechanism assumptions, measured
bootStackon one database file, on the untouched base 28bff18. Boot 1 saved a permission set and a position throughPUT /api/v1/meta/permission/NAMEandPUT /api/v1/meta/position/NAME. Both answered200; a new position name needs noOS_METADATA_WRITABLE. Boot 2, cold, added a package declaring both: it booted, with two[Registry] Collisionwarnings, and the by-name read answered the environment's definitions. Boot 3 hot-installed the same package:422 NAMESPACE_CONFLICT, both names held byenvironment.os serve/os dev/bootStack:environmentIdunset, hydration runs, the check runs (measured, dogfood);createStandaloneStack):environmentId: 'env_local'withhydrateMetadataFromDb: true, hydration runs, the check runs (measured, runtime pin);environmentIdand nohydrateMetadataFromDb: hydration is skipped, and the check runs over whatever reached the bare slot, normally nothing (code reading);protocolservice, or one withoutloadMetaFromDb: nothing hydrates, and the check runs with nothing to judge (code reading).loadMetadataFromServiceat the top ofstart()syncsobject,view,app,flowandhookonly, so no other boot-time path writes these two types into the bare slot.probe22307_setcarries_packageId: com.probe.addon22307and_provenance: package, so feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #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 feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's.bootStack, withOS_METADATA_WRITABLE=position, environment saves underorg_adminandeveryoneanswered200, and the restart boots, withGET /api/v1/meta/position/org_adminanswering the saved definition. S2b's pins are green:builtin-positions.boot.test.tsis in the plugin-security suite below.PUT /api/v1/meta/permission/NAMEover a package-held set answers403, 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, holderenvironment. A legacy row over one of the platform security plugin's own permission sets (member_default) refuses the restart, namingcom.objectstack.plugin-security. On base, all three boot.PUT /api/v1/meta/capability/NAMEanswers403("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'sbootStackon one database file unless the row says otherwise.[Registry] Collisionwarnings; the by-name read answers the environment's definitions (28bff18 and ablated)Plugin com.objectstack.engine.objectql failed to start, cause422 NAMESPACE_CONFLICT, two conflicts, incoming the package, holderenvironmentmanifest.register) of that package422, holderenvironment, both namescreateStandaloneStack,file:database), a package added over environment-saved namesorg_adminandeveryone, restartenvironment, both namesenvironmentmember_default, restartcom.objectstack.plugin-securityDELETE /api/v1/meta/permission/NAMEand/position/NAME, boot with it200, no row left, the boot with the package comes up403code-onlyIn-repo census
The examples ship no
sys_metadatarows, 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.permission/position) after the bootapp-crmapp-showcaseapp-multi-packageThe counts include the platform's own items (
plugin-security's 8 permission sets and 6 built-in positions). Names held twice: 0.app-tododeclares no catalog name (#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'sbuiltin-positions.boot.test.tsandbootstrap-declared-positions.test.ts.@objectstack/runtime, whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped.standalone-stack-security-catalog-one-holder.test.tshas 6, 1 of them new.The one red was this PR's own built-in control: its
PUT /api/v1/meta/position/org_adminanswered403with the hatch set. The protocol memoisesOS_METADATA_WRITABLEat 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.typecheckat 72dcb8e:objectql(tsc --noEmitpluscheck:test-typecheck: 40 files, 234 errors, 65 pinned signatures, no new signature) anddogfood, exit 0.runtimeat e3ae92a, exit 0; no runtime file changed after it.pnpm exec eslint --no-inline-config --format jsonover 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:eslint.config.mjsitself (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED), and all 7 files are in it;parserOptions.project, as stated ateslint.config.mjs:328), so this diff cannot move any untouched file's verdict.The whole-repo
pnpm lintis CI's.Ablation
The call was neutralised through
scripts/ablation-replace.mjs, which wraps the run and restores on exit. Inplugin.ts,this.refuseEnvironmentHeldSecurityCatalogNames();became the same call behind an always-false guard carrying the markerABLATION_22307_MARKER, so the method stays referenced and the DTS build still runs.399ddf47c127→2cf40c49e6e2.objectqlwas rebuilt (exit 0), andablation-dist-preflightfound the marker in 2 built files.src): 5 failed / 11 passed of 16 inprotocol-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.dist): 1 failed / 5 passed. The artifact-boot case went red; feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's five stayed green.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.member_defaultoverlay booted; S2b booted.399ddf47c127== HEAD,git diff HEADempty,git status --porcelainempty. After a rebuild,ablation-dist-preflight --absentis green: the marker is absent from all 14 built files and the tree is clean.The ablation ran at a15b8af. The second
mainmerge (e3ae92a) brought #22331'splugin.tshunks, 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.tsand the shared chunk declare no new exported name.findEnvironmentHeldSecurityCatalogNames,ENVIRONMENT_HELD_SECURITY_CATALOG_TYPESandSecurityCatalogNameConflictErrorare 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, soClause-②: nostands.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 81 commands at the head, 3c160a2. All 81 ran with exit codes recorded, and--ranreconciles 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 #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-loadsfirst 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-redisandservice-knowledge. On 72dcb8e and 3c160a2 it exits 0.The changeset gates:
check-changeset-no-major --baseexits 0 (pre modenext),check:adr-0087-registrationexits 0, andcheck:changeset-gate-self-testsexits 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/mainis 7 commits ahead of the head, among them #22352 (plugin-securitygrant readers) and #22353 (metadata-protocolseed loader); none touches a file of this PR.git merge-treeagainst it is clean, somainwas not merged again.Acceptance notes
check-empty-changesetstays red by design)..changeset/22135-security-catalog-one-holder.mdis feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135's pending note, not yet consumed by a release (packages/objectqlis at17.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.overlay_shadowrun inplugin-security'skernel:ready. A boot carrying an environment overlay of a package-declared set is now refused beforekernel: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(plugin-security: discard-overlay deletes the only stored row of a permission set saved into a writable runtime package — its eligibility reads "has a package id" as "package-declared", the defect #21789 fixes in the lock #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.start(), so the kernel wraps it.bootstrap()rejects withPlugin com.objectstack.engine.objectql failed to start - rollback complete: …, and the envelope is the wrapper'scause, as with anystart()-time refusal (feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's item-seam refusal fromplugin-security.startincluded). The pins readcause.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.sqlite-wasmfile can still flush after the refusal. In a probe, removing the database directory right after the refusedbootStackraisedENOENTfrom 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.packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts(new) andpackages/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 adatabaseUrl):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 #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.mdare 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:kernel:readyoverlay reading names the sets this release refuses. The audited Discard Overlay action, orDELETE /api/v1/meta/permission/NAME, removes each overlay without touching the database, including on the platform's own sets.The changeset also says that no
oscommand deletes asys_metadatarow 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:
scripts/ablation-replace.mjs(blob9b18363e90ef→b3701fcc3a70, marker indist/), a legacymember_defaultoverlay written at the driver, then a restart:kernel:readywarning, "[security] 1 package-declared permission set(s) are being shadowed by an environment overlay — … use the audited "Discard Overlay" action on it …", namingmember_default.drift_status: overlay_shadow, and Discard Overlay answered200and left no active row.DELETE /api/v1/meta/permission/viewer_readonlyover a legacy overlay of that platform set answered200("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.ablation-replaceput the blob back (== HEAD,git diff HEADempty). After the rebuild,ablation-dist-preflight --absentwas green ondist/at once. It was green on the tree once this round's doc edit, the one dirty path at that moment, was committed (cf1a9dd).member_defaultboots.permissionsandpositions(the legacy plurals) over package-held names refuse the restart, both named.draftrow over a third package-held name is not loaded and not named.loadMetaFromDbselectsstate: 'active'andorganization_id: null, and folds the type throughPLURAL_TO_SINGULAR, which mapspermissionstopermissionandpositionstopositiononmain. It sets nopackage_idcondition: a row bound to the package itself refuses too, measured in the first round.DELETEstatements, run through Python'ssqlite3against the refused database files (one per type, and one formember_default), deleted 1 row each. Each restart then booted.os meta deleteandos data deletebuild an API client and require a token (createApiClient,requireAuth), and no command underpackages/cli/src/commandsdeletes asys_metadatarow.discard_permission_set_overlay, labelled "Discard Overlay", onsys_permission_set, in the list-item and record-header locations, visible whiledrift_statusisoverlay_shadow. It is documented oncontent/docs/permissions/permission-sets.mdxunder "Declared ≠ enforced — diagnosing a frozen package set". Positions have no overlay reading (it reads thepermission/permissionstypes) and no such action.Gates at cf1a9dd.
dispatch-gates --commandsderived 107 commands; the doc page added the docs families. All 107 ran with exit codes recorded, and--ranreconciles 107/107 with 0 NOT-MEASURED. 106 exited 0, includingcheck-changeset-no-major --base,check-adr-0087-registration --base,check:doc-authoring,check:docs-*,check-doc-frontmatter,@objectstack/spec'scheck:docsandcheck:doc-formula-expressions. One exited 1 by design:check-empty-changeset --base origin/main, the confirmed #22135 correction.origin/mainis 12 commits ahead;git merge-treeagainst it is clean, somainwas 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 #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 pluralpermissions/positions, a shape the changeset's own "upgrade shape" paragraph names. This round changes.changeset/22307-cold-boot-catalog-refusal.mdonly. No code, pin, docs page or.changeset/22135-security-catalog-one-holder.mdchange. The head is 39ef237.Measured first; the review's reading holds.
scripts/ablation-replace.mjs, blob9b18363e90ef→b3701fcc3a70, marker indist/):viewer_readonlystored underpermissions:DELETE /api/v1/meta/permission/viewer_readonlyanswered200with{"success":true,"reset":false,"message":"No customization overlay found for permission/viewer_readonly — already at artifact default."}, and thepermissionsrow stayed active. Discard Overlay on the same set answered200and left no active row.mcp_agent_restrictedwith two active rows, one bound to no package and one bound tocom.objectstack.plugin-security: the firstDELETEanswered200"Customization overlay deleted — … reset to artifact default" and removed one row, leaving the bound one. A secondDELETEremoved it.permissions/positions. Booted without the package,DELETE /api/v1/meta/permission/pr2_setanswered200"No permission 'pr2_set' found — nothing to delete.", andDELETE /api/v1/meta/position/pr2_posanswered "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 byenvironment.git diff HEADempty. After the rebuild,ablation-dist-preflight --absentis green ondist/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:
DELETEroutes reach a row stored underpermissionorpositiononly, one row per call;200, nothing found, nothing removed;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 --commandsderived 107 commands. All 107 ran with exit codes recorded, and--ranreconciles 107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design:check-empty-changeset --base origin/main, the confirmed #22135 correction.origin/mainis 22 commits ahead.git merge-treeagainst it is clean, somainwas not merged.Generated by Claude Code