docs(adr): narrow ADR-0090 D10 rule 4 to agent principals; an API key is a credential (#18335) - #19322
docs(adr): narrow ADR-0090 D10 rule 4 to agent principals; an API key is a credential (#18335)#19322os-sam wants to merge 3 commits into
Conversation
… is a credential An API key resolves to its owner and to nothing else, so the assembled principal is `human` and carries no `performed_by`. Rule 4's 「every write」 therefore described a reach no door has ever had. Narrow the declared rule to agent principals (the OAuth/MCP client door, the one seam that produces `principalKind: 'agent'`) and add a dated note recording why an API-key caller sits outside it: a credential is how a principal acted, never a second who. Documentary only; no runtime behaviour changes. Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm Co-authored-by: Claude <noreply@anthropic.com>
|
What failed: Cause and fix: the job ( ⛔ Not a gate weakening. No test was skipped, disabled or quarantined; no check was removed; no empty commit was pushed and the PR was not closed and reopened. The label is the gate's own declared exemption for exactly this diff shape, ⛔ not a bypass of it. Result, read per check-name rather than as an aggregate: the
State: 5 checks still Generated by Claude Code |
Contract reviewServed-tier: 132/132 Isolated contract review of PR #19322 (card #18335; ruling 5690859150, batch #139 item 1, letter A, maintainer 「同意」 2026-09-16; reaffirmed 5731818788). Every reading below was taken independently against a detached review worktree at head Claim 1 (load-bearing) — the ruling's premise is true of the runtime: VERIFIED, with one misdescription in the note's proseRe-measured at
⇒ An API-key caller assembles as The misdescription. The note's Why paragraph says: 「The REST door, which is where keys are honoured, hands the assembler no OAuth provenance at all, so an agent principal is not representable there by construction.」 The tree honours API keys on three doors, not one — every door that runs the shared resolver:
On door 2 — the very door that mints agent principals — the reason an API key never becomes an agent is not 「by construction」: it is a runtime guard, Claim 2 — the narrowing says what an agent principal IS and reads absence positively: VERIFIEDRule 4 now reads 「An agent principal is a caller a door resolves to Claim 3 — no dangling restatement: VERIFIED; one count falsified without consequence; one stale quotation noted
Claim 4 — gates: VERIFIEDRun in the review worktree at
CI on Claim 5 — body and side effects: VERIFIED as far as the platform lets me read
Suggested lines
What must change
Nothing else is required. The rule-4 narrowing, the dated note's ruling record, the three anchors and the gates all stand; the PR stays a draft for the Tier H approval either way. Implemented-by: Generated by Claude Code |
The note's Why paragraph anchored the REST door and called the exclusion structural. Both halves misdescribed the tree. A key is honoured on every door that runs the shared resolver — REST, the runtime / MCP HTTP dispatcher and the MCP stdio transport. REST and stdio pass `oauth: undefined` by construction; on the HTTP dispatcher, the door that does mint agent principals, the exclusion is a two-line guard that refuses an `osk_` bearer and anything that is not a three-segment JWS. Name the guard, and say that loosening it would break this decision silently. A right conclusion reached through a wrong mechanism is a fresh declared ≠ enforced inside the sentence meant to close one. Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 8/8 Isolated re-review after patch round 1 of PR #19322 (card #18335). The review of record for everything outside the patched paragraph is comment 5749560296 (head Delta confined to the Why paragraph: VERIFIED
Item (a) — a key is honoured on every door that runs the shared resolver: TRUE of the tree; the enumeration falsified as exhaustive, without consequence for the decision
Item (b) —
|
| ADR at | anchors | symbol | declaration | literal | file-level | line |
|---|---|---|---|---|---|---|
e3b3cdd (base) |
2095 | 289 | 263 | 26 | 1779 | 0 |
82274c7 (prior head) |
2098 | 292 | 266 | 26 | 1779 | 0 |
b0bf1ae (head) |
2102 | 295 | 269 | 26 | 1780 | 0 |
Whole-PR delta +6 symbol / +6 declaration / +0 literal / +1 file-level / +0 line; this round +3 symbol, all declaration (#resolveAuthzContext a second time, #resolveStdioExecutionContext, #extractJwtBearer) and +1 file-level (rest-server.ts). The base reading agrees with the prior review and with the dev's; the dev's head numbers hold exactly.
Claim 2 — gates: VERIFIED as spot-checked, plus CI
Worktree at b0bf1ae, exit codes captured to disk before any pipe, 11:57:58Z–12:01:04Z: check-adr-links self-test 0, main 0 (680 destinations); check-adr-anchors self-test 0, main 0; symbol-anchors self-test 0; check-adr-symbol-anchors self-test 0, main 0; check-doc-authoring --self-test first exited 3 (PREREQUISITE NOT MET, no typescript in a fresh worktree — corroborating the exit-3 class as NOT MEASURED), then after pnpm install --frozen-lockfile --ignore-scripts self-test 0 and pnpm check:doc-authoring 0; check:nul-bytes 0; check:pm-prior-rulings 0; check:pm-governed-merges 0. dispatch-gates --commands derives the same 18 commands at b0bf1ae. Not re-run here: check:doc-formula-expressions (needs a formula + lint build; covered by the CI job below). One reading for the seat, not a finding: dispatch-gates reports this PR's base 9 commits behind origin/main (81e12e1, 11:09:49Z) with four gate-family scripts changed in that range, check-clause2-carriers.mjs among them — that delta is #18892 (claim-edit reading), nothing about a record's shape.
CI on b0bf1ae, read 2026-09-20T11:54:12Z, 45 check runs, 0 in progress, newest per check NAME: Lint & Repo Gates completed success (11:53:12Z); TypeScript Type Check success; Test Core success with shards 1/6–6/6 each success; Dogfood Regression Gate success; Governed Surface Queue Guard success; Check Documentation Links success; the four Type Check · jobs success; the three claim/path checks success; Check Changeset reduces to skipped (three runs at this head, every one skipped — no failure run this round); Check PR Size newest skipped (an earlier run at 11:42Z success); Build Core, Build Docs, Temporal Conformance (live PG + MySQL), Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke (opt-in) each skipped (docs-only diff).
Claim 3 — docs/design/permission-model.md 「attribution」 reads 1: CONFIRMED
grep -c attribution = 1, at :226 「reported per-layer with contributor attribution」 (the explain engine); performed_by 0, performedBy 0, every write 0, lit control agent 14. The substantive claim (no rule-4 restatement there) stands. ADR-0090 at head: attribution 1, both sides 1, every write 3, rule 4 2, where keys are honoured 0, where API keys are honoured 0, by construction 4 (one of them the note's), extractJwtBearer 1, resolveStdioExecutionContext 1, osk_ 1.
Claim 4 — the ⚠️ warning: FALSIFIED, see item (c) above
The seat's body writes: VERIFIED on the three asked; residuals noted
Stored body read 11:53:05Z (updated_at 11:51:45Z), 9273 bytes. (i) 「where API keys are honoured」 0, 「where keys are honoured」 0. (ii) The measurement-table row and the 維護者速讀 clause both name all three doors, oauth: undefined by construction on REST and stdio, and the extractJwtBearer guard — consistent with the ADR; neither carries the false counterfactual, so both survive the fix unchanged. (iii) Exactly 1 footer line (:69, bare form) under exactly 1 rule line under a blank line; 0 less-than and 0 greater-than characters. Residuals, said plainly: the body still says 「+38 / -2」 twice (the English summary and the 速讀) against the actual +48/−2; the footer is now the bare comment form where the 11:24Z read had the session-URL form, and session_ reads 0 in the body, so durable attribution now lives only in the commit trailers and the card's report comment 5749606401; the 「席位意見。」 heading has no text under it. The edit history could not be read (GraphQL is not reachable from this session), so 「twice」 is taken from the dispatch, not measured. Timeline since the prior review: the b0bf1ae commit (11:42:17Z) and one cross-reference (11:53:09Z) — no label, assignee, review or draft event; the PR is still a draft with auto_merge null.
What must change
- Required. Rewrite the
⚠️ sentence so that it is true of the tree. Either name the layers — the guard routes a key away from the JWT verifier;packages/plugins/plugin-auth/src/auth-manager.ts#verifyMcpAccessTokenindependently refuses anything that is not a JWS it can verify against the embedded authorization server (shape, signature, issuer, audience,sub,azp); the assembler mints an agent fromoauth.clientIdalone — so that loosening the guard alone hands a key to a verifier that refuses it and the key still resolves to its owner, and what would silently break this decision is a change to the verifier or the assembler; or drop the counterfactual and keep the guard described as what it is. ⛔ Do not keep 「IS this decision's enforcement」. Declaration-class anchor, no line numbers, docs only — still inside the ruling's shape. - Recommended, not blocking. In the same edit, qualify the 「every door」 appositive: the three named are the doors that hand the resolver's result to the assembler; five further seams run the resolver, honour keys, and assemble no principal.
- For the seat, body only, when next touched. 「+38 / -2」 → 「+48 / -2」 in both places.
Nothing else moved and nothing else is required; items (a) and (b) of the prior FAIL are closed, the anchors and gates hold, and the PR stays a draft for the Tier H approval either way.
Implemented-by: claude/issue-18335-api-key-not-agent-adr-0090
Reviewed-by: isolated contract-review subagent (re-review, dispatched from session_01HnRAeVTLJevtQ5iCPX6JSm)
VERDICT: FAIL
Generated by Claude Code
The sentence claimed the guard IS this decision's enforcement on the MCP HTTP door and that loosening it alone would make an API key an agent principal. Both halves are false of the tree. `verifyMcpAccessToken` refuses a non-JWS token on its own first line and then checks signature, issuer, audience, `sub` and `azp`; `extractApiKey` reads `x-api-key` and the `ApiKey` scheme before any bearer, so a key presented either way never meets the guard at all; and the assembler mints an agent from `oauth.clientId` alone. The paragraph is correct and complete without it: it names the three doors that feed the assembler, keeps "by construction" only where it holds, and describes the guard as a guard. The counterfactual was never part of the ruled deliverable, so it is deleted rather than rewritten a third time. Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 21/21 Isolated final pass after the prescribed deletion on PR #19322 (card #18335). The reviews of record are 5749560296 (head 1. The deletion is exactly what was prescribed and nothing else moved: VERIFIED
On the ADR at head, 2. Anchors unchanged: VERIFIED, numbers exact
3. Gates: 17 of 18 run here, all exit 0; the 18th covered by CI
4. CI on
|
维护者速读(终稿)—— PR #19322,等您一个批准席位 改了什么只动一份决策记录(ADR-0090),一个文件,47 行增、2 行删,零代码。 两件事:① 规则 4 原本写「每一次写入都记下 为什么改平台里只有一个地方会把调用方判成 AI 代理: ⇒ 所以规则写的是「每一次」,实现做的是「只有代理那一次」。这就是声明了但没兑现,也正是 ADR-0049 那一类。您在 2026-09-16 已裁 A(批 #139 item 1),裁决里同时明写:只维持现状而不改那句话,等于新留一条「说的和做的不一致」。这份 PR 就是把那句话改对,并把理由记在案。 风险与代价(含回滚)风险极低:纯文档,零代码,协议行为零变化,没有任何 schema 移动,没有迁移。回滚 = revert 三个提交。 代价是诚实的那一种:规则 4 对 API key 的写入不再承诺双重署名 —— 但那个承诺本来就没有兑现过,所以拿掉的是一句空话,不是一项能力。 席位意见建议批准。 三轮隔离达档复核(⛔ 无一是本席自审),最终
我收下它的理由:剩下五个调用方(service-datasource / service-settings / service-storage / cloud-connection / plugin-sharing)各自 ⇒ 若您希望那一句也改精确,那是一个从句的改动,我可以再派一轮;若您认为不值当,它可以随 #18374 顺手改(那张卡本来就要重述这组门)。 你要做的一个动作:批准或否决 PR #19322。 已向 Generated by Claude Code |
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: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 missingperformed_byis the record that the principal acted for itself.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/mainat basee3b3cdd, taken 2026-09-20T11:05–11:15Z.packages/core/src/security/assemble-execution-context.ts#entryFields—const agent = !anonymous && oauth?.clientId ? oauth : undefined, consumed byprincipalKind,onBehalfOfandperformedByprincipalKind/onBehalfOf/performedByinpackages/core/src/security/api-key.tsuserIdreads 6 in the same filepackages/core/src/security/resolve-authz-context.tsuserIdreads 48 in the same filepackages/runtime/src/security/api-key.tsresolve-authz-context.ts#resolveAuthzContext. REST and MCP stdio passoauth: undefinedby 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 anosk_-prefixed or non-JWT bearerperformedBy⇒ an API-key caller falls through
agent ? 'agent' : anonymous ? 'guest' : 'human'tohuman, carries noonBehalfOfand noperformedBy, and itssys_audit_logrow 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:
attributionoccurs once in the whole file (rule 4) andboth sidesonce (its second line). The companiondocs/design/permission-model.mddoes not restate the rule at all —performed_by/performedBy/attribution/every writeread 0 there, against a lit control of 14 foragent. The generatedcontent/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:
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-expressionsfirst exited 3 —PREREQUISITE NOT MET, the gate's own "nothing was measured" code — and was re-run to exit 0 afterturbo 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 asdeclarationclass; 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-changesetis owed and was deliberately NOT applied. This diff publishes nothing (docs/adr/**), soCheck Changesetneeds 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 Changesetis expected red and that red is not a finding about this diff.sys_audit_logrecordsuser_id/actor= the delegator and nothing names the client, so ADR-0090 D10 rule 4 「dual attribution: performed_by (agent) + on_behalf_of (user) + run id」 has no writer #17022 covered, and that card isclosed/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: [finding] ADR-0090 D10 rule 4 declaresperformed_by+on_behalf_of+ RUN ID — PR #18371 delivers the first two, and the third has no carrier anywhere on the request path #18374, which already owns the residual close-out in the same D10 area.GET /pulls/{n}/files). Exactly one touchesdocs/adr/**— spec: hold a predicate to what the engine can run; declare its fault semantics (ADR-0136) #18985, on ADR-0089 and ADR-0137, disjoint files. Zero open PRs hold ADR-0090. One caveat recorded rather than hidden: PR chore: version packages #17076 (chore: version packages) has more than 200 files; pages 1 and 2 were read (200 files, zerodocs/adr/hits) and the tail was not enumerated.:293consumed at:316/:317; one3b3cddthose are:294,:317,:318— the file gained theperformedByline from feat(spec,core,objectql,plugin-audit): record the acting agent on the audit row (ADR-0090 D10 rule 4 dual attribution) #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