Skip to content

docs(adr): ADR-0048 §3.4 narrowed — positions, permission sets and capabilities hold one name per deployment - #22198

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22135-adr-0048-narrowing-note
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22135-adr-0048-narrowing-note

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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.md only

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

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

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

Gates (at c383221)

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

维护者速读(草稿)

改了什么

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

为什么改

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

风险与代价(含回滚)

席位意见

你要做的

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


Generated by Claude Code

claude added 2 commits October 8, 2026 04:33
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

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 main: docs/adr/0048-cross-package-metadata-collision.md, +98/-0); the check-runs on the head; PR #22197's net diff for the "matches what was built" question. Read-only: fetched refs and gh api reads; nothing built, run or re-run.

Governed surface: docs/adr/**, Tier H in GOVERNED_SURFACES. This record lifts nothing: the PR lands only on the maintainer's approval. The Governed Surface Queue Guard run on the head is success (it names the tier; it does not clear it).

① Derived judgments

Check-runs on the head: 35, all completed; 24 success, 11 skipped (the code lanes a docs-only diff does not run: Build Core, Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke twice, and a second run's Auto Label, Check Changeset and Check PR Size rows); none failure, none in_progress. Green: Check Documentation Links, Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, Test Core 1-6/6, every Type Check lane.

  1. Original text untouched — RIGHT. The diff is two pure insertions: an eleven-line blockquote after §3.4's last paragraph (before ### 3.5) and a ninety-line addendum after the file's last line. No existing line is modified or removed; §§1-6 and the 2026-08-08 addendum stand verbatim. The header's **Addenda**: index line is one of those untouched lines (③ item 1).
  2. The ruled option is stated exactly — RIGHT. The addendum's blockquote of option A is byte-equal, after joining its wrapped lines, to the Q4 text quoted on the card (compared word by word). The dated note under §3.4 restates it: the three types hold one namespace per deployment; a package whose position, permission set or capability bears a name an installed package, the environment catalog or a built-in holds is refused at registration, the error naming both holders; §3.4's premise (every caller carries its package id) fails for a bare-name assignment; every other type keeps §3.4 as written. No wider: three types, every other type left under §3.4. No narrower: all three holders, both names in the error, install and registration alike.
  3. "Not taken" B and C — RIGHT in substance, with a gloss. The card lists B and C bare; the addendum adds a parenthetical reason to each (B rewrites the ADR-0131 D4 reference syntax; C makes the grant depend on load order). The reasons do not move the ruling's scope; they are the author's words, not the maintainer's, and are named here so the maintainer's eye lands on them.
  4. N.1 — RIGHT. The measured pre-ruling precedence (set and capability from the first-registered package, position from the last; an app's admin_full_access answering the by-name read) is PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197's base column.
  5. N.2 holders — matches what feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 built. "An installed package, including a disabled one" matches the claims map (released only by uninstall) and the registry's own doc line; "the environment catalog" matches the unstamped bare slot; "a built-in: built-in identity positions and audience anchors, curated capabilities" matches BUILT_IN_SECURITY_CATALOG_NAMES. One taxonomy point for the maintainer: the card's filing section listed the platform's permission sets under "built-in holders"; the note and the code hold them as plugin-security's package-held sets (the built-in permission set is empty), because that plugin declares them on its own manifest, configurably. The refusal and the both-holders-named outcome are identical in either registration order (pinned both ways), and the ruling's text leaves "built-in" undefined, so the note is consistent with the ruling; it is the one place its wording departs from the card's filing list. The envelope sentence (422, the §3.2 gate's code) matches the build.
  6. N.3 — RIGHT. Same-package reload, every other type, an environment save left under ADR-0005 (the card's "reported only"), no OS_METADATA_COLLISION=warn downgrade: each matches the card and a pin in feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197.
  7. N.4 — matches feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197, with one precision point: "every package registration reaches the package door before any in-memory registrar runs" holds for boot, artifact boot, hot install and a post-start manifest.register (verified on main: AppPlugin.init to manifest.register to registerApp to installPackage, Phase 1). The dev-loop HMR reload (_reloadAndAnnounce) re-registers without the door and is served until the restart refuses it; feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197 names it, unmeasured. A dev-only exception to an ADR sentence; the maintainer may want a qualifier or a footnote.
  8. Anchors and links — RIGHT. The §3.4 note's link slug is GitHub's heading slug for the new addendum, in the style of the existing 2026-08-08 addendum link; Check Documentation Links is green. The status line ("Accepted: it records a maintainer ruling; narrows §3.4 for three types and supersedes no other text") is the right shape for an ADR that lands only on the maintainer's approval.

② Semver level

skip-changeset — RIGHT. The one file is docs/adr/**, outside every package's published files; the diff publishes nothing from any released package. Check Changeset is success on the head. No Clause-② line is owed by a docs-only PR; the card's claim carries Clause-②: no for the pair, and this PR widens nothing.

③ Boundary flags

  1. The header **Addenda**: index line deliberately not edited (dev report 6053492929, PR body) — consistent with the order "original text untouched"; the §3.4 note carries the link, so the addendum is reachable from the section it narrows. The index now names one of two addenda. Escalated to the maintainer: say the word if the index line should be updated; that is an edit to an existing line, and only the maintainer's approval lands it.
  2. Split from the code PR; Part of #22135, closes nothing — RIGHT; "Part-of PR must not also close its card" is green; the card closes with feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197.
  3. Tier H, waits for the maintainer — RIGHT; no seat readies, queues or arms this PR; the PR body's 维护者速读(草稿) asks for the Approve.
  4. The 维护者速读's "席位意见" section is empty — the seat's to fill before the word is requested; escalated to the seat.
  5. origin/main merged once — the head is a merge commit over the one docs commit; the net diff is the one file.

Implemented-by: claude/issue-22135-adr-0048-narrowing-note
Reviewed-by: session_01EUBvqtauTDmHi2ZgY759p2

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)

domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant) · 2026-10-08T07:18Z。席位已对照本 PR 的 diff(+98/−0,只有两处插入)校正草稿。达档契约复审 PASS:6054692897。

改了什么

只改了架构决策记录 ADR-0048 的文字,没动任何代码。改动有两处:一是在 §3.4「跨包同名不再报错」下面加一段带日期的说明;二是在文末加一个附录,写明 #15196 上您的裁决 Q4 = A:职位、权限集、能力这三类安全目录,一个部署里一个名字只能有一个持有者。原文一个字没改。

为什么改

§3.4 当初允许两个包用同一个名字,前提是界面类元数据在使用时总知道自己属于哪个包。但给人分配职位、给职位挂权限集时只记名字、不记包。两个包都带同名的「销售经理」,谁的定义生效就取决于加载顺序,实测确实如此。代码已经按您的裁决拒绝这种同名(配套 PR #22197)。ADR 不跟着改,就会出现文档写「允许」、代码在「拒绝」的矛盾。

风险与代价(含回滚)

席位意见

建议批准。附录引用的裁决原文与 #15196 上的 Q4 选项 A 逐字一致,没有放宽也没有收窄。复审标出 4 处措辞,供您过目:

  1. 附录在 B、C 两个未选方案后面加了作者自己写的一句理由,卡片上只有方案本身。
  2. 平台自带的权限集(如 admin_full_access)在附录里算作 plugin-security 这个包持有,卡片当初把它列为「内置」。两种归类结果相同:都不能被别的包占用。
  3. 说明里写「每一次注册都经过这道检查」。开发环境的热重载有一个例外:改到重启前会暂时生效,重启后被拒。只影响开发环境。
  4. ADR 头部的附录索引行没有补这一条(保持原文不动),要不要补由您定。

你要做的

在 PR #22198 上批准(Approve)。批准后由席位落地。

@os-zhuang
os-zhuang marked this pull request as ready for review October 8, 2026 07:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit f4bed58 Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22135-adr-0048-narrowing-note branch October 8, 2026 08:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants