Repository navigation
docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and capabilities hold one name per deployment - #22198
Conversation
…pabilities hold one name per deployment (dated note + addendum) Records the maintainer's ruling Q4 = A (record 6050490870): the security catalog is taken out of §3.4's cross-package coexistence. Original text untouched; a dated note under §3.4 and an addendum. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…r-0048-narrowing-note
Contract reviewServed-tier: Inputs, and nothing else: card #22135 (body and all six comments); PR #22198 (body, the one-file list, the net diff against its merge base with Governed surface: ① Derived judgmentsCheck-runs on the head: 35, all
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读(终稿)
改了什么只改了架构决策记录 ADR-0048 的文字,没动任何代码。改动有两处:一是在 §3.4「跨包同名不再报错」下面加一段带日期的说明;二是在文末加一个附录,写明 #15196 上您的裁决 Q4 = A:职位、权限集、能力这三类安全目录,一个部署里一个名字只能有一个持有者。原文一个字没改。 为什么改§3.4 当初允许两个包用同一个名字,前提是界面类元数据在使用时总知道自己属于哪个包。但给人分配职位、给职位挂权限集时只记名字、不记包。两个包都带同名的「销售经理」,谁的定义生效就取决于加载顺序,实测确实如此。代码已经按您的裁决拒绝这种同名(配套 PR #22197)。ADR 不跟着改,就会出现文档写「允许」、代码在「拒绝」的矛盾。 风险与代价(含回滚)
席位意见建议批准。附录引用的裁决原文与 #15196 上的 Q4 选项 A 逐字一致,没有放宽也没有收窄。复审标出 4 处措辞,供您过目:
你要做的在 PR #22198 上批准(Approve)。批准后由席位落地。 |
…l, organization, ownership, and every guest key's fate (objectstack-ai#22239) Part of objectstack-ai#22146 Clause-②: no ## What this PR is Round 3 of objectstack-ai#22146: one new decision record, `docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md`, **Status: Proposed**. It transcribes the maintainer's ruling [`6054113537`](objectstack-ai#22146 (comment)) (batch objectstack-ai#290 item 1, 「22146 同意」: A on all eight questions and G2 on the gap, with the Q4 and Q2 clarifications) into nine decisions. Each one states its contract and its enforcement point. The revision round applies the ruling supplement [`6056614963`](objectstack-ai#22146 (comment)) (maintainer 「D1 A′ D2b R」): D1 is revised to A′, and D2b is closed as R. - **No code changes.** Nothing changes in `packages/**`, `content/docs/**` or `skills/**`. The ruling places every code change after acceptance, as execution cards E1 to E4 (the record's *Execution plan*). - **Tier H.** The diff touches `docs/adr/**`, so this PR stays draft and lands only by the maintainer's hand. - **The card stays open** for the execution cards, so line 1 is `Part of`. ## What the record says, one line per decision - **D1, identity and ownership (A′).** The guest is a principal and never owns a record; a forged owner is refused; the audit names the guest as the actor. Who owns a guest-written row is the business scenario's own metadata: the door's declaration, the object's hooks, record-change flows and assignment rules. The platform stamps nothing and declares no default owner. Empty state: the owner stays unset, as the form doors do today, with the existing authoring advisory and no publish refusal. The enforcer is the guest branch of the owner-anchor stamp in `SecurityPlugin` (card E3). - **D2, the closed list of doors.** Exactly five door classes serve an unauthenticated request: - the public form doors; - share links; - the public book and doc reads; - `authRequired: false` endpoints of type `object_operation`; - `authRequired: false` endpoints of type `flow`. Everything else answers 401, decided once per domain by `shouldDenyAnonymous`. The control-plane allowlist, the signed inbound-hook channel and MCP are credentialed or infrastructure, so they are outside the guest model. - **D2b, anonymous door × elevated flow (R).** Publish refuses an `authRequired: false` flow endpoint whose target declares `runAs: 'system'`, in both directions, with a prescription: an authenticated endpoint, the signed inbound-hook channel, a public form, and `runAs: 'automation'` once ADR-0073 M2 lands. No door triggers an elevated flow directly. Card E2 implements it with a registered ADR-0087 semantic entry. Record-change flows fired by a guest-written row stay recorded and undecided. - **D3, the grants channel (ADR-0090 D9, enforced).** The `guest` anchor's bindings resolve for the guest, read through the one binding reader every position uses. An empty set denies all. There is no second channel: not the baseline, not `everyone`, and not the position-name fold. The binding tier is unchanged. The row scope runs on one pipeline, and E1 pins it per sharing model. - **D4, organization.** The guest's organization is resolved only when the deployment has a unique organization, using the same predicate as ADR-0131 D9. On a multi-organization deployment the request is refused until D5 exists. The question is asked only where the organization is needed. - **Clarification (1), carried into the record:** the form doors' declared default-organization binding stays as it is. Nothing that serves today starts refusing. - **D5, site binding.** The record gives the shape only: match, organization, guest grants and allowed doors. ⛔ It declares no metadata type and reserves no key. The binding is built when a named deployment needs it. - **D6 and D7.** ADR-0106 D7, explain's `EXTERNAL` floor and ADR-0121 D6 are unchanged. The webhook signature vocabulary goes to a follow-up card, F1. That card is named in the record and not filed. - **D8, guest keys.** Every declared guest key gets a fate and an ADR-0087 disposition. - `sys_record_share`'s `guest` recipient is to be **removed** on its own card, E4. The basis is ADR-0090 D11 and the already-registered `sharing-rule-recipient-reconcile` entry. - The form doors' `guest_portal` set name was not on the card's list. It is recorded too, with the fate keep. - **D9.** The record now holds the maintainer's ruling of 2026-08-08 (Option A, `f586f1a89`), which until now lived only in the module doc of `assemble-execution-context.ts`. ## What the revision changed (supplement [`6056614963`](objectstack-ai#22146 (comment))) - **D1 → A′.** Points 3, 5 and 6 of the draft (the organization-level default owner, its cardinality, the empty-state refusal) are replaced: ownership is the scenario's own metadata, and the empty state is the owner left unset. Points 1, 2 and 4 are kept. - **D5.** The default-owner element is removed from the shape. - **D2b → R**, with its enforcement text. The four-axis table stays as the reasoning. M and the first-drafted default owner move to *Alternatives considered*. - **Execution plan.** E2 carries R and its registered ADR-0087 semantic entry. E3 shrinks to the stamp's guest branch (never the guest, never the system principal, a forged owner refused, the owner unset unless the scenario sets it): no new key and no disposition. - **Elsewhere.** The Consequences paragraph on owner-assigning hooks is withdrawn. *What the ruling did not settle* loses D2b and D1's empty state. Acceptance criterion 3 (D2b chosen) is met. The supplement is added to *Decided by*. - **Consistency edits the ruled changes forced:** the Status line, the Consumers line (the spec change is now D2b's refusal, not a D1 key), D4 point 3 (no owner stamp left to need the organization), D2's class 5 row, follow-up F3 (M2 now only extends R's prescription), and the References. ## What stays open - **The indirect path:** record-change flows fired by a guest-written row are recorded and not decided. - **The spelling of D5's binding** belongs to the card that builds it. - **The guest's row scope** is stated as a contract; E1 pins its measured outcome. ## How the number was chosen: 0138 - `origin/main` at `73a0a6bf1d`, after this round's merge, tops out at 0137, and 0136 is absent. Still no open PR adds 0136 or 0138; objectstack-ai#22198, the one ADR PR at the first count, has landed on ADR-0048. - **I checked every open PR's file list.** That is all 20 open PRs, paged to the end for objectstack-ai#22142 (104 files) and objectstack-ai#21988 (229 files). Only objectstack-ai#22198 touches `docs/adr/`, and it touches `0048-cross-package-metadata-collision.md`. No open PR adds 0136 or 0138. - **0136 is not reused.** It was handed to a decision once: unmerged PR objectstack-ai#18480 added `0136-declared-journeys-as-priority-anchor.md`, and objectstack-ai#18985 renumbered its own record from 0136 to 0137 because of it. `scripts/check-adr-anchors.mjs` computes the next free number as the highest number plus one, which gives 0138. ## Back-pointers: none in this PR, by house practice - **Where the lines go.** The house form for an amended record is a status-line continuation. ADR-0042, ADR-0046 and ADR-0131 carry lines of the form "· **Amended** (date, ADR-NNNN Dk) — …". These lines are written when the amendment is in force. No record in the registry carries such a line pointing at a Proposed record. So the exact lines for **ADR-0090** (D9) and **ADR-0056** (D2) are written into the record's *Acceptance criteria*, to land with the accepting change. - **Which ADRs get no line.** ADR-0106 D7, ADR-0121 D6 and ADR-0096 D5/E1 are left unchanged by this record, so they get none. **ADR-0135** gets none either: it mirrors cloud ADR-0024 and adds no clause of its own, and this record amends none of its decisions. ## Verification at `2d69b2b714` (the revision, on a merge of main `73a0a6bf1d`) I ran every command in the claim-time gate list at the revision head. Each exit code was captured before any pipe. | Command | Exit | |:--|:--| | `node scripts/check-adr-links.mjs` (and `--self-test`) | 0, 0 | | `node scripts/check-adr-symbol-anchors.mjs` (and `--self-test`) | 0, 0 | | `node scripts/check-ci-filter-parity.mjs` | 0 | | `node scripts/check-closing-keyword-parity.mjs` (and `--self-test`) | 0, 0 | | `node scripts/check-comment-mask-corpus.mjs` | 0 | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 (see note) | | `pnpm check:adr-anchors` | 0 | | `pnpm check:cross-package-test-inputs` | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:driver-memory-census` | 0 | | `pnpm check:gitlink-declared` | 0 | | `pnpm check:nul-bytes` | 0 | | `pnpm check:pm-governed-merges` | 0 | | `pnpm check:pm-prior-rulings` | 0 | | `pnpm check:refd-timer-probe` | 0 | | `pnpm check:watch-hint-literal` | 0 | - **`check-adr-symbol-anchors`.** It reports "2225 anchors across 141 records resolve". Positive control: the record count is 141, which is the 140 at base plus this record. No anchor carries a line number. - **`check:pm-prior-rulings`.** The self-test passes 155 cases. The tool's `--card 22146` read returns 16 ADR decision hits and 1 ruling on the thread (`6054113537`). - **`check:doc-formula-expressions`.** In the first round its first run exited 3 (PREREQUISITE NOT MET: the closure was not built), which measured nothing. I rebuilt the `@objectstack/formula` and `@objectstack/lint` closure under the verify lock after this round's merge (VERDICT command-exit 0), and the gate exited 0. - **Re-derivation.** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2d69b2b714` gives the same 19 families. The `--ran` reconciliation shows 19 run and 0 NOT-MEASURED, a zero derived from the recorded exit codes. The change set is one file, +640/−0, against merge base `73a0a6bf1`. ## Not measured, and named - **The G2 sweep (the booted per-door-class anonymous sweep).** I did not run it, by the ruling: it is an acceptance precondition, to be run in an environment that permits the probe. This round wrote no driver and no probe. - **The platform documentation links.** I could not re-fetch them, because the container's proxy answers 403 to CONNECT for those hosts. The record carries the URLs that the measurement round recorded. - **The cloud repository's anonymous surfaces.** This round did not read them. ## Changeset I read the Check Changeset job in `pr-automation.yml`. It has two exemptions: the `skip-changeset` label (read live, twice) and the Changesets release PR. Its failure text prescribes: "if it releases nothing …, apply the 'skip-changeset' label". This PR adds no `.changeset/*.md` and changes none, and `docs/adr/**` ships in no package's `files[]`. So the label is `skip-changeset`. ## Acceptance notes - **A doc comment that describes the old form-door semantics.** The comment above `RestServer.registerFormEndpoints` in `packages/rest/src/rest-server.ts` still says that security is delegated to a `guest_portal` set carried on the context, and that the middleware falls open when none is registered. What admits a form submission today is the `publicFormGrant` branch in `SecurityPlugin`. - This is comment drift only, and no reach was measured. It is noted, not filed. - Carrier: none. Card E2, once cut, edits that domain. ## 维护者速读(草稿) - **改了什么:** 按你「D1 A′ D2b R」的补充裁决修订 ADR-0138 草稿,其余内容不动。 - D1:访客永不拥有记录、伪造的 owner 一律拒绝、操作留痕记在访客名下,这三条保留。"本组织声明一个默认 owner"整条删掉:访客写进来的记录归谁,由各业务场景在元数据里自己定(入口声明、对象钩子、记录触发流程、分配规则),平台不打任何默认值。没人设置时 owner 就空着,跟今天公开表单一样;表单的作者视图照常提示,发布不拒绝。 - D2b:匿名入口不能触发以系统身份运行的流程。发布时直接拒绝,端点和流程两头都检查,并给出替代办法(带凭据的端点、签名 webhook 通道、公开表单,以及 M2 落地后的 automation)。 - 站点绑定的形状里去掉"默认 owner";执行卡 E3 缩成只校验访客分支,不新增任何键。 - **为什么改:** 你指出归属是业务场景的事,平台级默认值在多数部署里是错的,还会抢在对象自己的分配逻辑前面。D2b 选 R 之后,AI 照着 `runAs` 的说明写出"匿名入口 + 系统身份流程"时,发布就会被拦下。 - **风险与代价(含回滚):** - 本 PR 仍只改一个文档文件,合并后运行时行为不变;回滚就是删掉这个文件。 - D2b 的拒绝会让"匿名端点指向系统身份流程"这种写法从此发布不了。仓内没有这样的声明;仓外的部署本轮没有测。 - 接受前提还剩一项:在允许探测的环境里做一次启动后的逐类匿名入口实测(G2)。 - **席位意见:** - **你要做的:** 修订版就绪后,批准这份 ADR(Tier H,点 Approve 或亲手合并)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… 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>
Part of #22135
Records the maintainer's ruling Q4 = A on #15196 (ruling record 6050490870, 「15196 Q3 A Q4 A」) in ADR-0048. The security catalog (positions, permission sets, capabilities) is taken out of §3.4's cross-package coexistence: each of the three types holds one name per deployment.
This is the Tier H half of #22135, split from the code PR (#22197) so the code can land on its own record. It closes nothing: the card is closed by the code PR. #22135 is not addressed by this PR alone.
What changed —
docs/adr/0048-cross-package-metadata-collision.mdonlyAdditive. The original text is untouched; no existing line is edited.
OS_METADATA_COLLISION=warndowngrade;The header's
**Addenda**:index line is deliberately not edited, to keep the original text untouched. The note under §3.4 carries the link.Gates (at c383221)
dispatch-gates --commandsderived 19 commands; all 19 were run with exit codes recorded, and--ranreconciles 19/19 with 0 NOT MEASURED.check:doc-formula-expressionsfirst answered PREREQUISITE NOT MET (exit 3); it passed after@objectstack/formulaand@objectstack/lintwere built.维护者速读(草稿)
改了什么
只改了一份架构决策记录 ADR-0048 的文字,没动任何代码。在 §3.4「跨包同名不再报错」那一节下面加了一段带日期的说明,并在文末加了一个附录。内容是把您在 #15196 上的裁决(Q4 选 A)写进去:职位、权限集、能力这三类安全目录,一个部署里一个名字只能有一个持有者。原文一个字都没改。
为什么改
ADR-0048 §3.4 当初允许两个包用同一个名字,是因为界面类元数据被调用时总带着「我是哪个包」,系统能分清。但给用户分配职位、给职位挂权限集时,只记名字、不记包。两个包都带同名的「销售经理」,用户到底拿到哪一份权限,就取决于加载顺序。实测确实如此:同一套系统里,职位取后注册的那个包,权限集和能力取先注册的那个。一个应用自带一个叫
admin_full_access的权限集,按名字查到的就是应用自己那份,而不是平台的管理员权限集。您裁定这三类单独收紧,这份 ADR 要跟着记下来,否则 ADR 写着「允许同名」,代码却在拒绝,两边对不上。风险与代价(含回滚)
席位意见
你要做的
请审阅附录的措辞是否准确反映您的裁决,同意就批准(Approve)。这份 PR 属于 Tier H,只能由您批准后落地。
Generated by Claude Code