Repository navigation
Commit 8cbe255
docs(adr): ADR-0138 the guest model (Proposed) — doors, grants channel, organization, ownership, and every guest key's fate (#22239)
Part of #22146
Clause-②: no
## What this PR is
Round 3 of #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`](#22146 (comment))
(batch #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`](#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`](#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; #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 #22142 (104 files) and #21988 (229 files). Only
#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
#18480 added `0136-declared-journeys-as-priority-anchor.md`, and #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>1 parent de1610f commit 8cbe255
1 file changed
Lines changed: 640 additions & 0 deletions
0 commit comments