Repository navigation
fix(objectql): register stack-declared positions under their package so the save door refuses overrides - #22262
Conversation
The metadata save door refuses a save over a package-declared item of a type with no overlay channel, and decides "a package ships this" from the engine SchemaRegistry entry the package registered. METADATA_ARRAY_KEYS carried permissions and capabilities but still carried the retired `roles` spelling instead of `positions`, so a stack-declared position had no such entry and a save over it took the runtime-create tier. Adds `positions` to the provenance seam, drops the stale waiver row in check-stack-collection-maps, re-measures the seeder declaration-copy pin on a real artifact boot, and pins every security-domain allowOrgOverride:false type at the door on both topologies. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…sition-allow-org-override
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 20ac611edde4da6e30354f252ec6307af2402a2e && git checkout 20ac611edde4da6e30354f252ec6307af2402a2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a 7ed88a608124b912c391119d1b2d0e111ee7d5cf && git checkout -B drift-repro 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a && git merge --no-ff 7ed88a608124b912c391119d1b2d0e111ee7d5cf
node scripts/docs-audit/affected-docs.mjs --json 3513ac77814f5e6eb530c7b92e4797b4ae6b5e0a
|
Contract reviewServed-tier: Inputs: card #22203 (body; comments 6054396188 triage, 6055084143 claim, 6057851358 os-dev-report), PR #22262 (body, its 7-file list, the net diff against ① Derived judgments
② Semver level
③ Boundary flagsThe deliberate correction —
|
Landing note:
|
… name per deployment — a second holder is refused at registration, naming both (objectstack-ai#22197) Fixes objectstack-ai#22135 Clause-②: no Executes the maintainer's ruling Q4 = A on objectstack-ai#15196 (ruling record 6050490870): positions, permission sets and capabilities each hold one name per deployment. A package registering a name that an installed package, the environment catalog or a built-in already holds is refused, and the error names both holders. ADR-0048 §3.4's coexistence stands for every other metadata type. The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, objectstack-ai#22198, now on `main`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/security-catalog-namespace.ts` (new).** The rule in one place: the three types, the built-in names, the holder vocabulary (`package` / `environment` / `built-in`), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's own `positions` / `permissions` / `capabilities` and each nested `plugins[]` entry's, arrays only. A manifest-stage `permissions` grant block is never read as permission sets. - **`SchemaRegistry.installPackage` — the package door.** It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released by `uninstallPackage`. The claims are what the door reads for names no registered item records, and `installPackage` itself registers no items. Through `ObjectQL.registerApp` (every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since objectstack-ai#22262 landed on `main`. There the claims agree with the items. With the claims ablated, the `registerApp` doors still refuse and the direct `installPackage` door does not (Patch round 3), so the claims stay. - **`SchemaRegistry.registerItem` — the item seam.** A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now on `main`), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what every `sys_metadata` hydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling. - **The envelope** reuses the namespace gate's shape and registered code: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, already exported), `status: 422`, `httpStatus: 422`. The condition is the same one, a name in a deployment-wide namespace already taken, and so is the remedy: rename, or uninstall the other holder. The class (`SecurityCatalogNameConflictError`) stays unexported, as the registry's other refusal classes are. It is not the namespace gate's class, whose message names a `manifest.namespace` and offers the `OS_METADATA_COLLISION=warn` downgrade. Neither is true here, and `collisionPolicy: 'warn'` does not downgrade this refusal (pinned). - **No new error code; no `packages/spec` change.** ### Where the doors are, measured The card names three doors. What this PR measured is that all three are reached through ONE: `ObjectQL.registerApp` → `SchemaRegistry.installPackage`, which every package registration hits in the kernel's Phase 1, before any `start()`. - `AppPlugin`'s security registrar (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` registrar) both run in Phase 2. - Neither runs for a package the engine has not installed: `AppPlugin.init` registers every package of its bundle through the `manifest` service first, a multi-package artifact package by package. - So the producer-side fix is the package door, and `packages/metadata/src/plugin.ts` and `packages/runtime/src/app-plugin.ts` are unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs. ## Door table: base vs head "Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through `@objectstack/verify`'s `bootStack`; the artifact rows go through `createStandaloneStack`. | Door | Base | Head | |---|---|---| | Boot, door-less (`new AppPlugin(stack)`): two stacks sharing a position, a permission set and a capability name | boots. The by-name read answers the position from the LAST stack (metadata-service slot) and the set and the capability from the FIRST (registry order) | boot refused: `422 NAMESPACE_CONFLICT`, 3 conflicts, second stack vs first stack | | Boot: an app declaring `everyone` / `manage_users` / `admin_full_access` | boots. The by-name read of `admin_full_access` answers the APP's set | refused. Holder `built-in` for the first two. For `admin_full_access` the app registers before `plugin-security` in `bootStack`, so the platform's registration is the one stopped, naming the app | | Artifact boot: two packages of one artifact sharing names; a package declaring `everyone` | boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything | | Hot install: post-boot `manifest.register` over a held name | accepted, package record written | refused, no record | | Hot install: `POST /api/v1/marketplace/install-local`, inline manifest | `200`, installed | `422 PLUGIN_REGISTER_FAILED`, the route's own code, with this refusal's message in `error.message`; no record | | `POST /api/v1/packages` | `400`: the strict body refuses `positions`, the retired `capabilities` and a flat `permissions` list | unchanged. No catalog collection can arrive here | | Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holder `environment` | | Same-package hot reload | accepted | accepted | | Environment save over a package-held permission set (`PUT /api/v1/meta/permission/NAME`, with or without `?package=`) — not covered by the ruling | `403 NOT_OVERRIDABLE` (the packaged permission-set lock) | unchanged | | Environment save of a position over a package-held position name (`PUT /api/v1/meta/position/NAME`) — not covered by the ruling | `200`, and the saved position then answers the by-name read ahead of the package's | unchanged by this PR. Since objectstack-ai#22262 landed on `main`: `403 NOT_OVERRIDABLE` (see Acceptance notes) | Named but not measured: - **The artifact door's HMR reload** (`MetadataPlugin._reloadAndAnnounce`). It re-registers into the metadata service without `registerApp`, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it. - **`install-local`'s cloud-sourced install.** Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal at `error` and skips the package. That is code reading only (it needs a control plane). ## In-repo collision census (M2) **Instrument.** A tsx census over `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and `examples/app-multi-package`: each config's top level, its `packages[]` bodies and its nested `plugins[]`. Against those it reads the built-ins: `BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`, `PLATFORM_CAPABILITY_NAMES`, and `plugin-security`'s `securityDefaultPermissionSets`. **Result at 1604e09:** 50 declarations — crm 3 positions / 2 sets; showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6 positions / 10 capabilities / 8 sets. Names with more than one holder: **0**. Same-holder repeats: 0. **The guard, measured with the gate in place at 8ad6385:** `crm`, `showcase` and `multi-package` boot through `bootStack`, and `security-catalog-showcase.dogfood.test.ts` (3 postures) and `multi-package-artifact.dogfood.test.ts` are green. `app-todo` declares no catalog name. Deployed and marketplace packages are NOT MEASURED. ## The P1.2 pin, flipped S1's shared-name pin is the `security catalog read — a name two packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`. It added no `P1.2` label, which is why a `git grep` misses it. It now pins the ruled answer at the same seams: - a second package registering the name is refused (envelope + both holders), and every reader answers the one holder; - an override the holder stored for itself answers for every caller; - a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service. `core`'s `security-catalog.test.ts` pointed at a non-existent `security-catalog-shared-name.test.ts`. It now names that describe and the new door pins. `security-catalog.ts`'s module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer. `engine-capability-provenance.test.ts` pinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal. ## Tests (at 8ad6385) - `@objectstack/objectql`: `registry-security-catalog-namespace.test.ts` (new, 28 cases), `protocol-boot-hydration-scoped.test.ts`, `engine-capability-provenance.test.ts`, `registry-collision-order.test.ts` and `registry-artifact-co-ownership.test.ts`: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip. - `@objectstack/runtime`: `standalone-stack-security-catalog-one-holder.test.ts` (new, 4) and `standalone-stack-security-registrar.test.ts`: 2 files, 6 passed. - `@objectstack/core` `security-catalog.test.ts`: 14 passed. `@objectstack/plugin-security` `builtin-positions.boot.test.ts` + `builtin-positions.test.ts` (S2's): 18 passed. - dogfood: `security-catalog-showcase`, `multi-package-artifact`, plus a local door probe that is not committed: 3 files, 44 passed. - Downstream sweep before the merges, against the rebuilt `objectql` dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green. - `typecheck` for objectql, core and runtime (with `check:test-typecheck`): exit 0. No new test-typecheck debt. ## Ablation Both gates were ablated together through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit: - the package-door call became a `globalThis` marker write; - the item-seam condition gained an always-false marker conjunct. Both mutations landed on disk: anchor 1 → 0, blob `b96099a12688` → `12c018d406ab`. `objectql` was rebuilt and `ablation-dist-preflight` found both markers in `dist/`. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted. - **objectql pins (read from `src`):** 22 failed / 21 passed of 43. Every refusal pin went red, including both flipped P1.2 cases and the flipped capability pin. The controls stayed green: same-package reload, uninstall releases the name, the environment-registration carve-out, non-catalog coexistence, the grant-block reader, and the platform's own built-in registration. - **runtime boot pins (read from `dist`):** 3 failed / 1 passed. The control stayed green. **Restore:** the blob is back to `b96099a12688` and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green for both markers (dist and whole tree). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands on 8ad6385; all 78 were run, and `--ran` reconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first: `check-engine-split-ratio` refused the shallow clone (deepened with `git fetch --shallow-since=2026-07-03`), and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run, `origin/main` moved 6 commits, none of which touches a file in this PR. ## Acceptance notes - **Environment save of a position over a package-held position name.** Before objectstack-ai#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 objectstack-ai#22262 landed on `main`, a package's positions are registered items under the package, and the same save answers `403 NOT_OVERRIDABLE` ("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 with `PUT /api/v1/meta/position/shared_pos?package=w`. Outside this ruling either way; this PR changes nothing there. - **Cold boot vs the environment-catalog holder.** A package registers through `ObjectQL.registerApp` in Phase 1, and `sys_metadata` hydrates in Phase 2 (`ObjectQLPlugin.start`). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. The `CONTROL` case in `registry-security-catalog-namespace.test.ts` pins that the bare slot is not judged. A plugin's own `start()` is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holder `environment`). The exception is a built-in name, which the platform declares there itself (Patch round 2). A `git grep` for literal catalog-type `registerItem` calls in production source finds one such registration: `plugin-security`'s built-in positions. Carrier: objectstack-ai#22307 (ruled A: the cold boot refuses too; it lands separately). - **Order and the platform's permission sets.** `plugin-security` declares the platform's sets on its own manifest (configurable through `defaultPermissionSets`), so they are package-held. When an app registers before it, as `bootStack` composes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named. - **`install-local` inline import** answers its own `PLUGIN_REGISTER_FAILED` for any register refusal (this one and the namespace gate's alike), so `error.code` does not carry `NAMESPACE_CONFLICT` there. The refusal's text is in `error.message`. Not changed here. - **The ledger row comment for `NAMESPACE_CONFLICT`** in `packages/spec/src/api/error-code-ledger.zod.ts` describes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here. - **Files outside the engine lane:** `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (new test; `runtime` is `domain:cli`'s package); `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json` (new ADR anchor); and, from patch round 1, five `domain:cli` dogfood files: `packages/qa/dogfood/test/showcase-security.ts`, `showcase-d7-default-profile.dogfood.test.ts`, `authored-row-write-scope.dogfood.test.ts`, `bulk-widener-probe.dogfood.test.ts` and `owd-public-read-write-write-floor.dogfood.test.ts` (each: the `SecurityPlugin` construction, with its comment and imports). ## Patch round 1 — the dogfood fixtures declared one permission set twice The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot, `plugin-security` registered a permission set that the app package already held. The fixtures handed an app-declared set to `SecurityPlugin`'s `defaultPermissionSets`, which plugin-security declares on its own manifest, while the app declared the same set too: - `showcase_member_default`, through `showcaseAppDefaultSecurity()` and the D7 test; - `wscope_*`, `probe_widener` and `owdw_*`, in three fixtures. Measured: - `os serve` / `objectstack dev` never composes this. It hands the plugin only the default's NAME (`appSecurityPluginOptions`), and the app registers the set. A real `objectstack dev --fresh` boot of `examples/app-showcase` came up with the gate in place: health 200. - The two copies were the same definition: 101 of 101 leaves equal. Fixed at the producer: each fixture declares the set once, as the app's, and wires the default by name, as the CLI does. A runtime pin holds the refused composition. All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI. ## Patch round 2 — the platform's built-in positions met an environment row at boot The merge queue removed this PR (record 6056019838). `plugin-security`'s `registerBuiltinPositions` was refused at the item seam: position `org_admin`, incoming `com.objectstack.plugin-security`, holder `environment`. That refusal failed `SecurityPlugin.start`, and with it the boot. S2b's pins went red: `builtin-positions.boot.test.ts`, "a stored definition under a built-in name" (3 postures), and `bootstrap-declared-positions.test.ts`, "a stored definition shadowing a built-in name is neither seeded nor restamped". Measured on 19c86b7 (this branch with `main` merged, before the fix): - **Boot order.** `SecurityPlugin` depends on the engine. So `ObjectQLPlugin.start` hydrates `sys_metadata` into the bare slot BEFORE `SecurityPlugin.start` declares the built-in positions. Through a real door: with `OS_METADATA_WRITABLE=position`, `PUT /api/v1/meta/position/org_admin` answered `200`, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers `403 NOT_OVERRIDABLE`. - **Who registers.** `registerBuiltinPositions` registers exactly the six static built-in names (`BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder. - **What S2b needs.** The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it. Fixed at the producer, the item seam in `SchemaRegistry.registerItem`: for a built-in name, it no longer asks the environment holder. An environment item under a built-in name exists only because an environment save went over the platform's name, which is outside the ruling. Unchanged: - a second PACKAGE registering a built-in name at the item seam is refused, in either order; - the package door refuses a package declaring a built-in name (holder `built-in`); - for any other name, an environment item still refuses a package-bound registration at the item seam (holder `environment`). No same-definition exception, no `collisionPolicy` change, and S2b's pins are untouched. `registry-security-catalog-namespace.test.ts` gained three cases, one per behaviour above (the third is a `CONTROL`). **Reverse verification.** The new condition was mutated through `scripts/ablation-replace.mjs` to ask the environment holder again (blob `c60d9bad21bc` → `eeca074e4f80`). `objectql` was rebuilt, and `ablation-dist-preflight` found the marker in `dist/`. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red with `SecurityCatalogNameConflictError` (`org_admin` held by the environment catalog), and so did the new admit case. Restored: blob == HEAD, and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green. All suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI. ## Patch round 3 — objectstack-ai#22262 landed on `main` first objectstack-ai#22262 (squash 0b997ea) adds `positions` to the engine's `METADATA_ARRAY_KEYS`, so `ObjectQL.registerApp` now registers a package's positions under the package. This branch merged `main` at fbcbcf1. The merge touched none of this PR's files, and `registry.ts`'s logic is unchanged. **Comments only.** Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true on `main`: the `securityCatalogClaims` doc and the `installPackage` comment in `registry.ts`, the declared-names reader's note in `security-catalog-namespace.ts`, and the header of `standalone-stack-security-catalog-one-holder.test.ts`. No behaviour changed, so no reverse leg was re-run. **Measured with objectstack-ai#22262 in the tree** (f16fcd0, this branch with `main` merged; through `bootStack`; local probes, not committed): - A package's own positions arrive both as claims and as registry items under the same package, and stay one holder. The showcase boots with 10 positions under `com.example.showcase` and 6 under `com.objectstack.plugin-security`, and 10 claims. Re-registering the showcase is not refused. A second package declaring `contributor` is refused, holder `com.example.showcase`. - The built-in case is unchanged. With environment saves under `org_admin` and `everyone` (`OS_METADATA_WRITABLE=position`), the cold restart boots, and both names resolve to the environment's saved definitions. - `PUT /api/v1/meta/position/shared_pos?package=w` over a package-held position answers `403 NOT_OVERRIDABLE`. - The door probes behind the table above answer as before: crm, showcase and multi-package boot; the two-stack boot, the built-in names, the hot install and `install-local` are refused, each naming both holders; the same-package reload is accepted. **The claims, ablated.** This was measured on a throwaway local merge of objectstack-ai#22262's head 7ed88a6, never pushed. All seven files objectstack-ai#22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (`scripts/ablation-replace.mjs`), `objectql` was rebuilt, and the marker was proven in `dist/`. The runtime boot pins stayed green (5 of 5): every `registerApp` door refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and the `collisionPolicy: 'warn'` pin. Both reach the package door through a direct `installPackage` call, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild, `ablation-dist-preflight --absent` is green. **Tests at f16fcd0**, all under `os-verify-lock`: - `@objectstack/objectql`, whole suite: 383 files / 7572 passed. - `@objectstack/plugin-security`, whole suite: 179 files / 3775 passed, 45 skipped. - `@objectstack/runtime`, whole suite: 340 files / 5505 passed, 19 skipped. - Dogfood, the CI split: shard 1/3, 74 files / 557 passed; 2/3, 74 files / 535 passed, 1 skipped; 3/3, 73 passed + 1 skipped files / 661 passed, 8 skipped. - `typecheck` for `objectql` and `runtime` (`tsc --noEmit` + `check:test-typecheck`): exit 0. - Gates: `dispatch-gates --commands` derived 82 on f16fcd0. All 82 ran and exited 0, and `--ran` reconciles 82/82, 0 NOT MEASURED. CI on f16fcd0: 32 checks success, including Dogfood Regression Gate 1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs, Console Pin Gate, Packed-tarball smoke). ## Patch round 4 — the changeset level, one comment, the cold-boot carrier The contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only. - **The changeset level.** `.changeset/22135-security-catalog-one-holder.md` graded `@objectstack/objectql` `minor`, on the premise that Changesets pre mode was not yet on `main`. It is: `.changeset/pre.json` (mode `pre`, tag `next`) landed with objectstack-ai#22084 (a87d8be), an ancestor of this branch's merge base. The changeset now grades `major`, and its BREAKING sentence says the change ships as `major` on the v18 pre-release line. The ADR-0087 marker and `Clause-②: no` stay. `pnpm changeset status` resolves `@objectstack/objectql` to `18.0.0-next.0`. - **The cold-boot carrier.** One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot, and the registry's existing collision warning fires. A hot install of the same package is refused. objectstack-ai#22307 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two `[Registry] Collision` warnings, one for the permission set and one for the position. The hot install answered `422 NAMESPACE_CONFLICT`, both names held by `environment`, and left no package record. A capability cannot be saved in the environment (`403`, a code-only type), so the sentence names the two types an environment can hold. - **One comment.** The runtime pin's comment called the position one "no registry slot holds". That stopped being true when objectstack-ai#22262 put a package's positions into the registry under the package. The comment now says the refusal reports the position, the permission set and the capability the first package holds. A sweep of this PR's added lines finds no other sentence saying positions do not reach the registry. - **The `start()`-time half** of the round-2 question is settled as A (keep): the ruling names the environment catalog as a holder and does not distinguish phase. No change. **Checks at 9e4ed5d:** - The changeset gates: `check-changeset-no-major` `--self-test` and `--base`, exit 0 (pre mode, tag `next`, so the no-major guard stands aside; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset` `--self-test` and `--base`, exit 0; `check-changeset-fixed`, exit 0; `check:adr-0087-registration`, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition); `check:changeset-gate-self-tests`, exit 0. - `pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-security-catalog-one-holder.test.ts`: 1 file / 5 passed. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - Gates: `dispatch-gates --commands` for the two touched paths derived 61 commands. All 61 ran and exited 0, and `--ran` reconciles 61/61, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is clean, so `main` was not merged. ## Patch round 5 — objectstack-ai#22307 was ruled The maintainer ruled objectstack-ai#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 objectstack-ai#22307, which lands separately", which is true on this PR's merge and stays true after objectstack-ai#22307 lands. Nothing else changed. **Checks at 99fba80:** `check-changeset-no-major --base`, exit 0 (pre mode, tag `next`; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset --base`, exit 0; `check:adr-0087-registration`, exit 0. `dispatch-gates --commands` for the one touched path derived 20 commands; all 20 exited 0, and `--ran` reconciles 20/20, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22203
Clause-②: no
What changes
ObjectQL.registerApp()and the nested-plugin seam now register a stack'spositionscollection into the engine SchemaRegistry under the owning package. They stamp the same ADR-0010 provenance thatpermissionsandcapabilitiesalready get (METADATA_ARRAY_KEYS,packages/objectql/src/engine.ts). The metadata save door's existing type-level packaged-base check then coverspositionthe same way it covers the otherallowOrgOverride: falsetypes.Landing: the producer side, in
packages/objectql. The save door inpackages/metadata-protocolwas correct and was given the wrong input, so its code is unchanged. There is nopackages/specedit; the flag was already declared.Measured: why the type-level check answered for one type and not the other
allowOrgOverridein two places:refusePackagedBaseOverrideinsidesaveMetaItem(environment-scoped kernel) andSysMetadataRepository.assertAllowedunder theoverride-artifactintent (host-config kernel).isArtifactBacked→lookupArtifactItem→SchemaRegistry.getArtifactItem. That lookup only finds entries a package registered with_packageId.registerMetadataCollectionsoverMETADATA_ARRAY_KEYS. That list hadpermissionsandcapabilities. For positions it still had the retiredrolesspelling: ADR-0090 D3's rename reachedARTIFACT_FIELD_TO_TYPE(packages/metadata/src/plugin.ts) and never reached this list.check:stack-collection-mapsrecorded the absence as a waiver. The waiver's reason pointed at the metadata service's registry, not the SchemaRegistry.isArtifactBacked('position', NAME)answered false. The save took theruntime-onlyintent, andallowRuntimeCreate: trueaccepted it.plugin-security's packaged permission-set lock answers first. That lock isregisterPackagedPermissionSetLockGate, an authoring gate registered forpermissiononly. Under it, the type-level door also refuses a package-declared permission set. The pin below shows this with no security plugin composed. The lock stays as it is, a stricter type-specific layer on top of the type-level door.Population of the pin, read from the registry: every
domain: 'security'row withallowOrgOverride: false, which today ispermission,positionandcapability.Door table
Showcase (
pnpm dev -- --fresh), host-config kernel, seeded admin. The same requests were sent both times:positionsentry ablated. objectql was rebuilt, and the preflight proved the change reacheddist/.7ed88a6081(mergedorigin/mainf4bed58341).The same position, permission, capability and control answers were also measured before the merge, at base
7b926f7600and headf7d8d0e172.NOT_OVERRIDABLE?package=)WRITABLE_PACKAGE_REQUIREDITEM_LOCKEDplugin-security)NOT_OVERRIDABLENOT_OVERRIDABLE(unchanged)?package=NOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLENOT_OVERRIDABLEallowOrgOverride: true)/meta/positionBoot diagnostics: 4 warnings on both boots, the same four. The seeded
sys_positionrows carry the declared labels and descriptions.Pins
New:
packages/objectql/src/engine-security-catalog-package-door.test.ts. It uses a realObjectQL, a realObjectStackProtocolImplementationand the population above, read fromDEFAULT_METADATA_TYPE_REGISTRY. For each type it checks:{ code: 'NOT_OVERRIDABLE', status: 403 }and stores nothing, on both topologies.It also checks that the by-name read still serves the package's position after the refusal. Controls:
email_template(allowOrgOverride: true) still saves over its packaged item, on both topologies.Re-measured:
packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts. Its positions case asserted the old absence on an artifact that declared no position. The probe artifact now declares one, and the case asserts the registry holds it under the artifact's package beside the six built-ins. This is a realcreateStandaloneStackboot withSecurityPlugin.Widened:
packages/objectql/src/engine-nested-plugin-collections.test.ts.positionsjoins the property candidates: both seams register it identically.Gate:
scripts/check-stack-collection-maps.mjs. The stalemissing: ['positions']waiver onMETADATA_ARRAY_KEYSis removed. The gate fails on a stale waiver, and its reason was wrong about which registry it meant.Reverse verification (ablation)
The run was committed first, then mutated through
scripts/ablation-replace.mjs. The mutation removed'positions'fromMETADATA_ARRAY_KEYS: anchor count 1 → 0, blob8465f68a50a1→c4414cf91029. objectql was then rebuilt, andscripts/ablation-dist-preflight.mjs @objectstack/objectql '"positions"' --absent --source-marker="'positions'"exited 0.Predicted before running: 4 red in the new pin (seam, both topology refusals, by-name read) and 1 red in the runtime pin, everything else green. Measured exactly that:
Tests 4 failed | 47 passed (51);Tests 1 failed | 12 passed (13).Restore leg: blob == HEAD,
git diff HEADempty. objectql was rebuilt, and the preflight in default mode found the marker present in 4 built files with a clean tree. Both suites were green again: 51/51 and 13/13.The new pin was also run at base
7b926f7600before any fix existed. Exactly the 4 position cases were red, and the by-name read returned the environment fork.Local verification
All at head
7ed88a6081unless noted.pnpm --filter @objectstack/objectql test: 381 files / 7536 tests passed, at5188b2ad45. The merge brought noobjectql,metadata-protocolorcorechange.pnpm --filter @objectstack/objectql typecheckandpnpm --filter @objectstack/runtime typecheck: OK, test layers included.standalone-stack,standalone-stack-seeder-declaration-copy,standalone-stack-security-registrarandapp-plugin-artifact-forward-conversion: 48/48;artifact-collections: 8/8;pnpm --filter @objectstack/plugin-security test: 3739 passed, 45 skipped;security-catalog-showcase,showcase-declarative-rbac-seeding,position-address-readersandrls-runner: 53/53. These resolvedist/, rebuilt at this head.node scripts/pm/dispatch-gates.mjs --commandsgives 86 families, all run at7ed88a6081and reconciled with--ran.check-empty-changesetexits 1, by design. See the next section.pnpm check:pm-dispatch-gates: exit 0, with all 1976 self-test cases passing. The 900 s per-gate cap in the batch runner killed it first, so it was re-run on its own with its exit code captured..ts/.mjsfiles, at7ed88a6081.--format jsonreports 5 files linted, 0 errors, 0 warnings, none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the verdict on any file it does not touch.A pending release note this PR corrects: please confirm
.changeset/15196-core-security-catalog-read.md(pending,@objectstack/core) says the engine registry carries "no stack-declared position, and the metadata service carries the stack-declared positions". This PR makes that sentence false, so the sentence is rewritten in place to say the engine registry also carries stack-declared positions. No other text changed.check-empty-changesetstays red on it until a person confirms the correction. That is the gate's own route for a deliberate correction, and the file is not restored. If the seat prefers, the correction can be split into its own docs-only PR instead.For #22220 (serial, same region)
This PR does not touch
saveMetaItem,refusePackagedBaseOverride,packagedBaseRefusalorSysMetadataRepository.assertAllowed. The override check keeps its position and order, where its inputs come from, and its emitters. What changes is the value of one input:isArtifactBacked(type, name)now answers true for a stack-declared position. So the check now fires for positions where it already fired for permission sets: insaveMetaItembehindenvironmentId, and in the repository'sassertAllowedon a host-config kernel. #22220's reorder of the package door against the authoring gate will seepositionbehave likepermissionon the type-level path, without theplugin-securityauthoring lock, which is registered forpermissiononly.Acceptance notes (noted, not filed)
Older artifacts that spell the collection
roles: still open, measured, not fixed here. An artifact whose protocol floor predates ADR-0090 D3 can declare positions under the older collection key. Such a position still takes the runtime-create tier at the save door:registerMetadataCollections), so no SchemaRegistry entry exists for it.Measured on a scratch
createStandaloneStackboot. Control: the canonical key on the same boot is refused 403NOT_OVERRIDABLE. The scratch file was deleted. This belongs to the [finding] After #12892 step 2 an artifact boot with an engine still holds a THIRD, un-parsed copy ofpermissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 / Route ownership for the five artifact security collections — MEASURED: the two registrars' copies differ in TYPE on a key a consumer can read today, not just on some future retired key #12892 raw-copy divergence family and is handed to the seat in the report.Stale comment in
packages/metadata-protocol/src/protocol.ts. TheisNestedArtifactFieldTSDoc cites the org-override-registry-gate: thefieldoverlay lock is not enforced — an artifact-backed field PUT is accepted 200 (and is inert) #7743 census, which listspositionamong types that "genuinely ship no artifacts at all". That is no longer true. Carrier: metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220, the next PR in that file.Stale text in
packages/core/src/security/security-catalog.ts. The module doc's measurement table is dated to3d9188502eand the constructionTypeErrorrationale says the engine registry holds no stack-declared position. The read order and the result set are unchanged: the dogfood catalog suite stays green, and its header anticipates this exact move. Carrier: 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 (ADR-0131 C2).Refusal wording for
position. On a host-config kernel the refusal forpositionuses the repository's generic sentence, which mentionsOS_METADATA_WRITABLE.positionhas no ADR-0126 regime row (permissionhas one: clone). No new wording is added here.Not in scope, per triage. The cold-boot ordering boundary described on the card.
Generated by Claude Code