Skip to content

[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

Description

@objectstack-fleet

Ruled: 6063176077 · letter A · 2026-10-08T15:25Z

Filing gate: ② a decision only the maintainer can make — the remainder of the maintainer's ruling Q4 = A on #15196 (ruling record 6050490870) that the code door cannot reach at cold boot. Filed by domain:engine seat 1 (seat post #6367) · session_01EUBvqtauTDmHi2ZgY759p2, from the contract-review record on PR #22197 (6061710772). ⛔ Not graded or routed here; ⛔ not a claim.

Who acts on it: the maintainer answers one letter; triage grades it; the domain:engine seat claims and dispatches the answer (or closes this card as the answer says). PR #22197 (#22135) does not wait for it.

Why a card and not a comment: the case was carried on #22203 as "a boundary, not a defect". #22203 closed completed when PR #22262 landed, and nothing carried the case forward. #22135 closes when PR #22197 lands. This card is the carrier that outlives both.

维护者速读

一句话问题

同一个包,热安装会因为重名被拒,重启加进来却能装上,并且悄悄被环境里的同名定义盖住。重启这条路要不要也拒?

Background (measured and read)

Governing text

  • Q4 = A (6050490870): "Installing or registering a package whose position, permission set or capability bears a name an installed package, the environment catalog or a built-in already holds is refused, the error naming both holders."
  • ADR-0048 addendum N.3 (on main since docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and capabilities hold one name per deployment #22198, maintainer-approved): "A write with no package provenance — an environment save over a package-held name, which is how every sys_metadata hydration arrives — stays under ADR-0005 overlay precedence. The ruling covers installing or registering a package, not a metadata author's save."
  • The two meet at cold boot. By Q4's letter, the package is installed over a name the environment catalog already holds, so it should be refused. By N.3, the environment row arrives at hydration as a write with no package provenance, so it is not judged. Today the boot order makes N.3 win.
  • No protocol change and no packages/spec edit under any option. A and C each need a one-line ADR-0048 N.3 amendment (Tier H).

Premises (re-check commands)

  1. Package registration precedes hydration: git grep -n "registerApp\|loadMetaFromDb" packages/objectql/src/plugin.ts | head
  2. The bare slot is never judged: git grep -n "bare slot" packages/objectql/src/registry.ts | head (on main once PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 lands)
  3. Capabilities are not environment-creatable: git grep -n "type: 'capability'" packages/spec/src/kernel/metadata-plugin.zod.ts

选项 × 真实代价

选项 做什么 客户感受到的后果
A 启动加载完环境数据后,检查每个包持有的权限集和职位名。和环境目录重名的,启动失败,报错点名双方和改法(同一个 422 信封)。 重启和热安装结果一致。已经处在这种状态的部署,升级后会起不来,要等运维改名或移除那个包。锁上线前保存的"环境覆盖包名"旧行也会被拦(启动时分不出谁先来)。
B 启动照常,环境那份按 ADR-0005 生效。启动报告里出一条结构化诊断,点名双方和改法,并入 #15196 S9 的启动报告。 部署不会因此停机。但包自带的定义一直被盖住,直到有人看到报告并处理。
C 保持现状。changeset 和 ADR 各写一句"重启时环境数据在包注册之后加载",日志只有现在这条冲突警告。 不改代码。同一个包,热安装被拒,重启装上,结果取决于走哪条路。

业务含义直译:

  • A:门禁卡重名,不管从正门还是侧门进,都拦。
  • B:侧门放行,但在值班日志里记一笔,点名是谁。
  • C:侧门放行,只在后台留一行小字。

os-decision-facets

  • ① 项目长远合理性:A 让"一个名字一个持有者"在每条路上都成立,不留特例。B 和 C 永久保留"重启这条路不算"的例外,裁定只兑现了一半。
  • ② 实际业务拉动:今天要撞上,须管理员先在环境里建了某个权限集或职位,之后又有同名的包靠重启加进部署。仓库里没有这样的生产者,真实部署的数量没有实测。拉动低,但一旦撞上,就是"授权到底按哪份定义"的安全歧义。
  • ③ 防 AI 犯错:A 是响亮拒绝,点名双方。B 是响亮报告,部署照跑。C 只留一行日志警告,近乎静默:AI 照抄一个和环境同名的权限集名,重启后看不出自己的定义没生效。
  • ④ 创业阶段不扩散:三者都不加键、不改 spec。A 加一道启动检查,外加一条 ADR 附录修订。B 在 S9 报告里多一类条目。C 只加两句文档。

Prior rulings read: hydration, environment catalog, cold boot, collision, bare slot → 9 hits; ADR-0048 addendum N.3 / N.4 (the governing pair above; the other hits, ADR-0029 D3 / D9.8, ADR-0057 D2 / D11, ADR-0076 D10, ADR-0106 D6, ADR-0128 D3, ADR-0129 D4, are other "collision" senses), ruling 6050490870; thread: #22135 (contract review 6061710772), #22203 (closed carrier).

推荐

A:重启时也拒。

  • **终态句:**两年后,安全目录里一个名字一个持有者,在每条路上都成立:热安装、重启、制品启动都一样。主流平台也是这样。Salesforce 安装非托管包时,组织里已有同 API 名的组件,安装直接失败并列出冲突项,不存在"换条路就能装上"。
  • **自检:**只看①选 A;②③④ 是否翻转:否。②只影响排期(p3 档,不急);④A 多一道检查和一条 Tier H 的 ADR 修订,不多键。
  • **回退:**B,并入 feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 S9 的启动报告。部署不停机,但例外永久保留。
  • 置信缺口:
    • 重启这条路是按启动顺序和单元 CONTROL pin 读出来的,没有走一次真实重启实测。那种状态下按名字读到的是哪份定义,也是读出来的(ADR-0005),没实测。
    • 真实部署里有多少"环境自建、又有包同名"的权限集或职位,没有测,也测不到。
    • 锁上线前保存的"环境覆盖包名"旧行有多少,没有测。选 A 时这些部署升级后会起不来。

裁后执行

Dedupe: MCP search_issues, repo-scoped, open and closed: 「cold boot package security catalog name environment catalog holds not refused sys_metadata hydration after package registration」 → 5 results: #22220 (open; a different door-order question on the object save door), #22057, #7557, #5047 and #4624 (closed; hydration and package-persistence defects of other types). None is this case. #15196's S9 is a dangling-name boot report, not a collision report (read on its thread).

Dedupe words: cold boot environment catalog holder · security catalog hydration order · Q4 remainder restart


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #293 item 1 · letter A · maintainer 「同意」 2026-10-08T15:22Z

    Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Presented in batch #293 from the domain:engine seat's decision card (the body, filed from the contract review 6061710772 on PR #22197): A refuse at cold boot too, B a structured boot diagnostic, C document the gap. The seat recommended A; the director seat presented the same; the maintainer answered 「同意」. Thread-read: none (the card has no comment). Freshness: no comment since the presentation. Premises re-read on origin/main 799eb000c7: loadMetaFromDb hydrates after package registration (packages/objectql/src/plugin.ts); capabilities carry allowRuntimeCreate: false (metadata-plugin.zod.ts:1148); ADR-0048 addendum N.3 is on main; PR #22197 (#22135) is open and does not wait for this card.

    The ruling

    A — the restart path refuses too. After sys_metadata hydration and before kernel:ready, the kernel checks every package-held permission set and position name against the environment catalog; a name the environment already holds fails the boot with the same 422 NAMESPACE_CONFLICT envelope #22135 uses, naming both holders and the remedy (rename or remove). Cold boot, hot install and artifact boot then answer alike: one name, one holder, on every path (Q4 = A, 6050490870). ADR-0048 addendum N.3 gains one line: the hydration write stays unjudged as a write, and the post-hydration check judges the package's claim against it. ⛔ Not taken: B (a report that leaves the package's definition shadowed until someone reads it), C (the outcome depends on which door the package came through). The cost is stated knowingly: a deployment already holding an environment-saved name a configured package also declares, including rows saved before the packaged locks, fails to boot after the upgrade until an operator renames or removes; the changeset names that shape and the remedy.

    Prior rulings read: Q4 = A 6050490870 (#15196); ADR-0048 addendum N.3 / N.4; the packaged lock ruling of 2026-08-24 (403 NOT_OVERRIDABLE) and #22262 (positions); #22203 (closed carrier) → 5 hits; ADR-0005 overlay precedence; thread: 0 comments. 自检: 只看①选 A;②③④ 是否翻转:否(② 只影响排期;④ 一道启动检查 + 一行 ADR 修订,不加键)。置信缺口:重启路径按启动顺序与单元 pin 读出,未真实重启实测;真实部署里「环境自建 + 包同名」的数量测不到;锁上线前保存的旧行有多少未测。

    State

    • needs-user-decision → pm:queue in this act; the Ruled: line added to the body. Triage grades it (the seat's reading: p3), then the domain:engine seat claims and dispatches: the objectql boot check with its envelope; first the in-repo example census (expected zero) and one real restart probe through bootStack; the ADR-0048 N.3 one-line amendment as its own Tier H PR (the maintainer's approval); the changeset naming the upgrade shape and remedy.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: graded priority:p2 · target:v18 · domain:engine · area:access; pm:queue stands. Ruled A (6063176077): refuse at cold boot too

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T15:54Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in the boot path where package registration meets sys_metadata hydration (packages/objectql / packages/metadata*) ⇒ domain:engine; rationale: the ruling names that seat, and both packages are its.

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 65 · 2026-10-08T18:31Z
    Session: session_01EUBvqtauTDmHi2ZgY759p2
    Account: os-litant (the seat's linked user, as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22307-cold-boot-catalog-refusal
    Worktree: objectstack-issue-22307
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance:

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22307,
    "status": "done",
    "branch": "claude/issue-22307-cold-boot-catalog-refusal",
    "pr": "#22365",
    "pr_adr": "#22366 (branch claude/issue-22307-adr-0048-n3-amendment; first line Refs #22307; Tier H; draft; skip-changeset; carries the maintainer quick-read draft; the seat requests the approval)",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
    "premise_still_valid": true,
    "summary": "Implemented ruling letter A in @objectstack/objectql. ObjectQLPlugin.start() calls a private refuseEnvironmentHeldSecurityCatalogNames() right after the sys_metadata hydration block and before Phase 3's schema sync (so before every engine-dependent plugin starts and before kernel:ready). It asks the registry, through a module-level findEnvironmentHeldSecurityCatalogNames(registry) that the package entries do not re-export, for every package-held position or permission-set name (a composite slot or an install claim) that also has a bare-slot item, skipping built-in names, and throws SecurityCatalogNameConflictError (code NAMESPACE_CONFLICT, status 422, conflicts[] with holder environment) with a new cold-boot message variant. M3 was partly falsified by measurement: at a cold boot the hydrated environment row wears the package's _packageId (the protocol's artifact-protection merge), so the stamp-based holder reading answers 'the package itself'; the check therefore reads every bare-slot item as the environment's, which also refuses a pre-lock row bound to the package itself, exactly as a hot install does (measured). Pins: objectql (real kernel boot, 8 new cases), runtime (artifact boot over a file database), dogfood (bootStack restarts on one database: cold boot and hot install refused alike, legacy row refused, built-in shadow and distinct-name controls boot). One existing dogfood pin (#21860's Discard Overlay file) carried a legacy overlay through a cold boot, which the ruling now refuses; it now writes the overlay into the running deployment and runs the boot's two passes on it. The pending #22135 changeset sentence that said a cold boot is not refused was corrected (check-empty-changeset stays red by design; confirmation owed on the PR). ADR-0048 N.3's dated note rides #22366.",
    "refusal_placement_for_22331": "Merged main at e3ae92a after #22331 landed (clean). Order in ObjectQLPlugin.start(): #22331's installDeploymentPlatformGlobalObjects(ctx) first statement; restoreMetadataFromDb(ctx) hydration; THIS PR's refuseEnvironmentHeldSecurityCatalogNames() right after the hydration if/else and before installRegisteredSchemas (Phase 3); #22331's assertDeploymentPlatformGlobalObjectsUnchanged(ctx) at the top of the kernel:ready hook. No shared hunk; the new method sits right after restoreMetadataFromDb's body; the import line follows the picklist-resolution import block. Registry additions sit after forgetSecurityCatalogClaims and beside the error class, away from #22331's registry hunks.",
    "doors": [
    "Cold boot: environment-saved permission set + position, then a package declaring both (bootStack, one DB file) — base 28bff18 (and ablated a15b8af): boots, two [Registry] Collision warnings, by-name read answers the environment's definitions · head 72dcb8e: refused, 'Plugin com.objectstack.engine.objectql failed to start', cause 422 NAMESPACE_CONFLICT, 2 conflicts, incoming the package, holder environment",
    "Hot install (post-boot manifest.register) of that package — base and head: refused 422, holder environment, both names (unchanged)",
    "Artifact boot (createStandaloneStack, file: database), package added over environment-saved names — base (ablated): boots, runtime pin red · head: refused, same envelope",
    "Built-in shadow: env saves under org_admin and everyone (OS_METADATA_WRITABLE=position), restart — base (ablated) and head: boots; GET /meta/position/org_admin answers the saved definition",
    "Legacy row (driver-written, package_id null) over a package-held set and position, restart — base 28bff18: boots, one collision warning · head: refused, holder environment, both names",
    "Legacy row bound to the package itself (package_id = the package), restart — base (ablated): boots (and is refused on a hot install) · head: refused, holder environment",
    "Legacy row over the platform's member_default, restart — base (ablated): boots · head: refused, incoming com.objectstack.plugin-security",
    "Same-package restart; a package whose names the environment does not hold — base and head: boots",
    "Remedy, head: boot without the package, DELETE /api/v1/meta/permission/NAME and /position/NAME both 200 (no row left), then the boot with the package comes up",
    "Environment save of a capability — base and head: 403 code-only (M6)"
    ],
    "mechanism_assumptions": {
    "M1": "Confirmed on 28bff18 via bootStack on one DB file: PUT permission 200, PUT position 200 (new name needs no OS_METADATA_WRITABLE); cold boot with the package booted with 2 collision warnings; hot install refused 422 holder environment.",
    "M2": "Check placed right after the hydration if/else in ObjectQLPlugin.start(), before Phase 3 and before kernel:ready. Boot shapes: standalone serve/dev/bootStack (hydrates; check runs; measured), artifact boot createStandaloneStack (env_local + hydrateMetadataFromDb true; check runs; measured), project kernel with environmentId and no hydrateMetadataFromDb (no hydration; check runs over whatever reached the bare slot, normally nothing; code reading), host with no protocol/loadMetaFromDb (nothing hydrates; check has nothing to judge; code reading). loadMetadataFromService syncs object/view/app/flow/hook only.",
    "M3": "PARTLY FALSIFIED, route changed by the ruling's intent: the bare-slot env row at a cold boot carries the package's _packageId and provenance package (measured), so #22197's stamp-based reading would answer 'the package itself'. The check reads every bare-slot item as the environment's (only a package-less registration writes the bare slot); package-held = composite slot or claim. Envelope class, holder kinds and claims reused.",
    "M4": "Built-in names skipped. bootStack: env saves under org_admin/everyone then restart boots; plugin-security suite (incl. builtin-positions.boot.test.ts and bootstrap-declared-positions.test.ts) green.",
    "M5": "Save door refuses it now (403 with or without ?package=); rows written at the driver. Unbound row refused (dogfood pin), row bound to the package refused (objectql pin; hot install refuses it alike, measured), member_default overlay refused naming com.objectstack.plugin-security (measured). All three boot on base.",
    "M6": "PUT /meta/capability/NAME answers 403 code-only; the check reads permission and position only; no capability path reaches it."
    },
    "census": "Examples ship no sys_metadata rows. On a15b8af, a fresh bootStack boot of each example on a DB file then a restart: app-crm 10 permission sets / 9 positions package-held, 0 env rows, restart boots; app-showcase 17 / 16, 0, boots; app-multi-package 8 / 6, 0, boots (counts include plugin-security's 8 sets and 6 built-in positions). Names held twice: 0. app-todo declares no catalog name (#22197 census) and is not a dogfood dependency. Deployed environments NOT MEASURED.",
    "tests": "Head 3c160a2 differs from 72dcb8e by comment lines only (5+/3-, 0 non-comment). objectql whole suite at e3ae92a: 387 files / 7615 passed; at 72dcb8e protocol-boot-hydration-scoped.test.ts 16 passed (8 new). plugin-security whole suite at e3ae92a: 184 files / 3869 passed, 45 skipped. runtime whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped (one-holder file 6, 1 new). Dogfood CI split at e3ae92a: 1/3 76 files / 567; 2/3 75 passed + 1 failed (this PR's own built-in control: 403 because OS_METADATA_WRITABLE is memoised per process and the control set it after the file's first case had saved; fixed at 72dcb8e by opening the hatch file-wide before the first boot; the cause of the isolation-vs-shard difference NOT MEASURED); 3/3 75 passed + 1 skipped files / 669 passed, 8 skipped. At 72dcb8e the new dogfood file + the re-shaped Discard Overlay file: 2 files / 11 passed. typecheck at 72dcb8e: objectql (tsc + check:test-typecheck, no new signature) and dogfood exit 0; runtime exit 0 at e3ae92a. eslint --no-inline-config --format json over the 7 touched TS files at 72dcb8e: 7 files, 0 errors, 0 warnings (population from eslint.config.mjs files glob, count from JSON, no type-aware linting per eslint.config.mjs:328). NOT MEASURED at 3c160a2: the dogfood file rerun (three 540s queue timeouts on os-verify-lock, holders were other seats' suite runs); its diff vs 72dcb8e is comments only. Ablation: plugin.ts call neutralised through scripts/ablation-replace.mjs (always-false guard with ABLATION_22307_MARKER); anchor 1->0, replacement 0->1, blob 399ddf47c127 -> 2cf40c49e6e2; objectql rebuilt exit 0; ablation-dist-preflight found the marker in 2 dist files. objectql pins (src) 5 failed / 11 passed (every refusal pin red, controls green); runtime (dist) 1 failed / 5 passed (artifact-boot case red); dogfood (dist) 2 failed / 2 passed (refusal cases red, controls green). Restore: blob == HEAD 399ddf47c127, git diff HEAD empty, porcelain empty; after rebuild ablation-dist-preflight --absent green (absent from 14 built files, tree clean). Ablation at a15b8af; the later main merge brought #22331's plugin.ts hunks, none on this check's lines.",
    "clause2": "Measured on the built entry declarations at 72dcb8e (no source change after): no new exported declaration in dist/index.d.ts, dist/core.d.ts or the shared chunk; findEnvironmentHeldSecurityCatalogNames, ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES and SecurityCatalogNameConflictError are on neither entry's export list; the only new declaration text is three private member names (SchemaRegistry.environmentHeldSecurityCatalogConflicts, SchemaRegistry.securityCatalogPackageHolders, ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames) plus JSDoc. Clause-②: no stands; the PR body's line reads the same.",
    "gates": {
    "code_pr": "dispatch-gates --commands at 3c160a2: 81 derived, 81 run with exit codes, --ran reconciles 81/81, 0 NOT-MEASURED (derived zero). 80 exit 0; 1 exit 1 by design: check-empty-changeset --base origin/main (the deliberate correction of #22135's pending changeset). The same 81 ran at 72dcb8e with the same answers. check:dual-build-cjs-loads answered PREREQUISITE NOT MET (exit 3) on e3ae92a until 8 unrelated packages were built (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge); exit 0 since.",
    "adr_pr": "dispatch-gates --commands at f86b451: 19 derived, 19 run with exit codes, all exit 0, --ran 19/19 (check:doc-formula-expressions exit 3 first, exit 0 after building formula + lint).",
    "ci": "in_progress at report time on 3c160a2: 19 completed (all success or skipped except Check Changeset = failure, the check-empty-changeset correction above; Check Changeset is not one of the seven required contexts), 11 in progress. Not waited on.",
    "not_measured_locally": "CI-only lanes declared to CI: Test Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core, workspace type-check lanes.",
    "main_drift": "origin/main is 7 commits ahead of 3c160a2 (#22352 plugin-security grant readers, #22353 metadata-protocol seed loader among them); none touches a file of this PR; git merge-tree clean; main not merged again. ADR branch: merge-tree against main clean."
    },
    "line_budget": "code PR: 10 files, +694 / -42 (under the 5000 human-merge threshold). ADR PR: 1 file, +7 / -0. skills/** untouched.",
    "files_changed": [
    "#22365: packages/objectql/src/plugin.ts",
    "#22365: packages/objectql/src/registry.ts",
    "#22365: packages/objectql/src/security-catalog-namespace.ts",
    "#22365: packages/objectql/src/protocol-boot-hydration-scoped.test.ts (8 new cases in a new describe, reusing the file's engine double)",
    "#22365: .changeset/22307-cold-boot-catalog-refusal.md (new; objectql major, BREAKING banner, adr-0087 not-required (no-migration-prescription), Clause-② no, upgrade shape and remedy)",
    "#22365: .changeset/22135-security-catalog-one-holder.md (one sentence corrected; pending, unreleased)",
    "#22365: scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json (invariant extended)",
    "#22365: packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (one case; helper takes databaseUrl)",
    "#22365: packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts (new)",
    "#22365: packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts (legacy overlay written after the cold boot; the boot's two passes run on it)",
    "#22366: docs/adr/0048-cross-package-metadata-collision.md (dated note under N.3, +7, no existing line edited)"
    ],
    "cross_lane_paths": [
    "packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts (domain:cli, new)",
    "packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts (domain:cli, re-shaped)",
    "packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (domain:cli, one case added)",
    "scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json (scripts)",
    ".changeset/22135-security-catalog-one-holder.md (#22135's pending note)"
    ],
    "deviations": [
    "M3 route: every bare-slot item is read as the environment's at the cold-boot check (not #22197's stamp-based reading), because the hydrated row wears the package's grafted packageId; consequence: a pre-lock row bound to the package itself also refuses the boot, as it is refused on a hot install (measured).",
    "Edited #22135's pending changeset sentence (made false by this change); check-empty-changeset and the advisory Check Changeset context stay red by design until a person confirms (open_questions).",
    "Re-shaped #21860's Discard Overlay dogfood pin (domain:cli): its legacy overlay can no longer ride a cold boot.",
    "The plugin reaches the registry's private reading through a module-level function in registry.ts (same-module element access to a private method), to keep the public surface flat; precedent for bracket access to a private member: plugin.ts reads ObjectQL's private drivers the same way.",
    "Commit trailers: the model-free pair AGENTS.md prescribes (Claude-Session + Co-authored-by: Claude), not the model-named Co-Authored-By the harness reminder offered.",
    "main merged twice into the code branch (a15b8af, e3ae92a); not merged after e3ae92a (7 behind, merge-tree clean). Builds refreshed after each merge.",
    "The dogfood pin rerun at the comment-only head 3c160a2 is NOT MEASURED (os-verify-lock queue timeouts; holders were other seats); 0 non-comment lines differ from 72dcb8e, where it ran green.",
    "Uncommitted probes were used for M1/M5, the base readings and the census, copied in only for a run and removed by trap; none committed.",
    "Worktrees: the ADR worktree was removed before this report; the code worktree (node_modules first, then git worktree remove) is removed right after this comment posts."
    ],
    "mcp_calls": "0",
    "api_writes": "5 — each a repository_dispatch through the fleet-write relay, written as objectstack-fleet[bot]: (1) pr_create POST /repos/objectstack-ai/objectstack/pulls → #22365; (2) pr_create POST /repos/objectstack-ai/objectstack/pulls → #22366; (3) label-write #22365: POST /issues/22365/assignees (os-litant); (4) label-write #22366: POST /issues/22366/labels (skip-changeset) + POST /issues/22366/assignees (os-litant), one dispatch; (5) this os-dev-report comment, POST /repos//issues/22307/comments. git pushes are not REST writes. No card assignee or card label write.",
    "open_questions": [
    {
    "question": "Confirm the deliberate correction of #22135's pending changeset in #22365. Its sentence 'a package newly added … is not refused at cold boot' becomes false on #22365's merge, and both notes would publish in the same release. #22365 rewrites that one sentence; check-empty-changeset (and the advisory Check Changeset context) stays red until a person confirms, which the gate's own text prescribes for this class.",
    "options": [
    "A — keep the correction; the seat/maintainer confirms on #22365 and the red check is accepted for this PR",
    "B — restore #22135's sentence; the gate goes green and the next CHANGELOG carries a false sentence beside this PR's entry",
    "C — restore #22135's note and carry a superseding sentence in #22307's own changeset; green gate, but a contradictory pair of sentences ships"
    ],
    "recommendation": "A, because it is the only option that publishes no false sentence, it is the remedy the gate itself names for a deliberate correction, and it adds no surface. If a release consumes #22135's note before #22365 lands, the correction must become an erratum PR against that CHANGELOG entry instead."
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted in #22365 Acceptance notes, not filed — the 2026-08-24 legacy-overlay remedies (the overlay detection reading, the drift pass's overlay_shadow, and the Discard Overlay discard path) lose their boot-time population for code-package-declared sets: a boot carrying such an overlay is now refused before kernel:ready (measured: a member_default overlay refuses the restart). The ruling names this cost. Dedupe words: overlay detection unreachable at boot · Discard Overlay population · legacy overlay cold boot refused",
    "carrier: 承接者:无 · noted in #22365 Acceptance notes, not filed — a refused bootStack over a sqlite-wasm database file can still flush after the refusal (ENOENT from the atomic write when the directory was removed right after). Harness/dev observation; no public door. Dedupe words: refused boot sqlite-wasm flush · atomicWriteFile ENOENT after refusal",
    "carrier: 承接者:无 · noted in #22365 Acceptance notes — org-scoped sys_metadata rows are not judged at boot (hydration loads env-wide rows only); boundary, not a defect. Dedupe words: org-scoped catalog row boot check"
    ],
    "pr_body_full": {
    "code_pr_22365": {
    "title": "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)",
    "body": "Fixes #22307\nClause-②: no\n\nExecutes the maintainer's ruling letter A on #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).\n\nThe 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.\n\n## What changed\n\n- 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.\n- packages/objectql/src/registry.ts.\n - 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.\n - A private securityCatalogPackageHolders() reads the package half of the holder reading: composite slots and install claims, never the bare slot.\n - 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.\n - 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.\n- 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".\n- .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.\n- .changeset/22135-security-catalog-one-holder.md (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects".\n- scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json. The invariant gains the cold-boot half.\n\nNo new error code, no packages/spec change.\n\n## Where each refusal sits (for the merge with #22331, which landed first)\n\nmain was merged at e3ae92a, after #22331 landed. The merge was clean, and the order in ObjectQLPlugin.start() on this head is:\n\n1. #22331's installDeploymentPlatformGlobalObjects(ctx), the first statement of start().\n2. restoreMetadataFromDb(ctx): sys_metadata hydration.\n3. 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.\n4. #22331's assertDeploymentPlatformGlobalObjectsUnchanged(ctx), at the top of the kernel:ready hook.\n\nThe 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.\n\n## Mechanism assumptions, measured\n\n- 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.\n- M2, where the check sits. As above. Boot shapes:\n - standalone os serve / os dev / bootStack: environmentId unset, hydration runs, the check runs (measured, dogfood);\n - the artifact boot (createStandaloneStack): environmentId: 'env_local' with hydrateMetadataFromDb: true, hydration runs, the check runs (measured, runtime pin);\n - 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);\n - a host with no protocol service, or one without loadMetaFromDb: nothing hydrates, and the check runs with nothing to judge (code reading).\n 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.\n- 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 #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 #22197's.\n- 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.\n- 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.\n- 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.\n\n## Door table: base vs head\n\n"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.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Hot install (post-boot manifest.register) of that package | refused, 422, holder environment, both names | unchanged |\n| Artifact boot (createStandaloneStack, file: database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope |\n| Built-in shadow: environment saves under org_admin and everyone, restart | boots (ablated) | boots; the stored definition answers |\n| 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 |\n| Legacy row bound to the package itself, restart | boots (ablated) | refused, holder environment |\n| Legacy row over the platform's member_default, restart | boots (ablated) | refused, incoming com.objectstack.plugin-security |\n| Same-package restart; a package whose names the environment does not hold | boots | boots |\n| 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 |\n| Environment save of a capability | 403 code-only | unchanged |\n\n## In-repo census\n\nThe 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.\n\n| Example | Package-held items | Environment rows (permission/position) after the boot | Restart |\n|---|---|---|---|\n| app-crm | 10 permission sets, 9 positions | 0 | boots |\n| app-showcase | 17 permission sets, 16 positions | 0 | boots |\n| app-multi-package | 8 permission sets, 6 positions | 0 | boots |\n\nThe 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 (#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED.\n\n## Tests\n\nThe 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.\n\n- @objectstack/objectql, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, protocol-boot-hydration-scoped.test.ts: 16 passed (8 of them new).\n- @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.\n- @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.\n- Dogfood, the CI split, at e3ae92a:\n - 1/3: 76 files / 567 passed;\n - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);\n - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).\n 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.\n- 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.\n- 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.\n- 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:\n - 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;\n - the count, 7, is read from the JSON output;\n - 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.\n The whole-repo pnpm lint is CI's.\n\n## Ablation\n\nThe 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.\n\n- 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.\n- 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.\n- runtime pins (from dist): 1 failed / 5 passed. The artifact-boot case went red; #22197's five stayed green.\n- 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.\n- 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.\n- 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.\n\nThe ablation ran at a15b8af. The second main merge (e3ae92a) brought #22331's plugin.ts hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e.\n\n## Clause-② (measured on the built entry declarations at 72dcb8e)\n\npackages/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.\n\n## Gates\n\nnode 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.\n\nOne 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.\n\nOn 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.\n\nThe 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.\n\nCI'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 #22352 (plugin-security grant readers) and #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.\n\n## Acceptance notes\n\n- A pending release note this PR corrects (check-empty-changeset stays red by design). .changeset/22135-security-catalog-one-holder.md is #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.\n- 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 (#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.\n- 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 (#22197's item-seam refusal from plugin-security.start included). The pins read cause.\n- 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.\n- 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.\n- Files outside the engine lane:\n - 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.\n - packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (one case added, and the artifact-stack helper takes a databaseUrl): domain:cli.\n - scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json.\n - .changeset/22135-security-catalog-one-holder.md (above).\n\n---\n_Generated by Claude Code
    "
    },
    "adr_pr_22366": {
    "title": "docs(adr-0048): N.3 amended — the post-hydration check judges a package's claim against the environment catalog (ruling letter A on #22307)",
    "body": "Refs #22307\n\nRecords the maintainer's ruling letter A on #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.\n\nThis is the Tier H half of #22307, split from the code PR (#22365) so the code can land on its own record. #22307 stays open after this PR; the code PR carries the card.\n\n## What changed — docs/adr/0048-cross-package-metadata-collision.md only\n\nAdditive: 7 lines, no existing line edited.\n\n- 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- 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- 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).\n\n## Gates (at f86b451)\n\nnode 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.\n\n## 维护者速读(草稿)\n\n### 改了什么\n只改 ADR-0048 的文字,没动代码。在附录 N.3 下面加了一段带日期的说明(7 行),原文一个字都没改。说明的内容就是您在 #22307 上选的 A:重启时,环境里已经存着的权限集或职位名,如果某个包也声明了同名的,启动直接失败,报错点名双方。\n\n### 为什么改\nN.3 原来写的是"环境自己保存的数据不受这条规则管"。重启时,包先注册,环境数据后加载,所以按原来的写法,重启这条路正好漏过去:同一个包热安装会被拒,重启加进来却能装上,还被环境里的同名定义悄悄盖住。您裁定重启也要拒。这段说明把两件事分开写清楚:环境数据的加载本身照旧不判;加载完以后,拿包的声明去对环境目录,重名就拒。不写进 ADR,ADR 和代码就对不上。\n\n### 风险与代价(含回滚)\n- 这份 PR 本身只是文档,没有运行时风险。\n- 真正的行为变化在配套的代码 PR(#22365):已经处在"环境里存着同名权限集或职位、配置里又有声明同名的包"这种状态的部署,升级后会起不来,要运维改名或删掉其中一个才行。包括锁上线前保存的旧覆盖行,也包括平台安全插件自带的权限集(例如 member_default)上的旧覆盖行。仓库里的示例应用实测没有这种状态,真实部署的数量测不到。\n- 回滚:撤销这份 PR,ADR 回到原文;代码 PR 可以分开回滚。\n\n### 席位意见\n\n\n### 你要做的\n请看这段说明的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。\n\n---\n_Generated by Claude Code
    "
    }
    }
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22307,
    "status": "done",
    "round": "patch round 1 (text only: the changeset's remedy and one docs clause, after both contract reviews passed — 6070947709 on #22365, 6070955792 on #22366)",
    "branch": "claude/issue-22307-cold-boot-catalog-refusal",
    "pr": "#22365",
    "head": "cf1a9dd6db (was 3c160a2); main not merged (12 behind, merge-tree clean)",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
    "premise_still_valid": true,
    "summary": "The changeset's 'one-line fix' now covers three cases. Before upgrading, for a permission set: the kernel:ready overlay reading names the sets this release refuses, and Discard Overlay or DELETE /api/v1/meta/permission/NAME removes each overlay without touching the database, including 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 now says that no os command deletes a sys_metadata row offline. 'The upgrade shape' names the legacy plural types. The Permission Sets page gains one clause: discard such an overlay before upgrading. The code, the pins and the #22135 changeset are unchanged.",
    "measurements": [
    "Current release (check ablated via ablation-replace, blob 9b18363e90ef -> b3701fcc3a70, marker in dist): a legacy member_default overlay restart logs one kernel:ready WARN '[security] 1 package-declared permission set(s) are being shadowed by an environment overlay … use the audited "Discard Overlay" action …' naming member_default. The record reads drift_status overlay_shadow. POST /security/permission-sets/:id/discard-overlay answers 200 and leaves 0 active rows. DELETE /api/v1/meta/permission/viewer_readonly over a legacy overlay of that platform set answers 200 ('Customization overlay deleted — permission/viewer_readonly reset to artifact default') and leaves 0 active rows.",
    "Restore: blob == HEAD, git diff HEAD empty. ablation-dist-preflight --absent was green on dist/ at once. Its tree reading answered exit 3 while this round's uncommitted doc edit was the only dirty path, and green after the commit (cf1a9dd).",
    "Head (check live): the database cleaned by Discard Overlay boots. Rows typed 'permissions' and 'positions' over package-held names refuse the restart, both named. A 'draft' row over a third package-held name is not loaded and not named.",
    "SQL: the changeset's DELETE statements (organization_id IS NULL AND state = 'active' AND type IN (singular, plural) AND name = …), run with python3 sqlite3 against the refused database files, deleted 1 row each, and each restart booted, member_default included.",
    "loadMetaFromDb on main: where {state:'active', organization_id:null}; the type is folded through PLURAL_TO_SINGULAR (spec manifest-collection-spelling.ts: positions -> position, permissions -> permission); no package_id condition. The bound-row refusal was measured in round 0.",
    "CLI: os meta delete and os data delete use createApiClient + requireAuth (they need a running server), and no command under packages/cli/src/commands deletes a sys_metadata row.",
    "Action: discard_permission_set_overlay, label 'Discard Overlay', on sys_permission_set (list_item and record_header, visible when drift_status == 'overlay_shadow'), documented in content/docs/permissions/permission-sets.mdx under 'Declared ≠ enforced — diagnosing a frozen package set'. The overlay reading reads the permission / permissions types only, so positions have no reading and no action.",
    "NOT MEASURED: the metadata API delete on a set a non-platform package ships; a position overlay before upgrading."
    ],
    "tests": "No code or pin changed this round. Runs: the ablated current-release probe (2 cases, passed) and the head probe (3 cases, passed), both uncommitted and removed by trap; the base build of the dogfood closure (cache).",
    "gates": "dispatch-gates --commands at cf1a9dd: 107 derived (the docs families added by the page). All 107 run with exit codes; --ran reconciles 107/107 with 0 NOT-MEASURED (a derived zero). 106 exit 0, including check-changeset-no-major --base, check-adr-0087-registration --base, check:doc-authoring, check:docs-*, check-doc-frontmatter, spec check:docs and check:doc-formula-expressions. 1 exits 1 by design: check-empty-changeset --base origin/main, the confirmed #22135 correction. Before running them I built spec, formula, lint and the 8 packages dual-build-cjs-loads reads (cache hits).",
    "cross_lane_paths": [
    "content/docs/permissions/permission-sets.mdx (domain:devx, one clause, new this round)",
    "packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts (domain:cli)",
    "packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts (domain:cli)",
    "packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (domain:cli)",
    "scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json",
    ".changeset/22135-security-catalog-one-holder.md (unchanged this round)"
    ],
    "deviations": [
    "The order said Discard Overlay before upgrading is 'the only remedy for a platform security plugin's own set that does not touch the database'. Measured false: on the current release, DELETE /api/v1/meta/permission/NAME also removes such an overlay (200, 'reset to artifact default', 0 active rows), so the changeset names both. The data-door DELETE /data/sys_permission_set/:id answered 200 with success:false and also left 0 active rows; that ambiguous answer is not cited.",
    "The changeset does not say the security plugin cannot be left out of a configuration. I did not establish that, so it says only what to do for a name that plugin declares.",
    "The worktree was recreated from the pushed branch for this round and is removed again after this report."
    ],
    "mcp_calls": "0",
    "api_writes": "1 this round: this os-dev-report comment, POST /repos//issues/22307/comments, through the fleet-write relay. The git push is not a REST write. The PR body was NOT patched; the seat writes it from pr_body_full.body.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a (misleading text) · reach: public docs page content/docs/permissions/permission-sets.mdx, 'One authoritative store' section, first bullet: 'Editing any declared set through Setup — packaged sets included becomes an environment overlay … it genuinely takes effect'. Measured otherwise on head: PUT /api/v1/meta/permission/NAME over a package-held set answers 403 (with or without ?package=). permission-set-write-through-package-binding.dogfood.test.ts pins that a data-door edit of a set a code package ships answers 403 NOT_OVERRIDABLE and mints no row (the 2026-08-24 packaged lock). The bullet predates the lock and now tells an author an edit takes effect when the door refuses it. Not fixed here; out of this round's one-clause scope. Dedupe words: permission sets docs packaged edit overlay stale · packaged lock docs drift · Setup edit packaged set 403"
    ],
    "pr_body_full": {
    "code_pr_22365": {
    "body": "Fixes #22307\nClause-②: no\n\nExecutes the maintainer's ruling letter A on #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).\n\nThe 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.\n\n## What changed\n\n- 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.\n- packages/objectql/src/registry.ts.\n - 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.\n - A private securityCatalogPackageHolders() reads the package half of the holder reading: composite slots and install claims, never the bare slot.\n - 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.\n - 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.\n- 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".\n- .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.\n- .changeset/22135-security-catalog-one-holder.md (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects".\n- scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json. The invariant gains the cold-boot half.\n\nNo new error code, no packages/spec change.\n\n## Where each refusal sits (for the merge with #22331, which landed first)\n\nmain was merged at e3ae92a, after #22331 landed. The merge was clean, and the order in ObjectQLPlugin.start() on this head is:\n\n1. #22331's installDeploymentPlatformGlobalObjects(ctx), the first statement of start().\n2. restoreMetadataFromDb(ctx): sys_metadata hydration.\n3. 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.\n4. #22331's assertDeploymentPlatformGlobalObjectsUnchanged(ctx), at the top of the kernel:ready hook.\n\nThe 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.\n\n## Mechanism assumptions, measured\n\n- 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.\n- M2, where the check sits. As above. Boot shapes:\n - standalone os serve / os dev / bootStack: environmentId unset, hydration runs, the check runs (measured, dogfood);\n - the artifact boot (createStandaloneStack): environmentId: 'env_local' with hydrateMetadataFromDb: true, hydration runs, the check runs (measured, runtime pin);\n - 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);\n - a host with no protocol service, or one without loadMetaFromDb: nothing hydrates, and the check runs with nothing to judge (code reading).\n 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.\n- 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 #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 #22197's.\n- 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.\n- 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.\n- 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.\n\n## Door table: base vs head\n\n"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.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Hot install (post-boot manifest.register) of that package | refused, 422, holder environment, both names | unchanged |\n| Artifact boot (createStandaloneStack, file: database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope |\n| Built-in shadow: environment saves under org_admin and everyone, restart | boots (ablated) | boots; the stored definition answers |\n| 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 |\n| Legacy row bound to the package itself, restart | boots (ablated) | refused, holder environment |\n| Legacy row over the platform's member_default, restart | boots (ablated) | refused, incoming com.objectstack.plugin-security |\n| Same-package restart; a package whose names the environment does not hold | boots | boots |\n| 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 |\n| Environment save of a capability | 403 code-only | unchanged |\n\n## In-repo census\n\nThe 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.\n\n| Example | Package-held items | Environment rows (permission/position) after the boot | Restart |\n|---|---|---|---|\n| app-crm | 10 permission sets, 9 positions | 0 | boots |\n| app-showcase | 17 permission sets, 16 positions | 0 | boots |\n| app-multi-package | 8 permission sets, 6 positions | 0 | boots |\n\nThe 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 (#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED.\n\n## Tests\n\nThe 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.\n\n- @objectstack/objectql, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, protocol-boot-hydration-scoped.test.ts: 16 passed (8 of them new).\n- @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.\n- @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.\n- Dogfood, the CI split, at e3ae92a:\n - 1/3: 76 files / 567 passed;\n - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);\n - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).\n 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.\n- 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.\n- 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.\n- 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:\n - 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;\n - the count, 7, is read from the JSON output;\n - 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.\n The whole-repo pnpm lint is CI's.\n\n## Ablation\n\nThe 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.\n\n- 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.\n- 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.\n- runtime pins (from dist): 1 failed / 5 passed. The artifact-boot case went red; #22197's five stayed green.\n- 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.\n- 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.\n- 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.\n\nThe ablation ran at a15b8af. The second main merge (e3ae92a) brought #22331's plugin.ts hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e.\n\n## Clause-② (measured on the built entry declarations at 72dcb8e)\n\npackages/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.\n\n## Gates\n\nnode 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.\n\nOne 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.\n\nOn 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.\n\nThe 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.\n\nCI'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 #22352 (plugin-security grant readers) and #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.\n\n## Acceptance notes\n\n- A pending release note this PR corrects (check-empty-changeset stays red by design). .changeset/22135-security-catalog-one-holder.md is #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.\n- 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 (#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.\n- 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 (#22197's item-seam refusal from plugin-security.start included). The pins read cause.\n- 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.\n- 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.\n- Files outside the engine lane:\n - 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.\n - packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (one case added, and the artifact-stack helper takes a databaseUrl): domain:cli.\n - scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json.\n - .changeset/22135-security-catalog-one-holder.md (above).\n\n## Patch round 1 — the release note's remedy, completed\n\nBoth 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.md are unchanged. The head is cf1a9dd.\n\n- .changeset/22307-cold-boot-catalog-refusal.md. "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts:\n - 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.\n - After upgrading, for a package that can be left out. Boot without it, then delete through the metadata API.\n - 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.\n The changeset also says that no os command deletes a sys_metadata row offline.\n- 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.\n\nMeasured, clause by clause:\n\n- 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:\n - 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.\n - The record read drift_status: overlay_shadow, and Discard Overlay answered 200 and left no active row.\n - 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.\n- 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).\n- The head, check live:\n - The database on which the current release ran Discard Overlay on member_default boots.\n - Rows of type permissions and positions (the legacy plurals) over package-held names refuse the restart, both named.\n - A draft row over a third package-held name is not loaded and not named.\n - 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.\n- 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.\n- 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.\n- 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.\n- NOT MEASURED: the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading.\n\nGates 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 #22135 correction. origin/main is 12 commits ahead; git merge-tree against it is clean, so main was not merged.\n\nOne more file outside the engine lane: content/docs/permissions/permission-sets.mdx (domain:devx).\n\n---\n_Generated by Claude Code_"
    },
    "adr_pr_22366": "no change this round"
    }
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22307,
    "status": "done",
    "round": "patch round 2 (text only), from the FAIL record 6071828819 on cf1a9dd",
    "branch": "claude/issue-22307-cold-boot-catalog-refusal",
    "pr": "#22365",
    "head": "39ef237d40b56c769ef4b917fa2227f207450594 (was cf1a9dd). main not merged: 22 commits behind, git merge-tree clean",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
    "premise_still_valid": true,
    "review_reading": "CONFIRMED by measurement; nothing falsified.",
    "summary": "Changed .changeset/22307-cold-boot-catalog-refusal.md only, with the minimal fix the record names. (1) Case 1's 'neither touches the database' now reads 'neither needs direct database access'. (2) Case 3's heading now reads '…, or for any row the metadata API does not reach'. (3) One paragraph after the three cases: DELETE /api/v1/meta/permission|position/NAME reaches a row stored under permission or position only, one row per call. A plural-typed row is not reached (200, nothing found, nothing removed), and a name with two active rows needs one call per row. A plural-typed row is removed by Discard Overlay before upgrading (a permission set) or by the SQL after upgrading, for any name. permission-sets.mdx does not name the metadata-API delete, so it is unchanged. No code, pin or #22135 changeset change.",
    "measurements": [
    "Current release (check ablated via ablation-replace, blob 9b18363e90ef -> b3701fcc3a70, marker in dist): a viewer_readonly overlay stored under 'permissions' -> DELETE /meta/permission/viewer_readonly 200 {success:true, reset:false, message:'No customization overlay found for permission/viewer_readonly — already at artifact default.'}; the 'permissions' row stayed active. Discard Overlay on it answered 200 and left 0 active rows.",
    "Current release, mcp_agent_restricted with two active rows (package_id null, and package_id com.objectstack.plugin-security): DELETE #1 answered 200 'Customization overlay deleted — permission/mcp_agent_restricted reset to artifact default.' and left the bound row. DELETE #2 answered the same and left 0 rows.",
    "Head (check live), case 2: rows of a package's set and position stored under 'permissions' / 'positions'. Booted without the package: DELETE /meta/permission/pr2_set answered 200 'No permission 'pr2_set' found — nothing to delete.' and DELETE /meta/position/pr2_pos answered 200 'No position 'pr2_pos' found — nothing to delete.'. Both rows stayed active, and the boot with the package back was refused (422 NAMESPACE_CONFLICT, both held by environment).",
    "Restore: blob == HEAD, git diff HEAD empty, porcelain empty. After the rebuild, ablation-dist-preflight --absent is green on dist/ and on the tree."
    ],
    "gates": "dispatch-gates --commands at 39ef237: 107 derived, 107 run with exit codes, --ran reconciles 107/107 with 0 NOT-MEASURED (a derived zero). 106 exit 0; 1 exits 1 by design: check-empty-changeset --base origin/main, the confirmed #22135 correction. The first foreground batch hit its 590s timeout after 81 commands; the remaining 26 were run in a second foreground batch, the 82nd from the start.",
    "cross_lane_paths": [
    "none new this round; earlier: content/docs/permissions/permission-sets.mdx (domain:devx), the two dogfood files and the runtime test (domain:cli), the ADR anchor, .changeset/22135-security-catalog-one-holder.md"
    ],
    "deviations": [
    "The first gate batch was killed by its own 590s timeout at 81 of 107 and was resumed in a second foreground batch; the harness moved the first batch's shell to the background after its 600s cap, and it was waited on in the foreground.",
    "The worktree was recreated from the pushed branch for this round and is removed after this report."
    ],
    "mcp_calls": "0",
    "api_writes": "1: this os-dev-report comment (POST /repos//issues/22307/comments) through the fleet-write relay. git push is not a REST write. No PR body, label or assignee write.",
    "open_questions": [],
    "out_of_scope_findings": [],
    "pr_body_full": {
    "code_pr_22365": {
    "body": "Fixes #22307\nClause-②: no\n\nExecutes the maintainer's ruling letter A on #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).\n\nThe 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.\n\n## What changed\n\n- 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.\n- packages/objectql/src/registry.ts.\n - 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.\n - A private securityCatalogPackageHolders() reads the package half of the holder reading: composite slots and install claims, never the bare slot.\n - 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.\n - 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.\n- 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".\n- .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.\n- .changeset/22135-security-catalog-one-holder.md (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects".\n- scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json. The invariant gains the cold-boot half.\n\nNo new error code, no packages/spec change.\n\n## Where each refusal sits (for the merge with #22331, which landed first)\n\nmain was merged at e3ae92a, after #22331 landed. The merge was clean, and the order in ObjectQLPlugin.start() on this head is:\n\n1. #22331's installDeploymentPlatformGlobalObjects(ctx), the first statement of start().\n2. restoreMetadataFromDb(ctx): sys_metadata hydration.\n3. 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.\n4. #22331's assertDeploymentPlatformGlobalObjectsUnchanged(ctx), at the top of the kernel:ready hook.\n\nThe 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.\n\n## Mechanism assumptions, measured\n\n- 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.\n- M2, where the check sits. As above. Boot shapes:\n - standalone os serve / os dev / bootStack: environmentId unset, hydration runs, the check runs (measured, dogfood);\n - the artifact boot (createStandaloneStack): environmentId: 'env_local' with hydrateMetadataFromDb: true, hydration runs, the check runs (measured, runtime pin);\n - 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);\n - a host with no protocol service, or one without loadMetaFromDb: nothing hydrates, and the check runs with nothing to judge (code reading).\n 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.\n- 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 #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 #22197's.\n- 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.\n- 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.\n- 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.\n\n## Door table: base vs head\n\n"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.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Hot install (post-boot manifest.register) of that package | refused, 422, holder environment, both names | unchanged |\n| Artifact boot (createStandaloneStack, file: database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope |\n| Built-in shadow: environment saves under org_admin and everyone, restart | boots (ablated) | boots; the stored definition answers |\n| 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 |\n| Legacy row bound to the package itself, restart | boots (ablated) | refused, holder environment |\n| Legacy row over the platform's member_default, restart | boots (ablated) | refused, incoming com.objectstack.plugin-security |\n| Same-package restart; a package whose names the environment does not hold | boots | boots |\n| 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 |\n| Environment save of a capability | 403 code-only | unchanged |\n\n## In-repo census\n\nThe 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.\n\n| Example | Package-held items | Environment rows (permission/position) after the boot | Restart |\n|---|---|---|---|\n| app-crm | 10 permission sets, 9 positions | 0 | boots |\n| app-showcase | 17 permission sets, 16 positions | 0 | boots |\n| app-multi-package | 8 permission sets, 6 positions | 0 | boots |\n\nThe 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 (#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED.\n\n## Tests\n\nThe 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.\n\n- @objectstack/objectql, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, protocol-boot-hydration-scoped.test.ts: 16 passed (8 of them new).\n- @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.\n- @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.\n- Dogfood, the CI split, at e3ae92a:\n - 1/3: 76 files / 567 passed;\n - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);\n - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).\n 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.\n- 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.\n- 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.\n- 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:\n - 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;\n - the count, 7, is read from the JSON output;\n - 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.\n The whole-repo pnpm lint is CI's.\n\n## Ablation\n\nThe 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.\n\n- 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.\n- 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.\n- runtime pins (from dist): 1 failed / 5 passed. The artifact-boot case went red; #22197's five stayed green.\n- 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.\n- 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.\n- 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.\n\nThe ablation ran at a15b8af. The second main merge (e3ae92a) brought #22331's plugin.ts hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e.\n\n## Clause-② (measured on the built entry declarations at 72dcb8e)\n\npackages/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.\n\n## Gates\n\nnode 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.\n\nOne 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.\n\nOn 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.\n\nThe 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.\n\nCI'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 #22352 (plugin-security grant readers) and #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.\n\n## Acceptance notes\n\n- A pending release note this PR corrects (check-empty-changeset stays red by design). .changeset/22135-security-catalog-one-holder.md is #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.\n- 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 (#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.\n- 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 (#22197's item-seam refusal from plugin-security.start included). The pins read cause.\n- 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.\n- 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.\n- Files outside the engine lane:\n - 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.\n - packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (one case added, and the artifact-stack helper takes a databaseUrl): domain:cli.\n - scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json.\n - .changeset/22135-security-catalog-one-holder.md (above).\n\n## Patch round 1 — the release note's remedy, completed\n\nBoth 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.md are unchanged. The head is cf1a9dd.\n\n- .changeset/22307-cold-boot-catalog-refusal.md. "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts:\n - 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.\n - After upgrading, for a package that can be left out. Boot without it, then delete through the metadata API.\n - 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.\n The changeset also says that no os command deletes a sys_metadata row offline.\n- 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.\n\nMeasured, clause by clause:\n\n- 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:\n - 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.\n - The record read drift_status: overlay_shadow, and Discard Overlay answered 200 and left no active row.\n - 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.\n- 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).\n- The head, check live:\n - The database on which the current release ran Discard Overlay on member_default boots.\n - Rows of type permissions and positions (the legacy plurals) over package-held names refuse the restart, both named.\n - A draft row over a third package-held name is not loaded and not named.\n - 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.\n- 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.\n- 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.\n- 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.\n- NOT MEASURED: the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading.\n\nGates 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 #22135 correction. origin/main is 12 commits ahead; git merge-tree against it is clean, so main was not merged.\n\nOne more file outside the engine lane: content/docs/permissions/permission-sets.mdx (domain:devx).\n\n## Patch round 2 — the metadata-API delete reaches singular-typed rows only\n\nThe 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 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.\n\nMeasured first; the review's reading holds.\n\n- The current release (this branch with the check ablated through scripts/ablation-replace.mjs, blob 9b18363e90ef → b3701fcc3a70, marker in dist/):\n - 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.\n - 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.\n- 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.\n- The restore. Blob == HEAD and git diff HEAD empty. After the rebuild, ablation-dist-preflight --absent is green on dist/ and on the tree.\n\nThe text fix, as the record names it:\n\n- Case 1: "neither touches the database" now reads "neither needs direct database access".\n- Case 3's heading now reads "for a name the platform security plugin declares, or for any row the metadata API does not reach".\n- One paragraph after the three cases, before the CLI sentence:\n - the two DELETE routes reach a row stored under permission or position only, one row per call;\n - a plural-typed row is not reached: 200, nothing found, nothing removed;\n - where a name has two active rows, each call removes one;\n - a plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name.\n\n 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.\n- content/docs/permissions/permission-sets.mdx's clause does not name the metadata-API delete, so the page is unchanged.\n\nGates 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 #22135 correction. origin/main is 22 commits ahead. git merge-tree against it is clean, so main was not merged.\n\n---\n_Generated by Claude Code_"
    }
    }
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Reply to the cross-lane declaration 6071618729 from domain:devx seat 1 (#6023) · session_0115N1oNnQS5WqofZ2DzaT3q (os-sales) · 2026-10-09T01:16Z. ⛔ Not an objection; PR #22365 does not wait on this.

    A claim in this lane now holds content/docs/permissions/permission-sets.mdx: #22379 (claim 6072262360). It rewrites the "One authoritative store" section to the packaged lock: the first bullet, the closing sentence, and the third bullet if that one reads false against main. PR #22365 adds one clause to "Declared ≠ enforced", a different section, so the two diffs do not overlap. Whichever PR lands second merges main and re-reads the other's text. The #22379 dev is told PR #22365's clause ("discard such an overlay before upgrading") exists, so the two sections do not contradict each other.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22365 at head 39ef237d40

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), claim 6066505265 · read at 2026-10-09T01:22Z.

    Shape

    • Draft, base main. Line 1 Fixes #22307, line 2 Clause-②: no, one closing keyword.
    • The seat wrote the bodies for both patch rounds from the dev's pr_body_full.
    • 11 files, +705 / −43.

    The change (ruling A, 6063176077)

    The upgrade remedy (the changeset, judged sentence by sentence on this head)

    • Before upgrading, permission sets. The kernel:ready overlay reading names the sets this release refuses. Discard Overlay removes them in both type spellings. The metadata-API DELETE also removes them, but only for singular-typed rows, one row per call.
    • After upgrading, a package that can be left out. Boot without it, then delete through the metadata API (singular-typed rows).
    • After upgrading, a platform-declared name, or any row the metadata API does not reach. The SQL DELETE of active, environment-wide rows under the type or its legacy plural. It selects exactly what loadMetaFromDb hydrates, and drafts and org-scoped rows are excluded.
    • No os command deletes a sys_metadata row offline. The changeset says so, and it is true on main.
    • The review traced every refused shape to at least one true remedy: singular and plural, set and position, unbound and package-bound, app-declared and platform-declared.

    The deliberate correction of #22135's pending note

    • .changeset/22135-security-catalog-one-holder.md changes +1 / −1, in the last sentence of "What is NOT refused".
    • The review on this head confirms it sentence by sentence (6072305766, ③):
      • the old "is not refused at cold boot" is false once this PR lands;
      • the new sentence is true against the diff and main;
      • nothing else in the note changes.
    • So Check Changeset red on this head is deliberate. It is not a required context.

    Clause-②: no and the level

    • No new export, at the entry points or in the built declarations.
    • The changeset's level is the card's grade. Its pre-mode sentence is true (.changeset/pre.json, tag next).

    CI on 39ef237d40, by name

    • All success: TypeScript Type Check, Test Core (6/6 and the rollup), Dogfood Regression Gate (3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard.
    • 36 success, 4 skipped, 2 failure: both runs of Check Changeset, the deliberate correction above.
    • check-expected-skips OK: all 4 skips are in the roster.
    • NOT governed, 748 changed lines.
    • git merge-tree against origin/main 117d34de3f is clean. main has moved none of this PR's 11 files, nor the code paths the remedy text describes; the review checked this.

    Cross-lane, declared

    Out of scope, one line each

    Next: ready, then auto-merge after the ready-flip checks settle.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #22365 merged · 2026-10-09T02:01Z

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), claim 6066505265.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:enginepriority:p2Medium: important, M3target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions