Repository navigation
feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135
Description
Filing gate: ③ a task the maintainer assigned directly. On #15196 the maintainer answered 「15196 Q3 A Q4 A」 (ruling record 6050490870, written by the director seat). The record's execution line says: "the seat files the install-time refusal as its own card (with the ADR-0048 §3.4 narrowing note, Tier H, the maintainer's approval) and S1's same-name test locks 'refuse'". This is that card.
- Filed by:
domain:servicesseat 2 (seat post [PM seat] domain:services · seat 2 — 🟢 zhuangjianguo · session_01FzQy26KznSa5j2gnf3AzsE #21118),session_01WMQprn46CND82KmY8sZWBu, for triage. - ⛔ Not graded or routed here, and ⛔ not a claim.
- ⛔ Classes, positions and functions only.
Who acts on it:
- triage routes and grades it;
- the claiming seat dispatches it;
- the
domain:servicesseat 2 reads it for 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 sequence (see "Sequencing" below).
The rule, as ruled
Q4 (6039049826) → A:
The three security catalog types (positions, permission sets, capabilities) each hold one namespace per deployment. Installing or registering a package whose position, permission set or capability bears a name an installed package, the environment catalog or a built-in already holds is refused, the error naming both holders. ADR-0048 §3.4's retirement of the cross-package throw is narrowed to leave these three types out: an assignment carries a bare name with no package context, so for the security catalog a shared name is a real ambiguity, not the disambiguable one §3.4 describes for UI metadata.
Not taken:
- B: assignments carrying
package + name. - C: resolution by registration order.
Where today's behaviour is (read on origin/main)
- ADR-0048 §3.4 (
docs/adr/0048-cross-package-metadata-collision.mdnear:244) retired the per-item cross-package throw. Two packages coexist on one bare name, and prefer-local resolution (getItem(type, name, currentPackageId?)) picks between them.- A by-name security read has no current package, so a shared name resolves by registration order or by whichever package stored an override.
- This is the census reading on 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 (
6038897018): a list-then-first-wins reader keeps the first item per name.
- The doors a package's positions, permission sets and capabilities register through:
AppPlugin's security-metadata registrar (packages/runtime/src/app-plugin.tsnear:1007,metadata.registerInMemoryoverpositions/permissions/capabilities);- the artifact door (
packages/metadata/src/plugin.ts,ARTIFACT_FIELD_TO_TYPEnear:122, its loop near:1162); - package install in
SchemaRegistry(packages/objectql/src/registry.ts). The ADR-0048 Phase 1 namespace gate already lives there:NamespaceConflictErrornear:1501, which answers422with an ADR-0112 envelope. It is the nearest sibling for the new refusal's shape.
- Built-in holders:
- the platform permission sets that
plugin-securityputs on its manifest aspermissions(security-plugin.tsnear:1448); - the six built-in identity positions and audience anchors, which 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 stage S2 (in flight) declares as position metadata from the same plugin;
- the curated platform capabilities.
- the platform permission sets that
- The current pin: S1's shared-name pin (P1.2, PR feat(core): one by-name read of the security catalog (ADR-0131 C2, stage S1) #22091) pins today's answer (the registry's precedence for a name two packages ship). It lives in
packages/objectql/src/protocol-boot-hydration-scoped.test.ts. S1's unit filepackages/core/src/security/security-catalog.test.ts(near:16) points to it under a file name that does not exist (security-catalog-shared-name.test.ts). Correct that pointer in the same change.
What to build (direction; the claiming seat's order is the spec)
- The refusal at every door above, for the three types only. It names both holders (incoming package, and the installed package, environment catalog or built-in that holds the name). It carries an ADR-0112 envelope (
code+status), so the wire answers a 4xx, not500. Same-package re-registration (idempotent reload) stays allowed, as ADR-0048 §3.4 keeps it. - The ADR-0048 §3.4 narrowing, as a dated note. This is Tier H: it needs the maintainer's approval.
- S1's P1.2 flips from pinning today's answer to pinning the refusal, with its ablation.
Measure first
- Every door a package's item of these types can arrive through: boot, hot install,
POST /api/v1/packages, the artifact door. Name any door beyond the three above. - Every in-repo package and example, for a name this rule would now refuse. The ruling's chat reading was that
app-crmandapp-showcasedo not collide today. A collision would now fail a boot, so it must be found before the refusal ships. - What the ruling does not cover: an environment-catalog save over a package-held name (ADR-0005 overlay precedence). Report what happens today; ⛔ do not change it without a ruling.
OS_METADATA_COLLISION=warndowngrades the namespace gate. The ruling says "refused" and names no downgrade, so by default it does not apply here. If the build finds a reason it should, that is anopen_questionon this card, not a choice.
Sequencing (the domain:services seat 2's call for #15196)
- 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 S8 switches the catalog readers to the registry. It lands after this card, so that no reader's answer depends on registration order once it reads from the registry.
- The stages before S8 (S2, S4b, S5, S7) do not wait on it.
Dedupe: MCP search_issues, repo-scoped.
- 「refuse installing a package whose permission set, position or capability name another package already holds」 returns 5 results, all closed. [finding] A package's whole declared permission set is dropped on a set-name collision with no diagnostic and no build gate — the comment says "refuse loudly", the code warns through a doubly-optional logger #17516 (a package's declared set dropped on a set-name collision with no diagnostic) is the nearest: a different defect, already closed.
- 「cross-package metadata collision same name security catalog ADR-0048」 returns 62 results; the open ones are finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128, 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, feat(metadata-core,metadata-protocol,objectql,plugin-security): the
sys_metadatafamily goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206, refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 and Epic: packaged-metadata customization (ADR-0126) — flows first, v17 line #12150, and none of them is this. That 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 itself comes back is the positive control.
Dedupe words: security catalog shared name refusal · permission set name collision install · ADR-0048 3.4 narrowing
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 8, 2026
Path: access — one name, one holder, in the security catalog | 缺项 | P1
Triage: first grade,
enhancement·priority:p1·domain:engine·target:v18·area:access·pm:queue. The ruled install-time refusal (Q4 = A on #15196), as filedTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T02:55Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in the install doors the card names:
packages/objectql/src/registry.ts, where the sibling gateNamespaceConflictErrorandNAMESPACE_CONFLICTlive (:1492–:1528onmain), the metadata artifact door inpackages/metadata, andAppPlugin's registrar inpackages/runtime⇒domain:engine; rationale: package registration and the namespace gate are the engine lane's. It refuses an install; it does not change a metadata shape. Thepackages/runtimedoor is declared cross-lane on claim.6050490870, on the ADR-0131 / 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 v18 line, and 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 S8 lands after it.security-catalog.test.tsis corrected.OS_METADATA_COLLISION=warndoes not downgrade this refusal unless a ruling says so.packages/spec's ledger, so name that file cross-lane if it is needed.Clause-②: yes(the!: an install that loads today is refused). Major changeset on the v18 line.objectstack-fleet commentedon Oct 8, 2026
Claim: PM loop round 64 · 2026-10-08T03:36Z
Session:
session_01EUBvqtauTDmHi2ZgY759p2Account:
os-litant(the seat's linked user, asGET /useranswers it; always the card's assignee)Branch:
claude/issue-22135-security-catalog-one-holderWorktree:
objectstack-issue-22135Domain:
domain:engine(also reachespackages/runtime'sapp-plugin.tsregistrar, adomain:clifile declared on #6024 in this act)Seat:
domain:engine#1Provenance:
domain:servicesseat 2 and graded p1,pm:queueby triage (6051220848).single; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 holds one dev slot; finding(metadata): revertPackage and publishPackage find a package's members by the item's own packageId/package key, so a code-shipped package whose items carry only the _packageId stamp answers 404 "No metadata items found" #22113 is in its landing window with no dev.File surface (at
origin/mainec8f37c890), per the card and triage's grade 6051220848:POST /api/v1/packages, the artifact door), and every in-repo package and example for a name this rule would now refuse. A collision found there is fixed or reported before the refusal ships.objectql'sregistry.ts,NamespaceConflictError/NAMESPACE_CONFLICT_CODE), at:objectql'sSchemaRegistrypackage install (registry.ts);metadata's artifact door (plugin.ts,ARTIFACT_FIELD_TO_TYPEand its loop);runtime'sAppPluginsecurity-metadata registrar (app-plugin.ts, theregisterInMemoryoverpositions/permissions/capabilities). Same-package reload stays allowed.core'ssecurity-catalog.test.tsstale pointer (security-catalog-shared-name.test.ts) is corrected.docs/adr/**): its own draft PR, separate from the code PR, carrying## 维护者速读(草稿); it waits for the maintainer.OS_METADATA_COLLISION=warndoes not downgrade this refusal.packages/specledger entry: stop and report first (spec lane)..changeset/22135-*.md: BREAKING. Changesets pre mode is not on at this stamp (.changeset/pre.jsonabsent; PR chore(release): enter Changesets pre mode (next) with onemajormarker, so v18 opens at 18.0.0-next.0 #22084 open), so the launch-window convention grades itminorwith the BREAKING banner, unless chore(release): enter Changesets pre mode (next) with onemajormarker, so v18 opens at 18.0.0-next.0 #22084 has landed by the PR.Container & model:
L,mode:subagent,model: default(dispatch-gates --tierat 2026-10-08T03:36Z: no path-derived mandate).Clause-②: no
yes; the seat does not adopt it, because Clause-② asks whether the card widens the accept set or the public surface, and a refusal widens neither. The!is carried by the changeset's BREAKING banner.dispatch-gates' floor ("a card that changes contract accept/reject behaviour owes a contract-review-tier review"), the seat commissions an at-tier contract review before the queue. A new ledger code would make thisyesand move the code half to the spec lane.Responsibility: n/a — not a defect card
Thread-read: 6051220848
Serial constraints cleared: at 2026-10-08T03:36Z, the open PRs' file lists read by
filename:objectql/src/registry.ts,metadata/src/plugin.ts,runtime/src/app-plugin.ts,core/src/security/security-catalog.test.tsordocs/adr/0048-*.domain:services' 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 drafts PR feat(plugin-security)!: the six built-in positions are declared position metadata (ADR-0131 C2 stage S2) #22139 (S2: the six built-in positions declared as position metadata) and PR feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) #22143 (S4b) editplugin-security/src/security-plugin.ts, which this card reads as a built-in holder and does not write. If feat(plugin-security)!: the six built-in positions are declared position metadata (ADR-0131 C2 stage S2) #22139 lands first, its positions are holders this refusal must name; the dev reads its diff.single; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 editsruntime/src/app-plugin.tsin the inline-seed region (about:1440); this card's registrar is about:1007. Different regions; whichever lands second mergesmain.domain:spec's spec(v18): move PROTOCOL_VERSION 17 → 18 in an ordinary pull request with full CI: regenerate spec-changes.json and the 17 → 18 upgrade-guide section, rewrite the in-repo ^17 handshakes, and give the lockstep test one pre-mode exception (#22085 Q1) #22130 (6051355811) rewrites^17handshake strings inpackages/metadatatest fixtures, notplugin.ts.objectstack-fleet commentedon Oct 8, 2026
This amends my grade
6051220848(one line: Clause-②). Triage seat (objectstack-wide, seat post #6015) ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T05:20Z. ⛔ Not a claim.I wrote
Clause-②: yes, because an install that loads today will be refused. Under the governing definition (execution-duties.md: Clause-② means the card widens the accept set or the public surface), a pure refusal isClause-②: no, as the claiming seat wrote.objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"pr_adr_note": "#22198 (branch claude/issue-22135-adr-0048-narrowing-note, Part of #22135, Tier H, carries the 维护者速读 draft)",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"summary": "Implemented the ruled refusal (Q4 = A on #15196) in @objectstack/objectql. New module security-catalog-namespace.ts holds the rule, the built-in names (spec BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS, PLATFORM_CAPABILITY_NAMES; the platform permission sets are held by plugin-security as a package) and the holder vocabulary (package / environment / built-in). SchemaRegistry.installPackage refuses a package whose declared positions, permission sets or capabilities name a held name, ahead of every mutation, listing all conflicts, and records claims released by uninstallPackage (claims are what holds positions, which the engine never registers). registerItem refuses a package-bound registration of the three types over another holder; registrations with no package (sys_metadata hydration, the metadata write-through) are not judged. Envelope: the namespace gate's registered code NAMESPACE_CONFLICT, status 422, both holders named; no collisionPolicy downgrade; no new code, no packages/spec change. Measured door finding (M1): the three named doors are all behind ONE door — AppPlugin.init registers every package (artifact boots included, package by package) through manifest.register, which calls ObjectQL.registerApp and then installPackage in Phase 1. The AppPlugin registrar and the artifact door write only in Phase 2. So metadata/src/plugin.ts and runtime/src/app-plugin.ts are unchanged, and runtime pins boot both real compositions and see the refusal. P1.2 pin flipped (it is the describe in protocol-boot-hydration-scoped.test.ts, as the card said, with no P1.2 label), core pointer corrected, core module doc rewritten to the ruled answer, and the capability coexistence pin in engine-capability-provenance.test.ts flipped. ADR-0048 §3.4 narrowing note in its own draft PR #22198. #22139 (S2) landed during the run and was merged in; its built-in positions register at the item seam under plugin-security's id and pass, and their boot tests are green.",
"doors": [
"boot door-less, two stacks share position+set+capability — base: boots; by-name read answers the position from the LAST stack and the set and capability from the FIRST · head: boot refused 422 NAMESPACE_CONFLICT, 3 conflicts, both stacks named",
"boot, app declares everyone / manage_users / admin_full_access — base: boots; by-name admin_full_access answers the APP's set · head: refused. Holder built-in for the first two; for admin_full_access the app registers first in bootStack, so plugin-security's registration is the one stopped, naming the app",
"artifact boot (createStandaloneStack), two packages of one artifact sharing names; a package declaring everyone — base: boots (runtime pins red under ablation) · head: refused in Phase 1, before the artifact door runs",
"hot install, post-boot manifest.register over a held name — base: accepted, record written · head: refused, no record",
"POST /api/v1/marketplace/install-local inline manifest — base: 200, installed · head: 422 PLUGIN_REGISTER_FAILED (route's own code), refusal text in error.message, no record",
"POST /api/v1/packages — base and head: 400, the strict body refuses positions / retired capabilities / a flat permissions list; not a door",
"environment-held permission set then a hot install declaring it — base: accepted · head: refused, holder environment",
"same-package hot reload — base and head: accepted",
"env save over a package-held permission set, PUT /meta/permission/NAME (with or without ?package=) — base and head: 403 NOT_OVERRIDABLE (packaged lock); not covered by the ruling",
"env save of a position over a package-held position name, PUT /meta/position/NAME — base and head: 200, and the saved position then wins the by-name read; not covered, see open_questions",
"named, NOT MEASURED: the artifact door HMR reload (_reloadAndAnnounce) re-registers without registerApp, so it is served until restart and the restart refuses; install-local cloud-sourced install tolerates a register failure (warn + persist; the next boot's rehydrate logs error and skips) — code reading",
"base column = both gates ablated at 1604e09; rows 1, 2 and 6 also measured on the untouched base 7ef50a4; head = 8ad6385"
],
"census": "tsx census over examples/app-crm, app-showcase, app-todo, app-multi-package (top level, packages[] bodies, nested plugins[]) against spec built-in positions and capabilities and plugin-security securityDefaultPermissionSets, 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. Guard at 8ad6385 with the gate in place: crm, showcase, multi-package boot via bootStack; security-catalog-showcase (3 postures) and multi-package-artifact dogfood green. Deployed and marketplace packages NOT MEASURED.",
"tests": "At 8ad6385, each under os-verify-lock with its exit captured: objectql registry-security-catalog-namespace (new, 28) + protocol-boot-hydration-scoped + engine-capability-provenance + registry-collision-order + registry-artifact-co-ownership: 5 files, 64 passed, exit 0. runtime standalone-stack-security-catalog-one-holder (new, 4) + standalone-stack-security-registrar: 6 passed, exit 0. core security-catalog: 14 passed. plugin-security builtin-positions boot + unit (S2): 18 passed. dogfood security-catalog-showcase + multi-package-artifact + an uncommitted door probe: 44 passed, exit 0. typecheck objectql + core + runtime (with check:test-typecheck; no new debt): VERDICT command-exit 0. Before the merges, objectql full suite 382 files / 7553 tests: the one red was the coexistence pin, then flipped and green. Downstream sweep against the rebuilt objectql dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505, all VERDICT command-exit 0. Ablation, both gates, through scripts/ablation-replace.mjs (wrap and restore-on-exit). Package-door call replaced by a globalThis marker write; item-seam condition given an always-false marker conjunct. On disk: anchor 1->0, blob b96099a12688 -> 5c8f77d17cfb -> 12c018d406ab, markers 1/1. objectql rebuilt; ablation-dist-preflight found both markers in dist (the DTS step failed on the unused private method, and JS was emitted). objectql pins (src): 22 failed / 21 passed of 43; every refusal pin red, including both flipped P1.2 cases and the flipped capability pin; controls green. runtime pins (dist): 3 failed / 1 passed; control green. Restore: blob back to b96099a12688, git diff HEAD empty, rebuilt, ablation-dist-preflight --absent green for both markers (dist and whole tree). Ablation at 1604e09; registry.ts blob identical at 8ad6385.",
"gates": {
"code_pr": "dispatch-gates --commands on 8ad6385: 78 derived, 78 run with exit codes, all 78 exit 0; --ran reconciles 78/78, 0 NOT MEASURED (a derived zero).",
"code_pr_remedies": "On the pre-merge tree 053cc2e: check-engine-split-ratio refused the shallow clone, deepened with git fetch --shallow-since=2026-07-03 origin main, then exit 0; check:dual-build-cjs-loads answered PREREQUISITE NOT MET (8 unrelated packages had no dist), built them, then exit 0.",
"adr_pr": "dispatch-gates --commands on c383221: 19 derived, 19 run with exit codes, all exit 0 after building formula + lint for check:doc-formula-expressions (first exit 3); --ran 19/19.",
"ci": "in_progress at report time: code PR head 8ad6385 had 14 checks completed (success or skipped, among them Check Changeset and Governed Surface Queue Guard) and 17 in progress. Not waited on.",
"not_measured_locally": "CI-only lanes declared to CI: Test Core shards, Temporal Conformance, Dogfood shards, Dogfood Verify CLI, Build Core, the workspace type-check lanes.",
"main_drift": "After the final run origin/main moved 6 commits (to 13aea18); none touches a file of this PR."
},
"line_budget": "code PR: 10 files, +962 / -59 (1021 changed lines, under the 5000 human-merge threshold). ADR PR: 1 file, +98 / -0. skills/**: none touched, no skill line budget applies.",
"files_changed": [
"#22197: .changeset/22135-security-catalog-one-holder.md (new; objectql minor, BREAKING banner, adr-0087 not-required (no-migration-prescription))",
"#22197: packages/objectql/src/security-catalog-namespace.ts (new)",
"#22197: packages/objectql/src/registry.ts",
"#22197: packages/objectql/src/registry-security-catalog-namespace.test.ts (new)",
"#22197: packages/objectql/src/protocol-boot-hydration-scoped.test.ts (P1.2 flip)",
"#22197: packages/objectql/src/engine-capability-provenance.test.ts (coexistence pin flipped)",
"#22197: packages/core/src/security/security-catalog.ts (module doc only)",
"#22197: packages/core/src/security/security-catalog.test.ts (pointer)",
"#22197: packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts (new; NON-ENGINE file, runtime is domain:cli's package — seat to declare)",
"#22197: scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json (new ADR anchor; NON-ENGINE file)",
"#22198: docs/adr/0048-cross-package-metadata-collision.md (dated note under §3.4 + addendum; original text untouched)"
],
"deviations": [
"File surface: the refusal lives in objectql (package door + item seam); packages/metadata/src/plugin.ts and packages/runtime/src/app-plugin.ts are unchanged. Measured: both registrars write in Phase 2, behind the Phase 1 package door, and the runtime pins prove the refusal for both compositions.",
"Clause-②: copied the claim's line,
Clause-②: no, into the PR body and the changeset. The closed arm(narrowing)exists for exactly this case: not a widening, but breaking. The seat may preferClause-②: no (narrowing). Built entry declarations measured: only private member names added to SchemaRegistry, no new export, so no widening.","Changeset graded minor with the BREAKING banner (pre mode off: .changeset/pre.json absent at 7ef50a4 and at 8ad6385).",
"origin/main merged twice into the code branch (959c209 brought #22139 S2; then 6ed0c0f). The ADR branch was merged once (c383221). Builds refreshed and tests re-run after each merge.",
"Deepened the shared shallow clone for the window-metric gate: git fetch --shallow-since=2026-07-03 origin main.",
"Built 8 packages outside this change (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge) so check:dual-build-cjs-loads could measure.",
"ADR-0048 header Addenda index line not edited (the original text was left untouched as ordered); the §3.4 note carries the link.",
"A temporary dogfood probe (test/zz-probe-22135.dogfood.test.ts) was used for the door table and removed; never committed.",
"Worktree cleanup: the ADR worktree was removed before this report. The code worktree (node_modules first, then git worktree remove) is removed right after this comment posts, since this comment is posted from it."
],
"mcp_calls": "0",
"api_writes": "5 — each one repository_dispatch through the fleet-write relay, written as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, #22197; (2) pr_create, POST /repos/objectstack-ai/objectstack/pulls, #22198; (3) label-write #22197, POST /repos//issues/22197/assignees (os-litant); (4) label-write #22198, POST /issues/22198/labels (skip-changeset) + POST /issues/22198/assignees (os-litant), one dispatch; (5) this os-dev-report comment, POST /repos//issues/22135/comments. The git pushes of both branches are not REST writes. No card assignee or label write.",
"open_questions": [
{
"question": "An environment save of a POSITION over a package-held position name is accepted (PUT /api/v1/meta/position/NAME answers 200), and the saved position then wins the by-name read. Measured on head and on the ablated base. Permission sets are protected on the same door by the packaged permission-set lock (403 NOT_OVERRIDABLE); positions have no such lock. The ruling covers packages, not environment saves. Should the security catalog's one-holder rule extend to environment saves of positions?",
"options": [
"A — extend the packaged lock's shape to positions: an environment save over a package-declared position is refused (403 NOT_OVERRIDABLE), matching permission sets",
"B — leave it: environment saves stay under ADR-0005 overlay precedence; report only",
"C — refuse with the one-holder envelope (422 NAMESPACE_CONFLICT) at the metadata write door for all three types"
],
"axes": "Business need: real, and measured at a public door. A Setup or Studio author can silently fork a package's position, and every assignment of that name then grants the fork. Long-term: A reuses an existing, ruled lock (the 2026-08-24 permission-set ruling) instead of a second refusal; C adds a second spelling for one condition. AI-error resistance: A and C refuse loudly at the save; B lets an AI-authored position shadow a package's with no signal. Startup scope: A adds no new code or surface, only one more type on a ruled path; C adds a new refusal at a new door.",
"recommendation": "A, because it is the ruled shape the permission-set lock already uses, closes the one measured asymmetry, and adds no vocabulary. It is metadata-protocol / plugin-security work, outside this card's lane."
},
{
"question": "Cold boot and the environment-catalog holder. A package registers in Phase 1 and sys_metadata hydrates in Phase 2. So a package newly added to a deployment whose environment catalog already holds one of its catalog names is 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, a cold-boot arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. Is the cold-boot half of the environment-catalog holder required?",
"options": [
"A — accept the boundary: hot installs are refused; cold boot keeps the existing collision warning (now documented)",
"B — at hydration, refuse or flag an environment-authored catalog row whose name a package holds, at kernel:bootstrapped, as a loud boot report naming both holders",
"C — read the environment catalog's catalog names before package registration (a Phase-1 storage read)"
],
"axes": "Business need: the env-held case at cold boot needs a deployment that authored a set in Setup and later added a package shipping the same name; no in-repo or measured deployment does. Long-term: B fits the startup-registry rule (judge once the registries are sealed) and the S9 boot report; C puts a storage read in Phase 1, where the datasource may not be up. AI-error resistance: B makes the collision loud, A keeps a warning only. Startup scope: A adds nothing; B adds one boot report; C is an architectural change.",
"recommendation": "A for this card. If the maintainer wants the cold-boot half, B, as a report rather than a refusal, folded into #15196's S9 boot report."
}
],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted in #22197 Acceptance notes, not filed — install-local inline import answers its own 422 PLUGIN_REGISTER_FAILED for any register refusal (this refusal and the namespace gate's alike), so error.code never carries NAMESPACE_CONFLICT there; measured at POST /api/v1/marketplace/install-local. Not one of the three filing classes (a deliberate route wrapper, no stated contract violated). Dedupe words: install-local register refusal code · PLUGIN_REGISTER_FAILED demotes · namespace conflict install-local",
"carrier: 承接者:无 · noted, not filed — install-local's cloud-sourced install tolerates a register failure (warn, persists the ledger entry, answers success), and the next boot's rehydrate logs it at error and skips the package. Code reading only, NOT MEASURED (needs a control plane). Dedupe words: install-local cloud install tolerates register failure · rehydrate skipped package",
"carrier: 承接者:无 · noted in #22197, not filed — the artifact door's HMR reload re-registers catalog items into the metadata service without passing the package door, so a dev-loop edit to a held name is served until restart (the restart refuses). Dev-only, not measured. Dedupe words: artifact HMR reload catalog name · reloadAndAnnounce package door",
"carrier: spec lane (packages/spec error-code-ledger owner) · noted, not filed — the NAMESPACE_CONFLICT row comment in packages/spec/src/api/error-code-ledger.zod.ts describes only the manifest-namespace condition; this PR adds a second condition under the same registered code (face unchanged, provenance gate green). A one-line comment is owed by the spec lane. Dedupe words: NAMESPACE_CONFLICT ledger comment second condition",
"carrier: 承接者:无 · noted, not filed — manifest.register handed a stack-shaped object with no top-level id or name (
{ manifest, permissions }) registers its catalog items under no package, in the bare slot, where they read as the environment catalog. Measured on the base probe; reached only by a direct internal caller (AppPlugin and install-local flatten first), so no public door is named. Dedupe words: manifest register no id bare slot"]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Claim amendment · 2026-10-08T06:07Z ·
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), to the claim 6051656254, on the dev's report 6053492929.objectqlalone: a newsecurity-catalog-namespace.ts,registry.ts's package install and item seam.metadata'splugin.tsandruntime'sapp-plugin.tsare unchanged: measured, both registrars write in Phase 2, behind the Phase 1 package door (AppPlugin.init→manifest.register→registerApp→installPackage), and the runtime pins boot both real compositions and see the refusal.packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test, adomain:clipackage, declared on [PM seat] domain:cli — ⏳ vacant #6024 in this act);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(a gate-mandated anchor for the new module);packages/core/src/security/security-catalog.ts(module doc only).Fixes #22135,Clause-②: no) and docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and capabilities hold one name per deployment #22198 (the ADR-0048 §3.4 note,Part of #22135, Tier H, waits on the maintainer).objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"round": "patch round 1 (Dogfood Regression Gate red on 8ad6385)",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"pr_adr_note": "#22198 (unchanged this round)",
"head": "089b1c84b6 (was 8ad6385); origin/main not re-merged this round",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"measurement": {
"q1_config_path": "SecurityPlugin's own constructor option
defaultPermissionSets, supplied by dogfood fixtures through bootStack'ssecurity:option. plugin-security declares every entry on its OWN manifest: init() calls manifest.register({ ...securityPluginManifestHeader, permissions: this.bootstrapPermissionSets }), so each entry registers under com.objectstack.plugin-security. The app manifest path is the other holder: AppPlugin.init registers the stack'spermissionsunder the app's id. In bootStack the AppPlugin is composed before SecurityPlugin, so plugin-security registers second and is the one refused, naming the app. That is the CI signature, reproduced locally.","q1_who": "Static scan (58 files carry defaultPermissionSets) plus local runs. Producers:
showcaseAppDefaultSecurity()with no argument (packages/qa/dogfood/test/showcase-security.ts) handed the showcase's ownshowcase_member_default, and 11 files boot through it.showcase-d7-default-profile.dogfood.test.tspassed the same set inline. Three fixtures handed their stack's own sets: authored-row-write-scope (wscope_widener, wscope_plain), bulk-widener-probe (probe_widener) and owd-public-read-write-write-floor (owdw_editor, owdw_viewer, owdw_scoped). The other fixtures hand fixture-only sets their stacks never declare (one holder), including the scaffolding branch of the same helper, which renames the set to dogfood_showcase_member.","q2_production": "Production does not take the path. In-repo, the only production SecurityPlugin construction is serve.ts:
new SecurityPlugin(appSecurityPluginOptions(config)), which passes{ fallbackPermissionSet: NAME }and no sets. bootStack's default is the same helper. No production source passesdefaultPermissionSets. git grep over packages//src and examples, tests and packages/qa excluded, finds 3 hits: plugin-security's own definition and export, and one comment in cli meta/resync.ts that reads the plugin's service. None passes the option. A realobjectstack dev --fresh -p 38461 --seed-adminboot of examples/app-showcase, built at 8ad6385 with the gate in place, reported "Server is ready", and GET /api/v1/health answered 200. Self-registration is closed on that posture, so no member read was probed there; the bootStack default-composition dogfood (D7 and 11 helper callers) covers that. NOT MEASURED: compositions outside this repository (cloud, embedders) that may pass an app's set through the publicdefaultPermissionSetsoption. Such a composition is now refused at boot, naming both holders, which is the ruled outcome.","q3_same_definition": "Yes. Showcase: the app's declared
showcase_member_defaultagainst the copy the helper handed over,PermissionSetSchema.parse(declared): 101 of 101 leaves equal, nothing only on one side. The three fixtures passed the same object instances (WidenerSet as anyetc.) to both, so the definitions are identical by construction. The ruling names no same-definition exception, and the gate grants none."},
"summary": "Fixed at the producer, the dogfood harness fixtures: each fixture now declares a permission set once. The set is the app's, wired to plugin-security by NAME only (
fallbackPermissionSet), exactly asos servedoes (appSecurityPluginOptions). The helpershowcaseAppDefaultSecurity()(plain branch) and the D7 test now constructnew SecurityPlugin({ fallbackPermissionSet }); the three fixtures drop their stack's own sets fromdefaultPermissionSetsand keepfallbackPermissionSet: 'member_default'. Their comments now state why: the old note "the lightweight harness does not seed permission metadata" is measured false, because bootStack's AppPlugin registers the stack's permissions in the engine registry and the metadata service. A runtime pin holds the refused composition: an app declaring a set AND the same set handed to defaultPermissionSets refuses the boot with 422 NAMESPACE_CONFLICT, incoming com.objectstack.plugin-security, holder the app package. No change to objectql, plugin-security or verify; no gate tolerance, no collisionPolicy change, no same-definition exception.","files_changed": [
"packages/qa/dogfood/test/showcase-security.ts — domain:cli; header comment and the plain (no-argument) branch of showcaseAppDefaultSecurity()",
"packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts — domain:cli; imports, the unused declaredDefault binding, the beforeAll SecurityPlugin wiring and its comment",
"packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts — domain:cli; import line and the beforeAll SecurityPlugin option",
"packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts — domain:cli; import line and the beforeAll SecurityPlugin option",
"packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts — domain:cli; import line and the beforeAll SecurityPlugin option",
"packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts — this PR's own runtime test; one new case",
"plugin-security, verify and objectql: untouched this round"
],
"tests": "At 089b1c8, all under os-verify-lock. Dogfood, the CI split (OS_TEST_SHARD=i/3 pnpm --filter @objectstack/dogfood test): shard 1/3 74 files / 557 tests passed; 2/3 74 files / 535 passed, 1 skipped; 3/3 73 passed + 1 skipped files / 661 passed, 8 skipped; every shard VERDICT command-exit 0, 222 files in all. Targeted first: the 5 edited dogfood files plus the 10 other helper callers, 15 files / 144 passed; the runtime pin file, 5 passed. Reverse verification: the double declaration was put back, through scripts/ablation-replace.mjs (restore-on-exit), in showcase-security.ts (blob 930cce0c14dd -> 58b4f13503fb). showcase-fls-read-mask-strip, one of the CI-failing files, went red: SecurityCatalogNameConflictError, package "com.objectstack.plugin-security" cannot register, permission set "showcase_member_default" is already held by package "com.example.showcase". The first bulk-widener reverse leg was VOID: the mutation landed but referenced the removed import and failed with a ReferenceError, not the refusal. It was redone with an import-free mutation (blob a4290956779e -> 1d4c80130ebd), went red with the refusal on "probe_widener" held by the app package, and was restored. Every restore: blob == HEAD, git diff HEAD empty, git status clean. Existing CI on 089b1c8 at report time: Dogfood Regression Gate 1/3, 2/3 and 3/3 success, Lint & Repo Gates, all Type Check lanes, Build Core, Temporal Conformance, Governed Surface Queue Guard and Test Core 2-6/6 success; Test Core (1/6) in_progress. On 8ad6385: dogfood 3/3 failure; Test Core (1/6) cancelled when the new head was pushed; everything else success.",
"gates": "dispatch-gates --commands on 089b1c8: 82 derived, 82 run, exit codes recorded; --ran reconciles 82/82, 0 NOT MEASURED. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) in the fresh worktree; the same 8 unrelated packages were built and it then exited 0. All 82 exit 0.",
"line_budget": "this round: 6 files, +60 / -44. PR total vs merge base: 15 files, +1022 / -103 (1125 changed lines, under 5000). skills/: none.",
"pr_body_change": {
"note": "The seat writes it; no PATCH from this dev. Two edits to #22197's body.",
"edit_1_replace_bullet": "In "## Acceptance notes", replace the whole bullet that starts with "- Files outside the engine lane:" with this line:",
"edit_1_text": "- Files outside the engine lane:
packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test;runtimeisdomain:cli's package);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(new ADR anchor); and, from patch round 1, fivedomain:clidogfood files:packages/qa/dogfood/test/showcase-security.ts,showcase-d7-default-profile.dogfood.test.ts,authored-row-write-scope.dogfood.test.ts,bulk-widener-probe.dogfood.test.tsandowd-public-read-write-write-floor.dogfood.test.ts(each: theSecurityPluginconstruction, with its comment and imports).","edit_2_insert": "Insert this section immediately before the final "---" footer line (after the "## Acceptance notes" list):",
"edit_2_text": "## Patch round 1 — the dogfood fixtures declared one permission set twice\n\nThe Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot,
plugin-securityregistered a permission set that the app package already held.\n\nThe fixtures handed an app-declared set toSecurityPlugin'sdefaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:\n\n-showcase_member_default, throughshowcaseAppDefaultSecurity()and the D7 test;\n-wscope_*,probe_widenerandowdw_*, in three fixtures.\n\nMeasured:\n\n-os serve/objectstack devnever composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A realobjectstack dev --freshboot ofexamples/app-showcasecame up with the gate in place: health 200.\n- The two copies were the same definition: 101 of 101 leaves equal.\n\nFixed 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.\n\nAll three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.\n"},
"deviations": [
"Fixed in domain:cli files (packages/qa/dogfood), as the measurement showed the harness is the producer; regions named in files_changed. Nothing in plugin-security or verify was written.",
"origin/main was not re-merged this round; CI ran on the PR's merge ref.",
"The first bulk-widener reverse leg was void (ReferenceError), then redone; see tests.",
"Worktree: recreated from the local branch (== remote 8ad6385), with no force. It is removed again right after this comment posts, since the comment is posted from it."
],
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment, POST /repos//issues/22135/comments, through the fleet-write relay (repository_dispatch) as objectstack-fleet[bot]. git push of the code branch is not a REST write. No PR body or label write.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
ACCEPT (seat review) — PR #22197 at head
089b1c84b6; PR #22198 at headc3832215d3(Tier H, waits for the maintainer)domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant) · read at 2026-10-08T07:17Z. Reports: 6053492929 (build) and the patch-round report on this card. Contract reviews atCONTRACT_REVIEW_TIER, both PASS: 6054682234 on PR #22197 and 6054692897 on PR #22198.PR #22197 (code):
main, line 1Fixes #22135, line 2Clause-②: no. Full-body scan: one closing keyword, line 1. The seat wrote the dev's two body edits (patch-round section; the out-of-lane file list).objectql'ssecurity-catalog-namespace.ts(the rule, the built-in names, the holder vocabulary) andregistry.ts's package door (installPackage, ahead of every mutation) and item seam (registerItemwith a package id). EnvelopeNAMESPACE_CONFLICT/ 422, every conflict and both holders named; same-package reload allowed; nocollisionPolicydowngrade; no new code, nopackages/specfile.8ad6385441because five fixtures handed an app-declared permission set toSecurityPlugin'sdefaultPermissionSetsas well (a second holder). The producer was the harness fixtures;os servepasses the default's name only (appSecurityPluginOptions), and a realobjectstack devboot ofapp-showcasecame up with the gate in place. Fixed in the fixtures, with a runtime pin holding the refused composition. Reverse legs red with the refusal and byte-restored.install-localanswer, "what is NOT refused" and "OS_METADATA_COLLISION=warn… does not downgrade this refusal" each match the diff or a measurement.minor+ BREAKING banner + ADR-0087 marker: pre mode is off (.changeset/pre.jsonabsent).defaultPermissionSets), whose fix is to pass the name throughfallbackPermissionSet. No in-repo production path takes it, external compositions are NOT MEASURED, and the refusal names both holders. For the release notes when they are written.089b1c84b6, by name:Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal ConformanceandGoverned Surface Queue Guardallsuccess(37 success, 5 skipped in the roster per the review record). Readings againstorigin/main: NOT governed, 1125 changed lines,git merge-treeclean.packages/qa/dogfood/test/files (showcase-security.ts,showcase-d7-default-profile,authored-row-write-scope,bulk-widener-probe,owd-public-read-write-write-floor), declared on [PM seat] domain:cli — ⏳ vacant #6024 (6054338848). The seat keepsClause-②: no(the review'sno (narrowing)suggestion is optional; the claim's spelling is the gate's).environment) is added there in this act;install-localcloud-install tolerance of a register failure: dropped — code reading only, NOT MEASURED, no reach named (Acceptance notes);NAMESPACE_CONFLICTledger row comment describes only the manifest-namespace condition: carrierdomain:spec(one comment line); noted, not filed;manifest.registerwith no top-level id registers into the bare slot: dropped — no public door reaches it.PR #22198 (ADR-0048 §3.4 note, Tier H): two pure insertions (+98/−0), original text untouched; the addendum quotes Q4's option A byte-equal to the card;
skip-changesetright. Lands only on the maintainer's approval: the seat posts the final 维护者速读 on the PR, labels itneeds-user-decision, and requests the two approvers in this act. PR #22197 does not wait for it.objectstack-fleet commentedon Oct 8, 2026
Queue kick-out of PR #22197 recorded on the PR (2026-10-08T08:35Z): comment 6056019838. New signature from #15196's S2b (PR #22210) landing after this branch's last
mainmerge; not re-queued; patch round 2 to the dev.domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2.objectstack-fleet commentedon Oct 8, 2026
Landing record: PR #22198 merged (the ADR-0048 §3.4 note) · 2026-10-08T08:45Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant).f4bed58341onorigin/main, on the maintainer's approval (os-zhuang, APPROVED on headc3832215d3; contract review PASS 6054692897). Read by content after a fetch: the dated note and the addendum are indocs/adr/0048-cross-package-metadata-collision.mdonorigin/main.Part of #22135; the code half, PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 (Fixes #22135), is in patch round 2 after its queue kick-out (6056019838). Until it lands, the ADR states a rulemaindoes not yet enforce; the gap is documentation-only and closes when feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 lands.objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"round": "patch round 2 (merge-queue kick, record 6056019838) + seat heads-up on #22262",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"head": "ffa6d51913 (was 089b1c8). Commits this round: 19c86b7 merge of origin/main, f0e9ce4 fix, ffa6d51 changeset. ffa6d51 is now 21 commits behind origin/main 8cbe255; git merge-tree against it is clean, and #22262 is not on main yet.",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"measurement": {
"q1_boot_order": "SecurityPlugin declares a dependency on com.objectstack.engine.objectql, so the kernel starts ObjectQLPlugin first. ObjectQLPlugin.start runs restoreMetadataFromDb, which hydrates every sys_metadata row into the bare slot (registerItem with no packageId). SecurityPlugin.start then calls registerBuiltinPositions, which registers the six built-ins at the item seam under com.objectstack.plugin-security. Before the fix, the item seam asked the environment holder for every catalog name, so a stored row under org_admin refused the platform's own declaration. Real door, on 19c86b7 before the fix (dogfood probe, not committed): with OS_METADATA_WRITABLE=position, PUT /api/v1/meta/position/org_admin and /everyone answered 200 ("Saved customization overlay"); the cold restart on the same sqlite file failed with "Plugin com.objectstack.security failed to start - rollback complete: Security catalog name conflict: package com.objectstack.plugin-security cannot register a name another holder already holds — position org_admin ...". Without that setting the same saves answer 403 NOT_OVERRIDABLE, and the restart boots. My round-0 cold-boot reading covered Phase-1 package installs only; this Phase-2 registration is what it missed.",
"q2_builtin_holder_registration": "Yes. registerBuiltinPositions (packages/plugins/plugin-security/src/builtin-positions.ts) registers exactly the static set BUILTIN_IDENTITY_NAMES + AUDIENCE_ANCHOR_POSITIONS (6 names), under the platform's own package id, and nothing else. Those are the names the registry's BUILT_IN_SECURITY_CATALOG_NAMES.position holds. It is the built-in holder declaring its own names, not a second holder. A git grep for literal catalog-type registerItem calls in production source (tests excluded) finds that one call. The dynamic registerItem(type, ...) callers are metadata-protocol (no packageId, so the bare slot), the objectql metadata facade (packageId only from an artifact-stamped _packageId) and ObjectQLPlugin.loadMetadataFromService (object/view/app/flow/hook only).",
"q3_s2b_needs": "S2b pins that a stored definition under a built-in name shadows the built-in: it answers first from the bare slot (ADR-0005), and the platform's declaration is neither seeded nor restamped over it. Both can hold with the ruling and without a product choice, because the environment item under a built-in name is not a second holder the ruling covers: it exists only through an environment save over the platform's name, which the ruling leaves out. Fix, at the producer of the refusal (the item seam in SchemaRegistry.registerItem): for a built-in name it asks only whether another PACKAGE holds the name (environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName); built-ins are still not asked at that seam). 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 every other name an environment item still refuses a package-bound item-seam registration (holder environment). No same-definition exception, no collisionPolicy change, no edit to S2b's pins. Post-fix, the same cold-restart probe boots, and org_admin / everyone resolve to the environment's saved definitions (source registry, packageId null, label "env-saved ...")."
},
"with_22262": {
"how": "A throwaway local branch tmp/22135-with-22262 at 2d8862ff10: origin/main 9f0de32 merged into ffa6d51, then #22262's head 7ed88a6 (branch claude/issue-22203-position-allow-org-override, which adds positions to the engine's METADATA_ARRAY_KEYS) merged on top. Both merges were clean. It was never pushed; afterwards the branch and the temporary fetch refs were deleted (git ls-remote shows no such remote branch), and the worktree went back to ffa6d51 with a fresh install and a closure rebuild. Runs, each exit read on its own: objectql 5 files / 97 passed (mine plus #22262's engine-security-catalog-package-door and engine-nested-plugin-collections); runtime 3 files / 20 passed; plugin-security 3 files / 52 passed (builtin-positions.boot, bootstrap-declared-positions, builtin-positions); dogfood 5 files / 46 passed (security-catalog-showcase, multi-package-artifact and three probes, not committed).",
"q1_same_package": "Stays one holder; there is no self-refusal. With #22262, AppPlugin's registerApp registers the showcase's 10 positions as composite registry items under com.example.showcase, and installPackage records the same 10 names as claims under the same package id. The predicate excludes the incoming package's own id from both sources (the add() guard), so a re-registration of the showcase is not refused (probe: null). A second package declaring the showcase position contributor is refused with 422 NAMESPACE_CONFLICT, holder package com.example.showcase. crm, showcase and multi-package boot through bootStack.",
"q2_claims": "Redundant for the registerApp doors, still needed for the direct installPackage door. Ablation on the merged tree: the claim recording was replaced by a marker no-op through scripts/ablation-replace.mjs (NOCLAIM-22135; blob c60d9bad21bc -> 4da33d276d9a), objectql was rebuilt, and ablation-dist-preflight found the marker in 4 dist files. runtime standalone-stack-security-catalog-one-holder: 5 of 5 passed, so every registerApp boot is refused through the registered items alone. objectql: 2 failed of 39. One is protocol-boot-hydration-scoped "a position name two packages declare: the package door refuses the second" (AssertionError: the registration was refused: expected undefined to be defined). The other is registry-security-catalog-namespace "collisionPolicy warn does not downgrade it" (expected undefined to be NAMESPACE_CONFLICT). Both call SchemaRegistry.installPackage directly, which registers no items, so only the claims know the first package's names. Restored: blob == HEAD c60d9bad21bc, git diff HEAD empty; objectql rebuilt on ffa6d51, and ablation-dist-preflight NOCLAIM-22135 --absent is green (14 dist files, tree clean). Decision: keep the claims. The change is then correct on main without #22262 (where positions reach the registry only as claims) and with it.",
"q3_s2b": "Unchanged. #22262 adds the package's positions to the engine loop; the built-in positions stay plugin-security's start()-time registration. On the merged tree S2b's pins pass (52 of 52 in the three plugin-security files), and the cold-restart probe (OS_METADATA_WRITABLE=position, env saves under org_admin and everyone) boots, with the environment's definitions answering first.",
"side_effect_measured": "With #22262, PUT /api/v1/meta/position/shared_pos?package=w over a package-held position answers 403 NOT_OVERRIDABLE ("position is not allowOrgOverride in the registry"); on main without it, it answered 200 at 8ad6385. That is round 0's first open question (environment save of a position over a package-held position name), and #22262 answers it in the direction of that question's option A for the package-held case. Built-in names were already 403 on main (they are registered items).",
"note_for_merge_order": "Two code comments in packages/objectql/src/registry.ts describe main WITHOUT #22262: the securityCatalogClaims doc ("the engine's collection loop has no positions entry, so a package's positions reach only the metadata service's in-memory registrars") and the installPackage inline comment ("positions included, which the engine's collection loop never registers"). Both turn false when #22262 lands; behaviour does not change. They were not edited this round: a comment-only commit would move the head off the commit every suite, gate and reverse leg was measured on. Exact replacement text, true on both trees, for whichever of #22197 / #22262 merges second. For the doc sentence pair: "The registry's item store answers who holds a name only for items something registered into it, and installPackage registers none: a package's collections reach the item store through ObjectQL.registerApp's collection loop (positions only where that loop carries them), never through a bare installPackage. The claim is how the package door remembers every name a package declared, so a second package declaring one of them is refused like any other second holder." For the inline comment: "The names this package now holds, recorded whether or not an item store entry will carry them (see securityCatalogClaims).""
},
"summary": "The merge-queue failure was the item seam refusing plugin-security's own built-in positions. ObjectQLPlugin.start hydrates sys_metadata before SecurityPlugin.start, so an environment row stored under a built-in name was read as a second holder. Fixed at the producer (SchemaRegistry.registerItem): for a built-in name the item seam no longer asks the environment holder, because the platform's declaration is the built-in holder's own registration. A second package is still refused in either order, the package door still refuses built-in names, and non-built-in env items still refuse package-bound item-seam registrations. S2b passes unedited and the queue signature is reproduced under reverse verification. On main plus #22262 (local throwaway merge): same-package positions stay one holder, the claims are redundant for registerApp but still decide direct installPackage (so they stay), and S2b is unchanged.",
"files_changed": [
"packages/objectql/src/registry.ts — the item-seam condition in registerItem (the securityCatalogHoldersOtherThan call and its comment), and the predicate's opts parameter now carries builtIns and environment",
"packages/objectql/src/security-catalog-namespace.ts — the BUILT_IN_SECURITY_CATALOG_NAMES doc: the platform's declaration is the built-in holder's own registration",
"packages/objectql/src/registry-security-catalog-namespace.test.ts — three new cases in the built-in holders describe: the platform's declaration is admitted over an environment item under org_admin, which keeps answering first; a second PACKAGE registering a built-in name at the item seam is refused in either order; CONTROL, a non-built-in environment item still refuses (holder environment)",
".changeset/22135-security-catalog-one-holder.md — one paragraph on the built-in declaration",
"plugin-security, verify, runtime and dogfood: untouched this round. S2b's pins are not edited."
],
"tests": "All at ffa6d51, under os-verify-lock (slot dev-22135), whole suites. plugin-security: 178 files / 3739 passed, 45 skipped (VERDICT command-exit 0). objectql: 382 files / 7556 passed (clean rerun, command-exit 0); the first run had one file (protocol-meta-types-rich.test.ts) fail at import, "Failed to resolve entry for package @objectstack/metadata-protocol", while an unlocked gate was rebuilding that dist; the rerun is the measurement. runtime: 338 files / 5477 passed, 19 skipped (command-exit 0). Dogfood, the CI split (OS_TEST_SHARD=i/3 pnpm --filter @objectstack/dogfood test): 1/3 74 files / 557 passed (clean rerun after a fresh install and closure rebuild on ffa6d51, command-exit 0); 2/3 74 files / 535 passed, 1 skipped; 3/3 73 passed + 1 skipped files / 661 passed, 8 skipped (both command-exit 0). Shard 1's first run had one 5000 ms timeout in activity-parent-read-gate.dogfood.test.ts (sys_activity member list), a file this PR does not touch, under load; the rerun is green. objectql typecheck (tsc --noEmit + check:test-typecheck): command-exit 0, test-typecheck debt held. Reverse verification, through scripts/ablation-replace.mjs with restore-on-exit: environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName) -> a globalThis marker comparison that is always true (anchor 1 -> 0, blob c60d9bad21bc -> eeca074e4f80). objectql was rebuilt, and ablation-dist-preflight REVERSE-22135-R2 found the marker present in 4 dist files. objectql registry-security-catalog-namespace: 1 failed / 30 passed, the new admit case (SecurityCatalogNameConflictError, org_admin held by the environment catalog). plugin-security builtin-positions.boot + bootstrap-declared-positions: both files red, the 3 "a stored definition under a built-in name" postures (their 10 tests skipped behind the failed boot) and "a stored definition shadowing a built-in name is neither seeded nor restamped", with the queue's message: package com.objectstack.plugin-security cannot register ... position org_admin is already held by the environment catalog. Restored: blob == HEAD c60d9bad21bc, git diff HEAD empty; objectql rebuilt, and ablation-dist-preflight --absent is green for REVERSE-22135-R2 and NOCLAIM-22135 (14 dist files, tree clean). CI on ffa6d51 at report time (one check-runs read): 35 checks, 32 success, including Dogfood Regression Gate 1/3, 2/3 and 3/3, Test Core 1/6 to 6/6, Build Core, TypeScript Type Check, the four Type Check lanes, Lint & Repo Gates, Temporal Conformance and Check Changeset; 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke).",
"gates": "node scripts/pm/dispatch-gates.mjs --commands (no paths) on ffa6d51 derived 82 commands; all 82 ran, exit codes recorded. --repo objectstack-ai/objectstack --ran reconciles: 82 derived, 82 run, 0 NOT MEASURED (a derived zero: all 82 carry an exit code, none is 3). All 82 exited 0. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3). After building 8 unrelated packages (studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge) it exited 0: 106 require entry points across 66 packages load. The reconcile now prints STALE TREE (21 commits behind origin/main, 1 derived-from file changed across that range); CI runs on the merge ref.",
"line_budget": "this round: 4 files, +65 / -9. PR total vs merge base 3ae5966: 15 files, +1078 / -103 (1181 changed lines). skills/: none.",
"pr_body_change": {
"note": "The seat writes these; this dev makes no PATCH. Six edits to #22197's current body, as read at report time (it already carries the round-1 edits). Edit 5 corrects the cold-boot note, which covered Phase-1 installs only.",
"edit_1_where": "In "## What changed", the bullet starting "-
SchemaRegistry.installPackage— the package door.": replace this exact sentence","edit_1_old": "Positions need the claim: the engine's collection loop never registers them, and only the metadata service's in-memory registrars do.",
"edit_1_new": "The claims are what the door reads for names no registered item records. On
main, the engine's collection loop never registers positions (only the metadata service's in-memory registrars do), and a directinstallPackagecall registers no items at all. Measured with #22262 merged locally (it adds positions to that loop): the claims are then redundant for theregisterAppdoors, and still decide the directinstallPackagedoor, so they stay.","edit_2_where": "In "## What changed", the bullet starting "-
SchemaRegistry.registerItem— the item seam.": replace this exact sentence","edit_2_old": "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).","edit_2_new": "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.","edit_3_where": "The paragraph under the opening paragraph: replace it whole (optional; #22198 has merged)",
"edit_3_old": "The ADR-0048 §3.4 narrowing note is Tier H and rides its own draft PR, from branch
claude/issue-22135-adr-0048-narrowing-note. This PR carries nodocs/adr/**file.","edit_3_new": "The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on
main. This PR carries nodocs/adr/**file.","edit_4_where": "In "## Acceptance notes", replace the whole bullet that starts with "- Environment save of a position over a package-held position name" with this line",
"edit_4_new": "- Environment save of a position over a package-held position name is accepted (
200) onmaintoday, and the saved position then wins the by-name read. Permission sets are protected on the same door by the packaged permission-set lock (403). Outside the ruling, so reported, not changed here. Measured with #22262 merged locally: the same save (PUT /api/v1/meta/position/shared_pos?package=w) answers403 NOT_OVERRIDABLE, because the package's position is then a registered item, and positions are not org-overridable.","edit_5_where": "In "## Acceptance notes", replace the whole bullet that starts with "- Cold boot vs the environment-catalog holder." with this line",
"edit_5_new": "- Cold boot vs the environment-catalog holder. A package registers through
ObjectQL.registerAppin Phase 1, andsys_metadatahydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. TheCONTROLcase inregistry-security-catalog-namespace.test.tspins that the bare slot is not judged. A plugin's ownstart()is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holderenvironment). The exception is a built-in name, which the platform declares there itself (Patch round 2). Agit grepfor literal catalog-typeregisterItemcalls in production source finds one such registration:plugin-security's built-in positions. Raised for a decision in the report.","edit_6_where": "Insert this section after the "## Patch round 1" section, immediately before the final "---" footer line",
"edit_6_new": "## Patch round 2 — the platform's built-in positions met an environment row at boot\n\nThe merge queue removed this PR (record 6056019838).
plugin-security'sregisterBuiltinPositionswas refused at the item seam: positionorg_admin, incomingcom.objectstack.plugin-security, holderenvironment. That refusal failedSecurityPlugin.start, and with it the boot. S2b's pins went red:builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), andbootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".\n\nMeasured on 19c86b7 (this branch withmainmerged, before the fix):\n\n- Boot order.SecurityPlugindepends on the engine. SoObjectQLPlugin.starthydratessys_metadatainto the bare slot BEFORESecurityPlugin.startdeclares the built-in positions. Through a real door: withOS_METADATA_WRITABLE=position,PUT /api/v1/meta/position/org_adminanswered200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers403 NOT_OVERRIDABLE.\n- Who registers.registerBuiltinPositionsregisters exactly the six static built-in names (BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.\n- What S2b needs. The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it.\n\nFixed at the producer, the item seam inSchemaRegistry.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:\n\n- a second PACKAGE registering a built-in name at the item seam is refused, in either order;\n- the package door refuses a package declaring a built-in name (holderbuilt-in);\n- for any other name, an environment item still refuses a package-bound registration at the item seam (holderenvironment).\n\nNo same-definition exception, nocollisionPolicychange, and S2b's pins are untouched.registry-security-catalog-namespace.test.tsgained three cases, one per behaviour above (the third is aCONTROL). After the fix, the same cold restart boots, andorg_admin/everyoneresolve to the environment's saved definitions.\n\nReverse verification.** The new condition was mutated throughscripts/ablation-replace.mjsto ask the environment holder again (blobc60d9bad21bc→eeca074e4f80).objectqlwas rebuilt, andablation-dist-preflightfound the marker indist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red withSecurityCatalogNameConflictError(org_adminheld by the environment catalog), and so did the new admit case. Restored: blob == HEAD, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green.\n\nWith #22262 merged locally. This was a throwaway merge, never pushed: #22262's head 7ed88a6, ontomain9f0de32 and this branch.\n\n- 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 undercom.example.showcaseand 6 undercom.objectstack.plugin-security. Re-registering the showcase is not refused. A second package declaringcontributoris refused, holdercom.example.showcase.\n- The claims were ablated on the merged tree. The runtime boot pins stayed green (5 of 5), so theregisterAppdoors no longer need them. Two objectql pins went red: the P1.2 position case and thecollisionPolicy: 'warn'pin. Both reach the package door through a directinstallPackagecall, which registers no items. So the claims stay, and the change is correct with and without #22262.\n- The S2b built-in case is unchanged:plugin-security's S2b and S2 pins pass (3 files, 52 tests), and the cold restart boots with the environment's definitions answering first.\n\nTests at ffa6d51, all underos-verify-lock:\n\n-@objectstack/plugin-security, whole suite: 178 files / 3739 passed, 45 skipped.\n-@objectstack/objectql, whole suite: 382 files / 7556 passed.typecheck(tsc --noEmit+check:test-typecheck): exit 0.\n-@objectstack/runtime, whole suite: 338 files / 5477 passed, 19 skipped.\n- 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.\n- Gates:dispatch-gates --commandsderived 82 on ffa6d51. All 82 ran and exited 0, and--ranreconciles 82/82, 0 NOT MEASURED.\n\nCI on ffa6d51: 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).\n"},
"deviations": [
"The #22262 measurement used a throwaway local merge branch in this worktree. Two temporary refs were fetched into the shared .git (refs/os-22135/pr-22139 from an earlier round, refs/os-22135/pr-22262), and both were deleted afterwards. Nothing was pushed except the PR branch.",
"The registry.ts comments that turn stale when #22262 lands were not edited; the head stays on the measured commit. Replacement text is in with_22262.note_for_merge_order.",
"objectql's first whole-suite run had a transient import failure (dist being rebuilt by an unlocked gate), and dogfood shard 1's first run had one unrelated 5000 ms timeout. Both were rerun clean on ffa6d51, and the reruns are what tests reports.",
"The local dogfood probes (zz-probe, zz-probe2, zz-probe3) were never committed. They were removed from the worktree, and copies are kept in the scratchpad.",
"PR #22197 reads draft: false at report time; this dev did not change that state.",
"The worktree is removed right after this comment posts, since the comment is posted from it."
],
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment, POST /repos//issues/22135/comments, through the fleet-write relay (repository_dispatch) as objectstack-fleet[bot]. git push of the code branch is not a REST write. No PR body or label write. Reads: GET pulls/22197 and GET commits/ffa6d51913/check-runs (gh api).",
"open_questions": [
{
"question": "Round 0 open question 2 (cold boot and the environment-catalog holder) needs one correction, measured this round. It holds for packages registered through registerApp in Phase 1: they are not refused at cold boot. But a package-bound registration in a plugin's start() runs after sys_metadata hydration. For a non-built-in name it IS refused when an environment row holds the name (holder environment), and that failure is the boot. In-repo, the only literal catalog-type item-seam registration in a start() is plugin-security's built-in positions, which this round admits. Does the round-0 recommendation stand?",
"options": [
"A — keep it: Phase-1 packages keep the existing collision warning at cold boot; a start()-time package-bound registration over an environment-held non-built-in name refuses the boot (current behaviour, pinned by the CONTROL case)",
"B — fold the cold-boot half into #15196's S9 boot report as a loud report, as round 0 suggested, and also downgrade the start()-time case to that report",
"C — leave both as now, and only document the start()-time case"
],
"axes": "Business need: no in-repo producer reaches the start()-time non-built-in case; it needs an out-of-repo plugin registering a catalog item in start() over a name an environment saved. Long-term: A keeps one predicate at one seam; B adds a second disposition for one condition. AI-error resistance: A fails loudly at boot and names both holders; B keeps the deployment up and only reports. Startup scope: A adds nothing; B is S9 work.",
"recommendation": "A, because the ruling names the environment catalog as a holder and the refusal names both holders; the round-0 recommendation (A, with B as an S9 report if wanted) stands for Phase-1 packages."
}
],
"out_of_scope_findings": [
"carrier: whichever of #22197 / #22262 merges second (the seat at that merge) · noted, not filed — two packages/objectql/src/registry.ts comments state the engine's collection loop never registers positions; #22262 makes that false. Behaviour is correct on both trees (measured). Replacement text in with_22262.note_for_merge_order. Dedupe words: securityCatalogClaims comment positions loop · registry claims comment stale 22262",
"carrier: #22262 · noted, not filed — on main today an environment save of a position over a package-held position name answers 200 (round 0 open question 1); with #22262 merged locally the same save answers 403 NOT_OVERRIDABLE. Round 0's question 1 is answered for the package-held case when #22262 lands. Dedupe words: position env save package-held 403 · allowOrgOverride position package"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"round": "patch round 3 (#22262 landed on main first, squash 0b997ea)",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"head": "f16fcd0c7c (was ffa6d51). Commits this round: b5335b4 merge of origin/main at fbcbcf1 (contains 0b997ea), and f16fcd0 comment-only. origin/main has since moved to 6729e10 (10 commits); git merge-tree against it is clean.",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"summary": "Merged origin/main at fbcbcf1, which carries #22262's positions in METADATA_ARRAY_KEYS. The merge touched none of this PR's 15 files (306 files came in, 0 overlap), so registry.ts's logic is unchanged and no reverse leg was owed. The two registry.ts comment replacements from round 2 were applied with the words verbatim. Two more comments this PR added made the same now-false claim, and they were rewritten too: the declared-names reader's note in security-catalog-namespace.ts, and the runtime pin's header. Round 2's note had missed both. On the final head, every suite, the three dogfood shards, both typechecks and all 82 derived gates are green, and so is CI. The #22262 facts were re-measured on f16fcd0 with the local probes.",
"merge": {
"main_merged": "fbcbcf124f (0b997ea is an ancestor: merge-base --is-ancestor exit 0)",
"overlap": "The merge brought 306 files from main. None is among this PR's 15 files (git diff --name-only intersection: 0), so registry.ts, security-catalog-namespace.ts and every test of this PR came through untouched. No conflicts.",
"landed_vs_measured": "All seven files #22262 landed (engine.ts, engine-nested-plugin-collections.test.ts, engine-security-catalog-package-door.test.ts, standalone-stack-seeder-declaration-copy.test.ts, scripts/check-stack-collection-maps.mjs, two changesets) have the same blob at 0b997ea as at the head 7ed88a6 measured in round 2, and the same blob at f16fcd0."
},
"comments": {
"applied_from_round_2": "Both replacements from with_22262.note_for_merge_order went in with their words verbatim. The only change is markup: backticks around identifiers, matching the surrounding doc style. In the securityCatalogClaims doc those are installPackage (twice) and ObjectQL.registerApp; in the installPackage inline comment, securityCatalogClaims. No word was made false by the merged code. ObjectQL.registerPlugin, the nested-plugin seam that #22262 also covers, is called from inside registerApp (engine.ts registerApp calls this.registerPlugin, which runs the same registerMetadataCollections loop), so "through ObjectQL.registerApp's collection loop" holds. The parenthetical "(positions only where that loop carries them)" stays true, and now always holds.",
"added_beyond_the_two": [
"packages/objectql/src/security-catalog-namespace.ts, the declaredSecurityCatalogNames doc. Was: "positions is read although the engine's collection loop does not register it: the in-memory registrars ... do, for the very same package, so the package's claim covers it." Now: "positions is read like the other two collections. The engine's collection loop registers a package's positions under the package (ObjectQL.registerApp), and the in-memory registrars (AppPlugin's security block, the artifact door) register them for the very same package; the package's claim covers them either way."",
"packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts, the header. Was: "Positions reach no engine registry slot: AppPlugin's security block ... and the artifact door ... write them to the metadata service, in start()." Now: "Positions also reach the metadata service: AppPlugin's security block ... and the artifact door ... write them there, in start().""
],
"why_more_than_two": "After the merge, a grep of this PR's files for statements about the collection loop and the in-memory registrars found these two besides the pair round 2 named. Both are this PR's own lines and are false on main now. Round 2's note_for_merge_order missed them. Comment-only, inside the same commit."
},
"measured_with_22262_in_tree": "On f16fcd0, through bootStack, with the three local probes (copied into packages/qa/dogfood/test, run under os-verify-lock, 3 files / 6 passed, command-exit 0, then removed; git status clean afterwards). Showcase: engine positions by package {com.example.showcase: 10, com.objectstack.plugin-security: 6}; claims for position: 10. Re-registering the showcase through the manifest service: null, so not refused. A second package declaring contributor: 422 NAMESPACE_CONFLICT, holder package com.example.showcase. Cold restart after environment saves under org_admin and everyone (OS_METADATA_WRITABLE=position): boots; both resolve to the saved definitions (packageId null, label env-saved ...). PUT /api/v1/meta/position/shared_pos?package=w: 403 NOT_OVERRIDABLE ("position is not allowOrgOverride in the registry"). Door probes: crm, showcase and multi-package boot. The two-stack boot is refused (3 conflicts, holder com.probe.a); so are built-in everyone and manage_users (holder built-in) and admin_full_access (incoming plugin-security, holder the app). The hot install over a held position is refused and leaves no record; the same-package reload is accepted. install-local inline answers 422 PLUGIN_REGISTER_FAILED and leaves no record. A hot install over an env-held set is refused (holder environment). The claims ablation was not re-run (comment-only round); its round-2 result is reported against the byte-identical #22262 files (see merge.landed_vs_measured).",
"files_changed": [
"packages/objectql/src/registry.ts — comments only: the securityCatalogClaims doc paragraph and the inline comment above recordSecurityCatalogClaims in installPackage",
"packages/objectql/src/security-catalog-namespace.ts — comment only: one paragraph of the declaredSecurityCatalogNames doc",
"packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts — comment only: two header lines"
],
"tests": "All at f16fcd0, after a fresh install and rebuild: the dogfood closure, plus the 8 packages check:dual-build-cjs-loads needs, both command-exit 0. Each run under os-verify-lock (slot dev-22135), whole suites, VERDICT command-exit 0. objectql: 383 files / 7572 passed. plugin-security: 179 files / 3775 passed, 45 skipped. runtime: 340 files / 5505 passed, 19 skipped. Dogfood, the CI split (OS_TEST_SHARD=i/3 pnpm --filter @objectstack/dogfood test): 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 (pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheck): command-exit 0. Both scripts ran, tsc --noEmit then check:test-typecheck: objectql's test layer OK (40 files / 234 errors / 65 pinned signatures held), runtime's OK (27 / 190 / 68 held). No reverse verification this round: the round's own diff is comments only, and the merge touched no line of registry.ts. CI on f16fcd0 at report time (one check-runs read): 35 checks, 32 success, including Dogfood Regression Gate 1/3, 2/3 and 3/3, Test Core 1/6 to 6/6, Build Core, TypeScript Type Check and the four Type Check lanes, Lint & Repo Gates, Temporal Conformance and Check Changeset; 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke).",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) on f16fcd0 derived 82 commands, the same 82 as on ffa6d51. All 82 ran after the locked suites, in sequence, and all 82 exited 0, check:dual-build-cjs-loads included (106 require entry points across 66 packages load). --ran reconciles: 82 derived, 82 run, 0 NOT MEASURED (a derived zero: all 82 carry an exit code, none is 3). The reconcile prints STALE TREE: main moved 9 commits during the run, and 1 file it derives from changed.",
"line_budget": "this round (excluding the merge): 3 files, +16 / -14. PR total vs merge base fbcbcf1: 15 files, +1080 / -103. skills/: none.",
"pr_body_full": {
"note": "The full replacement body for #22197. The seat writes it; this dev makes no PATCH. Built from the body as read at report time, which still carried no round-2 edit. Every line not listed below is byte-identical to it. Changed: the ADR-note paragraph (#22198 merged); in What changed, one sentence each in the package-door and item-seam bullets; in the door table, the Head cell of the position-save row; in Acceptance notes, the position-save and cold-boot bullets. Patch round 2 and Patch round 3 are appended before the footer. Round 2's "with #22262 merged locally" paragraphs became round 3's measurements on f16fcd0. First line Fixes #22135, second Clause-②: no. It contains no angle bracket, no HTML comment and no control byte. Its one closing keyword is the first line's.",
"body": "Fixes #22135\nClause-②: no\n\nExecutes 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.\n\nThe ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on
main. This PR carries nodocs/adr/**file.\n\n## What changed\n\n-packages/objectql/src/security-catalog-namespace.ts(new). The rule in one place: the three types, the built-in names, the holder vocabulary (package/environment/built-in), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's ownpositions/permissions/capabilitiesand each nestedplugins[]entry's, arrays only. A manifest-stagepermissionsgrant block is never read as permission sets.\n-SchemaRegistry.installPackage— the package door. It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released byuninstallPackage. The claims are what the door reads for names no registered item records, andinstallPackageitself registers no items. ThroughObjectQL.registerApp(every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since #22262 landed onmain. There the claims agree with the items. With the claims ablated, theregisterAppdoors still refuse and the directinstallPackagedoor does not (Patch round 3), so the claims stay.\n-SchemaRegistry.registerItem— the item seam. A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now onmain), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what everysys_metadatahydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling.\n- 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 amanifest.namespaceand offers theOS_METADATA_COLLISION=warndowngrade. Neither is true here, andcollisionPolicy: 'warn'does not downgrade this refusal (pinned).\n- No new error code; nopackages/specchange.\n\n### Where the doors are, measured\n\nThe card names three doors. What this PR measured is that all three are reached through ONE:ObjectQL.registerApp→SchemaRegistry.installPackage, which every package registration hits in the kernel's Phase 1, before anystart().\n\n-AppPlugin's security registrar (registerInMemory, the'app-plugin'registrar) and the artifact door (MetadataPlugin._registerArtifactBodyCollections, the'artifact-door'registrar) both run in Phase 2.\n- Neither runs for a package the engine has not installed:AppPlugin.initregisters every package of its bundle through themanifestservice first, a multi-package artifact package by package.\n- So the producer-side fix is the package door, andpackages/metadata/src/plugin.tsandpackages/runtime/src/app-plugin.tsare unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs.\n\n## Door table: base vs head\n\n"Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through@objectstack/verify'sbootStack; the artifact rows go throughcreateStandaloneStack.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Boot: an app declaringeveryone/manage_users/admin_full_access| boots. The by-name read ofadmin_full_accessanswers the APP's set | refused. Holderbuilt-infor the first two. Foradmin_full_accessthe app registers beforeplugin-securityinbootStack, so the platform's registration is the one stopped, naming the app |\n| Artifact boot: two packages of one artifact sharing names; a package declaringeveryone| boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything |\n| Hot install: post-bootmanifest.registerover a held name | accepted, package record written | refused, no record |\n| 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 inerror.message; no record |\n|POST /api/v1/packages|400: the strict body refusespositions, the retiredcapabilitiesand a flatpermissionslist | unchanged. No catalog collection can arrive here |\n| Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holderenvironment|\n| Same-package hot reload | accepted | accepted |\n| 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 |\n| 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 onmain:403 NOT_OVERRIDABLE(see Acceptance notes) |\n\nNamed but not measured:\n\n- The artifact door's HMR reload (MetadataPlugin._reloadAndAnnounce). It re-registers into the metadata service withoutregisterApp, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it.\n-install-local's cloud-sourced install. Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal aterrorand skips the package. That is code reading only (it needs a control plane).\n\n## In-repo collision census (M2)\n\nInstrument.** A tsx census overexamples/app-crm,examples/app-showcase,examples/app-todoandexamples/app-multi-package: each config's top level, itspackages[]bodies and its nestedplugins[]. Against those it reads the built-ins:BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS,PLATFORM_CAPABILITY_NAMES, andplugin-security'ssecurityDefaultPermissionSets.\n\nResult 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.\n\nThe guard, measured with the gate in place at 8ad6385:crm,showcaseandmulti-packageboot throughbootStack, andsecurity-catalog-showcase.dogfood.test.ts(3 postures) andmulti-package-artifact.dogfood.test.tsare green.app-tododeclares no catalog name. Deployed and marketplace packages are NOT MEASURED.\n\n## The P1.2 pin, flipped\n\nS1's shared-name pin is thesecurity catalog read — a name two packages shipdescribe inpackages/objectql/src/protocol-boot-hydration-scoped.test.ts. It added noP1.2label, which is why agit grepmisses it. It now pins the ruled answer at the same seams:\n\n- a second package registering the name is refused (envelope + both holders), and every reader answers the one holder;\n- an override the holder stored for itself answers for every caller;\n- a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service.\n\ncore'ssecurity-catalog.test.tspointed at a non-existentsecurity-catalog-shared-name.test.ts. It now names that describe and the new door pins.security-catalog.ts's module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer.engine-capability-provenance.test.tspinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal.\n\n## Tests (at 8ad6385)\n\n-@objectstack/objectql:registry-security-catalog-namespace.test.ts(new, 28 cases),protocol-boot-hydration-scoped.test.ts,engine-capability-provenance.test.ts,registry-collision-order.test.tsandregistry-artifact-co-ownership.test.ts: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip.\n-@objectstack/runtime:standalone-stack-security-catalog-one-holder.test.ts(new, 4) andstandalone-stack-security-registrar.test.ts: 2 files, 6 passed.\n-@objectstack/coresecurity-catalog.test.ts: 14 passed.@objectstack/plugin-securitybuiltin-positions.boot.test.ts+builtin-positions.test.ts(S2's): 18 passed.\n- dogfood:security-catalog-showcase,multi-package-artifact, plus a local door probe that is not committed: 3 files, 44 passed.\n- Downstream sweep before the merges, against the rebuiltobjectqldist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green.\n-typecheckfor objectql, core and runtime (withcheck:test-typecheck): exit 0. No new test-typecheck debt.\n\n## Ablation\n\nBoth gates were ablated together throughscripts/ablation-replace.mjs, which wraps the run and restores on exit:\n\n- the package-door call became aglobalThismarker write;\n- the item-seam condition gained an always-false marker conjunct.\n\nBoth mutations landed on disk: anchor 1 → 0, blobb96099a12688→12c018d406ab.objectqlwas rebuilt andablation-dist-preflightfound both markers indist/. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted.\n\n- objectql pins (read fromsrc): 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.\n- runtime boot pins (read fromdist): 3 failed / 1 passed. The control stayed green.\n\nRestore: the blob is back tob96099a12688andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green for both markers (dist and whole tree).\n\n## Gates\n\nnode scripts/pm/dispatch-gates.mjs --commandsderived 78 commands on 8ad6385; all 78 were run, and--ranreconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first:check-engine-split-ratiorefused the shallow clone (deepened withgit fetch --shallow-since=2026-07-03), andcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run,origin/mainmoved 6 commits, none of which touches a file in this PR.\n\n## Acceptance notes\n\n- Environment save of a position over a package-held position name. Before #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 #22262 landed onmain, a package's positions are registered items under the package, and the same save answers403 NOT_OVERRIDABLE("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 withPUT /api/v1/meta/position/shared_pos?package=w. Outside this ruling either way; this PR changes nothing there.\n- Cold boot vs the environment-catalog holder. A package registers throughObjectQL.registerAppin Phase 1, andsys_metadatahydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. TheCONTROLcase inregistry-security-catalog-namespace.test.tspins that the bare slot is not judged. A plugin's ownstart()is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holderenvironment). The exception is a built-in name, which the platform declares there itself (Patch round 2). Agit grepfor literal catalog-typeregisterItemcalls in production source finds one such registration:plugin-security's built-in positions. Raised for a decision in the report.\n- Order and the platform's permission sets.plugin-securitydeclares the platform's sets on its own manifest (configurable throughdefaultPermissionSets), so they are package-held. When an app registers before it, asbootStackcomposes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named.\n-install-localinline import answers its ownPLUGIN_REGISTER_FAILEDfor any register refusal (this one and the namespace gate's alike), soerror.codedoes not carryNAMESPACE_CONFLICTthere. The refusal's text is inerror.message. Not changed here.\n- The ledger row comment forNAMESPACE_CONFLICTinpackages/spec/src/api/error-code-ledger.zod.tsdescribes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here.\n- Files outside the engine lane:packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test;runtimeisdomain:cli's package);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(new ADR anchor); and, from patch round 1, fivedomain:clidogfood files:packages/qa/dogfood/test/showcase-security.ts,showcase-d7-default-profile.dogfood.test.ts,authored-row-write-scope.dogfood.test.ts,bulk-widener-probe.dogfood.test.tsandowd-public-read-write-write-floor.dogfood.test.ts(each: theSecurityPluginconstruction, with its comment and imports).\n\n## Patch round 1 — the dogfood fixtures declared one permission set twice\n\nThe Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot,plugin-securityregistered a permission set that the app package already held.\n\nThe fixtures handed an app-declared set toSecurityPlugin'sdefaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:\n\n-showcase_member_default, throughshowcaseAppDefaultSecurity()and the D7 test;\n-wscope_*,probe_widenerandowdw_*, in three fixtures.\n\nMeasured:\n\n-os serve/objectstack devnever composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A realobjectstack dev --freshboot ofexamples/app-showcasecame up with the gate in place: health 200.\n- The two copies were the same definition: 101 of 101 leaves equal.\n\nFixed 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.\n\nAll three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.\n\n## Patch round 2 — the platform's built-in positions met an environment row at boot\n\nThe merge queue removed this PR (record 6056019838).plugin-security'sregisterBuiltinPositionswas refused at the item seam: positionorg_admin, incomingcom.objectstack.plugin-security, holderenvironment. That refusal failedSecurityPlugin.start, and with it the boot. S2b's pins went red:builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), andbootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".\n\nMeasured on 19c86b7 (this branch withmainmerged, before the fix):\n\n- Boot order.SecurityPlugindepends on the engine. SoObjectQLPlugin.starthydratessys_metadatainto the bare slot BEFORESecurityPlugin.startdeclares the built-in positions. Through a real door: withOS_METADATA_WRITABLE=position,PUT /api/v1/meta/position/org_adminanswered200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers403 NOT_OVERRIDABLE.\n- Who registers.registerBuiltinPositionsregisters exactly the six static built-in names (BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.\n- What S2b needs. The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it.\n\nFixed at the producer, the item seam inSchemaRegistry.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:\n\n- a second PACKAGE registering a built-in name at the item seam is refused, in either order;\n- the package door refuses a package declaring a built-in name (holderbuilt-in);\n- for any other name, an environment item still refuses a package-bound registration at the item seam (holderenvironment).\n\nNo same-definition exception, nocollisionPolicychange, and S2b's pins are untouched.registry-security-catalog-namespace.test.tsgained three cases, one per behaviour above (the third is aCONTROL).\n\nReverse verification. The new condition was mutated throughscripts/ablation-replace.mjsto ask the environment holder again (blobc60d9bad21bc→eeca074e4f80).objectqlwas rebuilt, andablation-dist-preflightfound the marker indist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red withSecurityCatalogNameConflictError(org_adminheld by the environment catalog), and so did the new admit case. Restored: blob == HEAD, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green.\n\nAll suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI.\n\n## Patch round 3 — #22262 landed onmainfirst\n\n#22262 (squash 0b997ea) addspositionsto the engine'sMETADATA_ARRAY_KEYS, soObjectQL.registerAppnow registers a package's positions under the package. This branch mergedmainat fbcbcf1. The merge touched none of this PR's files, andregistry.ts's logic is unchanged.\n\nComments only. Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true onmain: thesecurityCatalogClaimsdoc and theinstallPackagecomment inregistry.ts, the declared-names reader's note insecurity-catalog-namespace.ts, and the header ofstandalone-stack-security-catalog-one-holder.test.ts. No behaviour changed, so no reverse leg was re-run.\n\nMeasured with #22262 in the tree (f16fcd0, this branch withmainmerged; throughbootStack; local probes, not committed):\n\n- 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 undercom.example.showcaseand 6 undercom.objectstack.plugin-security, and 10 claims. Re-registering the showcase is not refused. A second package declaringcontributoris refused, holdercom.example.showcase.\n- The built-in case is unchanged. With environment saves underorg_adminandeveryone(OS_METADATA_WRITABLE=position), the cold restart boots, and both names resolve to the environment's saved definitions.\n-PUT /api/v1/meta/position/shared_pos?package=wover a package-held position answers403 NOT_OVERRIDABLE.\n- 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 andinstall-localare refused, each naming both holders; the same-package reload is accepted.\n\nThe claims, ablated. This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (scripts/ablation-replace.mjs),objectqlwas rebuilt, and the marker was proven indist/. The runtime boot pins stayed green (5 of 5): everyregisterAppdoor refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and thecollisionPolicy: 'warn'pin. Both reach the package door through a directinstallPackagecall, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild,ablation-dist-preflight --absentis green.\n\nTests at f16fcd0, all underos-verify-lock:\n\n-@objectstack/objectql, whole suite: 383 files / 7572 passed.\n-@objectstack/plugin-security, whole suite: 179 files / 3775 passed, 45 skipped.\n-@objectstack/runtime, whole suite: 340 files / 5505 passed, 19 skipped.\n- 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.\n-typecheckforobjectqlandruntime(tsc --noEmit+check:test-typecheck): exit 0.\n- Gates:dispatch-gates --commandsderived 82 on f16fcd0. All 82 ran and exited 0, and--ranreconciles 82/82, 0 NOT MEASURED.\n\nCI 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).\n\n---\n_Generated by Claude Code_\n\n"},
"deviations": [
"Two comments beyond the two that round 2 named were rewritten (security-catalog-namespace.ts and the runtime pin's header), because the merge made them false; see comments.why_more_than_two.",
"The worktree was recreated from the local branch, which equalled the remote ffa6d51, with no force. It is removed again right after this comment posts.",
"The local probes were copied into the worktree for one locked run and removed before the gate phase; they were never committed. git status was clean before the gates ran.",
"PR #22197 reads draft: false and mergeable_state blocked at report time; this dev changed neither."
],
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment, POST /repos//issues/22135/comments, through the fleet-write relay (repository_dispatch) as objectstack-fleet[bot]. git push of the code branch is not a REST write. No PR body or label write. Reads: GET pulls/22197 and GET commits/f16fcd0c7c/check-runs (gh api).",
"open_questions": [
{
"question": "Carried from round 2, unchanged and still open: a package-bound registration in a plugin's start() runs after sys_metadata hydration. For a non-built-in name it is refused when an environment row holds the name (holder environment), and that refusal fails the boot. Phase-1 registerApp packages are not refused at cold boot. Does the round-0 recommendation stand?",
"options": [
"A — keep it: Phase-1 packages keep the existing collision warning at cold boot; a start()-time package-bound registration over an environment-held non-built-in name refuses the boot (current behaviour, pinned by the CONTROL case)",
"B — fold the cold-boot half into #15196's S9 boot report as a loud report, and also downgrade the start()-time case to that report",
"C — leave both as now, and only document the start()-time case"
],
"axes": "Business need: no in-repo producer reaches the start()-time non-built-in case; it needs an out-of-repo plugin registering a catalog item in start() over a name an environment saved. Long-term: A keeps one predicate at one seam; B adds a second disposition for one condition. AI-error resistance: A fails loudly at boot and names both holders; B keeps the deployment up and only reports. Startup scope: A adds nothing; B is S9 work.",
"recommendation": "A, because the ruling names the environment catalog as a holder and the refusal names both holders."
}
],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"round": "patch round 4 (contract review FAIL on f16fcd0, record 6061710772; text only)",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"head": "9e4ed5d53b (was f16fcd0). One commit this round, text only. Not merged with main: git merge-tree against origin/main 4e4111c (19 commits ahead of the merge base fbcbcf1) is clean.",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"summary": "The changeset now grades @objectstack/objectql major. Its BREAKING sentence says major on the v18 pre-release line, and the false pre-mode premise and the launch-window wording are gone. The ADR-0087 marker and Clause-②: no stay. The changeset gates accept the major in pre mode, and changeset status resolves objectql to 18.0.0-next.0. One sentence in the changeset states the cold-boot boundary and its carrier, #22307, and every clause of it was measured on 9e4ed5d with a probe. The runtime pin's "no registry slot holds" comment is corrected. The Acceptance-notes cold-boot bullet now ends "Carrier: #22307 (a maintainer decision)." The start()-time half is settled as A; nothing changed for it.",
"files_changed": [
".changeset/22135-security-catalog-one-holder.md — frontmatter: objectql minor -> major. The BREAKING paragraph's grading sentence is rewritten. One sentence is appended to the "What is NOT refused" paragraph: the cold-boot boundary, open on #22307.",
"packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts — comment only, two lines (was 124-125): "Every conflicting name in one refusal: the position, the permission set and the capability the first package holds.""
],
"changeset": {
"level": "'@objectstack/objectql': major. a87d8be (#22084, "enter Changesets pre mode (next)") is an ancestor of the merge base fbcbcf1: merge-base --is-ancestor exit 0, and the control 0b997ea also exits 0. .changeset/pre.json reads {"mode":"pre","tag":"next"}. pnpm changeset status --verbose: exit 0, and objectql resolves to 18.0.0-next.0 under major.",
"breaking_sentence": "Now reads: "BREAKING — an accept-set narrowing at the package registration door, shipped as
majoron the v18 pre-release line (.changeset/pre.jsonis innextpre mode onmain)." The rest of that paragraph is unchanged. The phrasing follows the existing major changesets on main, for example 19939-flow-value-slot-template-dialect-refused.md.","cold_boot_sentence": "Appended to the "What is NOT refused" paragraph, as one sentence: "At cold boot, packages register before the environment catalog loads from
sys_metadata, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; whether a cold boot should refuse too is open on #22307." It says nothing about which definition then answers the by-name read.","cold_boot_measured": "Every clause was measured on 9e4ed5d with a local probe (zz-probe4, copied in for one locked run and removed; never committed): 1 file / 1 passed, command-exit 0. Boot 1, the deployment without the package: PUT /api/v1/meta/permission/cold_set answered 200, and PUT /api/v1/meta/position/cold_pos answered 200. PUT /api/v1/meta/capability/cold_cap answered 403, because capability is a code-only type (allowRuntimeCreate=false, allowOrgOverride=false); that is why the sentence names permission sets and positions only. Boot 2, cold, on the same database with the package added beside the app (extraPlugins AppPlugin com.probe.newpkg): no error. Two warnings fired: "[Registry] Collision: permission/cold_set is shipped by package com.probe.newpkg and a runtime-authored row with the same name has just been registered from sys_metadata ..." and the same for position/cold_pos. The package record is present. Boot 3, the deployment without the package, then manifest.register of the same package: SecurityCatalogNameConflictError, code NAMESPACE_CONFLICT, status 422, two conflicts (position cold_pos and permission cold_set, both holder environment), and no package record.",
"gates_in_pre_mode": [
"node scripts/check-changeset-no-major.mjs --self-test — exit 0",
"node scripts/check-changeset-no-major.mjs --base fbcbcf1 — exit 0: "Changesets is in pre-release mode (tag: next) — major bumps are the expected product of an RC window; skipping the no-major guard"; notice "major introduced by this diff ... @objectstack/objectql". Without a payload the level axis is NOT APPLICABLE.",
"node scripts/check-changeset-no-major.mjs --base fbcbcf1 --event (a pull_request payload carrying this PR's body and labels) — exit 0: "LEVEL AXIS: this PR declares clause-② no"",
"node scripts/check-empty-changeset.mjs --self-test — exit 0",
"node scripts/check-empty-changeset.mjs --base fbcbcf1 — exit 0: no empty-frontmatter changeset introduced (1 declaring changeset added); no merge-base changeset modified or deleted",
"node scripts/check-changeset-fixed.mjs — exit 0: the fixed group is in sync with 69 public workspace packages",
"pnpm check:adr-0087-registration — exit 0: 1 declared-breaking changeset, carrying an ADR-0087 disposition (the no-migration-prescription exemption)",
"pnpm check:changeset-gate-self-tests — exit 0",
"pnpm changeset status --verbose — exit 0; objectql -> 18.0.0-next.0"
]
},
"comment_sweep": "After the fix, the PR diff's added lines were grepped for "position" next to registry, engine, slot, metadata service, loop or claim. Every remaining hit was read: none says positions do not reach the registry. The P1.2 case title "... so one stack's declaration reaches the metadata service" is still true; the case calls installPackage directly, the door where the claims decide. A wider grep of the 15 PR files found one pre-existing line, not this PR's; see out_of_scope_findings.",
"tests": "At 9e4ed5d, under os-verify-lock (slot dev-22135), after a fresh install and a dogfood-closure build (command-exit 0). pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/standalone-stack-security-catalog-one-holder.test.ts: 1 file / 5 passed, command-exit 0. pnpm --filter @objectstack/runtime typecheck: command-exit 0; tsc --noEmit, then check:test-typecheck OK (27 files / 190 errors / 68 pinned signatures held). The first attempt of each queue-timed out (exit 99, NOT MEASURED) behind other agents' runs; each was re-taken on the same slot and the reported runs are the completed ones. The whole runtime suite was not re-run (comment-only there, as instructed). No reverse verification: no code changed. CI on 9e4ed5d at report time (one check-runs read, minutes after the push): 17 runs so far, 15 in_progress, 1 success, 1 skipped; NOT MEASURED. On f16fcd0 CI was green on all checks.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .changeset/22135-security-catalog-one-holder.md packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts derived 61 commands for the two touched paths. All 61 ran after the locked runs, and all 61 exited 0. Among them: check-adr-0087-registration --base and --self-test, check-changeset-no-major --base and --self-test, check-empty-changeset --base and --self-test, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-changeset-deadline-census, check:nul-bytes, and check:dual-build-cjs-loads (the 8 prerequisite packages were built first under the lock). --ran with the same paths reconciles 61 derived, 61 run, 0 NOT MEASURED (a derived zero). The reconcile prints STALE TREE: origin/main moved during the run, and 2 files it derives families from changed (scripts/analytics-reconcile/boot.ts, scripts/platform-object-tenancy-census.json). git merge-tree against origin/main 4e4111c is clean (exit 0), so main was not merged.",
"line_budget": "this round: 2 files, +5 / -5. PR total vs merge base fbcbcf1: 15 files, +1080 / -103 (1183 changed lines). skills/: none.",
"pr_body_full": {
"note": "The full replacement body for #22197. The seat writes it; this dev makes no PATCH. It is the body the seat wrote at 13:54Z (read back at report time, and byte-identical to round 3's pr_body_full) with two changes. First, the Acceptance-notes cold-boot bullet's last sentence "Raised for a decision in the report." now reads "Carrier: #22307 (a maintainer decision)." Second, a "Patch round 4" section is appended before the footer. Every other byte is unchanged. First line Fixes #22135, second Clause-②: no. It has no angle bracket, no HTML comment and no control byte, and its only closing keyword is in the first line.",
"body": "Fixes #22135\nClause-②: no\n\nExecutes 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.\n\nThe ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on
main. This PR carries nodocs/adr/**file.\n\n## What changed\n\n-packages/objectql/src/security-catalog-namespace.ts(new). The rule in one place: the three types, the built-in names, the holder vocabulary (package/environment/built-in), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's ownpositions/permissions/capabilitiesand each nestedplugins[]entry's, arrays only. A manifest-stagepermissionsgrant block is never read as permission sets.\n-SchemaRegistry.installPackage— the package door. It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released byuninstallPackage. The claims are what the door reads for names no registered item records, andinstallPackageitself registers no items. ThroughObjectQL.registerApp(every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since #22262 landed onmain. There the claims agree with the items. With the claims ablated, theregisterAppdoors still refuse and the directinstallPackagedoor does not (Patch round 3), so the claims stay.\n-SchemaRegistry.registerItem— the item seam. A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now onmain), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what everysys_metadatahydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling.\n- 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 amanifest.namespaceand offers theOS_METADATA_COLLISION=warndowngrade. Neither is true here, andcollisionPolicy: 'warn'does not downgrade this refusal (pinned).\n- No new error code; nopackages/specchange.\n\n### Where the doors are, measured\n\nThe card names three doors. What this PR measured is that all three are reached through ONE:ObjectQL.registerApp→SchemaRegistry.installPackage, which every package registration hits in the kernel's Phase 1, before anystart().\n\n-AppPlugin's security registrar (registerInMemory, the'app-plugin'registrar) and the artifact door (MetadataPlugin._registerArtifactBodyCollections, the'artifact-door'registrar) both run in Phase 2.\n- Neither runs for a package the engine has not installed:AppPlugin.initregisters every package of its bundle through themanifestservice first, a multi-package artifact package by package.\n- So the producer-side fix is the package door, andpackages/metadata/src/plugin.tsandpackages/runtime/src/app-plugin.tsare unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs.\n\n## Door table: base vs head\n\n"Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through@objectstack/verify'sbootStack; the artifact rows go throughcreateStandaloneStack.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Boot: an app declaringeveryone/manage_users/admin_full_access| boots. The by-name read ofadmin_full_accessanswers the APP's set | refused. Holderbuilt-infor the first two. Foradmin_full_accessthe app registers beforeplugin-securityinbootStack, so the platform's registration is the one stopped, naming the app |\n| Artifact boot: two packages of one artifact sharing names; a package declaringeveryone| boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything |\n| Hot install: post-bootmanifest.registerover a held name | accepted, package record written | refused, no record |\n| 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 inerror.message; no record |\n|POST /api/v1/packages|400: the strict body refusespositions, the retiredcapabilitiesand a flatpermissionslist | unchanged. No catalog collection can arrive here |\n| Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holderenvironment|\n| Same-package hot reload | accepted | accepted |\n| 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 |\n| 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 onmain:403 NOT_OVERRIDABLE(see Acceptance notes) |\n\nNamed but not measured:\n\n- The artifact door's HMR reload (MetadataPlugin._reloadAndAnnounce). It re-registers into the metadata service withoutregisterApp, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it.\n-install-local's cloud-sourced install. Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal aterrorand skips the package. That is code reading only (it needs a control plane).\n\n## In-repo collision census (M2)\n\nInstrument.** A tsx census overexamples/app-crm,examples/app-showcase,examples/app-todoandexamples/app-multi-package: each config's top level, itspackages[]bodies and its nestedplugins[]. Against those it reads the built-ins:BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS,PLATFORM_CAPABILITY_NAMES, andplugin-security'ssecurityDefaultPermissionSets.\n\nResult 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.\n\nThe guard, measured with the gate in place at 8ad6385:crm,showcaseandmulti-packageboot throughbootStack, andsecurity-catalog-showcase.dogfood.test.ts(3 postures) andmulti-package-artifact.dogfood.test.tsare green.app-tododeclares no catalog name. Deployed and marketplace packages are NOT MEASURED.\n\n## The P1.2 pin, flipped\n\nS1's shared-name pin is thesecurity catalog read — a name two packages shipdescribe inpackages/objectql/src/protocol-boot-hydration-scoped.test.ts. It added noP1.2label, which is why agit grepmisses it. It now pins the ruled answer at the same seams:\n\n- a second package registering the name is refused (envelope + both holders), and every reader answers the one holder;\n- an override the holder stored for itself answers for every caller;\n- a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service.\n\ncore'ssecurity-catalog.test.tspointed at a non-existentsecurity-catalog-shared-name.test.ts. It now names that describe and the new door pins.security-catalog.ts's module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer.engine-capability-provenance.test.tspinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal.\n\n## Tests (at 8ad6385)\n\n-@objectstack/objectql:registry-security-catalog-namespace.test.ts(new, 28 cases),protocol-boot-hydration-scoped.test.ts,engine-capability-provenance.test.ts,registry-collision-order.test.tsandregistry-artifact-co-ownership.test.ts: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip.\n-@objectstack/runtime:standalone-stack-security-catalog-one-holder.test.ts(new, 4) andstandalone-stack-security-registrar.test.ts: 2 files, 6 passed.\n-@objectstack/coresecurity-catalog.test.ts: 14 passed.@objectstack/plugin-securitybuiltin-positions.boot.test.ts+builtin-positions.test.ts(S2's): 18 passed.\n- dogfood:security-catalog-showcase,multi-package-artifact, plus a local door probe that is not committed: 3 files, 44 passed.\n- Downstream sweep before the merges, against the rebuiltobjectqldist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green.\n-typecheckfor objectql, core and runtime (withcheck:test-typecheck): exit 0. No new test-typecheck debt.\n\n## Ablation\n\nBoth gates were ablated together throughscripts/ablation-replace.mjs, which wraps the run and restores on exit:\n\n- the package-door call became aglobalThismarker write;\n- the item-seam condition gained an always-false marker conjunct.\n\nBoth mutations landed on disk: anchor 1 → 0, blobb96099a12688→12c018d406ab.objectqlwas rebuilt andablation-dist-preflightfound both markers indist/. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted.\n\n- objectql pins (read fromsrc): 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.\n- runtime boot pins (read fromdist): 3 failed / 1 passed. The control stayed green.\n\nRestore: the blob is back tob96099a12688andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green for both markers (dist and whole tree).\n\n## Gates\n\nnode scripts/pm/dispatch-gates.mjs --commandsderived 78 commands on 8ad6385; all 78 were run, and--ranreconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first:check-engine-split-ratiorefused the shallow clone (deepened withgit fetch --shallow-since=2026-07-03), andcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run,origin/mainmoved 6 commits, none of which touches a file in this PR.\n\n## Acceptance notes\n\n- Environment save of a position over a package-held position name. Before #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 #22262 landed onmain, a package's positions are registered items under the package, and the same save answers403 NOT_OVERRIDABLE("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 withPUT /api/v1/meta/position/shared_pos?package=w. Outside this ruling either way; this PR changes nothing there.\n- Cold boot vs the environment-catalog holder. A package registers throughObjectQL.registerAppin Phase 1, andsys_metadatahydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. TheCONTROLcase inregistry-security-catalog-namespace.test.tspins that the bare slot is not judged. A plugin's ownstart()is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holderenvironment). The exception is a built-in name, which the platform declares there itself (Patch round 2). Agit grepfor literal catalog-typeregisterItemcalls in production source finds one such registration:plugin-security's built-in positions. Carrier: #22307 (a maintainer decision).\n- Order and the platform's permission sets.plugin-securitydeclares the platform's sets on its own manifest (configurable throughdefaultPermissionSets), so they are package-held. When an app registers before it, asbootStackcomposes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named.\n-install-localinline import answers its ownPLUGIN_REGISTER_FAILEDfor any register refusal (this one and the namespace gate's alike), soerror.codedoes not carryNAMESPACE_CONFLICTthere. The refusal's text is inerror.message. Not changed here.\n- The ledger row comment forNAMESPACE_CONFLICTinpackages/spec/src/api/error-code-ledger.zod.tsdescribes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here.\n- Files outside the engine lane:packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test;runtimeisdomain:cli's package);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(new ADR anchor); and, from patch round 1, fivedomain:clidogfood files:packages/qa/dogfood/test/showcase-security.ts,showcase-d7-default-profile.dogfood.test.ts,authored-row-write-scope.dogfood.test.ts,bulk-widener-probe.dogfood.test.tsandowd-public-read-write-write-floor.dogfood.test.ts(each: theSecurityPluginconstruction, with its comment and imports).\n\n## Patch round 1 — the dogfood fixtures declared one permission set twice\n\nThe Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot,plugin-securityregistered a permission set that the app package already held.\n\nThe fixtures handed an app-declared set toSecurityPlugin'sdefaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:\n\n-showcase_member_default, throughshowcaseAppDefaultSecurity()and the D7 test;\n-wscope_*,probe_widenerandowdw_*, in three fixtures.\n\nMeasured:\n\n-os serve/objectstack devnever composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A realobjectstack dev --freshboot ofexamples/app-showcasecame up with the gate in place: health 200.\n- The two copies were the same definition: 101 of 101 leaves equal.\n\nFixed 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.\n\nAll three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.\n\n## Patch round 2 — the platform's built-in positions met an environment row at boot\n\nThe merge queue removed this PR (record 6056019838).plugin-security'sregisterBuiltinPositionswas refused at the item seam: positionorg_admin, incomingcom.objectstack.plugin-security, holderenvironment. That refusal failedSecurityPlugin.start, and with it the boot. S2b's pins went red:builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), andbootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".\n\nMeasured on 19c86b7 (this branch withmainmerged, before the fix):\n\n- Boot order.SecurityPlugindepends on the engine. SoObjectQLPlugin.starthydratessys_metadatainto the bare slot BEFORESecurityPlugin.startdeclares the built-in positions. Through a real door: withOS_METADATA_WRITABLE=position,PUT /api/v1/meta/position/org_adminanswered200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers403 NOT_OVERRIDABLE.\n- Who registers.registerBuiltinPositionsregisters exactly the six static built-in names (BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.\n- What S2b needs. The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it.\n\nFixed at the producer, the item seam inSchemaRegistry.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:\n\n- a second PACKAGE registering a built-in name at the item seam is refused, in either order;\n- the package door refuses a package declaring a built-in name (holderbuilt-in);\n- for any other name, an environment item still refuses a package-bound registration at the item seam (holderenvironment).\n\nNo same-definition exception, nocollisionPolicychange, and S2b's pins are untouched.registry-security-catalog-namespace.test.tsgained three cases, one per behaviour above (the third is aCONTROL).\n\nReverse verification. The new condition was mutated throughscripts/ablation-replace.mjsto ask the environment holder again (blobc60d9bad21bc→eeca074e4f80).objectqlwas rebuilt, andablation-dist-preflightfound the marker indist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red withSecurityCatalogNameConflictError(org_adminheld by the environment catalog), and so did the new admit case. Restored: blob == HEAD, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green.\n\nAll suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI.\n\n## Patch round 3 — #22262 landed onmainfirst\n\n#22262 (squash 0b997ea) addspositionsto the engine'sMETADATA_ARRAY_KEYS, soObjectQL.registerAppnow registers a package's positions under the package. This branch mergedmainat fbcbcf1. The merge touched none of this PR's files, andregistry.ts's logic is unchanged.\n\nComments only. Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true onmain: thesecurityCatalogClaimsdoc and theinstallPackagecomment inregistry.ts, the declared-names reader's note insecurity-catalog-namespace.ts, and the header ofstandalone-stack-security-catalog-one-holder.test.ts. No behaviour changed, so no reverse leg was re-run.\n\nMeasured with #22262 in the tree (f16fcd0, this branch withmainmerged; throughbootStack; local probes, not committed):\n\n- 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 undercom.example.showcaseand 6 undercom.objectstack.plugin-security, and 10 claims. Re-registering the showcase is not refused. A second package declaringcontributoris refused, holdercom.example.showcase.\n- The built-in case is unchanged. With environment saves underorg_adminandeveryone(OS_METADATA_WRITABLE=position), the cold restart boots, and both names resolve to the environment's saved definitions.\n-PUT /api/v1/meta/position/shared_pos?package=wover a package-held position answers403 NOT_OVERRIDABLE.\n- 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 andinstall-localare refused, each naming both holders; the same-package reload is accepted.\n\nThe claims, ablated. This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (scripts/ablation-replace.mjs),objectqlwas rebuilt, and the marker was proven indist/. The runtime boot pins stayed green (5 of 5): everyregisterAppdoor refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and thecollisionPolicy: 'warn'pin. Both reach the package door through a directinstallPackagecall, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild,ablation-dist-preflight --absentis green.\n\nTests at f16fcd0, all underos-verify-lock:\n\n-@objectstack/objectql, whole suite: 383 files / 7572 passed.\n-@objectstack/plugin-security, whole suite: 179 files / 3775 passed, 45 skipped.\n-@objectstack/runtime, whole suite: 340 files / 5505 passed, 19 skipped.\n- 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.\n-typecheckforobjectqlandruntime(tsc --noEmit+check:test-typecheck): exit 0.\n- Gates:dispatch-gates --commandsderived 82 on f16fcd0. All 82 ran and exited 0, and--ranreconciles 82/82, 0 NOT MEASURED.\n\nCI 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).\n\n## Patch round 4 — the changeset level, one comment, the cold-boot carrier\n\nThe contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only.\n\n- The changeset level..changeset/22135-security-catalog-one-holder.mdgraded@objectstack/objectqlminor, on the premise that Changesets pre mode was not yet onmain. It is:.changeset/pre.json(modepre, tagnext) landed with #22084 (a87d8be), an ancestor of this branch's merge base. The changeset now gradesmajor, and its BREAKING sentence says the change ships asmajoron the v18 pre-release line. The ADR-0087 marker andClause-②: nostay.pnpm changeset statusresolves@objectstack/objectqlto18.0.0-next.0.\n- The cold-boot carrier. One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads fromsys_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. Whether a cold boot should refuse too is open on #22307. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two[Registry] Collisionwarnings, one for the permission set and one for the position. The hot install answered422 NAMESPACE_CONFLICT, both names held byenvironment, and left no package record. A capability cannot be saved in the environment (403, a code-only type), so the sentence names the two types an environment can hold.\n- One comment. The runtime pin's comment called the position one "no registry slot holds". That stopped being true when #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.\n- Thestart()-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.\n\nChecks at 9e4ed5d:\n\n- The changeset gates:check-changeset-no-major--self-testand--base, exit 0 (pre mode, tagnext, so the no-major guard stands aside; given this PR's body, the level axis readsClause-②: no);check-empty-changeset--self-testand--base, exit 0;check-changeset-fixed, exit 0;check:adr-0087-registration, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition);check:changeset-gate-self-tests, exit 0.\n-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.\n- Gates:dispatch-gates --commandsfor the two touched paths derived 61 commands. All 61 ran and exited 0, and--ranreconciles 61/61, 0 NOT MEASURED.git merge-treeagainstorigin/main4e4111c is clean, somainwas not merged.\n\n---\n_Generated by Claude Code_\n\n"},
"deviations": [
"The worktree was recreated from the local branch, which equalled the remote f16fcd0, with no force. It is removed again right after this comment posts.",
"Per the round order, the cold-boot sentence was measured, not taken as given. The probe confirmed every clause. It also showed that a capability cannot be saved in the environment (403, code-only), which bears out the instruction's "permission-set or position".",
"The check-changeset-no-major level axis needs a pull_request payload. It was run once with a locally built payload carrying this PR's body and labels. CI reads the real one.",
"Two locked runs queue-timed out once (exit 99, behind other agents' integration and full builds). They were re-taken in place on the same slot and are reported from the completed runs."
],
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment, POST /repos//issues/22135/comments, through the fleet-write relay (repository_dispatch) as objectstack-fleet[bot]. git push of the code branch is not a REST write. No PR body or label write. Reads: GET pulls/22197 and GET commits/9e4ed5d53b/check-runs (gh api).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed — packages/core/src/security/security-catalog.test.ts:78, a pre-existing case title (not this PR's line, in a file this PR edits elsewhere): "a name only the metadata service holds is answered by it (stack-declared positions)". It is a stand-in-reader test, and its parenthetical example stopped describing a real boot when #22262 put stack-declared positions into the registry. The assertion itself is about the stand-in and stays true. A stale example in a test title, so not a filing class. Dedupe words: security-catalog test title stack-declared positions metadata service only"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22135,
"status": "done",
"round": "patch round 5 (#22307 ruled A at 15:22Z; one-sentence changeset fix)",
"branch": "claude/issue-22135-security-catalog-one-holder",
"pr": "#22197",
"head": "99fba80b64 (was 9e4ed5d). One commit, the changeset only. Not merged with main: git merge-tree against origin/main 4e4111c is clean (exit 0).",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 — mode:subagent, the parent PM session id (shared)",
"premise_still_valid": true,
"summary": "In .changeset/22135-security-catalog-one-holder.md, the cold-boot sentence's last clause "whether a cold boot should refuse too is open on #22307" is replaced by "a cold-boot refusal is ruled and tracked on #22307, which lands separately". That clause is true on this PR's merge and stays true after #22307 lands. The measured clauses before it are unchanged, and nothing describes #22307's mechanism as shipped. The changeset gates and the 20 gates derived for the touched path all exit 0.",
"files_changed": [
".changeset/22135-security-catalog-one-holder.md — the last clause of the cold-boot sentence in the "What is NOT refused" paragraph (1 line, +1 / -1). The sentence now reads: "At cold boot, packages register before the environment catalog loads from
sys_metadata, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on #22307, which lands separately.""],
"tests": "Text-only change to a changeset: no package test applies and none was run. No reverse verification. CI on 99fba80: NOT MEASURED at report time (pushed minutes earlier). The round-4 measurements stand unchanged for the clauses kept: the cold boot is not refused, two collision warnings fire, and the hot install is refused with 422 NAMESPACE_CONFLICT.",
"changeset_gates": [
"node scripts/check-changeset-no-major.mjs --base fbcbcf1 — exit 0: pre-release mode (tag next), the no-major guard stands aside",
"node scripts/check-changeset-no-major.mjs --base fbcbcf1 --event (a pull_request payload with this PR's body and labels) — exit 0: "LEVEL AXIS: this PR declares clause-② no"",
"node scripts/check-empty-changeset.mjs --base fbcbcf1 — exit 0: no empty-frontmatter changeset introduced (1 declaring changeset added); no merge-base changeset modified or deleted",
"pnpm check:adr-0087-registration — exit 0: 1 declared-breaking changeset, carrying an ADR-0087 disposition"
],
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack .changeset/22135-security-catalog-one-holder.md derived 20 commands; all 20 ran and exited 0: check-adr-0087-registration --base and --self-test, check-changeset-no-major --base and --self-test, check-closing-keyword-parity and --self-test, check-comment-mask-corpus, check-empty-changeset --base and --self-test, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:changeset-gate-self-tests, check:driver-memory-census, check:gitlink-declared, check:nul-bytes, check:objectui-changeset, check:pm-changeset-deadline-census, check:published-files, check:refd-timer-probe, check:watch-hint-literal. --ran with the same path reconciles 20 derived, 20 run, 0 NOT MEASURED (a derived zero). The reconcile prints STALE TREE: 2 family-source files changed on main since the merge base; main was not merged because merge-tree is clean.",
"line_budget": "this round: 1 file, +1 / -1. PR total vs merge base fbcbcf1: 15 files, +1080 / -103. skills/: none.",
"pr_body_full": {
"note": "The full replacement body for #22197; the seat writes it, and this dev makes no PATCH. It is round 4's pr_body_full.body with three changes. 1: the Acceptance-notes cold-boot bullet now ends "Carrier: #22307 (ruled A: the cold boot refuses too; it lands separately)." 2: in round 4's "The cold-boot carrier" bullet, "Whether a cold boot should refuse too is open on #22307." now reads "#22307 has since been ruled A (the cold boot refuses too); see Patch round 5." 3: a "Patch round 5" section sits before the footer. Every other byte is unchanged. At report time the live PR body was still the round-3 body (it ends "Raised for a decision in the report." and has no Patch round 4 section), so this replacement also carries round 4's two edits. First line Fixes #22135, second Clause-②: no. It has no angle bracket, no HTML comment and no control byte, and its only closing keyword is in the first line.",
"body": "Fixes #22135\nClause-②: no\n\nExecutes 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.\n\nThe ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on
main. This PR carries nodocs/adr/**file.\n\n## What changed\n\n-packages/objectql/src/security-catalog-namespace.ts(new). The rule in one place: the three types, the built-in names, the holder vocabulary (package/environment/built-in), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's ownpositions/permissions/capabilitiesand each nestedplugins[]entry's, arrays only. A manifest-stagepermissionsgrant block is never read as permission sets.\n-SchemaRegistry.installPackage— the package door. It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released byuninstallPackage. The claims are what the door reads for names no registered item records, andinstallPackageitself registers no items. ThroughObjectQL.registerApp(every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since #22262 landed onmain. There the claims agree with the items. With the claims ablated, theregisterAppdoors still refuse and the directinstallPackagedoor does not (Patch round 3), so the claims stay.\n-SchemaRegistry.registerItem— the item seam. A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now onmain), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what everysys_metadatahydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling.\n- 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 amanifest.namespaceand offers theOS_METADATA_COLLISION=warndowngrade. Neither is true here, andcollisionPolicy: 'warn'does not downgrade this refusal (pinned).\n- No new error code; nopackages/specchange.\n\n### Where the doors are, measured\n\nThe card names three doors. What this PR measured is that all three are reached through ONE:ObjectQL.registerApp→SchemaRegistry.installPackage, which every package registration hits in the kernel's Phase 1, before anystart().\n\n-AppPlugin's security registrar (registerInMemory, the'app-plugin'registrar) and the artifact door (MetadataPlugin._registerArtifactBodyCollections, the'artifact-door'registrar) both run in Phase 2.\n- Neither runs for a package the engine has not installed:AppPlugin.initregisters every package of its bundle through themanifestservice first, a multi-package artifact package by package.\n- So the producer-side fix is the package door, andpackages/metadata/src/plugin.tsandpackages/runtime/src/app-plugin.tsare unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs.\n\n## Door table: base vs head\n\n"Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through@objectstack/verify'sbootStack; the artifact rows go throughcreateStandaloneStack.\n\n| Door | Base | Head |\n|---|---|---|\n| 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 |\n| Boot: an app declaringeveryone/manage_users/admin_full_access| boots. The by-name read ofadmin_full_accessanswers the APP's set | refused. Holderbuilt-infor the first two. Foradmin_full_accessthe app registers beforeplugin-securityinbootStack, so the platform's registration is the one stopped, naming the app |\n| Artifact boot: two packages of one artifact sharing names; a package declaringeveryone| boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything |\n| Hot install: post-bootmanifest.registerover a held name | accepted, package record written | refused, no record |\n| 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 inerror.message; no record |\n|POST /api/v1/packages|400: the strict body refusespositions, the retiredcapabilitiesand a flatpermissionslist | unchanged. No catalog collection can arrive here |\n| Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holderenvironment|\n| Same-package hot reload | accepted | accepted |\n| 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 |\n| 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 onmain:403 NOT_OVERRIDABLE(see Acceptance notes) |\n\nNamed but not measured:\n\n- The artifact door's HMR reload (MetadataPlugin._reloadAndAnnounce). It re-registers into the metadata service withoutregisterApp, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it.\n-install-local's cloud-sourced install. Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal aterrorand skips the package. That is code reading only (it needs a control plane).\n\n## In-repo collision census (M2)\n\nInstrument.** A tsx census overexamples/app-crm,examples/app-showcase,examples/app-todoandexamples/app-multi-package: each config's top level, itspackages[]bodies and its nestedplugins[]. Against those it reads the built-ins:BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS,PLATFORM_CAPABILITY_NAMES, andplugin-security'ssecurityDefaultPermissionSets.\n\nResult 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.\n\nThe guard, measured with the gate in place at 8ad6385:crm,showcaseandmulti-packageboot throughbootStack, andsecurity-catalog-showcase.dogfood.test.ts(3 postures) andmulti-package-artifact.dogfood.test.tsare green.app-tododeclares no catalog name. Deployed and marketplace packages are NOT MEASURED.\n\n## The P1.2 pin, flipped\n\nS1's shared-name pin is thesecurity catalog read — a name two packages shipdescribe inpackages/objectql/src/protocol-boot-hydration-scoped.test.ts. It added noP1.2label, which is why agit grepmisses it. It now pins the ruled answer at the same seams:\n\n- a second package registering the name is refused (envelope + both holders), and every reader answers the one holder;\n- an override the holder stored for itself answers for every caller;\n- a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service.\n\ncore'ssecurity-catalog.test.tspointed at a non-existentsecurity-catalog-shared-name.test.ts. It now names that describe and the new door pins.security-catalog.ts's module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer.engine-capability-provenance.test.tspinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal.\n\n## Tests (at 8ad6385)\n\n-@objectstack/objectql:registry-security-catalog-namespace.test.ts(new, 28 cases),protocol-boot-hydration-scoped.test.ts,engine-capability-provenance.test.ts,registry-collision-order.test.tsandregistry-artifact-co-ownership.test.ts: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip.\n-@objectstack/runtime:standalone-stack-security-catalog-one-holder.test.ts(new, 4) andstandalone-stack-security-registrar.test.ts: 2 files, 6 passed.\n-@objectstack/coresecurity-catalog.test.ts: 14 passed.@objectstack/plugin-securitybuiltin-positions.boot.test.ts+builtin-positions.test.ts(S2's): 18 passed.\n- dogfood:security-catalog-showcase,multi-package-artifact, plus a local door probe that is not committed: 3 files, 44 passed.\n- Downstream sweep before the merges, against the rebuiltobjectqldist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green.\n-typecheckfor objectql, core and runtime (withcheck:test-typecheck): exit 0. No new test-typecheck debt.\n\n## Ablation\n\nBoth gates were ablated together throughscripts/ablation-replace.mjs, which wraps the run and restores on exit:\n\n- the package-door call became aglobalThismarker write;\n- the item-seam condition gained an always-false marker conjunct.\n\nBoth mutations landed on disk: anchor 1 → 0, blobb96099a12688→12c018d406ab.objectqlwas rebuilt andablation-dist-preflightfound both markers indist/. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted.\n\n- objectql pins (read fromsrc): 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.\n- runtime boot pins (read fromdist): 3 failed / 1 passed. The control stayed green.\n\nRestore: the blob is back tob96099a12688andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green for both markers (dist and whole tree).\n\n## Gates\n\nnode scripts/pm/dispatch-gates.mjs --commandsderived 78 commands on 8ad6385; all 78 were run, and--ranreconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first:check-engine-split-ratiorefused the shallow clone (deepened withgit fetch --shallow-since=2026-07-03), andcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run,origin/mainmoved 6 commits, none of which touches a file in this PR.\n\n## Acceptance notes\n\n- Environment save of a position over a package-held position name. Before #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 #22262 landed onmain, a package's positions are registered items under the package, and the same save answers403 NOT_OVERRIDABLE("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 withPUT /api/v1/meta/position/shared_pos?package=w. Outside this ruling either way; this PR changes nothing there.\n- Cold boot vs the environment-catalog holder. A package registers throughObjectQL.registerAppin Phase 1, andsys_metadatahydrates in Phase 2 (ObjectQLPlugin.start). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. TheCONTROLcase inregistry-security-catalog-namespace.test.tspins that the bare slot is not judged. A plugin's ownstart()is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holderenvironment). The exception is a built-in name, which the platform declares there itself (Patch round 2). Agit grepfor literal catalog-typeregisterItemcalls in production source finds one such registration:plugin-security's built-in positions. Carrier: #22307 (ruled A: the cold boot refuses too; it lands separately).\n- Order and the platform's permission sets.plugin-securitydeclares the platform's sets on its own manifest (configurable throughdefaultPermissionSets), so they are package-held. When an app registers before it, asbootStackcomposes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named.\n-install-localinline import answers its ownPLUGIN_REGISTER_FAILEDfor any register refusal (this one and the namespace gate's alike), soerror.codedoes not carryNAMESPACE_CONFLICTthere. The refusal's text is inerror.message. Not changed here.\n- The ledger row comment forNAMESPACE_CONFLICTinpackages/spec/src/api/error-code-ledger.zod.tsdescribes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here.\n- Files outside the engine lane:packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts(new test;runtimeisdomain:cli's package);scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json(new ADR anchor); and, from patch round 1, fivedomain:clidogfood files:packages/qa/dogfood/test/showcase-security.ts,showcase-d7-default-profile.dogfood.test.ts,authored-row-write-scope.dogfood.test.ts,bulk-widener-probe.dogfood.test.tsandowd-public-read-write-write-floor.dogfood.test.ts(each: theSecurityPluginconstruction, with its comment and imports).\n\n## Patch round 1 — the dogfood fixtures declared one permission set twice\n\nThe Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot,plugin-securityregistered a permission set that the app package already held.\n\nThe fixtures handed an app-declared set toSecurityPlugin'sdefaultPermissionSets, which plugin-security declares on its own manifest, while the app declared the same set too:\n\n-showcase_member_default, throughshowcaseAppDefaultSecurity()and the D7 test;\n-wscope_*,probe_widenerandowdw_*, in three fixtures.\n\nMeasured:\n\n-os serve/objectstack devnever composes this. It hands the plugin only the default's NAME (appSecurityPluginOptions), and the app registers the set. A realobjectstack dev --freshboot ofexamples/app-showcasecame up with the gate in place: health 200.\n- The two copies were the same definition: 101 of 101 leaves equal.\n\nFixed 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.\n\nAll three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI.\n\n## Patch round 2 — the platform's built-in positions met an environment row at boot\n\nThe merge queue removed this PR (record 6056019838).plugin-security'sregisterBuiltinPositionswas refused at the item seam: positionorg_admin, incomingcom.objectstack.plugin-security, holderenvironment. That refusal failedSecurityPlugin.start, and with it the boot. S2b's pins went red:builtin-positions.boot.test.ts, "a stored definition under a built-in name" (3 postures), andbootstrap-declared-positions.test.ts, "a stored definition shadowing a built-in name is neither seeded nor restamped".\n\nMeasured on 19c86b7 (this branch withmainmerged, before the fix):\n\n- Boot order.SecurityPlugindepends on the engine. SoObjectQLPlugin.starthydratessys_metadatainto the bare slot BEFORESecurityPlugin.startdeclares the built-in positions. Through a real door: withOS_METADATA_WRITABLE=position,PUT /api/v1/meta/position/org_adminanswered200, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers403 NOT_OVERRIDABLE.\n- Who registers.registerBuiltinPositionsregisters exactly the six static built-in names (BUILTIN_IDENTITY_NAMES+AUDIENCE_ANCHOR_POSITIONS), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder.\n- What S2b needs. The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it.\n\nFixed at the producer, the item seam inSchemaRegistry.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:\n\n- a second PACKAGE registering a built-in name at the item seam is refused, in either order;\n- the package door refuses a package declaring a built-in name (holderbuilt-in);\n- for any other name, an environment item still refuses a package-bound registration at the item seam (holderenvironment).\n\nNo same-definition exception, nocollisionPolicychange, and S2b's pins are untouched.registry-security-catalog-namespace.test.tsgained three cases, one per behaviour above (the third is aCONTROL).\n\nReverse verification. The new condition was mutated throughscripts/ablation-replace.mjsto ask the environment holder again (blobc60d9bad21bc→eeca074e4f80).objectqlwas rebuilt, andablation-dist-preflightfound the marker indist/. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red withSecurityCatalogNameConflictError(org_adminheld by the environment catalog), and so did the new admit case. Restored: blob == HEAD, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentis green.\n\nAll suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI.\n\n## Patch round 3 — #22262 landed onmainfirst\n\n#22262 (squash 0b997ea) addspositionsto the engine'sMETADATA_ARRAY_KEYS, soObjectQL.registerAppnow registers a package's positions under the package. This branch mergedmainat fbcbcf1. The merge touched none of this PR's files, andregistry.ts's logic is unchanged.\n\nComments only. Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true onmain: thesecurityCatalogClaimsdoc and theinstallPackagecomment inregistry.ts, the declared-names reader's note insecurity-catalog-namespace.ts, and the header ofstandalone-stack-security-catalog-one-holder.test.ts. No behaviour changed, so no reverse leg was re-run.\n\nMeasured with #22262 in the tree (f16fcd0, this branch withmainmerged; throughbootStack; local probes, not committed):\n\n- 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 undercom.example.showcaseand 6 undercom.objectstack.plugin-security, and 10 claims. Re-registering the showcase is not refused. A second package declaringcontributoris refused, holdercom.example.showcase.\n- The built-in case is unchanged. With environment saves underorg_adminandeveryone(OS_METADATA_WRITABLE=position), the cold restart boots, and both names resolve to the environment's saved definitions.\n-PUT /api/v1/meta/position/shared_pos?package=wover a package-held position answers403 NOT_OVERRIDABLE.\n- 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 andinstall-localare refused, each naming both holders; the same-package reload is accepted.\n\nThe claims, ablated. This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (scripts/ablation-replace.mjs),objectqlwas rebuilt, and the marker was proven indist/. The runtime boot pins stayed green (5 of 5): everyregisterAppdoor refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and thecollisionPolicy: 'warn'pin. Both reach the package door through a directinstallPackagecall, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild,ablation-dist-preflight --absentis green.\n\nTests at f16fcd0, all underos-verify-lock:\n\n-@objectstack/objectql, whole suite: 383 files / 7572 passed.\n-@objectstack/plugin-security, whole suite: 179 files / 3775 passed, 45 skipped.\n-@objectstack/runtime, whole suite: 340 files / 5505 passed, 19 skipped.\n- 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.\n-typecheckforobjectqlandruntime(tsc --noEmit+check:test-typecheck): exit 0.\n- Gates:dispatch-gates --commandsderived 82 on f16fcd0. All 82 ran and exited 0, and--ranreconciles 82/82, 0 NOT MEASURED.\n\nCI 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).\n\n## Patch round 4 — the changeset level, one comment, the cold-boot carrier\n\nThe contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only.\n\n- The changeset level..changeset/22135-security-catalog-one-holder.mdgraded@objectstack/objectqlminor, on the premise that Changesets pre mode was not yet onmain. It is:.changeset/pre.json(modepre, tagnext) landed with #22084 (a87d8be), an ancestor of this branch's merge base. The changeset now gradesmajor, and its BREAKING sentence says the change ships asmajoron the v18 pre-release line. The ADR-0087 marker andClause-②: nostay.pnpm changeset statusresolves@objectstack/objectqlto18.0.0-next.0.\n- The cold-boot carrier. One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads fromsys_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. #22307 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two[Registry] Collisionwarnings, one for the permission set and one for the position. The hot install answered422 NAMESPACE_CONFLICT, both names held byenvironment, and left no package record. A capability cannot be saved in the environment (403, a code-only type), so the sentence names the two types an environment can hold.\n- One comment. The runtime pin's comment called the position one "no registry slot holds". That stopped being true when #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.\n- Thestart()-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.\n\nChecks at 9e4ed5d:\n\n- The changeset gates:check-changeset-no-major--self-testand--base, exit 0 (pre mode, tagnext, so the no-major guard stands aside; given this PR's body, the level axis readsClause-②: no);check-empty-changeset--self-testand--base, exit 0;check-changeset-fixed, exit 0;check:adr-0087-registration, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition);check:changeset-gate-self-tests, exit 0.\n-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.\n- Gates:dispatch-gates --commandsfor the two touched paths derived 61 commands. All 61 ran and exited 0, and--ranreconciles 61/61, 0 NOT MEASURED.git merge-treeagainstorigin/main4e4111c is clean, somainwas not merged.\n\n## Patch round 5 — #22307 was ruled\n\nThe 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.\n\nChecks at 99fba80:check-changeset-no-major --base, exit 0 (pre mode, tagnext; given this PR's body, the level axis readsClause-②: no);check-empty-changeset --base, exit 0;check:adr-0087-registration, exit 0.dispatch-gates --commandsfor the one touched path derived 20 commands; all 20 exited 0, and--ranreconciles 20/20, 0 NOT MEASURED.git merge-treeagainstorigin/main4e4111c is clean, somainwas not merged.\n\n---\n_Generated by Claude Code_\n\n"},
"deviations": [
"The worktree was recreated from the local branch, which equalled the remote 9e4ed5d, with no force. It is removed again right after this comment posts.",
"The live PR body still lacks round 4's edits, as the pr_body_full note says. This body is built on round 4's, as instructed."
],
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment, POST /repos//issues/22135/comments, through the fleet-write relay (repository_dispatch) as objectstack-fleet[bot]. git push of the code branch is not a REST write. No PR body or label write. Reads: GET pulls/22197 (gh api).",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
ACCEPT (seat review) — PR #22197 at head
99fba80b64domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant) · read at 2026-10-08T16:33Z.089b1c84b6, after the queue kick-out 6056019838 and the patch rounds that followed.CONTRACT_REVIEW_TIER: PASS 6064469150 on this head. It replaces the FAIL 6061710772 onf16fcd0c7c.What moved since the previous ACCEPT
plugin-security's own built-in positions meet 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 S2b's stored shadow without a refusal.mainwith fix(objectql): register stack-declared positions under their package so the save door refuses overrides #22262. Comments only.@objectstack/objectqlis nowmajor: pre mode has been on since chore(release): enter Changesets pre mode (next) with onemajormarker, so v18 opens at 18.0.0-next.0 #22084, so the false "pre mode is not yet in" premise is gone. The ADR-0087 marker andClause-②: nostay.9e4ed5d53b.Code unchanged since
f16fcd0c7cinstallPackagecallers.Clause-②: noOpen question (round 2)
start()-time half is answered by the ruling's letter: A, keep.target:v18, serial after this PR).CI on
99fba80b64, by namesuccess:Lint & Repo Gates,TypeScript Type Check,Test Core(6/6),Dogfood Regression Gate(3/3),Build Core,Temporal ConformanceandGoverned Surface Queue Guard.git merge-treeagainstorigin/mainis clean.Serial
singlethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186 (feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195) is in the merge queue, and so is PR fix(metadata): the object read's sortability follows the caller's field permission for every caller class (#22250) #22308 (metadata read: for a platform-admin caller the object read and the sortability projection keep a field that caller's field-level permission marks unreadable, while the data route refuses it #22250). They share no file with this PR.Next: auto-merge. The PR is already ready and
clean, and the body-write checks have settled.objectstack-fleet commentedon Oct 8, 2026
Landing record: PR #22197 merged · 2026-10-08T17:01Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6051656254.fe98cc63a4onorigin/main(single parent). The queue tested it behind feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195's PR feat(plugin-auth,objectql,metadata-protocol,runtime)!: undersinglethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186 (34dba5ae1e, merged one minute earlier), so C1's boot shape was measured with it. Read by content after a fetch:SecurityCatalogNameConflictErrorinpackages/objectql/src/registry.ts(7 hits);packages/objectql/src/security-catalog-namespace.tspresent;'@objectstack/objectql': major.f4bed58341). The code is this PR.completed;pm:dispatchedremoved in this act. Only this card was named by a closing keyword.domain:servicesseat 2): S8 can now switch the catalog readers to the registry, because no reader's answer depends on registration order.docs(pm-dispatch): a card that narrows a published accept set is Clau…
docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and ca…
feat(objectql)!: positions, permission sets and capabilities hold one…
feat(objectql)!: a cold boot refuses a package-held position or permi…