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

Open
objectstack-fleet[bot] wants to merge 13 commits into
mainfrom
claude/issue-22135-security-catalog-one-holder
Open

objectstack-fleet[bot] wants to merge 13 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. Raised for a decision in the report.
  • 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).


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 fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c.

⛔ 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 fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dc59f1371fb60bdc9fa1e32742c515543a0195df — the merge of head f16fcd0c7c1c808a1397887fc4334ce804326aa8 into base fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c, 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 dc59f1371fb60bdc9fa1e32742c515543a0195df && git checkout dc59f1371fb60bdc9fa1e32742c515543a0195df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c f16fcd0c7c1c808a1397887fc4334ce804326aa8 && git checkout -B drift-repro fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c && git merge --no-ff f16fcd0c7c1c808a1397887fc4334ce804326aa8

node scripts/docs-audit/affected-docs.mjs --json fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c

⚠️ 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 fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c → 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.

…uilt-in holder's own registration

At the item seam a built-in position or capability name is held by the
platform, and plugin-security's registerBuiltinPositions (SecurityPlugin.start)
is that holder declaring it. The environment catalog is no longer asked for a
built-in name there: an environment item under a built-in name exists only
where an environment save went over the platform's name (outside the ruling),
and boot hydration (ObjectQLPlugin.start) registers it before the platform
declares, so asking it refused the platform's own declaration and the boot.
The stored definition keeps answering first from the bare slot (S2b's
shadowing). A second PACKAGE registering a built-in name is still refused, in
either order; non-built-in names are judged as before.

Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2
Co-authored-by: Claude <noreply@anthropic.com>
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.

This branch has not been deployed

No deployments
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