Skip to content

feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both - #22197

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-22135-security-catalog-one-holder
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-22135-security-catalog-one-holder

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22135
Clause-②: no

Executes the maintainer's ruling Q4 = A on #15196 (ruling record 6050490870): positions, permission sets and capabilities each hold one name per deployment. A package registering a name that an installed package, the environment catalog or a built-in already holds is refused, and the error names both holders. ADR-0048 §3.4's coexistence stands for every other metadata type.

The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on main. This PR carries no docs/adr/** file.

What changed

  • packages/objectql/src/security-catalog-namespace.ts (new). The rule in one place: the three types, the built-in names, the holder vocabulary (package / environment / built-in), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's own positions / permissions / capabilities and each nested plugins[] entry's, arrays only. A manifest-stage permissions grant block is never read as permission sets.
  • SchemaRegistry.installPackage — the package door. It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released by uninstallPackage. The claims are what the door reads for names no registered item records, and installPackage itself registers no items. Through ObjectQL.registerApp (every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 landed on main. There the claims agree with the items. With the claims ablated, the registerApp doors still refuse and the direct installPackage door does not (Patch round 3), so the claims stay.
  • SchemaRegistry.registerItem — the item seam. A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now on main), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what every sys_metadata hydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling.
  • The envelope reuses the namespace gate's shape and registered code: code: 'NAMESPACE_CONFLICT' (NAMESPACE_CONFLICT_CODE, already exported), status: 422, httpStatus: 422. The condition is the same one, a name in a deployment-wide namespace already taken, and so is the remedy: rename, or uninstall the other holder. The class (SecurityCatalogNameConflictError) stays unexported, as the registry's other refusal classes are. It is not the namespace gate's class, whose message names a manifest.namespace and offers the OS_METADATA_COLLISION=warn downgrade. Neither is true here, and collisionPolicy: 'warn' does not downgrade this refusal (pinned).
  • No new error code; no packages/spec change.

Where the doors are, measured

The card names three doors. What this PR measured is that all three are reached through ONE: ObjectQL.registerApp → SchemaRegistry.installPackage, which every package registration hits in the kernel's Phase 1, before any start().

  • AppPlugin's security registrar (registerInMemory, the 'app-plugin' registrar) and the artifact door (MetadataPlugin._registerArtifactBodyCollections, the 'artifact-door' registrar) both run in Phase 2.
  • Neither runs for a package the engine has not installed: AppPlugin.init registers every package of its bundle through the manifest service first, a multi-package artifact package by package.
  • So the producer-side fix is the package door, and packages/metadata/src/plugin.ts and packages/runtime/src/app-plugin.ts are unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs.

Door table: base vs head

"Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through @objectstack/verify's bootStack; the artifact rows go through createStandaloneStack.

Door Base Head
Boot, door-less (new AppPlugin(stack)): two stacks sharing a position, a permission set and a capability name boots. The by-name read answers the position from the LAST stack (metadata-service slot) and the set and the capability from the FIRST (registry order) boot refused: 422 NAMESPACE_CONFLICT, 3 conflicts, second stack vs first stack
Boot: an app declaring everyone / manage_users / admin_full_access boots. The by-name read of admin_full_access answers the APP's set refused. Holder built-in for the first two. For admin_full_access the app registers before plugin-security in bootStack, so the platform's registration is the one stopped, naming the app
Artifact boot: two packages of one artifact sharing names; a package declaring everyone boots (runtime pins red under ablation) refused in Phase 1, before the artifact door registers anything
Hot install: post-boot manifest.register over a held name accepted, package record written refused, no record
Hot install: POST /api/v1/marketplace/install-local, inline manifest 200, installed 422 PLUGIN_REGISTER_FAILED, the route's own code, with this refusal's message in error.message; no record
POST /api/v1/packages 400: the strict body refuses positions, the retired capabilities and a flat permissions list unchanged. No catalog collection can arrive here
Environment catalog holds a permission set, then a package declaring it is hot-installed accepted refused, holder environment
Same-package hot reload accepted accepted
Environment save over a package-held permission set (PUT /api/v1/meta/permission/NAME, with or without ?package=) — not covered by the ruling 403 NOT_OVERRIDABLE (the packaged permission-set lock) unchanged
Environment save of a position over a package-held position name (PUT /api/v1/meta/position/NAME) — not covered by the ruling 200, and the saved position then answers the by-name read ahead of the package's unchanged by this PR. Since #22262 landed on main: 403 NOT_OVERRIDABLE (see Acceptance notes)

Named but not measured:

  • The artifact door's HMR reload (MetadataPlugin._reloadAndAnnounce). It re-registers into the metadata service without registerApp, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it.
  • install-local's cloud-sourced install. Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal at error and skips the package. That is code reading only (it needs a control plane).

In-repo collision census (M2)

Instrument. A tsx census over examples/app-crm, examples/app-showcase, examples/app-todo and examples/app-multi-package: each config's top level, its packages[] bodies and its nested plugins[]. Against those it reads the built-ins: BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS, PLATFORM_CAPABILITY_NAMES, and plugin-security's securityDefaultPermissionSets.

Result at 1604e09: 50 declarations — crm 3 positions / 2 sets; showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6 positions / 10 capabilities / 8 sets. Names with more than one holder: 0. Same-holder repeats: 0.

The guard, measured with the gate in place at 8ad6385: crm, showcase and multi-package boot through bootStack, and security-catalog-showcase.dogfood.test.ts (3 postures) and multi-package-artifact.dogfood.test.ts are green. app-todo declares no catalog name. Deployed and marketplace packages are NOT MEASURED.

The P1.2 pin, flipped

S1's shared-name pin is the security catalog read — a name two packages ship describe in packages/objectql/src/protocol-boot-hydration-scoped.test.ts. It added no P1.2 label, which is why a git grep misses it. It now pins the ruled answer at the same seams:

  • a second package registering the name is refused (envelope + both holders), and every reader answers the one holder;
  • an override the holder stored for itself answers for every caller;
  • a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service.

core's security-catalog.test.ts pointed at a non-existent security-catalog-shared-name.test.ts. It now names that describe and the new door pins. security-catalog.ts's module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer. engine-capability-provenance.test.ts pinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal.

Tests (at 8ad6385)

  • @objectstack/objectql: registry-security-catalog-namespace.test.ts (new, 28 cases), protocol-boot-hydration-scoped.test.ts, engine-capability-provenance.test.ts, registry-collision-order.test.ts and registry-artifact-co-ownership.test.ts: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip.
  • @objectstack/runtime: standalone-stack-security-catalog-one-holder.test.ts (new, 4) and standalone-stack-security-registrar.test.ts: 2 files, 6 passed.
  • @objectstack/core security-catalog.test.ts: 14 passed. @objectstack/plugin-security builtin-positions.boot.test.ts + builtin-positions.test.ts (S2's): 18 passed.
  • dogfood: security-catalog-showcase, multi-package-artifact, plus a local door probe that is not committed: 3 files, 44 passed.
  • Downstream sweep before the merges, against the rebuilt objectql dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green.
  • typecheck for objectql, core and runtime (with check:test-typecheck): exit 0. No new test-typecheck debt.

Ablation

Both gates were ablated together through scripts/ablation-replace.mjs, which wraps the run and restores on exit:

  • the package-door call became a globalThis marker write;
  • the item-seam condition gained an always-false marker conjunct.

Both mutations landed on disk: anchor 1 → 0, blob b96099a12688 → 12c018d406ab. objectql was rebuilt and ablation-dist-preflight found both markers in dist/. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted.

  • objectql pins (read from src): 22 failed / 21 passed of 43. Every refusal pin went red, including both flipped P1.2 cases and the flipped capability pin. The controls stayed green: same-package reload, uninstall releases the name, the environment-registration carve-out, non-catalog coexistence, the grant-block reader, and the platform's own built-in registration.
  • runtime boot pins (read from dist): 3 failed / 1 passed. The control stayed green.

Restore: the blob is back to b96099a12688 and git diff HEAD is empty. After a rebuild, ablation-dist-preflight --absent is green for both markers (dist and whole tree).

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 78 commands on 8ad6385; all 78 were run, and --ran reconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first: check-engine-split-ratio refused the shallow clone (deepened with git fetch --shallow-since=2026-07-03), and check:dual-build-cjs-loads answered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run, origin/main moved 6 commits, none of which touches a file in this PR.

Acceptance notes

  • Environment save of a position over a package-held position name. Before fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 it was accepted (200), and the saved position then won the by-name read; permission sets were protected on the same door by the packaged permission-set lock (403). Since fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 landed on main, a package's positions are registered items under the package, and the same save answers 403 NOT_OVERRIDABLE ("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 with PUT /api/v1/meta/position/shared_pos?package=w. Outside this ruling either way; this PR changes nothing there.
  • Cold boot vs the environment-catalog holder. A package registers through ObjectQL.registerApp in Phase 1, and sys_metadata hydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. The CONTROL case in registry-security-catalog-namespace.test.ts pins that the bare slot is not judged. A plugin's own start() is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holder environment). The exception is a built-in name, which the platform declares there itself (Patch round 2). A git grep for literal catalog-type registerItem calls in production source finds one such registration: plugin-security's built-in positions. Carrier: [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 (ruled A: the cold boot refuses too; it lands separately).
  • Order and the platform's permission sets. plugin-security declares the platform's sets on its own manifest (configurable through defaultPermissionSets), so they are package-held. When an app registers before it, as bootStack composes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named.
  • install-local inline import answers its own PLUGIN_REGISTER_FAILED for any register refusal (this one and the namespace gate's alike), so error.code does not carry NAMESPACE_CONFLICT there. The refusal's text is in error.message. Not changed here.
  • The ledger row comment for NAMESPACE_CONFLICT in packages/spec/src/api/error-code-ledger.zod.ts describes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here.
  • Files outside the engine lane: packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (new test; runtime is domain:cli's package); scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json (new ADR anchor); and, from patch round 1, five domain:cli dogfood files: packages/qa/dogfood/test/showcase-security.ts, showcase-d7-default-profile.dogfood.test.ts, authored-row-write-scope.dogfood.test.ts, bulk-widener-probe.dogfood.test.ts and owd-public-read-write-write-floor.dogfood.test.ts (each: the SecurityPlugin construction, with its comment and imports).

Patch round 1 — the dogfood fixtures declared one permission set twice

The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot, plugin-security registered a permission set that the app package already held.

The fixtures handed an app-declared set to SecurityPlugin's defaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:

  • showcase_member_default, through showcaseAppDefaultSecurity() and the D7 test;
  • wscope_*, probe_widener and owdw_*, in three fixtures.

Measured:

  • os serve / objectstack dev never composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A real objectstack dev --fresh boot of examples/app-showcase came up with the gate in place: health 200.
  • The two copies were the same definition: 101 of 101 leaves equal.

Fixed at the producer: each fixture declares the set once, as the app's, and wires the default by name, as the CLI does. A runtime pin holds the refused composition.

All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.

Patch round 2 — the platform's built-in positions met an environment row at boot

The merge queue removed this PR (record 6056019838). plugin-security's registerBuiltinPositions was refused at the item seam: position org_admin, incoming com.objectstack.plugin-security, holder environment. That refusal failed SecurityPlugin.start, and with it the boot. S2b's pins went red: builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), and bootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".

Measured on 19c86b7 (this branch with main merged, before the fix):

  • Boot order. SecurityPlugin depends on the engine. So ObjectQLPlugin.start hydrates sys_metadata into the bare slot BEFORE SecurityPlugin.start declares the built-in positions. Through a real door: with OS_METADATA_WRITABLE=position, PUT /api/v1/meta/position/org_admin answered 200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers 403 NOT_OVERRIDABLE.
  • Who registers. registerBuiltinPositions registers exactly the six static built-in names (BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.
  • What S2b needs. The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it.

Fixed at the producer, the item seam in SchemaRegistry.registerItem: for a built-in name, it no longer asks the environment holder. An environment item under a built-in name exists only because an environment save went over the platform's name, which is outside the ruling. Unchanged:

  • a second PACKAGE registering a built-in name at the item seam is refused, in either order;
  • the package door refuses a package declaring a built-in name (holder built-in);
  • for any other name, an environment item still refuses a package-bound registration at the item seam (holder environment).

No same-definition exception, no collisionPolicy change, and S2b's pins are untouched. registry-security-catalog-namespace.test.ts gained three cases, one per behaviour above (the third is a CONTROL).

Reverse verification. The new condition was mutated through scripts/ablation-replace.mjs to ask the environment holder again (blob c60d9bad21bc → eeca074e4f80). objectql was rebuilt, and ablation-dist-preflight found the marker in dist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red with SecurityCatalogNameConflictError (org_admin held by the environment catalog), and so did the new admit case. Restored: blob == HEAD, and git diff HEAD is empty. After a rebuild, ablation-dist-preflight --absent is green.

All suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI.

Patch round 3 — #22262 landed on main first

#22262 (squash 0b997ea) adds positions to the engine's METADATA_ARRAY_KEYS, so ObjectQL.registerApp now registers a package's positions under the package. This branch merged main at fbcbcf1. The merge touched none of this PR's files, and registry.ts's logic is unchanged.

Comments only. Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true on main: the securityCatalogClaims doc and the installPackage comment in registry.ts, the declared-names reader's note in security-catalog-namespace.ts, and the header of standalone-stack-security-catalog-one-holder.test.ts. No behaviour changed, so no reverse leg was re-run.

Measured with #22262 in the tree (f16fcd0, this branch with main merged; through bootStack; local probes, not committed):

  • A package's own positions arrive both as claims and as registry items under the same package, and stay one holder. The showcase boots with 10 positions under com.example.showcase and 6 under com.objectstack.plugin-security, and 10 claims. Re-registering the showcase is not refused. A second package declaring contributor is refused, holder com.example.showcase.
  • The built-in case is unchanged. With environment saves under org_admin and everyone (OS_METADATA_WRITABLE=position), the cold restart boots, and both names resolve to the environment's saved definitions.
  • PUT /api/v1/meta/position/shared_pos?package=w over a package-held position answers 403 NOT_OVERRIDABLE.
  • The door probes behind the table above answer as before: crm, showcase and multi-package boot; the two-stack boot, the built-in names, the hot install and install-local are refused, each naming both holders; the same-package reload is accepted.

The claims, ablated. This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (scripts/ablation-replace.mjs), objectql was rebuilt, and the marker was proven in dist/. The runtime boot pins stayed green (5 of 5): every registerApp door refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and the collisionPolicy: 'warn' pin. Both reach the package door through a direct installPackage call, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild, ablation-dist-preflight --absent is green.

Tests at f16fcd0, all under os-verify-lock:

  • @objectstack/objectql, whole suite: 383 files / 7572 passed.
  • @objectstack/plugin-security, whole suite: 179 files / 3775 passed, 45 skipped.
  • @objectstack/runtime, whole suite: 340 files / 5505 passed, 19 skipped.
  • Dogfood, the CI split: shard 1/3, 74 files / 557 passed; 2/3, 74 files / 535 passed, 1 skipped; 3/3, 73 passed + 1 skipped files / 661 passed, 8 skipped.
  • typecheck for objectql and runtime (tsc --noEmit + check:test-typecheck): exit 0.
  • Gates: dispatch-gates --commands derived 82 on f16fcd0. All 82 ran and exited 0, and --ran reconciles 82/82, 0 NOT MEASURED.

CI on f16fcd0: 32 checks success, including Dogfood Regression Gate 1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs, Console Pin Gate, Packed-tarball smoke).

Patch round 4 — the changeset level, one comment, the cold-boot carrier

The contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only.

  • The changeset level. .changeset/22135-security-catalog-one-holder.md graded @objectstack/objectql minor, on the premise that Changesets pre mode was not yet on main. It is: .changeset/pre.json (mode pre, tag next) landed with chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084 (a87d8be), an ancestor of this branch's merge base. The changeset now grades major, and its BREAKING sentence says the change ships as major on the v18 pre-release line. The ADR-0087 marker and Clause-②: no stay. pnpm changeset status resolves @objectstack/objectql to 18.0.0-next.0.
  • The cold-boot carrier. One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads from sys_metadata, so a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot, and the registry's existing collision warning fires. A hot install of the same package is refused. [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two [Registry] Collision warnings, one for the permission set and one for the position. The hot install answered 422 NAMESPACE_CONFLICT, both names held by environment, and left no package record. A capability cannot be saved in the environment (403, a code-only type), so the sentence names the two types an environment can hold.
  • One comment. The runtime pin's comment called the position one "no registry slot holds". That stopped being true when fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 put a package's positions into the registry under the package. The comment now says the refusal reports the position, the permission set and the capability the first package holds. A sweep of this PR's added lines finds no other sentence saying positions do not reach the registry.
  • The start()-time half of the round-2 question is settled as A (keep): the ruling names the environment catalog as a holder and does not distinguish phase. No change.

Checks at 9e4ed5d:

  • The changeset gates: check-changeset-no-major --self-test and --base, exit 0 (pre mode, tag next, so the no-major guard stands aside; given this PR's body, the level axis reads Clause-②: no); check-empty-changeset --self-test and --base, exit 0; check-changeset-fixed, exit 0; check:adr-0087-registration, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition); check:changeset-gate-self-tests, exit 0.
  • pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-security-catalog-one-holder.test.ts: 1 file / 5 passed. pnpm --filter @objectstack/runtime typecheck: exit 0.
  • Gates: dispatch-gates --commands for the two touched paths derived 61 commands. All 61 ran and exited 0, and --ran reconciles 61/61, 0 NOT MEASURED. git merge-tree against origin/main 4e4111c is clean, so main was not merged.

Patch round 5 — #22307 was ruled

The maintainer ruled #22307 A while round 4 ran: a cold boot is to refuse too. That work lands in its own PR, not in this one. The changeset's cold-boot sentence keeps its measured clauses and now ends "a cold-boot refusal is ruled and tracked on #22307, which lands separately", which is true on this PR's merge and stays true after #22307 lands. Nothing else changed.

Checks at 99fba80: check-changeset-no-major --base, exit 0 (pre mode, tag next; given this PR's body, the level axis reads Clause-②: no); check-empty-changeset --base, exit 0; check:adr-0087-registration, exit 0. dispatch-gates --commands for the one touched path derived 20 commands; all 20 exited 0, and --ran reconciles 20/20, 0 NOT MEASURED. git merge-tree against origin/main 4e4111c is clean, so main was not merged.


Generated by Claude Code

claude added 7 commits October 8, 2026 04:10
…use a second holder of a position, permission set or capability name

The package door (SchemaRegistry.installPackage, ahead of every mutation)
refuses a package whose declared positions, permission sets or capabilities
name something an installed package, the environment catalog or a built-in
already holds; the item seam (registerItem with a package id) refuses the
same for direct package-bound registrations. ADR-0112 envelope: the
namespace gate's code, NAMESPACE_CONFLICT, status 422; the message names
both holders. Same-package reload stays allowed; no collisionPolicy
downgrade; bare-slot (environment) registrations are not judged.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…red-name catalog pin to the refusal

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…e one-holder refusal

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
…ckage door; changeset; ADR anchor

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/objectql, touching 30 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/security-catalog.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4e4111ca054fe995b5a08a07d273cad18a749ef7.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/security-catalog.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4e4111ca054fe995b5a08a07d273cad18a749ef7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c95e6bd80f9ae5ab88a91c701d1d419667bcc13c — the merge of head 99fba80b6490f9984eb6cead8b08d21b4d044360 into base 4e4111ca054fe995b5a08a07d273cad18a749ef7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c95e6bd80f9ae5ab88a91c701d1d419667bcc13c && git checkout c95e6bd80f9ae5ab88a91c701d1d419667bcc13c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e4111ca054fe995b5a08a07d273cad18a749ef7 99fba80b6490f9984eb6cead8b08d21b4d044360 && git checkout -B drift-repro 4e4111ca054fe995b5a08a07d273cad18a749ef7 && git merge --no-ff 99fba80b6490f9984eb6cead8b08d21b4d044360

node scripts/docs-audit/affected-docs.mjs --json 4e4111ca054fe995b5a08a07d273cad18a749ef7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4e4111ca054fe995b5a08a07d273cad18a749ef7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…s set is not also handed to plugin-security

The dogfood fixtures declared one permission set under two packages: the
app's own `permissions`, and the same set handed to SecurityPlugin's
`defaultPermissionSets`, which plugin-security declares on its own manifest.
Under one-name-one-holder that boot is refused (NAMESPACE_CONFLICT, both
holders named). `os serve` never composes it: it hands the plugin the
default's NAME only (`appSecurityPluginOptions`), and the app registers the
set. The fixtures now wire it the same way. A runtime pin holds the refused
composition.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 089b1c84b680b8a500f26c095edada45c49c13d1
Local-runs: none

Inputs, and nothing else: card #22135 (body and all six comments: triage grade 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929 and 6054287813); PR #22197 (body, the 15-file list, the net diff against its merge base with main); the check-runs on the head. Read-only: fetched refs and gh api reads; nothing built, run or re-run.

① Derived judgments

Check-runs on the head: 42, all completed; 37 success, 5 skipped (Build Docs, Console Pin Gate, the opt-in Packed-tarball smoke, and a second run's Auto Label and Check PR Size rows); none failure, none in_progress. Green among them: Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, Build Core, Test Core 1-6/6, Dogfood Regression Gate 1-3/3, Dogfood Verify CLI, Temporal Conformance, every Type Check lane, Spec property liveness. Governed surfaces: none in the file list. packages/spec: untouched.

Accept-set and public-surface changes the diff implies, each judged:

  1. Package door, SchemaRegistry.installPackage — RIGHT. A package whose declared positions / permissions (arrays only) / capabilities, at the top level and one level of plugins[], name something another holder holds is refused ahead of every mutation (placed beside the namespace and co-ownership gates, before the record write), every conflict in one refusal, the same package excluded. Holders read: the static built-ins (BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS for positions, PLATFORM_CAPABILITY_NAMES for capabilities, none for permission sets), the bare slot (environment when unstamped, the stamped package otherwise), every package:name composite slot (none of the three types carries an ITEM_KEY_DISCRIMINATORS suffix, so the key scan is exact), and the install-time claims. Verified on main: ObjectQL.registerApp calls installPackage as its step 1 before any item registration, and AppPlugin.init (Phase 1) hands every package to the manifest service, so boot, artifact boot (package by package), hot install and a post-start manifest.register all cross this door. This is the ruling's refusal: installed package, environment catalog, built-in; both holders named.
  2. Item seam, registerItem with a package id — RIGHT. A package-bound registration of one of the three types over a registered holder is refused before applyProtection stamps or collection.set stores. Built-ins are not asked here: the platform (S2, on main) registers its own built-in positions at this seam under its package id. Every non-test caller on main that passes a package id (the engine's collection loop, metadata-facade.register with a stamped _packageId, the objectql plugin's hydration re-registration, plugin-security's builtin-positions.ts) is behind the package door or a same-package re-registration, so the built-in gap at this seam has no package-door bypass.
  3. Claims, securityCatalogClaims — RIGHT and necessary. METADATA_ARRAY_KEYS in engine.ts has no positions entry; positions reach only the two in-memory registrars in start(), so without the claim a second package's position could not be refused. Recorded after a successful install, released by uninstallPackage (pinned), additive on a same-package re-install.
  4. Bare-slot registrations never judged — RIGHT per the card. An environment save over a package-held name is "reported only" (card, triage); the CONTROL pin holds it.
  5. collisionPolicy: 'warn' does not downgrade — RIGHT per the card (pinned).
  6. Envelope — RIGHT per triage. code: NAMESPACE_CONFLICT (registered; NAMESPACE_CONFLICT_CODE already exported), status and httpStatus 422, the message naming the incoming package and the holder of every conflicting name. The class stays unexported, as the index's own rule for the registry's refusal classes requires. No new ledger code.
  7. Public surface — no widening, RIGHT. packages/objectql/src/index.ts on main re-exports a named list from ./registry.js (no export *); SecurityCatalogNameConflict, SecurityCatalogNameConflictError and the new module's exports are not re-exported, and the SchemaRegistry additions are private, so nothing new is reachable from the entry declarations. Clause-②: no is a truthful value (see ②).
  8. Test flips — RIGHT, as the card ordered. S1's shared-name describe in protocol-boot-hydration-scoped.test.ts now pins the refusal and the one-holder read at the same seams; the engine-capability-provenance.test.ts coexistence pin flipped; core's pointer to a non-existent file corrected; security-catalog.ts module doc rewritten to the ruled answer (no code). Door pins: 28 cases through the real manifest service; 5 runtime boot cases over createStandaloneStack and the door-less AppPlugin.
  9. Dogfood fixtures (@objectstack/dogfood, private: true) — RIGHT, a producer-side fix. Five fixtures handed an app-declared set to SecurityPlugin.defaultPermissionSets, which plugin-security declares on its OWN manifest (security-plugin.ts: manifest.register({ ...header, permissions: this.bootstrapPermissionSets })): one set, two packages, refused by item 1. The fix wires the default by NAME (fallbackPermissionSet), which is all os serve's appSecurityPluginOptions returns. Not a test tolerance. @objectstack/verify's rlsProbeSecurity hands a verifier-authored set the app never declares: one holder, unaffected (Dogfood Verify CLI green).
  10. File surface against the claim — RIGHT, with the measurement. metadata/src/plugin.ts and runtime/src/app-plugin.ts unchanged: both registrars write in start(), behind the Phase 1 door (verified on main), and the runtime pins boot both compositions.

Kept as the ruling keeps them: same-package reload; every other metadata type (page/home coexistence pinned); a manifest-stage permissions grant block.

② Semver level

  • .changeset/22135-security-catalog-one-holder.md: @objectstack/objectql: minor, summary feat(objectql)!:, a **BREAKING** banner, the adr-0087: not-required (no-migration-prescription) marker comment, Clause-②: no. Matches what the diff publishes: the one released package whose behaviour changes is objectql (core: a comment; runtime: a test; dogfood: private).
  • Level — RIGHT. .changeset/pre.json is absent on main and on the head, so the launch-window guard (check-changeset-no-major.mjs) refuses major and breaking-ness is carried by the banner plus the ADR-0087 disposition (pr-automation.yml, "WHICH LEVEL"). Triage's "major on the v18 line" is what that guard forbids outside pre mode; the claim anticipated it. Check Changeset and Lint & Repo Gates are green on the head; no-migration-prescription is a listed category and is apt (no key, export or field removed; a refused name cannot be converted).
  • Clause-②: no — RIGHT as a value: no widening of the accept set or the public surface (① item 7). The direction arm is absent, and the closed pair exists for exactly this case (no (narrowing): not a widening, but breaking). An absent arm declares no direction; the ADR-0087 gate reads breaking-ness from the banner and the ! regardless, so the declaration stands. The dev's flag is answered in ③ item 2.

③ Boundary flags

From os-dev-report 6053492929, patch-round report 6054287813 and the PR's acceptance notes:

  1. The refusal lives in objectql alone — answered, ① item 10.
  2. Clause-②: no versus no (narrowing) — the seat's line: amending claim 6051656254, the PR body and the changeset to Clause-②: no (narrowing) is the well-formed spelling (the arm is the carrier AGENTS.md names for breaking-ness). Recommended; this verdict does not turn on it (②).
  3. minor with the BREAKING banner, pre mode off — answered, ②.
  4. origin/main merged twice into the code branch; not re-merged in patch round 1 — the net diff is taken from the branch's last merge with main; CI ran on the merge ref; the six later main commits the dev names touch no PR file. Fine.
  5. Shallow clone deepened; eight unrelated packages built locally — local; nothing of it is in the diff.
  6. A temporary dogfood probe, never committed — confirmed: 15 files, no probe.
  7. The ADR-0048 Addenda index line untouched — PR docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and capabilities hold one name per deployment #22198's flag, answered on its own record.
  8. open_questions: an environment save of a POSITION over a package-held name; cold boot against the environment holder — outside the ruling (card: "reported only"); claim amendment 6053548364 says both are filed as [finding] an environment save of a position over a package-declared position name answers 200 and wins the by-name read; a permission set's is refused 403 NOT_OVERRIDABLE #22203 for triage, and the card lands without them. One derived consequence to carry into [finding] an environment save of a position over a package-declared position name answers 200 and wins the by-name read; a permission set's is refused 403 NOT_OVERRIDABLE #22203: after an unbound environment save of a position over a package-held name, a HOT reload of that same package is refused with holder environment (the ruling's text supports the refusal; the trap is the unruled save door). Cold boot is unaffected, Phase 1 running before hydration.
  9. out_of_scope_findings — (a) install-local's inline import answers its own PLUGIN_REGISTER_FAILED with this refusal's text in message: a 4xx envelope naming both holders; noted, acceptable. (b) install-local's cloud-sourced install tolerates a register failure (warn, persist, success; the next boot skips the package) — pre-existing for the namespace gate too, NOT MEASURED; escalated to the seat to file or rule, since the ruled "refused" is not what that caller is told. (c) The artifact door's HMR reload re-registers without registerApp, so a dev-loop edit to a held name is served until the restart refuses it — escalated to the seat as an enforcement gap to file or rule. (d) The NAMESPACE_CONFLICT ledger row comment names one condition — spec lane, a one-line follow-up; noted. (e) manifest.register of a stack-shaped object with no top-level id lands in the bare slot — internal callers only; noted.
  10. Patch round 1, the defaultPermissionSets double declaration — answered, ① item 9. Escalated to the seat's ACCEPT prose check (changeset prose is the seat's face, not this record's): the changeset's migration paragraph prescribes rename or uninstall, and does not name the one producer composition the gate caught in-repo (an app-declared set also handed to SecurityPlugin({ defaultPermissionSets })) nor its one-line fix (declare the set once, in the app; hand the plugin the default's NAME through fallbackPermissionSet). One sentence; an embedder on the old fixture pattern reads CHANGELOG.md, not this PR.
  11. Files outside the engine lane — the runtime test, the ADR anchor and the core module doc are declared by claim amendment 6053548364; the five domain:cli dogfood files from patch round 1 are declared in the PR body only. The seat owes the card one claim-amendment line naming them.

Implemented-by: claude/issue-22135-security-catalog-one-holder
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 07:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 37746963251 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (6/6) — 失败步骤: Run this shard's tests

    @objectstack/plugin-security:test:  FAIL  src/builtin-positions.boot.test.ts > [ADR-0131 D2] the built-in positions as declared metadata — single, a stored definition under a built-in name
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/plugin-security:test:  FAIL  src/builtin-positions.boot.test.ts > [ADR-0131 D2] the built-in positions as declared metadata — single + organization, a stored definition under a built-in n
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/plugin-security:test:  FAIL  src/builtin-positions.boot.test.ts > [ADR-0131 D2] the built-in positions as declared metadata — walled, a stored definition under a built-in name
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/plugin-security:test:  FAIL  src/bootstrap-declared-positions.test.ts > bootstrapDeclaredPositions — one catalog read, both sources (ADR-0131 C2 S2b) > a stored definition shadowing a bui
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/builtin-positions.boot.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • src/bootstrap-declared-positions.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 4 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Queue kick-out: new signature, not re-queued · 2026-10-08T08:35Z

domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), holding #22135.

  • Exit: removed_from_merge_queue at 2026-10-08T08:28Z with no merged. The merge_group build gh-readonly-queue/main/pr-22197-3ae59661dc ran CI run 37746963251 → failure; every other workflow success.
  • Signature (Test Core (6/6), job 113210751656; @objectstack/plugin-security#test, 2 files / 4 cases): SecurityCatalogNameConflictError at SchemaRegistry.registerItem (objectql/src/registry.ts:3761) ← registerBuiltinPositions (plugin-security/src/builtin-positions.ts:135) ← SecurityPlugin.start. Envelope NAMESPACE_CONFLICT / 422, conflicts: [{ catalogType: 'position', name: 'org_admin', incomingPackageId: 'com.objectstack.plugin-security', existingHolder: { kind: 'environment' } }]. Cases: builtin-positions.boot.test.ts › "a stored definition under a built-in name" (single, single + organization, walled) and bootstrap-declared-positions.test.ts › "a stored definition shadowing a built-in name is neither seeded nor restamped".
  • Initial read: a semantic collision with a sibling that landed after this branch's last main merge: 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's stage S2b (PR fix(plugin-security): the declared-positions seeder reads through the security catalog read (ADR-0131 C2 stage S2b) #22210, 17e4425301, 07:02Z), which pins that a stored environment definition under a built-in position name shadows the built-in. This PR's item seam reads plugin-security's own registration of a built-in position as a package registering over an environment-held name, so the boot refuses. Not the known-flaky ledger, not a re-run case: a new signature, so ⛔ not re-queued. The PR goes back to its dev (patch round 2), merges main, and resolves it at the producer.

claude added 2 commits October 8, 2026 12:07
…ngine's collection loop

With positions registered by ObjectQL.registerApp's collection loop (now on
main), three comments this change added said the loop never registers them.
The claims doc and the installPackage comment now say what the claims are for
on either tree: installPackage itself registers no items, so the claim is how
the package door remembers every declared name. The declared-names reader's
note and the runtime pin's header no longer say positions reach no engine
slot. Comments only; no behaviour change.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f16fcd0c7c1c808a1397887fc4334ce804326aa8
Local-runs: none

Read at 2026-10-08T14:09Z. Inputs, and nothing else: card #22135 (body and all eleven comments: triage 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929, 6054287813, 6058641140 and 6061349995, the ACCEPT 6054723926, the kick-out note 6056029522, the landing record 6056178904); the ruling record 6050490870 on #15196 and Q4's option A as the card quotes it; PR #22197 (body, the 15-file list, the net diff from merge base fbcbcf124f to the head); the check-runs on the head. The earlier record on this PR (6054682234, PASS) judged head 089b1c84b6; this record re-judges on the merged tree, where two of its premises no longer hold (② and ③ item 4). Read-only: fetched refs and gh api reads; nothing built, run or re-run. One read outside the list, to check a claim the card's comments make: the state of #22203, which they name as the cold-boot carrier.

① Derived judgments

Check-runs on the head: 42, all completed; 37 success, 5 skipped (Build Docs, Console Pin Gate, the opt-in Packed-tarball smoke, and the second PR-Automation run's Auto Label and Check PR Size), none failure, none in_progress at the final read. The seven required contexts by name, each success: TypeScript Type Check, Test Core (and 1–6/6), Dogfood Regression Gate (and 1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard. Check Changeset is success on both PR-Automation runs. Governed surfaces: none in the file list. packages/spec: untouched. Merge base: fbcbcf124f, which carries #22262 (0b997ea447 is an ancestor) and #22084 (a87d8be299, Changesets pre mode next, is an ancestor too — see ②).

Accept-set and public-surface changes the diff implies, each judged:

  1. Envelope — RIGHT. SecurityCatalogNameConflictError carries code = NAMESPACE_CONFLICT_CODE (the namespace gate's registered code), status and httpStatus 422, conflicts[] with { catalogType, name, incomingPackageId, existingHolder }, and a message naming the incoming package and every holder. No new ledger code; no packages/spec file. The class is module-exported from registry.ts and not re-exported (item 8).
  2. Package door, installPackage — RIGHT. refuseSecurityCatalogNameConflicts(manifest, selfId) runs beside the namespace and co-ownership gates, before collection.set and before any claim is recorded; every conflict is collected into one refusal; a manifest with no identity is not judged. Holders read with builtIns: true, environment: true: the static built-ins, the bare slot (stamped → that package; unstamped → environment), every composite packageId:name slot (none of the three types carries an ITEM_KEY_DISCRIMINATORS suffix, so the suffix scan is exact), and the claims. Verified on the merged tree: ObjectQL.registerApp calls installPackage (engine.ts :6878) before registerMetadataCollections (:6967) and before registerPlugin, so boot (AppPlugin.init → manifest.register), artifact boot package by package, hot install and a post-start manifest.register all cross this door ahead of any item registration.
  3. Item seam, registerItem with a package id — RIGHT, including the round-2 carve-out. if (packageId && isSecurityCatalogType(type)) refuses before applyProtection stamps and before collection.set; a registration with no package (every sys_metadata hydration — metadata-protocol registry.registerItem(type, …, 'name') passes none — and the metadata write-through) is never judged. The seam asks { builtIns: false, environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(name) }. Against the ruling's letter: the platform's registerBuiltinPositions registers exactly BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS under com.objectstack.plugin-security (the one production catalog-type registerItem call); that is the built-in holder declaring its own names, not "a package registering a name a built-in already holds", so admitting it is inside the letter. An environment item under a built-in name exists only through the save direction the card leaves out, and it keeps answering first from the bare slot (S2b, pinned). What the carve-out admits that round 1 refused is exactly one shape: a package-bound item-seam registration of a BUILT-IN name while an environment row holds it. No public door reaches that shape with a non-platform package (item 2 refuses a package declaring a built-in name, holder built-in); a plugin registering a built-in name directly under its own id is refused at once when the platform registered first (holder package com.objectstack.plugin-security, pinned in either order), or, if it registered first, stops the platform's own declaration — the boot still fails naming both. Confirmed, by code and by the pins: a second PACKAGE registering a built-in name is refused at both seams; a non-built-in name the environment holds still refuses a package-bound registration at the item seam (the CONTROL case, holder environment). No hole.
  4. Claims (securityCatalogClaims) — RIGHT, and still needed on the merged tree. Recorded after a successful install, additive on re-install, released by uninstallPackage. With 'positions' now in METADATA_ARRAY_KEYS, every registerApp door also holds a package's positions as items (the dev's ablation: the runtime boot pins stay green without the claims). The claims still decide the direct installPackage door, and that door exists in production, not only in the two objectql pins the dev named: @objectstack/service-package's sys_packages rehydrate calls registry.installPackage(rec.manifest) with no registerApp, and metadata-protocol's installPackage request (the POST /api/v1/packages route and the runtime fallback) does the same. For a package arriving that way the claims are the only record of its declared names. Code reading, NOT MEASURED.
  5. Same-package reload — RIGHT. securityCatalogHoldersOtherThan excludes exceptPackageId from both the slots and the claims, so a re-registration is one holder (pinned per type, and on the showcase's 10 positions with fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 in the tree).
  6. collisionPolicy: 'warn' does not downgrade — RIGHT per the card (pinned through a direct installPackage; the option's doc now says so).
  7. Flipped pins — RIGHT, as the card ordered. S1's security catalog read — a name two packages ship describe in protocol-boot-hydration-scoped.test.ts now pins the refusal (envelope + both holders) and the one-holder read at the same seams, the holder's own stored override, and the position case through the package door; engine-capability-provenance.test.ts flips coexistence to the refusal; core's pointer names the real describe; security-catalog.ts's module doc states the ruled answer.
  8. Public surface — no widening, RIGHT. packages/objectql/src/index.ts re-exports a named list (no export *); the package's exports map is . and ./core only; SecurityCatalogNameConflict, SecurityCatalogNameConflictError and security-catalog-namespace.ts are not reachable from the entry declarations; the SchemaRegistry additions are private; SchemaRegistryOptions.collisionPolicy changes doc only. The thrown error's shape is observable behaviour, not a declared type. Clause-②: no is the right value (②).
  9. Dogfood fixtures — RIGHT, producer-side (unchanged since 6054682234; private package).
  10. Doors outside the diff — RIGHT, with the measurement: metadata/src/plugin.ts and runtime/src/app-plugin.ts unchanged; both registrars write in start(), behind the Phase-1 door; the runtime pins boot both real compositions.

Kept as the ruling keeps them: every other metadata type's §3.4 coexistence (pinned on page/home); a manifest-stage permissions grant block (pinned).

Comment truth on the merged tree. The four comments round 3 rewrote read true: the securityCatalogClaims doc ("positions only where that loop carries them" — the loop now always carries them), the installPackage inline comment, the declaredSecurityCatalogNames doc, and the runtime pin's header. One line round 3 missed reads false: packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts:124–125, "the position no registry slot holds is reported beside the two the engine registers" — on this tree the first package's position IS a registry item under com.test.first (and a claim), so the refusal lists three registered holders. The PR body's "each now reads true on main" is false for that line.

② Semver level

③ Boundary flags

From os-dev-reports 6058641140 (round 2) and 6061349995 (round 3), the earlier record, and the PR's acceptance notes:

  1. The open question (carried from round 2: options A keep / B downgrade to an S9 report / C document only). Two halves, judged apart.
  2. Claims kept after fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 — answered, ① item 4, with the production door the dev did not name.
  3. The S2b carve-out — answered, ① item 3.
  4. Round 3's comment sweep — one false line remains, ① "Comment truth". One line; the seat corrects it in the same push as ②.
  5. Clause-②: no versus no (narrowing) — the bare no stands (②).
  6. Files outside the engine lane (runtime test, ADR anchor, five dogfood files) — declared by claim amendment 6053548364 and the ACCEPT 6054723926; fine.
  7. Out-of-scope findings from round 0 — install-local's own PLUGIN_REGISTER_FAILED code, the cloud-sourced install's tolerance, the artifact door's HMR reload, the NAMESPACE_CONFLICT ledger-row comment, manifest.register with no id: each dispositioned by the ACCEPT; nothing in this head changes them.
  8. Two observations, neither this PR's to fix: (a) on main, packages/plugins/plugin-security/src/builtin-positions.ts:35–40 still says the engine's collection loop "has no positions entry" and registers "no position at all" — fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262 made that false; one line for that card's seat. (b) An orphaned stored override stamped with an uninstalled package's id (the registry's own uninstall warning names this shape) makes a later package's registration of that name refuse with holder package X where X is no longer installed; the refusal itself is the ruling's (a tenant-authored row holds the name), only the holder label is imprecise. Noted.
  9. Local runs — none; the check-runs answered every derived gate family.

Implemented-by: claude/issue-22135-security-catalog-one-holder
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: FAIL

What turns this to PASS, in one push: the changeset re-graded as the card graded it (major) with the false pre-mode parenthetical removed, the one stale test comment corrected, and — a seat act, not a diff — a live carrier named for the cold-boot half. ① is right throughout; nothing in the code changes.

…e cold-boot boundary is open on #22307

The changeset graded objectql minor on the premise that Changesets pre mode
was not yet on main. It is: .changeset/pre.json is in next pre mode, so the
accept-set narrowing ships as a major on the v18 pre-release line. One
sentence now states the cold-boot boundary: a package newly added over a
permission-set or position name the environment catalog already holds is
not refused at cold boot (the existing collision warning fires), a hot
install of it is, and whether a cold boot should refuse too is open on
#22307.

The runtime pin's comment no longer says no registry slot holds the
position: the first package holds all three conflicting names. Comment
only.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
… which lands separately

#22307 was ruled while the previous text was in review, so the changeset no
longer calls the cold-boot question open. The measured clauses before it
are unchanged.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 99fba80b6490f9984eb6cead8b08d21b4d044360
Local-runs: none

Read at 2026-10-08T16:32Z. Inputs, and nothing else: card #22135 (body and all thirteen comments: triage 6051220848 and its Clause-② amendment 6052924911, claim 6051656254 and its amendment 6053548364, os-dev-reports 6053492929, 6054287813, 6058641140, 6061349995, 6063717789 and 6063894392, the ACCEPT 6054723926, the kick-out note 6056029522, the landing record 6056178904); the ruling records 6050490870 on #15196 (Q4 = A, as the card quotes it) and 6063176077 on #22307 (letter A, the cold-boot remainder), with #22307's body and its triage 6063800858; PR #22197 (body, the 15-file list, all five comments — the previous records 6054682234 PASS on 089b1c84b6 and 6061710772 FAIL on f16fcd0c7c, the queue triage 6055901077, the kick-out 6056019838, the docs-drift note 6053440684 — and the net diff from merge base fbcbcf124f to the head); the check-runs on the head. Read-only: fetched refs and gh api reads; nothing built, run or re-run.

① Derived judgments

What moved since the FAIL. git diff f16fcd0c7c..99fba80b64 touches two files and nothing else: .changeset/22135-security-catalog-one-holder.md (commits 9e4ed5d53b and 99fba80b64: the level, the BREAKING sentence, one appended sentence) and packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (two comment lines, 124–125). Every other blob of the 15 is byte-identical to f16fcd0c7c, so 6061710772's ten ① judgments on the code carry over. Re-read on this head, each holds, and nothing in the code changed:

  1. Envelope — RIGHT. SecurityCatalogNameConflictError: code = NAMESPACE_CONFLICT_CODE, status and httpStatus 422, conflicts[] with { catalogType, name, incomingPackageId, existingHolder }, the message naming the incoming package and every holder. No new ledger code; no packages/spec file.
  2. Package door, installPackage — RIGHT. refuseSecurityCatalogNameConflicts(manifest, selfId) runs beside the namespace and co-ownership gates, before collection.set and before the claim is recorded; every conflict in one refusal; a manifest with no identity is not judged; holders read with builtIns: true, environment: true.
  3. Item seam, registerItem with a package id — RIGHT, the round-2 carve-out included (builtIns: false, environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName)); a registration with no package — every sys_metadata hydration, the write-through — is never judged (the CONTROL pin).
  4. Claims — RIGHT, still needed for the direct installPackage door (service-package's sys_packages rehydrate, metadata-protocol's install request), where no item is registered.
  5. Same-package reload — RIGHT. 6. collisionPolicy: 'warn' does not downgrade — RIGHT (pinned). 7. Flipped pins — RIGHT, as the card ordered (the S1 describe, the capability coexistence pin, core's pointer, the module doc). 8. Public surface — no widening, RIGHT (index.ts re-exports a named list; the new class and module are not on it; the SchemaRegistry additions are private; Clause-②: no is the right value). 9. Dogfood fixtures — RIGHT, producer-side. 10. Doors outside the diff — RIGHT, with the measurement (both registrars write in start(), behind the Phase-1 door).

The three FAIL items, on this head:

  • Level. '@objectstack/objectql': major; the BREAKING sentence reads "shipped as major on the v18 pre-release line (.changeset/pre.json is in next pre mode on main)" — true: pre.json is {"mode":"pre","tag":"next"} on origin/main (4e4111ca05) and on the merge base fbcbcf124f; the false "not yet in on main" premise and the launch-window wording are gone. The ADR-0087 marker and Clause-②: no are kept. RIGHT (②).
  • The runtime pin's comment at 124–125 now reads "the position, the permission set and the capability the first package holds" — true on the merged tree, where the first package's position is a registry item under com.test.first ('positions' is in METADATA_ARRAY_KEYS at the merge base, engine.ts :3072ff) and a claim. A sweep of the diff's added lines finds no other line saying positions do not reach the registry; the remaining hits (the P1.2 case title, "positions only where that loop carries them", "Positions also reach the metadata service") read true. RIGHT.
  • The cold-boot carrier. [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 exists and is open: Ruled: 6063176077 · letter A, graded priority:p2 · target:v18 · domain:engine · pm:queue (triage 6063800858), serial after this PR; the ruling itself says this PR does not wait for it. It outlives this PR. RIGHT.

Comment truth on this head: the four round-3 rewrites and the round-4 one read true, so the PR body's Patch round 3 sentence "each now reads true on main" — false on f16fcd0c7c for one line — is true here.

main drift since the merge base (156 files on origin/main, none of this PR's 15; git merge-tree clean): engine.ts moved +9/−3 with nothing at installPackage, registerApp or METADATA_ARRAY_KEYS; security-plugin.ts moved under #22275 and #22297 with nothing at manifest.register, bootstrapPermissionSets / defaultPermissionSets or registerBuiltinPositions. The premises ① rests on hold on origin/main.

The changeset's cold-boot sentence, clause by clause — on this PR's merge, and after #22307 lands:

Check-runs on the head: 42, all completed; 37 success, 5 skipped (Build Docs, Console Pin Gate, the opt-in Packed-tarball smoke, and the second PR-Automation run's Auto Label and Check PR Size), none failure, none in_progress at the final read — the first read, minutes after the seat's body write, had nine rows still in_progress (the Test Core and Dogfood shards, Lint & Repo Gates, Temporal Conformance, Type Check · workspace), and the record waited for them. The seven required contexts by name, each success: TypeScript Type Check, Test Core (and 1–6/6), Dogfood Regression Gate (and 1–3/3), Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard. The body-scoped checks re-ran after the body write and their latest rows are success: Check Changeset, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card.

Governed surfaces: none in the file list. packages/spec: untouched. Fork: head repo = base repo. Size: 1,183 changed lines, under the 5,000 line.

② Semver level

  • .changeset/22135-security-catalog-one-holder.md: '@objectstack/objectql': major, feat(objectql)!: summary, the BREAKING banner, the ADR-0087 marker not-required (no-migration-prescription) (a listed category, apt: no key, export or field moves, and a refused name cannot be converted), Clause-②: no. The one released package whose behaviour changes is objectql; core carries a module-doc comment, runtime a test, dogfood is private — no changeset owed for those.
  • Level — RIGHT on this head. The card's grade (claim 6051656254: "minor … unless chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084 has landed by the PR"; triage 6052924911: "a major changeset on the v18 line") and the tree agree: a87d8be299 (chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084, pre mode next) is an ancestor of the merge base, so major is what the card graded, and the changeset's stated premise is now true. In pre mode check-changeset-no-major stands aside (the dev's run; Check Changeset on the head), the ADR-0087 gate finds its disposition, and the fixed group already carries majors, so major moves no version. The previous FAIL's ② is closed.
  • Clause-②: no — RIGHT as a value, unchanged: the diff narrows (a second holder refused) and widens nothing (① item 8). The PR body's line 2 and the changeset carry the same spelling; the (narrowing) arm stays optional.
  • One prose note, not reworked (carried from 6054682234 ③ item 10): the changeset's bold one-line fix names the two-package case; the environment-holder remedy ("rename or delete the environment's item first") is in the thrown message, not in the bold fix. For the release notes.

③ Boundary flags

From os-dev-reports 6063717789 (round 4) and 6063894392 (round 5), the previous records, and the PR's Acceptance notes:

  1. The previous FAIL's three items — each answered, ①.
  2. The cold-boot half, escalated by 6061710772 — carried and ruled: [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, letter A, with its own changeset, ADR-0048 N.3 amendment (Tier H) and landing. The start()-time half stands as A (the ruling's letter; the CONTROL case pins it). Nothing further is owed on this PR.
  3. open_questions — rounds 4 and 5: none. Rounds 0–3's three questions are each closed: the position env-save (answered by fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262, 403 NOT_OVERRIDABLE), the cold-boot half ([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), the start()-time half (A).
  4. out_of_scope_findings — round 4: packages/core/src/security/security-catalog.test.ts:78, a pre-existing case title whose parenthetical example ("stack-declared positions") stopped describing a real boot after fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262; the assertion is about the stand-in reader and holds; not this PR's line and not a filing class — noted. Round 5: none. Rounds 0–3's findings were each dispositioned by the ACCEPT 6054723926 and by 6061710772 ③ item 7; nothing on this head changes them.
  5. Deviations — round 4: worktree recreated without force; the cold-boot sentence measured by a probe never committed (15 files, no probe); the no-major level axis run against a locally built payload (CI reads the real one: Check Changeset is success on both PR-Automation runs); two locked runs re-taken. Round 5: worktree recreated; the live body lacked round 4's edits at report time — the seat has since written the round-5 body. Read back: line 1 Fixes #22135, line 2 Clause-②: no, exactly one closing keyword in the whole body, no angle bracket, the Acceptance-notes cold-boot bullet ends "Carrier: [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 (ruled A: the cold boot refuses too; it lands separately)", Patch round 4 and Patch round 5 sections present. Fine.
  6. The PR body's Patch round 4 / 5 sections against this head — each statement checked: the level and the BREAKING sentence (true); pre.json with chore(release): enter Changesets pre mode (next) with one major marker, so v18 opens at 18.0.0-next.0 #22084 an ancestor of the merge base (true); the one comment corrected and the sweep finding no other (true); the cold-boot clauses (consistent with the code read in ①); "[decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 has since been ruled A" (true); round 5's "Nothing else changed" (true: 1 file, +1/−1); git merge-tree clean against 4e4111ca05 (true on my read). Nothing false found. The Door table, Tests and Ablation sections are dated to 8ad6385441 / f16fcd0c7c and read as history, which they are.
  7. Files outside the engine lane — declared (claim amendment 6053548364, the ACCEPT); unchanged this round.
  8. Two observations, neither this PR's to fix (6061710772 ③ item 8: builtin-positions.ts:35–40's stale comment; the orphaned-override holder label) — unchanged, noted.
  9. Local runs — none: the check-runs on the head answer every derived gate family, and the two commits since f16fcd0c7c touch no code.

Implemented-by: claude/issue-22135-security-catalog-one-holder
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit fe98cc6 Oct 8, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22135-security-catalog-one-holder branch October 8, 2026 16:59
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…pabilities hold one name per deployment (objectstack-ai#22198)

Part of objectstack-ai#22135

Records the maintainer's ruling Q4 = A on objectstack-ai#15196 (ruling record
6050490870, 「15196 Q3 A Q4 A」) in ADR-0048. The security catalog
(positions, permission sets, capabilities) is taken out of §3.4's
cross-package coexistence: each of the three types holds one name per
deployment.

This is the Tier H half of objectstack-ai#22135, split from the code PR (objectstack-ai#22197) so
the code can land on its own record. It closes nothing: the card is
closed by the code PR. objectstack-ai#22135 is not addressed by this PR alone.

## What changed — `docs/adr/0048-cross-package-metadata-collision.md`
only

Additive. The original text is untouched; no existing line is edited.

- **A dated note directly beneath §3.4:** "Narrowed (2026-10-08) — the
security catalog is out of §3.4", with a link to the addendum.
- **A new addendum at the end:** "Addendum (2026-10-08): the security
catalog holds one name per deployment — §3.4 narrowed". It carries:
  - the ruled option's text, verbatim, and the options not taken (B, C);
- N.1, why §3.4's premise (every caller carries its package id) does not
hold for bare-name assignments, with the measurement that motivated the
ruling;
  - N.2, what is refused and who the holders are;
- N.3, what stays as §3.4 has it: same-package reload, every other type,
an environment save, no `OS_METADATA_COLLISION=warn` downgrade;
  - N.4, where it is implemented.

The header's `**Addenda**:` index line is deliberately not edited, to
keep the original text untouched. The note under §3.4 carries the link.

## Gates (at c383221)

`dispatch-gates --commands` derived 19 commands; all 19 were run with
exit codes recorded, and `--ran` reconciles 19/19 with 0 NOT MEASURED.
`check:doc-formula-expressions` first answered PREREQUISITE NOT MET
(exit 3); it passed after `@objectstack/formula` and `@objectstack/lint`
were built.

## 维护者速读(草稿)

### 改了什么
只改了一份架构决策记录 ADR-0048 的文字,没动任何代码。在
§3.4「跨包同名不再报错」那一节下面加了一段带日期的说明,并在文末加了一个附录。内容是把您在 objectstack-ai#15196 上的裁决(Q4 选
A)写进去:职位、权限集、能力这三类安全目录,一个部署里一个名字只能有一个持有者。原文一个字都没改。

### 为什么改
ADR-0048 §3.4
当初允许两个包用同一个名字,是因为界面类元数据被调用时总带着「我是哪个包」,系统能分清。但给用户分配职位、给职位挂权限集时,只记名字、不记包。两个包都带同名的「销售经理」,用户到底拿到哪一份权限,就取决于加载顺序。实测确实如此:同一套系统里,职位取后注册的那个包,权限集和能力取先注册的那个。一个应用自带一个叫
`admin_full_access` 的权限集,按名字查到的就是应用自己那份,而不是平台的管理员权限集。您裁定这三类单独收紧,这份 ADR
要跟着记下来,否则 ADR 写着「允许同名」,代码却在拒绝,两边对不上。

### 风险与代价(含回滚)
- 这份 PR 本身只是文档,没有运行时风险。
- 真正的行为变化在配套的代码 PR(objectstack-ai#22197):装包或启动时遇到同名会直接报错。仓库里四个示例应用加平台内置名,一共 50
个声明,实测没有一处同名,所以现有示例都能照常启动。已部署环境和应用市场里的包没有测过。
- 回滚:撤销这份 PR 即可,ADR 回到原文;代码 PR 可以分开回滚。

### 席位意见


### 你要做的
请审阅附录的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… the Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) (objectstack-ai#22186)

Fixes objectstack-ai#15195
Clause-②: yes (narrowing)

ADR-0131 C1: the Default Organization is load-bearing under `single`.
Scope as triage ruled it: 6040634630 (Q1 → B) for the implementation,
6053354661 (Q1 → A, Q2 → A) for landing it. This is one atomic,
cross-lane PR. It carries the `@objectstack/verify` `bootStack` re-pin
and the dogfood re-pins with the implementation. `packages/qa/dogfood`
and `packages/verify` (`domain:cli`) join it, declared on objectstack-ai#6024.

## What this does

- **The boot invariant (D3).** Under the `single` posture,
`AuthPlugin.start()` finds or creates the Default Organization (`slug:
'default'`), with or without a platform admin. A failed read or insert
throws and fails the boot.
- `AppPlugin` declares `com.objectstack.auth` as an order-if-present
dependency, so the kernel starts the auth plugin first wherever a host
registered it.
- The organization therefore exists before the inline seed and before
`kernel:listening`.
- **The seed-exemption withdrawal (D3/D9).** The seed loader stamps the
install's organization on every row of an object that carries an
`organization_id` column, `sys_` / `cloud_` / `ai_` seeds included.
- Suppose no organization is pinned and none can be derived (zero
organizations, or several), on an install that registers the
organization object. Such a row is refused, counted and named.
  - An object with no organization column is never stamped.
- **The owner pin (D3 / ADR-0093 D7).** The reconciler binds the first
admin as `member` before the owner bind learns who they are.
- While the once-only bind is undecided and the Default Organization has
no owner, the bootstrap promotes that row to `owner` in place.
  - It records the decision as `promoted`.
- **The derivation rule for the objects already in scope (D9).**
`resolveSystemInsertOrganization` derives at exactly one organization.
It refuses at zero (new: `reason: 'no-organization'`), at several, and
under a wall.
- The refusal reuses `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status
500).
  - No `packages/spec` edit.
- **`bootStack` boots the production `single` shape (D3 / D11).** The
harness no longer pins the owner bind off (`autoDefaultOrganization:
!!opts.orgContext` is gone). Every boot has the Default Organization,
every sign-up is its member, and the harness admin is its owner, as
`objectstack dev` / `serve` boot it.
- `orgContext` is now only the vacuity guard: it asserts that the
admin's session carries an organization, and refuses the boot otherwise.
It still refuses to compose with `multiTenant: 'posture-only'`.
  - There is no test-only org-less mode and no escape hatch.
- **Unchanged by ruling.**
- The 49 gated platform objects keep
`isPlatformObjectOutOfTenantAuditScope` until C8.
- The lean-install branch is unchanged, with a pin. That is
`probeInstallOrganizations` answering `[]` when no organization object
is registered.
- No C3, C5 or C6 surface, no seeder and no `applyTenantScope` is
touched.

## Boot order: fresh `single`, `examples/app-showcase` through
`bootStack` (measured)

Base `51290bca2c`, implementation head `c49f46bab1`. Seeds: 132 rows
over 19 objects. The last two rows were measured with the harness at its
old pin. Since this PR, `bootStack` always boots the owner-bind-on row.

| step | base | head |
| :--- | :--- | :--- |
| `AuthPlugin.start()` | no organization | Default Organization
inserted: the first insert of the boot (seq 0 of 98) |
| `AppPlugin.start()` inline seed | 132 rows, all `organization_id` NULL
(`sys_business_unit` 5 of 5 NULL) | 132 rows, 0 NULL,
`sys_business_unit` included |
| `kernel:ready` | 0 organizations | 1 |
| `kernel:listening` | 0 organizations (old harness default). 1 only
when a dev admin existed at `kernel:ready`; even then the organization
was the 49th insert, after every seed, and 130 of 132 seed rows stayed
NULL | 1 |
| dev admin, owner bind on (`bootStack` now, always) | org created and
admin bound `owner` directly | reconciler binds `member`; the bootstrap
logs "promoted the platform admin to owner"; `isPlatformAdmin: true`,
positions `platform_admin`, `org_owner` |
| dev admin, owner bind off (the old harness default, removed) | no
membership, no active organization | `member` of the Default
Organization, the session's active organization |

## The derivation rule: `resolveSystemInsertOrganization`, objects in
scope

| posture | organizations | organization object registered | write
carries one | base | head |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `single` | 1 | yes | no | derived | derived |
| `single` | 0 | yes | no | lands NULL (measured, probe) | refused
`no-organization` (measured, pins) |
| `single` | 2 or more | yes | no | refused `ambiguous-organization` |
unchanged |
| `single` | 0 | no (lean composition) | no | lands unstamped |
unchanged, pinned |
| `isolated` / `group` | any | yes | no | refused `walled-posture` |
unchanged (measured on a `posture-only` isolated showcase boot: code
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500; the same insert
carrying `tenantId` lands stamped) |
| any | any | any | yes | stamped with it | unchanged |
| the 49 gated platform objects | any | any | no | exempt | unchanged
(C8) |

## Acceptance pins (implementation head `4fc2472fd0`, base `ec8f37c890`)

`runtime/src/default-organization-boot-invariant.pin.test.ts` boots a
real kernel twice:
- **Kernel A:** a fresh deployment nobody signed up to.
- **Kernel B:** `objectstack dev`, where the dev admin is created after
the organization exists.

`AppPlugin` is registered before `AuthPlugin` on purpose.

| pin | where | base | head | control |
| :--- | :--- | :--- | :--- | :--- |
| (a) organization before the listener and before the first seed row; an
`isSystem` insert with no organization lands stamped | kernel A, 3 cases
| red | green | an object declaring `tenancy: { enabled: false }` takes
none |
| (b) `isolated` / `group` refuse, with the code and status; the insert
carrying `tenantId` lands | `objectql`
`system-write-organization.test.ts` and the measured isolated boot above
| green (since objectstack-ai#8844) | green | the carrying insert |
| (c) the first admin is the owner, by promotion | kernel B | red |
green | `isPlatformAdmin` read back unchanged |
| (d) every seed row stamped, `sys_` seeds included | kernel A | red |
green | the tenancy-off seed lands with no organization |
| (e) lean-install branch unchanged | `objectql` and `metadata-protocol`
`[ADR-0131 Q2, held as-is]` cases | green | green | the
registered-but-empty case refuses |

**Reverse verification of the implementation** (from committed
`4fc2472fd0`):
1. The eight implementation source paths were restored to `ec8f37c890`
in the tree only.
2. The four packages were rebuilt, and
`scripts/ablation-dist-preflight.mjs` confirmed each head marker absent
from `dist/`.
3. The pin run: 5 failed and 2 passed, as tabled.
4. A trap restore, proven by `git diff HEAD` empty and per-path blob
hashes equal to HEAD.
5. A rebuild with the markers back.

**Ablations** (`scripts/ablation-replace.mjs`, each restored and
rebuilt):
- **The promotion call disabled:** (c) turns red (`member`, not
`owner`).
- **`com.objectstack.auth` removed from
`AppPlugin.optionalDependencies`:** every seed row is refused, and (a)'s
ordering case and (d) turn red.

## M5: the existing tests C1 moved, judged one by one

Each flip was judged against the ruling and none was restored. There are
two judgements:
- **flipped:** the old expectation was the defect C1 fixes, and the pin
now holds the new answer.
- **re-pinned:** the fixture changed and the subject did not.

| file | package | judgement | what changed |
| :--- | :--- | :--- | :--- |
| `seed-loader-sole-organization-read-failure.test.ts` |
metadata-protocol | flipped | no organization, several, or an
unprovisioned table no longer write the seed row NULL |
| `seed-loader-org-fallback.test.ts` | objectql | flipped | `sys_` seeds
take the organization; ambiguity refuses |
| `engine-organization-probe-outage.test.ts` (cause 2) | objectql |
flipped | the empty probe still is not an outage, but zero organizations
now refuses |
| `system-write-tenancy-autonumber-split.integration.test.ts` | runtime
| flipped | the first-boot write is refused at zero organizations, with
the stamped control |
| `auth-plugin.test.ts`, "`autoDefaultOrganization: false` opts out" |
plugin-auth | flipped | the organization exists anyway |
| `seed-loader-engine-schema-fallback.test.ts` | metadata-protocol |
re-pinned | the one new engine read is the organization-object
registration read |
| `seed-loader-existing-records-read-failure.test.ts` |
metadata-protocol | re-pinned | the metadata double no longer claims an
organization object |
| `protocol-publish-package-drafts.test.ts` | objectql | re-pinned | the
install holds its Default Organization |
| `packages-seed-apply-disclosure.test.ts`,
`packages-seed-apply-read-decorations.test.ts`,
`http-dispatcher.test.ts` | runtime | re-pinned | each install holds its
Default Organization |
| `seed-tenancy-autonumber-split.integration.test.ts` | runtime |
re-pinned | the split is reproduced from pre-C1 residue written at the
driver, since the loader can no longer produce it; a new case pins the
refusal |
| `auth-plugin.test.ts`, the two `app:seeded` cases | plugin-auth |
re-pinned | they delete the organization to reach "no target" |
| `status-mirror-cascade.integration.test.ts` | plugin-approvals |
re-pinned | The rig registered `sys_organization` but left it
unprovisioned and empty, then system-inserted `opportunity`, so CI
refused it (`no-organization`). It now provisions `sys_organization` and
seeds the Default Organization before the first insert.
`sys_organization` leaves the expected-absent probe list, by that
channel's own contract (a table that started resolving is provisioned
now). The subject, an approval decision cascading as the deciding user,
is unchanged. The delegation channel still fires (`afterAll` green). |
| `claim-seed-ownership-warm-boot.test.ts` | plugin-security | re-pinned
| The rig registered the real identity objects with no auth plugin, so
CI refused the `crm_case` seed inserts in four cases. Every boot of the
rig now finds or creates the Default Organization, as the invariant does
on every boot. The subject, the warm-boot seed-ownership claim and its
target, is unchanged: all 5 cases pass with the same expectations. |
| `runas-system-stamping.integration.test.ts`, the SecurityPlugin flip
block | service-automation | re-pinned | The rig composed the identity
objects with no auth plugin, so the user-less run's `create_record` was
refused (found by the sweep below; CI never reached this suite). The rig
now seeds the Default Organization as `org_1`, the member's
organization. The NULL-born case also pins the row's derived
organization (`org_1`), so the `403` is decided by the stamp columns
alone, which is the subject. The lean block above it registers no
organization object and is unchanged. |

None of these weakens the `no-organization` refusal, adds tolerance in
the engine, or skips a case.

## `bootStack` and the dogfood: the 24 re-pinned files

At this PR's implementation alone, 24 dogfood files failed; at base they
pass. Each was re-pinned on its own cause, to the production `single`
shape. Pins that C1 flips flip to the new answer and none is deleted.
The shared helper is `leaveOrganization` (new, `test/armed.ts`): it
deletes the user's `sys_member` rows in system context, signs in again,
and throws if a membership survives. A user removed from their
organization is an ordinary production state, not a test mode.

| file | cause | what changed | why it keeps the original intent at the
production shape |
| :--- | :--- | :--- | :--- |
| `analytics-adhoc-query-isolation` | its `memory` leg: `driver-memory`
refuses a tenant-scoped read (503), and every session now carries the
Default Organization | the `memory` leg became an `objectql-strategy`
leg on `sqlite-wasm`, with the analytics plugin's `queryCapabilities`
withholding native SQL. `Restart-when: objectstack-ai#15212 closed` | the file pins
isolation under both analytics strategies; the memory driver was only
the route to the ObjectQL strategy, and the capability switch reaches it
on a driver that answers |
| `analytics-contains-membership` | as above | as above | as above |
| `analytics-inline-dataset-admission` | as above | as above | as above
|
| `analytics-inline-dataset-isolation` | as above | as above | as above
|
| `armed` | the "outside" class was an org-less sign-up; every sign-up
is now a member | `orgless`/`orgbound` renamed `outside`/`inside`; the
outside principal leaves the organization; the write-floor disarm text
names "Keep the principal a member of the organization" | the floor is
still measured on a principal with no active organization against one
inside it |
| `delegated-admin-invite` | it minted its own `slug: 'default'`
organization (`DuplicateRecordError`) | reads the boot's Default
Organization | same subject inside the deployment's one organization |
| `delegation-of-duty` | the delegator is now a member, so the gate
reads the delegator's organization's positions (ADR-0091 D3 rule 5), and
the fixture's positions had none | `sys_position` / `sys_user_position`
rows carry the Default Organization; the session double carries
`activeOrganizationId` | same delegation rules, on rows held the way an
org-bound deployment holds them |
| `invitation-ledger-row-scope` | it minted a second organization
(`acme-8095`) while the reconciler binds every sign-up to the Default
Organization | uses the Default Organization | the ledger's row scope is
measured inside the deployment's one organization |
| `membership-actor-attribution` | minted its own `default` organization
| reads the boot's | unchanged subject |
| `membership-ended-session-revoke` | minted its own `default`
organization | reads the boot's | unchanged subject |
| `membership-reconciler` | minted its own `default` organization |
reads the boot's | unchanged subject: the reconciler binding through the
real sign-up pipeline |
| `membership-role-vocabulary` | minted its own `default` organization |
reads the boot's | unchanged subject |
| `org-admin-affordance-reach` | it minted `reach-org` while sign-ups
bind to the Default Organization | uses the Default Organization |
grades measured in the organization the members are in |
| `organization-delete-federated-fixture` | deleting the Default
Organization now runs the delete behaviour of every row the deployment
owns, the seed included | deletes a second organization (`org-21910`)
that the admin owns and no row belongs to | the cascade scan probes
every reference on any organization's delete, so the federated anchor is
still reached, without a different question attached |
| `parent-derived-write-refusal-not-visible` | an org-less principal was
the precondition | `boot(inside)`: the outside principal leaves the
organization; the inside boot keeps `orgContext: true` | same refusal
shape, for a principal outside the organization |
| `permission-set-lock-row-provenance` | the harness admin was a
`member` | no edit: the harness owner bind | the admin is the
deployment's administrator, the Default Organization's owner as
`objectstack dev` boots it |
| `permission-set-write-through-package-binding` | as above | no edit |
as above |
| `predicate-write-unreadable-not-matched` | org-less precondition | as
`parent-derived-write-refusal-not-visible` | as above |
| `sharing-rule-org-less-caller` | the org-less personas, and the
harness admin as the org-less platform operator | the org-less personas
leave the organization. The operator is a new user holding
`admin_full_access` globally who leaves it. The control persona's
Default membership is removed before its tenant-A one. Preconditions
judge live sessions only, because leaving revokes the sign-up session
(objectstack-ai#15784) | a sharing rule still must not widen reads for a caller with
no organization; each caller is now a production shape |
| `showcase-permission-projection` | the harness admin was a `member` |
no edit | as `permission-set-lock-row-provenance` |
| `single-tenant-identity-create` | the old expectation, a
`sys_business_unit` with no organization, is the defect C1 fixes |
flipped: `organization_id` equals the Default Organization's id |
ADR-0057's property (creatable single-tenant, no `VALIDATION_FAILED`) is
still the pin |
| `sys-file-metadata-write-refusal` | the harness admin was a `member` |
no edit | as `permission-set-lock-row-provenance` |
| `two-doors-permission` | the harness admin was a `member` | no edit |
as `permission-set-lock-row-provenance` |
| `write-door-unreadable-is-not-found` | org-less precondition | as
`parent-derived-write-refusal-not-visible` | as above |

**Files beyond the declared set** (`packages/verify/src/harness.ts` and
the 24 files):
- `packages/qa/dogfood/test/armed.ts`, which holds the
`leaveOrganization` helper.
- `packages/verify/src/harness.org-context.test.ts`. The default-boot
case now pins the production shape, including the admin's `owner`
membership.
- `showcase-external-autoconnect.dogfood.test.ts` and
`showcase-scope-depth.dogfood.test.ts`: comments only, correcting the
description of the removed org-less boot.
- `.changeset/15195-verify-bootstack-production-single.md`.
- Cross-lane (`domain:services`) test fixtures, round 3, tabled under
M5:
`packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts`,
`packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts`
and
`packages/services/service-automation/src/runas-system-stamping.integration.test.ts`.
- The tenant-audit census
(`content/docs/permissions/tenant-audit-census.mdx`,
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`). After
each `main` merge it was regenerated with the gate's own write mode,
outside the MERGE state, and the prose figures the gate holds were moved
with it: 234 → 236.

**Reverse verification of the harness change** (committed `05dedeea79`,
and again on `9aee4d05f1` with identical results; round 3 changes no
file it reads):
1. `scripts/ablation-replace.mjs` (WRAP mode) restored the old pin in
`harness.ts`, with a string-literal marker (`REVERSE-15195-OLD-PIN`) the
bundler keeps.
2. `@objectstack/verify` was rebuilt, and `ablation-dist-preflight`
confirmed the marker present in `dist/`.
3. The mutated leg turned red:
- dogfood, the 24 files: 5 files and 14 tests failed, 204 tests passed.
The five are exactly the "no edit" rows above.
- `harness.org-context.test.ts`: 1 of 5 failed (`expected [ 'member' ]
to deeply equal [ 'owner' ]`).
4. The file was restored: blob equal to HEAD and `git diff HEAD` empty.
`@objectstack/verify` was rebuilt, and the preflight `--absent` passed.

The same leg on `c2f7e36d6e` left the harness unit test green, because
its two assertions hold without the owner bind. `05dedeea79` adds the
`owner` assertion so the unit test reads the line itself. The first
attempt was void and is not counted: the tool refused it because the
replacement contained the anchor, and nothing ran.

## The in-memory driver until C8 (triage Q2 → A)

`@objectstack/driver-memory` refuses tenant-scoped reads. Under
`single`, every session now carries the Default Organization, so on that
driver signed-in reads answer `503` until C8 (objectstack-ai#15212, ADR-0131 D8) gives
`single` no read predicate. The `plugin-auth` changeset states this.

This is not new in production terms. At base, a showcase boot with the
owner bind on (the shape `objectstack dev` boots) already answered the
platform admin's `GET /data/showcase_category` with `503
SERVICE_UNAVAILABLE` on the memory driver. Only the harness's org-less
pin kept the four analytics `memory` variants green. Those variants now
run on the SQL in-memory driver, with `Restart-when: objectstack-ai#15212 closed` in
each file.

## Clause-②: the built entry declarations, base `ec8f37c890` against
head

- **`@objectstack/objectql`:**
- `SystemWriteOrganizationDecision`'s `'no-organization-yet'` becomes
`'no-organization-object'`.
  - `SystemWriteRefusalReason` gains `'no-organization'`.
- `resolveSystemWriteOrganization` takes a required
`organizationObjectRegistered`.
- **`@objectstack/plugin-auth`:**
- `EnsureDefaultOrganizationOnceOptions` gains an optional
`organizationCreatedByThisProcess`.
- `EnsureDefaultOrganizationResult` gains an optional `ownerPromoted`
and the `'owner_promotion_failed'` reason.
- **`@objectstack/verify`:** no declaration change. `bootStack` now
boots an org-bound admin for every caller, so a fixture that relied on
an org-less one breaks.
- **`@objectstack/metadata-protocol`, `@objectstack/runtime`:** no
public declaration changes.

The accept sets narrow, so the answer is `yes (narrowing)`. The
objectql, plugin-auth, metadata-protocol and verify changesets are
`minor`, with the BREAKING banner and an ADR-0087 disposition. The
runtime changeset is `patch`. Driven offline with this body as the
`pull_request` payload (`--event`), `check-changeset-no-major` reads
`Clause-②: yes (narrowing)` and passes the level axis.
`check-adr-0087-registration` also passes.

## Verification (head `ede1fbd345`, merge base `9f0de32a03`)

Every package that depends on `@objectstack/objectql` was run in full.
That is 45 packages (`pnpm --filter '...@objectstack/objectql'`); 44
have a `test` script, and `metadata-protocol` was added. A package that
does not register `sys_organization` cannot reach the `no-organization`
refusal, but the sweep measured every package rather than relying on
that argument. In each log, every `SystemWriteOrganizationRequiredError`
/ `no-organization` match was read; none remain after the fixes.

- **Round 3, the three touched packages, at `ede1fbd345`**
(plugin-approvals and plugin-security also ran at `b7d0b3469e`, which
already carried their fixes):
  - `plugin-approvals`: 62 files, 905 passed.
  - `plugin-security`: 179 files, 3,775 passed, 45 skipped.
  - `service-automation`: 175 files, 2,120 passed.
- **`runtime`, re-run at `ede1fbd345`:** 339 files, 5,495 passed, 19
skipped.
- **`cli`:** unit tier 264 files, 3,915 passed. Integration tier in four
slices: 93 files, 900 passed, 2 skipped.
- **The rest of the sweep, at `b7d0b3469e` / `ede1fbd345`:**

| package | files | tests |
| :--- | ---: | ---: |
| plugin-audit | 40 | 641 |
| plugin-sharing | 40 | 1,002 |
| trigger-record-change | 11 | 114 |
| trigger-schedule | 8 | 174 |
| service-analytics | 180 | 4,441 (262 skipped) |
| service-datasource | 41 | 760 |
| service-knowledge | 4 | 49 |
| service-messaging | 48 | 534 |
| service-settings | 39 | 707 |
| service-storage | 43 | 717 |
| service-queue | 5 | 77 |
| service-sms | 5 | 74 |
| rest | 262 | 4,938 (326 skipped) |
| hono | 5 | 122 |
| http-conformance | 8 | 102 |
| downstream-contract | 3 | 31 |
| client | 51 | 653 |
| client-react | 3 | 34 |
| cloud-connection | 41 | 505 |
| connector-mcp / -openapi / -rest / -slack | 3 / 4 / 4 / 3 | 23 / 36 /
26 / 10 |
| driver-mongodb | 31 (5 skipped) | 690 (182 skipped) |
| knowledge-memory / knowledge-ragflow | 1 / 1 | 8 / 10 |
| organizations | 11 | 151 |
| plugin-dev | 9 | 86 |
| plugin-email | 31 | 535 |
| plugin-pinyin-search | 2 | 21 |
| plugin-webhooks | 15 | 165 |
| example-crm / -embed-objectql / -showcase / -todo | 5 / 1 / 33 / 7 |
45 / 2 / 408 / 238 |

- **At `9aee4d05f1`.** Round 3 changes no file these read:
- dogfood, all 8 shards (`OS_TEST_SHARD=k/8`): 222 files (1 skipped),
1,753 tests passed, 9 skipped.
  - `objectql`: 381 files, 7,527 passed.
  - `metadata-protocol`: 222 files (3 skipped), 28,283 passed.
  - `plugin-auth`: 128 files, 2,655 passed.
  - `verify`: 18 files, 133 passed.
- **Typecheck:** green for plugin-approvals, plugin-security and
service-automation at `ede1fbd345` (`check:test-typecheck` OK: the
plugin-approvals debt ledger is held, the other two ledgers are empty).
Earlier: objectql, runtime, metadata-protocol, plugin-auth, verify and
dogfood.
- **Gates:** `dispatch-gates --commands` derived 112 for this tree (two
i18n families joined), all 112 ran with exit 0, and `--ran` reconciled
112 of 112 with recorded exit codes. The roster gates whose list lives
in a touched directory all exit 0. `check-single-claim-paths` passed
with this PR's context.
- **Lint:** `eslint --no-inline-config` over the 51 script and
TypeScript files this diff adds or modifies: 0 errors and 0 warnings, 51
files in the JSON report. The config enables no type-aware linting, so
this diff cannot move an untouched file's verdict. The full `pnpm lint`
run is CI's.
- **Serial:** objectstack-ai#22197 and objectstack-ai#22215 are still open. Neither shares a file
with this PR; no dogfood showcase file and no `showcase-security.ts` is
touched.
- **`main` since the merge base:** 27 commits, not merged here. `git
merge-tree` against it is clean. 16 test files they add or change name
the organization object (for example
`plugin-security/src/grant-holder-membership-refusal.test.ts`,
`service-settings/src/settings-organization-isolation.pin.test.ts` and
`dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`).
They are NOT MEASURED against C1 here; CI's merge-ref run measures them.

## Acceptance notes

- **The wall's sole-organization seed fallback.** The seed loader still
derives under a walled posture when a load names no organization and the
install holds exactly one organization. This was read, not measured. The
walled inline seed is suppressed, and the per-organization replay always
names one. `carrier:` the objectstack-ai#15195 claimant.
- **The owner-bind ledger.** Its `admin-already-member` second insert
(`DUPLICATE_RECORD` on two concurrent triggers, present at base too) is
objectstack-ai#22099.
- **Stale comments.**
`packages/plugins/plugin-sharing/src/sharing-service.ts`,
`sharing-rule-service.ts` and `sharing-service.test.ts` still describe
the harness's `autoDefaultOrganization: false`. They are not edited here
(outside the declared set). `carrier:` none named, so this is noted here
only.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ssion-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (objectstack-ai#22365)

Fixes objectstack-ai#22307
Clause-②: no

Executes the maintainer's ruling letter A on objectstack-ai#22307 (ruling record
6063176077): the restart path refuses too. After `sys_metadata`
hydration and before `kernel:ready`, the engine checks every
package-held permission set and position name against the environment
catalog, and a name the environment already holds fails the boot with
the 422 `NAMESPACE_CONFLICT` envelope the package door uses, naming both
holders. A cold boot, a hot install and an artifact boot now answer
alike (Q4 = A, ruling record 6050490870).

The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft
PR, from branch `claude/issue-22307-adr-0048-n3-amendment`. This PR
carries no `docs/adr/**` file.

## What changed

- **`packages/objectql/src/plugin.ts`.** `ObjectQLPlugin.start()` calls
a new private `refuseEnvironmentHeldSecurityCatalogNames()` right after
the hydration block (`restoreMetadataFromDb`, or the project-kernel skip
line) and before Phase 3's schema sync. Any conflict throws
`SecurityCatalogNameConflictError` with `door: 'cold-boot'`, which fails
`start()` and with it the boot. It runs whether or not the kernel
hydrated.
- **`packages/objectql/src/registry.ts`.**
- A private `SchemaRegistry.environmentHeldSecurityCatalogConflicts()`
returns every package-held position and permission-set name that also
has a bare-slot item. Built-in names are skipped. Results are sorted by
type, then name.
- A private `securityCatalogPackageHolders()` reads the package half of
the holder reading: composite slots and install claims, never the bare
slot.
- A module-level `findEnvironmentHeldSecurityCatalogNames(registry)` is
the plugin's handle on that reading. It is not re-exported from
`index.ts` or `core.ts`, so the public surface does not grow.
- `SecurityCatalogNameConflictError` takes an optional `{ door:
'cold-boot' }`, which changes only the message: which package declares
each name, and a remedy stated for a restart. `code`, `status`,
`httpStatus` and `conflicts[]` are unchanged.
- **`packages/objectql/src/security-catalog-namespace.ts`.**
`ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` (`position`, `permission`: the
two types the metadata-type registry declares `allowRuntimeCreate:
true`), and a module-doc section, "The cold boot".
- **`.changeset/22307-cold-boot-catalog-refusal.md`** (new).
`'@objectstack/objectql': major`, the BREAKING banner, the ADR-0087
marker `not-required (no-migration-prescription)`, the upgrade shape and
the remedy.
- **`.changeset/22135-security-catalog-one-holder.md`** (pending, not
yet released). See Acceptance notes, "A pending release note this PR
corrects".
-
**`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.**
The invariant gains the cold-boot half.

No new error code, no `packages/spec` change.

## Where each refusal sits (for the merge with objectstack-ai#22331, which landed
first)

`main` was merged at e3ae92a, after objectstack-ai#22331 landed. The merge was
clean, and the order in `ObjectQLPlugin.start()` on this head is:

1. objectstack-ai#22331's `installDeploymentPlatformGlobalObjects(ctx)`, the first
statement of `start()`.
2. `restoreMetadataFromDb(ctx)`: `sys_metadata` hydration.
3. **This PR's `refuseEnvironmentHeldSecurityCatalogNames()`**: right
after the hydration `if`/`else` and before Phase 3's
`installRegisteredSchemas`. It runs before any plugin that depends on
the engine starts, and before `kernel:ready`.
4. objectstack-ai#22331's `assertDeploymentPlatformGlobalObjectsUnchanged(ctx)`, at
the top of the `kernel:ready` hook.

The two changes share no hunk. This PR's new method sits directly after
`restoreMetadataFromDb`'s method body, and its import line comes after
the `picklist-resolution` import block.

## Mechanism assumptions, measured

- **M1, the admission today.** Reproduced through `bootStack` on one
database file, on the untouched base 28bff18. Boot 1 saved a
permission set and a position through `PUT /api/v1/meta/permission/NAME`
and `PUT /api/v1/meta/position/NAME`. Both answered `200`; a new
position name needs no `OS_METADATA_WRITABLE`. Boot 2, cold, added a
package declaring both: it booted, with two `[Registry] Collision`
warnings, and the by-name read answered the environment's definitions.
Boot 3 hot-installed the same package: `422 NAMESPACE_CONFLICT`, both
names held by `environment`.
- **M2, where the check sits.** As above. Boot shapes:
- standalone `os serve` / `os dev` / `bootStack`: `environmentId` unset,
hydration runs, the check runs (measured, dogfood);
- the artifact boot (`createStandaloneStack`): `environmentId:
'env_local'` with `hydrateMetadataFromDb: true`, hydration runs, the
check runs (measured, runtime pin);
- a project kernel with `environmentId` and no `hydrateMetadataFromDb`:
hydration is skipped, and the check runs over whatever reached the bare
slot, normally nothing (code reading);
- a host with no `protocol` service, or one without `loadMetaFromDb`:
nothing hydrates, and the check runs with nothing to judge (code
reading).
`loadMetadataFromService` at the top of `start()` syncs `object`,
`view`, `app`, `flow` and `hook` only, so no other boot-time path writes
these two types into the bare slot.
- **M3, the holder reading. Partly falsified, route changed by the
ruling's intent.** At a cold boot the hydrated environment row is NOT an
unstamped bare-slot item. Hydration runs after the package registered,
and the protocol's artifact-protection merge grafts the package's
envelope onto the stored row. Measured on base: the bare slot
`probe22307_set` carries `_packageId: com.probe.addon22307` and
`_provenance: package`, so objectstack-ai#22197's stamp-based reading answers "the
package itself" and finds no second holder. The check therefore reads
every bare-slot item as the environment's, whatever stamp it wears: only
a registration with no package writes the bare slot. A package holds a
name through a composite slot or a claim, never through the bare slot.
The envelope class, holder kinds and claims are objectstack-ai#22197's.
- **M4, built-ins.** Built-in names are skipped. Through `bootStack`,
with `OS_METADATA_WRITABLE=position`, environment saves under
`org_admin` and `everyone` answered `200`, and the restart boots, with
`GET /api/v1/meta/position/org_admin` answering the saved definition.
S2b's pins are green: `builtin-positions.boot.test.ts` is in the
plugin-security suite below.
- **M5, the legacy shape.** The save door refuses it now (`PUT
/api/v1/meta/permission/NAME` over a package-held set answers `403`,
with or without `?package=`), so the rows were written at the driver. A
row bound to no package refuses the restart, naming both holders
(pinned). So does a row bound to the package itself (`package_id` = the
package; objectql pin). A hot install refuses that bound row alike:
measured, holder `environment`. A legacy row over one of the platform
security plugin's own permission sets (`member_default`) refuses the
restart, naming `com.objectstack.plugin-security`. On base, all three
boot.
- **M6, capabilities.** `PUT /api/v1/meta/capability/NAME` answers `403`
("code-only … allowRuntimeCreate=false"), so the environment catalog
holds no capability. The check reads permission sets and positions only,
and no capability path reaches it.

## Door table: base vs head

"Base" is the untouched 28bff18, or a15b8af with the check
ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes
comments only). Boots go through `@objectstack/verify`'s `bootStack` on
one database file unless the row says otherwise.

| Door | Base | Head |
|---|---|---|
| Cold boot: environment-saved permission set and position, then a
package declaring both | boots; two `[Registry] Collision` warnings; the
by-name read answers the environment's definitions (28bff18 and
ablated) | refused: `Plugin com.objectstack.engine.objectql failed to
start`, cause `422 NAMESPACE_CONFLICT`, two conflicts, incoming the
package, holder `environment` |
| Hot install (post-boot `manifest.register`) of that package | refused,
`422`, holder `environment`, both names | unchanged |
| Artifact boot (`createStandaloneStack`, `file:` database), a package
added over environment-saved names | boots (ablated: runtime pin red) |
refused, same envelope |
| Built-in shadow: environment saves under `org_admin` and `everyone`,
restart | boots (ablated) | boots; the stored definition answers |
| Legacy row (written at the driver, bound to no package) over a
package-held set and position, restart | boots, one collision warning
(28bff18) | refused, holder `environment`, both names |
| Legacy row bound to the package itself, restart | boots (ablated) |
refused, holder `environment` |
| Legacy row over the platform's `member_default`, restart | boots
(ablated) | refused, incoming `com.objectstack.plugin-security` |
| Same-package restart; a package whose names the environment does not
hold | boots | boots |
| Remedy: boot without the package, `DELETE
/api/v1/meta/permission/NAME` and `/position/NAME`, boot with it | (n/a)
| both `200`, no row left, the boot with the package comes up |
| Environment save of a capability | `403` code-only | unchanged |

## In-repo census

The examples ship no `sys_metadata` rows, so the environment catalog
holds no names on a fresh boot. Measured on a15b8af: a fresh boot of
each example on a database file, then a restart.

| Example | Package-held items | Environment rows
(`permission`/`position`) after the boot | Restart |
|---|---|---|---|
| `app-crm` | 10 permission sets, 9 positions | 0 | boots |
| `app-showcase` | 17 permission sets, 16 positions | 0 | boots |
| `app-multi-package` | 8 permission sets, 6 positions | 0 | boots |

The counts include the platform's own items (`plugin-security`'s 8
permission sets and 6 built-in positions). Names held twice: 0.
`app-todo` declares no catalog name (objectstack-ai#22197's census) and is not a
dogfood dependency, so it was not booted. Deployed environments: NOT
MEASURED.

## Tests

The head is 3c160a2. Against 72dcb8e it changes comment lines
only, in the new dogfood file (5 added, 3 removed, 0 outside a `//`
comment). The runs below are at 72dcb8e or earlier, as each line
says.

- `@objectstack/objectql`, whole suite at e3ae92a: 387 files / 7615
passed. At 72dcb8e, `protocol-boot-hydration-scoped.test.ts`: 16
passed (8 of them new).
- `@objectstack/plugin-security`, whole suite at e3ae92a: 184 files /
3869 passed, 45 skipped. That includes S2b's
`builtin-positions.boot.test.ts` and
`bootstrap-declared-positions.test.ts`.
- `@objectstack/runtime`, whole suite at e3ae92a: 340 files / 4777
passed, 19 skipped.
`standalone-stack-security-catalog-one-holder.test.ts` has 6, 1 of them
new.
- Dogfood, the CI split, at e3ae92a:
  - 1/3: 76 files / 567 passed;
  - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped);
  - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped).
The one red was this PR's own built-in control: its `PUT
/api/v1/meta/position/org_admin` answered `403` with the hatch set. The
protocol memoises `OS_METADATA_WRITABLE` at its first read in a process,
and the control set it only after the file's first case had already
saved through the metadata door. It passed in isolation before the
second merge and failed in the full shard after it; what made that
difference is NOT MEASURED. At 72dcb8e the file opens the hatch
before its first boot. The new file and the re-shaped Discard Overlay
file then ran: 2 files / 11 passed.
- Before the second merge, at bdfba35: dogfood 1/3 76 files passed;
2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since);
3/3 74 passed and 1 skipped.
- `typecheck` at 72dcb8e: `objectql` (`tsc --noEmit` plus
`check:test-typecheck`: 40 files, 234 errors, 65 pinned signatures, no
new signature) and `dogfood`, exit 0. `runtime` at e3ae92a, exit 0;
no runtime file changed after it.
- `pnpm exec eslint --no-inline-config --format json` over the 7 touched
TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This
narrowed run is a measurement, not a skipped one, on three grounds:
- the population comes from `eslint.config.mjs` itself (`files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`), and all
7 files are in it;
  - the count, 7, is read from the JSON output;
- the config enables no type-aware linting (no `parserOptions.project`,
as stated at `eslint.config.mjs:328`), so this diff cannot move any
untouched file's verdict.
  The whole-repo `pnpm lint` is CI's.

## Ablation

The call was neutralised through `scripts/ablation-replace.mjs`, which
wraps the run and restores on exit. In `plugin.ts`,
`this.refuseEnvironmentHeldSecurityCatalogNames();` became the same call
behind an always-false guard carrying the marker
`ABLATION_22307_MARKER`, so the method stays referenced and the DTS
build still runs.

- **Landed on disk:** anchor 1 → 0, replacement 0 → 1, blob
`399ddf47c127` → `2cf40c49e6e2`. `objectql` was rebuilt (exit 0), and
`ablation-dist-preflight` found the marker in 2 built files.
- **objectql pins (from `src`):** 5 failed / 11 passed of 16 in
`protocol-boot-hydration-scoped.test.ts`. All 5 refusal pins went red:
per type, the environment-held name and the row bound to the package,
plus every conflict in one refusal. The controls stayed green: distinct
names per type, and a built-in name the platform declares beside a
stored definition.
- **runtime pins (from `dist`):** 1 failed / 5 passed. The artifact-boot
case went red; objectstack-ai#22197's five stayed green.
- **dogfood pins (from `dist`):** 2 failed / 2 passed. The cold-boot
case and the legacy-row case went red; the built-in shadow and
distinct-name controls stayed green.
- **Base readings under ablation** (an uncommitted probe): the cold boot
booted with two collision warnings; the row bound to the package booted
cold and was refused hot; the `member_default` overlay booted; S2b
booted.
- **Restore:** blob back to `399ddf47c127` == HEAD, `git diff HEAD`
empty, `git status --porcelain` empty. After a rebuild,
`ablation-dist-preflight --absent` is green: the marker is absent from
all 14 built files and the tree is clean.

The ablation ran at a15b8af. The second `main` merge (e3ae92a)
brought objectstack-ai#22331's `plugin.ts` hunks, none of them on this check's lines,
and the refusal pins were re-run green at 72dcb8e.

## Clause-② (measured on the built entry declarations at 72dcb8e)

`packages/objectql/dist/{index,core}.d.ts` and the shared chunk declare
no new exported name. `findEnvironmentHeldSecurityCatalogNames`,
`ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` and
`SecurityCatalogNameConflictError` are absent from the entries' export
lists. The only new declaration text is three private member names
(`SchemaRegistry.environmentHeldSecurityCatalogConflicts`,
`SchemaRegistry.securityCatalogPackageHolders`,
`ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) plus JSDoc.
No widening was found, so `Clause-②: no` stands.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` derived 81 commands at
the head, 3c160a2. All 81 ran with exit codes recorded, and `--ran`
reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The
same 81 were derived and run at 72dcb8e, with the same answers.

One exited 1, by design: `check-empty-changeset --base origin/main`. It
is the deliberate correction of objectstack-ai#22135's pending note (see Acceptance
notes), and the gate's own text says to confirm that class on the PR,
not restore the note.

On e3ae92a, `check:dual-build-cjs-loads` first answered PREREQUISITE
NOT MET (exit 3) until eight packages outside this change were built:
`studio`, `client-react`, `embedder-openai`, `knowledge-memory`,
`knowledge-ragflow`, `organizations`, `service-cluster-redis` and
`service-knowledge`. On 72dcb8e and 3c160a2 it exits 0.

The changeset gates: `check-changeset-no-major --base` exits 0 (pre mode
`next`), `check:adr-0087-registration` exits 0, and
`check:changeset-gate-self-tests` exits 0.

CI's own lanes are declared to CI and are NOT MEASURED here: the Test
Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and
the workspace type-check lanes. `origin/main` is 7 commits ahead of the
head, among them objectstack-ai#22352 (`plugin-security` grant readers) and objectstack-ai#22353
(`metadata-protocol` seed loader); none touches a file of this PR. `git
merge-tree` against it is clean, so `main` was not merged again.

## Acceptance notes

- **A pending release note this PR corrects (`check-empty-changeset`
stays red by design).**
`.changeset/22135-security-catalog-one-holder.md` is objectstack-ai#22135's pending
note, not yet consumed by a release (`packages/objectql` is at
`17.7.0`). Its "What is NOT refused" paragraph said a package added at
cold boot over an environment-held name "is not refused at cold boot".
On this PR's merge that sentence is false, and both notes would publish
in the same release. That one sentence now says the door cannot see the
name at cold boot, and that the engine checks it right after the
environment catalog loads and refuses the boot. Nothing else in the note
changed. The gate's own text names this shape a DELIBERATE CORRECTION,
to be confirmed on the PR, not restored. If a release consumes the note
before this PR lands, the edit no longer reaches a published CHANGELOG,
and the correct move then is an erratum PR against that CHANGELOG entry.
- **The 2026-08-24 legacy-overlay remedies lose their boot-time
population for code-package-declared sets.** The overlay detection
reading and the drift pass's `overlay_shadow` run in `plugin-security`'s
`kernel:ready`. A boot carrying an environment overlay of a
package-declared set is now refused before `kernel:ready`, so on a
deployment that boots, those branches see no such overlay. The same
holds for the Discard Overlay action's discard path for such a set. The
ruling names this cost ("including rows saved before the packaged
locks"). The upgrade route is in the changeset: rename, or remove the
row. A deployment can also run Discard Overlay on the release it runs
now, before upgrading.
`permission-set-discard-overlay-eligibility.dogfood.test.ts` (objectstack-ai#21860's
pin) wrote its legacy overlay before a cold boot, which is now refused.
It now writes the overlay into the running deployment and runs the two
passes the boot ran for it, by the functions the security plugin's boot
calls (`reconcilePermissionSetProjection`, then the drift pass), so its
preconditions and its control still hold.
- **The refusal leaves `start()`, so the kernel wraps it.**
`bootstrap()` rejects with `Plugin com.objectstack.engine.objectql
failed to start - rollback complete: …`, and the envelope is the
wrapper's `cause`, as with any `start()`-time refusal (objectstack-ai#22197's
item-seam refusal from `plugin-security.start` included). The pins read
`cause`.
- **Org-scoped rows are not judged.** Boot hydration loads env-wide rows
only (`organization_id IS NULL`), and org-scoped rows never reach the
registry, so the check judges the env-wide catalog. That is the
population hydration serves.
- **A refused boot over a `sqlite-wasm` file can still flush after the
refusal.** In a probe, removing the database directory right after the
refused `bootStack` raised `ENOENT` from the driver's atomic write. The
committed dogfood file keeps its database files in the test file's
working directory, which the dogfood run removes at its end, and never
boots a file again after it was refused. Noted, not filed: a boot that
failed has no process left to serve.
- **Files outside the engine lane:**
-
`packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts`
(new) and
`packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts`
(re-shaped, above): `domain:cli`.
-
`packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts`
(one case added, and the artifact-stack helper takes a `databaseUrl`):
`domain:cli`.
-
`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.
  - `.changeset/22135-security-catalog-one-holder.md` (above).

## Patch round 1 — the release note's remedy, completed

Both contract reviews passed: 6070947709 on this PR, which also confirms
the correction of objectstack-ai#22135's pending note, and 6070955792 on the ADR PR.
This round changes text only. The code, the pins and
`.changeset/22135-security-catalog-one-holder.md` are unchanged. The
head is cf1a9dd.

- **`.changeset/22307-cold-boot-catalog-refusal.md`.** "The upgrade
shape" names the legacy plural types. "The one-line fix" now has three
parts:
- **Before upgrading, for a permission set.** The `kernel:ready` overlay
reading names the sets this release refuses. The audited Discard Overlay
action, or `DELETE /api/v1/meta/permission/NAME`, removes each overlay
without touching the database, including on the platform's own sets.
- **After upgrading, for a package that can be left out.** Boot without
it, then delete through the metadata API.
- **After upgrading, for a name the platform security plugin declares.**
The SQL delete of the active, environment-wide rows under the type or
its legacy plural.
The changeset also says that no `os` command deletes a `sys_metadata`
row offline.
- **`content/docs/permissions/permission-sets.mdx`.** One clause under
"Declared ≠ enforced", on the Discard Overlay remedy: discard such an
overlay before you upgrade, because a deployment that still holds one
does not boot.

**Measured, clause by clause:**

- **The current release.** This branch with the check ablated through
`scripts/ablation-replace.mjs` (blob `9b18363e90ef` → `b3701fcc3a70`,
marker in `dist/`), a legacy `member_default` overlay written at the
driver, then a restart:
- The boot logged one `kernel:ready` warning, "[security] 1
package-declared permission set(s) are being shadowed by an environment
overlay — … use the audited "Discard Overlay" action on it …", naming
`member_default`.
- The record read `drift_status: overlay_shadow`, and Discard Overlay
answered `200` and left no active row.
- On the same release, `DELETE /api/v1/meta/permission/viewer_readonly`
over a legacy overlay of that platform set answered `200`
("Customization overlay deleted — permission/viewer_readonly reset to
artifact default") and left no active row. So Discard Overlay is not the
only database-free remedy before the upgrade; the changeset names both.
- **The restore.** `ablation-replace` put the blob back (== HEAD, `git
diff HEAD` empty). After the rebuild, `ablation-dist-preflight --absent`
was green on `dist/` at once. It was green on the tree once this round's
doc edit, the one dirty path at that moment, was committed (cf1a9dd).
- **The head, check live:**
- The database on which the current release ran Discard Overlay on
`member_default` boots.
- Rows of type `permissions` and `positions` (the legacy plurals) over
package-held names refuse the restart, both named.
- A `draft` row over a third package-held name is not loaded and not
named.
- `loadMetaFromDb` selects `state: 'active'` and `organization_id:
null`, and folds the type through `PLURAL_TO_SINGULAR`, which maps
`permissions` to `permission` and `positions` to `position` on `main`.
It sets no `package_id` condition: a row bound to the package itself
refuses too, measured in the first round.
- **The SQL.** The changeset's `DELETE` statements, run through Python's
`sqlite3` against the refused database files (one per type, and one for
`member_default`), deleted 1 row each. Each restart then booted.
- **The CLI.** `os meta delete` and `os data delete` build an API client
and require a token (`createApiClient`, `requireAuth`), and no command
under `packages/cli/src/commands` deletes a `sys_metadata` row.
- **The action.** `discard_permission_set_overlay`, labelled "Discard
Overlay", on `sys_permission_set`, in the list-item and record-header
locations, visible while `drift_status` is `overlay_shadow`. It is
documented on `content/docs/permissions/permission-sets.mdx` under
"Declared ≠ enforced — diagnosing a frozen package set". Positions have
no overlay reading (it reads the `permission` / `permissions` types) and
no such action.
- **NOT MEASURED:** the metadata API delete on a set a non-platform
package ships, and a position overlay before upgrading.

**Gates at cf1a9dd.** `dispatch-gates --commands` derived 107
commands; the doc page added the docs families. All 107 ran with exit
codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106
exited 0, including `check-changeset-no-major --base`,
`check-adr-0087-registration --base`, `check:doc-authoring`,
`check:docs-*`, `check-doc-frontmatter`, `@objectstack/spec`'s
`check:docs` and `check:doc-formula-expressions`. One exited 1 by
design: `check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135
correction. `origin/main` is 12 commits ahead; `git merge-tree` against
it is clean, so `main` was not merged.

**One more file outside the engine lane:**
`content/docs/permissions/permission-sets.mdx` (`domain:devx`).

## Patch round 2 — the metadata-API delete reaches singular-typed rows
only

The at-tier contract review on cf1a9dd (6071828819) failed two remedy
sentences, and judged everything else right: the code, the objectstack-ai#22135
correction (confirmed on that head), case 3's SQL, the CLI sentence, the
docs clause and the semver. The two sentences are case 1's "So does
`DELETE /api/v1/meta/permission/NAME`" and case 2's metadata-API delete.
Both are false for a row stored under the legacy plural `permissions` /
`positions`, a shape the changeset's own "upgrade shape" paragraph
names. This round changes
`.changeset/22307-cold-boot-catalog-refusal.md` only. No code, pin, docs
page or `.changeset/22135-security-catalog-one-holder.md` change. The
head is 39ef237.

**Measured first; the review's reading holds.**

- **The current release** (this branch with the check ablated through
`scripts/ablation-replace.mjs`, blob `9b18363e90ef` → `b3701fcc3a70`,
marker in `dist/`):
- A legacy overlay of `viewer_readonly` stored under `permissions`:
`DELETE /api/v1/meta/permission/viewer_readonly` answered `200` with
`{"success":true,"reset":false,"message":"No customization overlay found
for permission/viewer_readonly — already at artifact default."}`, and
the `permissions` row stayed active. Discard Overlay on the same set
answered `200` and left no active row.
- `mcp_agent_restricted` with two active rows, one bound to no package
and one bound to `com.objectstack.plugin-security`: the first `DELETE`
answered `200` "Customization overlay deleted — … reset to artifact
default" and removed one row, leaving the bound one. A second `DELETE`
removed it.
- **The head, check live, case 2.** A package's permission set and
position stored under `permissions` / `positions`. Booted without the
package, `DELETE /api/v1/meta/permission/pr2_set` answered `200` "No
permission 'pr2_set' found — nothing to delete.", and `DELETE
/api/v1/meta/position/pr2_pos` answered "No position 'pr2_pos' found —
nothing to delete." Both rows stayed active, and the boot with the
package added back was refused, both names held by `environment`.
- **The restore.** Blob == HEAD and `git diff HEAD` empty. After the
rebuild, `ablation-dist-preflight --absent` is green on `dist/` and on
the tree.

**The text fix, as the record names it:**

- **Case 1:** "neither touches the database" now reads "neither needs
direct database access".
- **Case 3's heading** now reads "for a name the platform security
plugin declares, or for any row the metadata API does not reach".
- **One paragraph after the three cases**, before the CLI sentence:
- the two `DELETE` routes reach a row stored under `permission` or
`position` only, one row per call;
- a plural-typed row is not reached: `200`, nothing found, nothing
removed;
  - where a name has two active rows, each call removes one;
- a plural-typed row is removed by Discard Overlay before upgrading (a
permission set), or by the SQL above after upgrading, for any name.

This also corrects round 1's summary above: the metadata-API delete is a
database-free remedy before the upgrade only for a row stored under the
singular type.
- `content/docs/permissions/permission-sets.mdx`'s clause does not name
the metadata-API delete, so the page is unchanged.

**Gates at 39ef237.** `dispatch-gates --commands` derived 107
commands. All 107 ran with exit codes recorded, and `--ran` reconciles
107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design:
`check-empty-changeset --base origin/main`, the confirmed objectstack-ai#22135
correction. `origin/main` is 22 commits ahead. `git merge-tree` against
it is clean, so `main` was not merged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants