Skip to content

Commit 35fc4ac

Browse files
Samclaude
andauthored
docs(adr): narrow ADR-0090 D10 rule 4 to agent principals; an API key is a credential (#18335) (#19322)
Fixes #18335 Clause-②: no ⛔ **Governed surface — this PR parks as a draft by design.** `docs/adr/**` is Tier H (Prime Directive #14). No ready-flip, no enqueue, no auto-merge, and no approval by any agent seat. An authorized human approval is owed before this lands; a draft with the work done is the complete deliverable. ## What this lands ADR-0090 D10 rule 4 read 「every write records `performed_by` (agent) + `on_behalf_of` (user) + run id」. No door has ever read it that widely, and the gap had never been written down. Per the ruling on the card (comment 5690859150, batch #139 item 1, letter **A**, maintainer 「同意」 2026-09-16, reaffirmed at 5731818788), this PR closes it documentarily: 1. **Rule 4 is narrowed** to *every write by an agent principal*, and it now says what an agent principal IS — a caller a door resolves to `principalKind: 'agent'`, today the OAuth / MCP client door alone — so a later reader can classify a new caller type without re-litigating. It also states the positive reading of the absence: a missing `performed_by` is the record that the principal acted for itself. 2. **A dated note** at the end of D10 (`Note (2026-09-16, #18335)`) records that an API key is its owner's **credential**, not an agent principal, and *why* — a credential is how a principal acted, never a second who — plus what was refused (API keys as a principal category of their own) and on what basis. Documentary only. **No code**: one file, +47 / -2. ## The ruling's premise, measured rather than inherited The ruling asserts that API-key writes audit as the owner today. A note that misdescribed the enforcement would recreate the very defect this card closes, so the assertion was measured first. All readings against `origin/main` at base `e3b3cdd`, taken 2026-09-20T11:05–11:15Z. | reading | result | |---|---| | the one seam that produces an agent principal | `packages/core/src/security/assemble-execution-context.ts#entryFields` — `const agent = !anonymous && oauth?.clientId ? oauth : undefined`, consumed by `principalKind`, `onBehalfOf` and `performedBy` | | `principalKind` / `onBehalfOf` / `performedBy` in `packages/core/src/security/api-key.ts` | **0 / 0 / 0** — lit control: `userId` reads **6** in the same file | | the same three in `packages/core/src/security/resolve-authz-context.ts` | **0 / 0 / 0** — lit control: `userId` reads **48** in the same file | | the same three in `packages/runtime/src/security/api-key.ts` | **0 / 0 / 0** (a 25-line re-export module) | | which doors honour an API key, and what each hands the assembler | **all three** that run `resolve-authz-context.ts#resolveAuthzContext`. REST and MCP **stdio** pass `oauth: undefined` **by construction** (`rest-server.ts`, `mcp/src/plugin.ts#resolveStdioExecutionContext`); the runtime / MCP **HTTP** dispatcher excludes keys with a **guard**, `resolve-execution-context.ts#extractJwtBearer`, refusing an `osk_`-prefixed or non-JWT bearer | | repo-wide non-test writers of `performedBy` | the MCP/OAuth seam, the spec + hook declarations of the field, and the audit writer that reads it. No API-key path | ⇒ an API-key caller falls through `agent ? 'agent' : anonymous ? 'guest' : 'human'` to `human`, carries no `onBehalfOf` and no `performedBy`, and its `sys_audit_log` row is the owner's own. **The measurement agrees with the ruling's premise**, so the narrowing describes the enforcement rather than changing it. ## Was there a dangling D6 sentence? No. The clause 「explain (D6) reports both sides of the intersection」 lives *inside* rule 4, so the narrowing carries it. Measured on the ADR: `attribution` occurs once in the whole file (rule 4) and `both sides` once (its second line). The companion `docs/design/permission-model.md` does not restate the rule at all — `performed_by` / `performedBy` / `attribution` / `every write` read **0** there, against a lit control of **14** for `agent`. The generated `content/docs/references/**` tables carry the field's own `.describe()` text, which already says 「Set only at the /mcp OAuth door … absent everywhere else」 — already narrow, nothing to correct. ## Gates Derived in this worktree from the real change set and reconciled with the run log: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack # 18 commands node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_FILE Run reconciliation — 18 derived, 18 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 18 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 18 exit 0, each captured to disk **before** any pipe. `check-adr-links`, `check-adr-symbol-anchors` (+ both self-tests), `check:adr-anchors`, `check:doc-authoring`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-prior-rulings`, `check:comment-mask-corpus`, `check:cross-package-test-inputs`, `check:driver-memory-census`, `check:refd-timer-probe`, `check:watch-hint-literal`, `check:ci-filter-parity`, `check:closing-keyword-parity` (+ self-test) are among them. `check:doc-formula-expressions` first exited **3** — `PREREQUISITE NOT MET`, the gate's own "nothing was measured" code — and was re-run to exit 0 after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`, taken under the shared verify lock. The three new symbol anchors in the note (`#entryFields`, `#resolveApiKeyAdmission`, `#resolveAuthzContext`) resolve as `declaration` class; no line-number anchor was introduced. ## 维护者速读(草稿) **改了什么。** 只动一份决策记录(ADR-0090),一个文件,47 行增、2 行删。一句原本写着「**每一次**写入都要记下『谁代谁干的』」的规则,被收窄成「**代理主体**的每一次写入」;同一节末尾新增一段带日期的说明,写明 **API key 不在这条规则里,以及为什么**。⛔ 没有改任何代码,平台行为一行都没变。 **为什么改。** 平台里只有一个地方会把调用方判成「AI 代理」:MCP 的 OAuth 门。**三个门都认 API key**,但没有一个会把它变成代理:REST 与 MCP stdio 结构上就不传 OAuth 凭据,MCP HTTP 门则靠一道两行的**守卫**挡住 `osk_` 开头的 bearer。三条路都只认出**钥匙的主人**,所以审计日志记的就是主人本人——这一点本轮在源码上实测过,与裁定的前提一致。于是规则写的是「每一次」,实现做的是「只有代理那一次」,这就是**声明面与执行面对不上**。维护者已裁 A(API key 是主人的**凭证**,不是第二个「谁」),裁定里同时明写:只维持现状而不改那句话,等于新留一条「说的和做的不一致」。这份 PR 就是把那句话改对,并把理由记在案。 **风险与代价(含回滚)。** 风险低:纯文档,无运行时、无 API、无数据结构变化,不发布任何 npm 包。真正的代价是**语义上的**:这条规则从此明确**不**覆盖 API key。将来若出现合规要求「要分得清是人按的还是钥匙跑的」,那是一张新卡、新决策,而不是重读这一条——这一点也写进了 Note 里。回滚是一次 `git revert`,零迁移、零数据影响。 **席位意见。** **你要做的(一个动作)。** 读一遍 D10 rule 4 那六行和它后面那段带日期的 Note,回「同意」或指出要改的措辞。这是受管面(`docs/adr/**`,Tier H),在你点头之前它会一直停在 draft。 ## Acceptance notes - **`skip-changeset` is owed and was deliberately NOT applied.** This diff publishes nothing (`docs/adr/**`), so `Check Changeset` needs that label; the dispatch forbids this executor from touching any label on a governed-surface PR, so the label is left to the seat. Until it is applied, `Check Changeset` is expected red and that red is not a finding about this diff. - **Noted, not filed — D10's 2026-07 Status blockquote still lists 「the agent audit-provenance gap」 as an open follow-up.** That gap is the one #17022 covered, and that card is `closed`/`completed` (2026-09-16T06:41Z). This is a stale pointer in prose, not a reproducible defect, not a contract violation and not a metadata-authoring trap, so it is not one of the three filing classes. Carrier: **#18374**, which already owns the residual close-out in the same D10 area. - **Seat assumption 2 re-measured wider than dispatched.** The dispatch named four PRs; all **36** open PRs in the repo were read at 2026-09-20T11:10Z (`GET /pulls/{n}/files`). Exactly one touches `docs/adr/**` — #18985, on ADR-0089 and ADR-0137, disjoint files. Zero open PRs hold ADR-0090. One caveat recorded rather than hidden: PR #17076 (`chore: version packages`) has more than 200 files; pages 1 and 2 were read (200 files, zero `docs/adr/` hits) and the tail was not enumerated. - **A small line drift in the dispatch's measurement table, reported as instructed.** The card body cites the agent channel at `:293` consumed at `:316`/`:317`; on `e3b3cdd` those are `:294`, `:317`, `:318` — the file gained the `performedBy` line from #18371. The rule 4 sentence itself was anchored on its text and matched verbatim, at line 391 as the seat read it. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cdb0db commit 35fc4ac

1 file changed

Lines changed: 47 additions & 2 deletions

File tree

‎docs/adr/0090-permission-model-v2-concept-convergence.md‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,12 @@ depends on:
388388
permissions, or superuser wildcards; an agent principal never runs `isSystem`.
389389
3. **Human co-sign for `DESTRUCTIVE_OPERATIONS`** regardless of grants — grants decide what an
390390
agent may *initiate*, not what it may *complete alone*.
391-
4. **Dual attribution**: every write records `performed_by` (agent) + `on_behalf_of` (user) +
392-
run id; explain (D6) reports both sides of the intersection.
391+
4. **Dual attribution**: every write **by an agent principal** records `performed_by` (agent) +
392+
`on_behalf_of` (user) + run id; explain (D6) reports both sides of the intersection. An
393+
agent principal is a caller a door resolves to `principalKind: 'agent'` — today the OAuth /
394+
MCP client door alone. A caller of any other kind records the principal it authenticates as,
395+
and the missing `performed_by` is itself the record that the principal acted for itself; for
396+
the API-key case see the 2026-09-16 note at the end of this decision.
393397
Task-scoped, time-boxed agent grants build on the grant-lifecycle follow-up ADR (see
394398
*Named follow-ups*).
395399
- **`guest`** — resolves to the `guest` position (D9) and nothing else.
@@ -398,6 +402,47 @@ The ctx **shape** (kind / audience / onBehalfOf) is a P1 deliverable: it must ex
398402
even where evaluation semantics phase in later — retrofitting a principal model post-launch is an
399403
alias tax on every API.
400404

405+
> **Note (2026-09-16, #18335) — an API key is its owner's CREDENTIAL, not an agent principal, so
406+
> rule 4 does not reach it.** [ruled]
407+
>
408+
> Rule 4 as first written said 「every write」, which left one caller unclassified: an **API key**.
409+
> Is it an agent acting for its owner, or a tool the owner acted through? It is the tool — decided
410+
> here, not merely observed.
411+
>
412+
> **Why.** A credential is *how* a principal acted, never a second *who*. That is the mainstream
413+
> audit reading, and it is already what the doors do. An agent principal is produced at exactly one
414+
> seam — the `/mcp` OAuth door, where an access token naming an authorized client (`azp`) becomes
415+
> `principalKind: 'agent'` + `onBehalfOf` + `performedBy`
416+
> (`packages/core/src/security/assemble-execution-context.ts#entryFields`). An API key takes a
417+
> different path — `packages/core/src/security/api-key.ts#resolveApiKeyAdmission` into
418+
> `packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext` — which resolves the
419+
> key's OWNER and nothing else. The assembled principal is therefore `human`, carries neither
420+
> `onBehalfOf` nor `performedBy`, and its `sys_audit_log` row is the owner's own. A key is honoured
421+
> on **every** door that runs
422+
> `packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext` — REST, the runtime /
423+
> MCP **HTTP** dispatcher, and the MCP **stdio** transport — and none of the three turns one into
424+
> an agent, by two different mechanisms. REST and stdio hand the assembler
425+
> `oauth: undefined` **by construction** (`packages/rest/src/rest-server.ts`,
426+
> `packages/mcp/src/plugin.ts#resolveStdioExecutionContext`), so agent provenance is not
427+
> representable on either. On the HTTP dispatcher — the one door that does mint agent principals —
428+
> it is a **guard** rather than a structural impossibility:
429+
> `packages/runtime/src/security/resolve-execution-context.ts#extractJwtBearer` refuses an
430+
> `osk_`-prefixed bearer and anything that is not a three-segment JWS, so no OAuth provenance is
431+
> ever derived from a key.
432+
>
433+
> **What this changes.** Nothing in the runtime — the behaviour above IS the decided behaviour.
434+
> What changes is the DECLARATION: rule 4's 「every write」 is narrowed to agent principals in the
435+
> same edit, so the declared rule says what the doors enforce. ⛔ A note without that narrowing
436+
> would have left a fresh declared ≠ enforced — the defect class ADR-0049 exists to refuse.
437+
>
438+
> **What is NOT decided here.** Making the key a principal category of its own — key-held grants,
439+
> a key-scoped audit subject — was considered and refused: zero measured pull, and a principal
440+
> category is hard to retire once granted. If a compliance requirement to tell credential-driven
441+
> writes from hand-driven ones is ever stated, that is a new decision with the requirement named,
442+
> ⛔ not a reading of this one.
443+
>
444+
> Ruling: director seat, batch #139 item 1, maintainer 「同意」 2026-09-16 — letter **A**.
445+
401446
### D11 — OWD gains an external dimension (`externalSharingModel`)
402447

403448
Portal and partner scenarios need the Salesforce insight: internal and external record baselines

0 commit comments

Comments
 (0)