You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a99de44
Browse filesBrowse the repository at this point in the historyBrowse files
docs(adr): ADR-0090 D10 rule 5, the agent forward credential (step 1 of #28367) (#28455)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoEY1cKkXmjt2KhuP1QWGYsM)_
Part of #28367. This is step 1 of the ruling's order, the ADR revision.
The code PR is step 2, a separate change made after this lands, so the
card stays open.
Clause-②: yes (widening, at the named cells of the #28178 B refusal
table only)
Metadata-protocol: none
## 维护者速读
- 改了什么:ADR-0090 D10 新增第 5 条 agent 规则「转发带的是 agent,不是用户」:agent 运行 `api`
动作时,进程内转发改带一张服务端自己签发的转发凭据,只对这一次转发有效(一个方法、一个路由、一次动作运行),一次性、短时、不可重放、出不了进程;到目标端解析成同一个
agent 主体(scope 派生的权限、onBehalfOf、performedBy)。绝不带调用方的 token 或 API
key,也绝不签发人类会话(C 方案继续拒绝)。本 PR 只改 ADR,不写代码。
- 为什么改:Tesla 在第 434 批第 4 项答「同意」(裁决记录 6545950368,go as shaped)。今天 OAuth
连接的 agent 跑任何转发到身份路由或平台路由的 `api` 动作都会按 #28178 B 表被点名拒绝,所以 ADR-0011 已开放给
agent 的身份动作实际一个都跑不通;裁决要求先在 ADR 里逐格写明哪些格子翻转,再写代码。
- 风险与回滚:放宽只落在两格:OAuth agent × 身份路由、OAuth agent × 平台路由,后者受 scope
族规则约束(只授权读的 agent 通过动作也改不了任何状态,元数据门的写操作不翻转)。API key × 身份路由继续点名拒绝:API key
是主人自己的凭据而非 agent 主体(D10 2026-09-16 注记),给它开等于换个名字的人类会话。外部 OAuth token
在哪些门有效不变。若认为 API key × 身份路由也该翻转,那是另一项裁决。回滚即 revert 本 PR;代码尚未写,今天无运行时变化。
- 先看:`docs/adr/0090-permission-model-v2-concept-convergence.md` 的 D10
末尾新增节「Amendment (2026-10-31, #28367)」中的表格(标 FLIPS 的两格)、"No human
session" 段(身份路由处 better-auth 只看到委托用户的请求级视图)、scope 族规则四条。
- 两分钟验证:打开该文件搜 `FLIPS`,预期恰好两格翻转,`api_key` 行身份列仍为 refuse by name。
- 需要你做的:以 os-zhuang 身份审批本 PR(Tier H,席位会发起评审请求)。
## What changes
One file, `docs/adr/0090-permission-model-v2-concept-convergence.md`. No
code and no `packages/**`.
- **Status block:** one dated `Amended (2026-10-31, ruling of record
6545950368 on #28367)` line.
- **TL;DR D10 bullet:** one sentence pointing to rule 5.
- **D10's agent bullet:** "Four defining rules" becomes five. Rule 5 is
a short statement pointing to the amendment.
- **The amendment** (a dated subsection at the end of D10, after the
2026-09-20 status note):
- **The credential.** Four binding properties: the server mints it after
the action door's own gates pass; it is bound to one method, one route
and one action run in the consent's organization; it is single-use,
short-lived and non-replayable; it never leaves the process and is never
stored, logged, relayed or audited.
- **What it resolves to.** The same agent principal, built by
`entryFields` from handed-over provenance: the scope-derived
`permissions`, the existing `systemPermissions` rule, `onBehalfOf`,
`performedBy`, `oauthScopes` and the consent's organization.
- **No human session.** At the identity mount, better-auth gets a
request-scoped in-memory view of the delegating user for that one
request. There is no `sys_session` row, token or cookie. Two gates
stand: the action door's gate (the agent's) and better-auth's own (the
user's). Identity writes are attributed to the agent and the user.
Credential-rotation headers are never relayed to an agent caller.
- **The table.** The #28178 B table is reproduced with the flipped cells
marked. Two cells flip: `oauth` × `identity` and `oauth` × `platform`.
`api_key` × `identity` keeps refusing by name, with the reason. `oauth`
× `mcp` is unchanged.
- **The scope-family rule for targets outside MCP.** `actions:execute`
is always required. A safe method needs `data:read` or `data:write`, and
any other method needs `data:write`. A state-changing method on the
metadata door stays refused. Refusals are by name before the mint, and
`list_actions` marks the action from the same predicate.
- **Where the code half lives.** `packages/runtime` (the action doors
and the HTTP dispatcher's resolution), `packages/rest` (the REST face's
resolution) and `packages/plugins/plugin-auth` (the identity mount).
`packages/core` is unchanged. The amendment also lists the pins that PR
owes.
- **What does not change**, and the two sentences elsewhere in D10 that
the code PR must correct when it lands.
## Acceptance notes
- **The API-key cell is the one judgement call.** The card's title names
"an OAuth- or API-key-connected agent". The ruling binds the credential
to resolve to "the same agent principal". D10's 2026-09-16 note rules
that an API key is its owner's credential and not an agent principal. So
the amendment keeps `api_key` × `identity` refusing by name and says
why. If the maintainer wants that cell flipped too, it is a separate
decision: what a key would carry is the owner's own reach.
- **The table's code lives on the #28178 PR (#28442, draft at the time
of writing).** On `main` the API-key half is
`apiActionIdentityRouteKeyRefusal`. The amendment names the table by
what it decides and cites only symbols that exist on `main`, so
`check:adr-symbol-anchors` stays green whichever lands first.
- **The 2026-10-31 agent-door finding on the connection door.** An org
admin's agent with `actions:execute` carries the admin's
`systemPermissions`. That finding is the shape the scope-family rule
addresses for forwards. The rule does not change the connection door
itself.
- No changeset: `docs/adr/**` publishes nothing, so `skip-changeset`
goes on at creation.
- Branch `claude/project-thread-hem8y5` is the card thread's
harness-pinned branch. Claim 6546502251 names it.
## Verification
Local, on `5e899ebd`: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 21 families for the one path.
Each ran with stdin from `/dev/null`, and its exit code was captured by
redirect before any pipe.
<details><summary>Gate readings</summary>
- First pass: 13 exited 0. Eight exited 3 (PREREQUISITE NOT MET, no
`node_modules` in the fresh worktree). After `pnpm install
--frozen-lockfile`, 7 of the 8 exit 0. `check:doc-formula-expressions`
exited 3 again because `@objectstack/formula` and `@objectstack/lint`
were not built. After `turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` under the verify lock (VERDICT command-exit
0), it exits 0.
- Final: all 21 exit 0, including `check:adr-links`,
`check:adr-anchors`, `check:adr-index`, `check:adr-symbol-anchors`,
`check:doc-authoring`, `check:closing-keyword-parity`, `check:nul-bytes`
and `check:pm-governed-merges`.
- `dispatch-gates --ran`: `21 derived famil(ies) accounted for — 21 run,
0 NOT-MEASURED`, exit 0.
- Outside the derivation: `node scripts/check-role-word.mjs` (ADR-0090
D3 ratchet) exit 0.
- `pnpm lint`: no package touched, so there is no scoped lint run.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa)_
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/adr/0090-permission-model-v2-concept-convergence.md
+190-2Lines changed: 190 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,6 +8,7 @@
8
8
· **Amended** (2026-10-15, ruling of record 6181988435 on #23488, letter A) — D12's position↔set binding right and its environment-set authoring right are retired (`AdminScope.manageBindings` and `AdminScope.authorEnvironmentSets`, ADR-0049 enforce-or-remove): a position's sets and a permission set's body are environment definitions, and the definition write is `manage_metadata`'s per [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3, never a delegate scope's. D12's *what* is user↔position assignments (and direct set grants) inside the subtree; read its bindings and authoring clauses as superseded by this line.
9
9
· **Amended** (2026-10-14, [ADR-0138](./0138-guest-model-anonymous-principal-doors-grants-and-organization.md) D3 item 5, ruling C of record 6159279205 on #23267) — D9's last bullet holds for `everyone` alone: a package suggests `everyone` bindings and the admin confirms each, while no door binds a set to `guest` (no package suggestion, no confirmation, no operator bind) until ADR-0138 D3 item 5's bind door is built, an act of the environment's administrator, never of a package. Read that bullet's `guest` half as superseded by this line.
10
10
· **Amended** (2026-10-17, ruling of record 6228728551 on #23960, letter B) — D12 names three kinds of administrator, tenant-level, organization-level and delegated: an organization administrator (`organization_admin` / `organization_admin_no_bypass` for the organization, not a platform administrator) creates and removes `sys_user_position` / `sys_user_permission_set` rows for that organization's members when every set the row distributes has no `*` wildcard superuser bit, no `adminScope` and only `scope: 'org'` capabilities; `admin_full_access`, platform sets, `organization_admin` itself and every delegated set stay outside, and organization-administrator status is conferred by membership grade only. Strict containment and the `everyone` / `guest` anchors are unchanged. See the note under D12.
11
+
· **Amended** (2026-10-31, ruling of record 6545950368 on #28367, go as shaped) — D10 gains rule 5, the **agent forward credential**: an `api` action's in-process forward carries the agent principal in a credential the server mints itself, bound to that one forward, single-use, short-lived and unreachable out of process, never the caller's token or key and never a human session. Two cells of the credential-kind × target-door table flip from "refuse by name" to "accept with a forward credential" (an OAuth-connected agent at an identity route and at a platform route), and a scope-family rule bounds every target outside MCP. See the amendment under D10.
therefore hold at the target as they hold at the action door. No principal kind is added.
544
+
545
+
**No human session.** Option C of #28178, minting a session for the forward, stays refused: a
546
+
session is valid at every identity route, for its whole lifetime, wherever it is presented, so it
547
+
would hand the agent the user's whole reach. The identity mount serves its routes through
548
+
better-auth, which reads a session and nothing else, so at that door the credential is resolved
549
+
before better-auth runs and better-auth is shown the delegating user as its caller for that one
550
+
request only: a request-scoped view built in memory, with no `sys_session` row, no token, no
551
+
cookie, and nothing that outlives the request or is handed back to anyone. Two gates then stand,
552
+
and the agent passes only if both do: the action door's (the agent's intersected authority, the
553
+
exposure and the confirmation, checked before the mint) and better-auth's own (the delegating
554
+
user's, at the target). That is rule 1's intersection, read across the two doors. The agent and
555
+
its user are carried into the identity mount's attribution scope
556
+
(`packages/plugins/plugin-auth/src/auth-actor-attribution.ts#runWithAuthActorScope`), so an
557
+
identity write records `performed_by` (the agent) and `on_behalf_of` (the user). And the
558
+
credential-rotation headers a target can answer (`API_FORWARD_RELAYED_RESPONSE_HEADERS`) are never
559
+
relayed to a caller whose forward carried a forward credential: a target that mints a credential
560
+
cannot pass one to an agent, whatever the action declares.
561
+
562
+
**The table, cell by cell.** The table the #28178 ruling set (credential kind × target door,
563
+
shared by `run_action`, the REST action door and `list_actions`), with the cells this amendment
564
+
flips marked **FLIPS**. Every other cell keeps its answer, and every refusal it keeps is still
565
+
given by name, before the forward, with the target never called.
566
+
567
+
| Caller's credential at the action door |`identity` — the auth mount (`/api/v1/auth/…`) |`mcp` — the MCP endpoint |`platform` — every other same-origin route (data door, declared endpoints, plugin routes) |
568
+
|:--|:--|:--|:--|
569
+
|`session` (a signed-in person) | accept, the caller's own session (unchanged) | accept (unchanged) | accept (unchanged) |
570
+
|`api_key` (its owner's credential) |**refuse by name (unchanged)**| accept (unchanged) | accept (unchanged) |
571
+
|`oauth` (an agent principal) |**FLIPS:** refuse by name → accept with a forward credential | accept, the caller's own token (unchanged; no forward credential) |**FLIPS:** refuse by name → accept with a forward credential, under the scope-family rule below |
572
+
573
+
Two cells flip and one refusal stays:
574
+
575
+
-**`oauth` × `identity` flips.** This is the cell that keeps the identity actions ADR-0011
576
+
exposes out of an agent's reach today.
577
+
-**`oauth` × `platform` flips**, bounded by the scope-family rule. Without the rule this cell
578
+
would let an action reach a route that checks system permissions only, past the families the
579
+
user consented to.
580
+
-**`api_key` × `identity` keeps refusing by name.** An API key is its owner's credential, not an
581
+
agent principal (the 2026-09-16 note above): the principal a key admits is the owner, a human.
582
+
The forward credential is defined to carry an agent principal, so a key has none for it to
583
+
carry. A credential the identity mount accepted on a key's behalf would carry the owner's whole
584
+
reach, which is a human session under another name: option C. A key caller who needs an
585
+
identity action uses a signed-in session, as the existing refusal already tells them.
586
+
Reopening this cell is its own decision, ⛔ not a reading of this one.
587
+
-**`oauth` × `mcp` is unchanged.** The MCP endpoint honours the caller's own token, so no forward
588
+
credential is minted for it.
589
+
- An anonymous caller is in no row: the action door's own floor answers it first, and nothing is
590
+
minted.
591
+
592
+
**The scope-family rule, for every target outside MCP.** At the MCP endpoint the consented tool
593
+
families (`data:read`, `data:write`, `actions:execute`, `metadata:write`) decide which tools
594
+
exist, and the scope-derived ceiling bounds object access. Outside MCP there are no tool families.
595
+
A target that gates on system permissions only, with no object check for the ceiling to narrow
596
+
(identity routes, plugin administration routes, setup routes), would see an agent's
597
+
`systemPermissions`, which under `actions:execute` are the delegator's whole list. So a forward
598
+
credential is minted only when the agent holds the family the forward needs:
599
+
600
+
1.**Every forward credential needs `actions:execute`.** Only the action doors mint it, and they
601
+
serve an agent only under that scope.
602
+
2.**The method names a data family.** A safe method (`GET`, `HEAD`) needs `data:read` or
603
+
`data:write`. Any other method needs `data:write`. An agent the user consented to read with can
604
+
read through an action, and can never change state through one, at any target, including a
605
+
target that has no object check.
606
+
3.**A state-changing method on the metadata door (`/api/v1/meta/…`) is outside the flip** and
607
+
keeps refusing by name. An agent authors metadata through the MCP authoring tools under
608
+
`metadata:write`, which save drafts that a person publishes (cloud ADR-0112), never through an
609
+
action forward.
610
+
4. A forward the rule refuses is refused by name before the mint, the same way the table's other
611
+
refusals are, and `list_actions` marks the action from the same predicate, so the listing and
612
+
the run door cannot disagree.
613
+
614
+
**Where the code half lives, and what it pins.** None of it is written by this amendment; the
615
+
code PR is a separate change on #28367, made after this amendment lands.
616
+
617
+
-`packages/runtime`, the action doors (`packages/runtime/src/action-execution.ts#forwardApiAction`
618
+
and the table beside it): the mint after the gates, bound to the built request; the two flipped
619
+
cells and the scope-family predicate on the one table, read by `run_action`, the REST action
620
+
door and `list_actions` alike; the in-process registry that spends and expires each credential.
621
+
-`packages/runtime`, the HTTP dispatcher's context resolution
622
+
(`packages/runtime/src/security/resolve-execution-context.ts`): a forward credential presented
623
+
through the in-process injector resolves to the agent provenance it was minted with.
624
+
-`packages/rest`, the REST face's context resolution (`packages/rest/src/rest-server.ts`, which
625
+
hands the assembler `oauth: undefined` by construction today): it accepts the forward
626
+
credential, and nothing else, as agent provenance. An OAuth access token presented from outside
627
+
stays unaccepted there.
628
+
-`packages/plugins/plugin-auth`, the identity mount
629
+
(`packages/plugins/plugin-auth/src/auth-manager-request-routing.ts#handleRequest`): the
630
+
resolution before better-auth runs, the request-scoped view, the agent's attribution, and no
631
+
credential header relayed to an agent caller.
632
+
-`packages/core` is unchanged: the provenance passes through `entryFields`, so the agent envelope
633
+
is still built in one place.
634
+
635
+
The code PR owes these pins, and each refusal pin is ablated (the forbidden behaviour put back,
636
+
seen red, restored):
637
+
638
+
- each flipped cell, positively, driven by a real OAuth-connected agent rather than a session:
639
+
an exposed identity action and a platform action are served, and the audit row records both
640
+
`performed_by` and `on_behalf_of`;
641
+
- every refusal the table keeps still refuses by name: `api_key` × `identity`, a state-changing
642
+
method on the metadata door, and each scope-family refusal (a `data:read`-only agent with a
643
+
state-changing method, at an identity target and at a platform target);
644
+
- the credential's properties: a second presentation refused; a different method or route
645
+
refused; an expired one refused; one presented from outside the process resolving as nobody;
646
+
none in any response header, log line or audit row;
647
+
- the intersection at the target: an agent whose user lacks the target's permission is refused
648
+
by the target;
649
+
- no session: a forward creates no `sys_session` row, and a credential-rotating target answers
650
+
no credential header to the agent;
651
+
- the boundary, unmoved: an outside OAuth access token is still refused at the data door and at
652
+
the identity mount;
653
+
- the mint and each acceptance seam carry this decision's id in the code, with an anchor in
654
+
`scripts/adr-anchors/`.
655
+
656
+
**What does not change.** Where an outside caller's OAuth access token is honoured (the MCP
657
+
endpoint alone); which credentials an identity route accepts from outside the process; the API-key
658
+
reading of the 2026-09-16 note; rules 1 to 4; ADR-0011's exposure, buckets and confirmation; the
659
+
principal kinds. The forward credential is bound to one action run, but it is not the run id the
660
+
2026-09-20 status awaits: it lives for one forward and is never recorded.
661
+
662
+
**Two sentences above stop being exhaustive when the code half lands**, and that PR corrects
663
+
them in the same change: rule 4's "today the OAuth / MCP client door alone", and the 2026-09-16
664
+
note's "agent provenance is not representable" on the REST face. A forward credential becomes the
665
+
second way a door resolves an agent principal, and it is only ever minted from the first: an agent
666
+
the MCP OAuth door already admitted. Until then both sentences stay true as written.
667
+
668
+
Ruling: director seat, batch #434 item 4, maintainer 「同意」 2026-10-31 — **go, as shaped**
669
+
(record 6545950368; the card's shape and triage 6540849042). The cells are named here, before any
670
+
code, as the ruling orders.
671
+
484
672
### D11 — OWD gains an external dimension (`externalSharingModel`)
485
673
486
674
> **Amended 2026-10-14 (ruling of record 6159300530 on #23268, letter A — the maintainer's 「23268
0 commit comments