Skip to content

docs(adr): narrow ADR-0090 D10 rule 4 to agent principals; an API key is a credential (#18335) - #19322

Draft
os-sam wants to merge 3 commits into
mainfrom
claude/issue-18335-api-key-not-agent-adr-0090
Draft

os-sam wants to merge 3 commits into
mainfrom
claude/issue-18335-api-key-not-agent-adr-0090

Conversation

@os-sam

@os-sam os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

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#entryFieldsconst 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 3PREREQUISITE 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


Generated by Claude Code

… 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>

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Check Changeset red → resolved by the label it asks for; recording the gate and the cause. Seat domain:spec#3, session_01HnRAeVTLJevtQ5iCPX6JSm, ⏱️ 2026-09-20T11:22Z.

What failed: Check Changeset, run 106069581977, head 82274c767ca4, conclusion failure at 11:20:31Z. ⚠️ Its output carried no title and no summary, so the verdict had to be derived from the job definition rather than read off the check — recorded because a gate that fails without saying why costs every reader the same detour.

Cause and fix: the job (.github/workflows/pr-automation.yml, changeset-check) exempts exactly two shapes, and its own comment states the meaning — 「this PR declares no release of its own」: the skip-changeset label, or the Changesets release PR. This diff is one file, docs/adr/0090-permission-model-v2-concept-convergence.md, verified via GET /pulls/19322/files — zero package sources, so it releases nothing and a changeset would be the wrong artefact. ⇒ the seat applied skip-changeset; labels read back documentation, size/s, skip-changeset.

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 labeled event fired a second run, 106069954592 at 11:21:34Z, conclusion skipped — the exemption short-circuited the job as designed.

⚠️ The older failure run still sits on this head and will keep sitting there. That is structural, not unresolved: this workflow's own comment records it — 「the labeled run's green verdict does not clear the opened run's red one」. ⇒ reduce per check NAME, newest run wins, which is this repo's standing discipline for exactly this shape; on that reduction Check Changeset reads skipped. ⛔ An aggregate 「there is a red on this head」 reading is not a gate verdict here.

State: 5 checks still in_progress at this reading — ⛔ in_progress is an honest reading and is not called green. ⛔ This PR is governed docs/adr/** (Tier H) and stays draft: no ready-flip, no enqueue, no auto-merge, and no approval from any agent seat. The seat follows CI to completion and then parks it for an authorized human approval.


Generated by Claude Code

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 132/132 CONTRACT_REVIEW_TIER
Head-sha: 82274c767ca400a876e844df7cb70d0dbc51e329

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 82274c7 (branch claude/issue-18335-api-key-not-agent-adr-0090; merge-base with origin/main = e3b3cdd; one commit; the tree diff is byte-identical to the API patch, +38/−2 on one file), between 2026-09-20T11:24Z and 11:38Z. The stamp control on the line above: the transcript that produced this record holds 132 assistant lines, all at tier, over 54 requests, read at 2026-09-20T11:38:50Z. ⛔ The ruling itself is not re-adjudicated here, and the #17022 residual class (client_id-without-azp tokens classed human; other sys_audit_log inserters) is #18374's, not a finding here.

Claim 1 (load-bearing) — the ruling's premise is true of the runtime: VERIFIED, with one misdescription in the note's prose

Re-measured at 82274c7, grep -c per file (matching lines):

file principalKind onBehalfOf performedBy lit control userId
packages/core/src/security/api-key.ts (412 lines) 0 0 0 6
packages/core/src/security/resolve-authz-context.ts (1630 lines) 0 0 0 48
packages/runtime/src/security/api-key.ts (25-line re-export from @objectstack/core) 0 0 0 0
packages/core/src/security/assemble-execution-context.ts (399 lines) 7 5 2 10
  • The one producer: assemble-execution-context.ts#entryFields (declared :279) — :294 const agent = !anonymous && oauth?.clientId ? oauth : undefined, consumed at :317 (principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human'), :318 (onBehalfOf) and :331 (performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined). Repo-wide non-test performedBy occurrences: that writer, the spec declarations (execution-context.zod.ts:237, hook.zod.ts:1054), the objectql pass-through (engine.ts:4154) and the audit writer that records it (plugin-audit/src/audit-writers.ts:1337, :1472 performed_by:). No API-key path writes it.
  • clientId comes from azp alone: plugin-auth/src/auth-manager.ts:6330–6345 (...(azp ? { clientId: azp } : {})).
  • API-key admission resolves the owner and nothing else: resolve-authz-context.ts:372 calls resolveApiKeyAdmission; :389–391 set userId = keyPrincipal.userId.
  • The REST door passes oauth: undefined by construction: packages/rest/src/rest-server.ts:3057, with its own comment 「not representable here」 at :3056.

⇒ An API-key caller assembles as human, no onBehalfOf, no performedBy; its sys_audit_log row is the owner's own. The dispatched claim stands.

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:

  1. REST — rest-server.ts:3057, oauth: undefined (as the note says);
  2. the runtime / MCP HTTP dispatcher — packages/runtime/src/security/resolve-execution-context.ts:217 runs resolveAuthzContext({ ql, headers, … }), and packages/core/src/security/api-key.ts:9–10 states it serves 「BOTH inbound surfaces — the runtime dispatcher / MCP path (resolveExecutionContext) and the REST data API」;
  3. the MCP stdio transport — packages/mcp/src/plugin.ts:164 resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey }, … }) inside #resolveStdioExecutionContext (:158), then :171 oauth: undefined (its comment: 「a stdio process presents an API key, never a bearer」).

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, resolve-execution-context.ts#extractJwtBearer (:88–93), which returns undefined for a bearer that startsWith('osk_') (:91) or is not three dot-separated segments, so oauthPrincipal is never derived from a key and the assembler receives oauth: undefined (:259). The enforced outcome is identical on all three doors, so the ruling's premise holds — but the note anchors the REST door, where the guarantee is structural, and is silent about the two-line guard that does the work on the MCP door. An author who deletes or loosens :91 would break this decision with the ADR pointing them at a different file. The ADR is contract text, and the ruling made the note's why mandatory; a why that names the wrong door and the wrong mechanism is the declared ≠ enforced shape this card exists to close.

Claim 2 — the narrowing says what an agent principal IS and reads absence positively: VERIFIED

Rule 4 now reads 「An agent principal is a caller a door resolves to principalKind: 'agent' — today the OAuth / MCP client door alone」 and 「the missing performed_by is itself the record that the principal acted for itself」 (docs/adr/0090-permission-model-v2-concept-convergence.md:391–396). True of the tree: principalKind: 'agent' is assigned at exactly one site (assemble-execution-context.ts:317), and the spec's own .describe() at execution-context.zod.ts:243 already says 「absent everywhere else, and the absence is the record that the principal acted for itself」 — ADR and spec now agree. The rule keeps the D6 clause and names what replaces the removed 「every write」: what a non-agent caller records (the principal it authenticates as) and where the API-key case is decided.

Claim 3 — no dangling restatement: VERIFIED; one count falsified without consequence; one stale quotation noted

  • ADR-0090 at head (matching lines): attribution 1 (rule 4), both sides 1 (rule 4's second line), every write 3 (rule 4 plus the note's two quotations of the old text), rule 4 2. Matches.
  • docs/design/permission-model.md: performed_by 0, performedBy 0, every write 0, lit control agent 14 — matches. attribution reads 1, not the reported 0: :226 「per-layer with contributor attribution」 (explain engine), not a restatement of rule 4. The zero was mis-reported; the substantive claim holds.
  • content/docs/**: the three files carrying performed_by carry the field's .describe() text (「Set only at the /mcp OAuth door … absent everywhere else」) — already narrow, nothing to correct.
  • One repo-wide restatement of the old sentence survives: packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts:4–5 quotes 「Dual attribution: every write records performed_by (agent) + on_behalf_of (user)」 as a historical quotation in a test header. A code file, outside the ruling's 「no code」 shape, so not required here; [finding] ADR-0090 D10 rule 4 declares performed_by + on_behalf_of + RUN ID — PR #18371 delivers the first two, and the third has no carrier anywhere on the request path #18374 edits the same rule-4 sentence for the run id and is the natural carrier.

Claim 4 — gates: VERIFIED

Run in the review worktree at 82274c7, exit codes captured before any pipe (11:30:44Z–11:32:26Z):

  • check-adr-links.mjs --self-test exit 0; main exit 0 (680 relative destinations resolve).
  • check-adr-anchors.mjs --self-test exit 0; main exit 0.
  • symbol-anchors.mjs --self-test 0, check-adr-symbol-anchors.mjs --self-test 0, main 0 — 2098 anchors across 139 records, 292 symbol (266 declaration, 26 literal), 0 line anchors. Differential with the ADR restored to e3b3cdd in the same worktree: 289 symbol (263 declaration, 26 literal) ⇒ the three new anchors (#entryFields at :279, #resolveApiKeyAdmission at api-key.ts:271, #resolveAuthzContext at resolve-authz-context.ts:352) resolve as declaration class, +0 literal, +0 line anchors. The file was restored to HEAD afterwards (0 diff lines).
  • check-doc-authoring.mjs first exited 3 — 「PREREQUISITE NOT MET — the dependency typescript is not installed … Nothing was measured」 — which corroborates the dev's reading of exit 3 as NOT MEASURED rather than red; after pnpm install --frozen-lockfile --ignore-scripts in the worktree, --self-test exit 0 and main exit 0 (11:32:26Z).

CI on 82274c7, read 2026-09-20T11:35:12Z, 35 check runs, 0 in progress, reduced per check NAME with the newest run winning: Lint & Repo Gates completed success (11:30:02Z); TypeScript Type Check completed success (11:35:01Z); Test Core completed success (11:26:46Z), shards 1/6–6/6 each completed success; Dogfood Regression Gate completed success; Governed Surface Queue Guard completed success; Check Documentation Links completed success; Type Check · source gates, · consumer gates, · debt ledger, · workspace each completed success; Check Changeset reduces to completed skipped (run 106069954592 at 11:21:34Z supersedes the failure run 106069581977 at 11:20:31Z); Build Core, Build Docs, Temporal Conformance (live PG + MySQL), Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke (opt-in) each completed skipped (docs-only diff).

Claim 5 — body and side effects: VERIFIED as far as the platform lets me read

  • Body read at 11:24:13Z: 8878 bytes, 0 less-than and 0 greater-than characters, 1 rule line, 1 footer (session-URL form, the form the PR-body surface keeps). The byte-identical read-back after the write is the dev's own transcript reading; I cannot re-take it, and the stored body is well-formed.
  • Timeline (11:25:09Z): size/s and documentation labelled by github-actions[bot]; skip-changeset labelled by os-sam at 11:21:31Z — the seat's comment 5749481741 claims that write, and under one shared identity I cannot tell seat from dev, so I record it as consistent with the dispatch's account; zero assignee events. Issues created in the repo since 10:50Z: none by os-sam other than this PR itself ([finding] AssembledInstalledPackage.manifest erodes to an index-signature type in the published .d.ts, so the assembled arm absorbs the authoring arm #19324 is os-project-manager's). Zero reviews on the PR.

Suggested lines

What must change

  1. In the note's Why paragraph, replace the sentence 「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.」 with prose that (a) states that keys are honoured on every door that runs packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext — REST, the runtime / MCP HTTP dispatcher and the MCP stdio transport; (b) states that the REST and stdio doors pass oauth: undefined by construction (packages/rest/src/rest-server.ts, packages/mcp/src/plugin.ts#resolveStdioExecutionContext); and (c) names the guard on the door that does mint agents: packages/runtime/src/security/resolve-execution-context.ts#extractJwtBearer refuses an osk_-prefixed or non-JWT bearer, so no OAuth provenance is ever derived from an API key. Symbol anchors as declaration class; no line numbers. Docs only — still inside the ruling's shape.

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: claude/issue-18335-api-key-not-agent-adr-0090
Reviewed-by: isolated contract-review subagent (transcript agent-ae3dfe482652fbf70, dispatched from session_01HnRAeVTLJevtQ5iCPX6JSm)
VERDICT: FAIL


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>

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 8/8 CONTRACT_REVIEW_TIER
Head-sha: b0bf1ae280b8c64e839f2ffde057e01231adf631

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 82274c7, FAIL on one sentence, PASS on the rest); nothing below re-adjudicates it. Every reading here was taken independently against a detached review worktree at head b0bf1ae (branch claude/issue-18335-api-key-not-agent-adr-0090; merge-base with origin/main = e3b3cdd; two commits, both carrying the model-free trailer pair), between 2026-09-20T11:53Z and 12:01Z. Stamp control on the line above: the transcript that produced this record holds 8 assistant prose lines (tool calls excluded), all at tier, over 10 requests, counted at the moment of posting.

Delta confined to the Why paragraph: VERIFIED

git diff 82274c7 b0bf1ae: one file, +13/−3, one hunk (@@ -417,9 +417,19 @@), entirely inside the Why paragraph of Note (2026-09-16, #18335). Cumulative vs e3b3cdd: +48/−2, one file. Rule 4 (:391–396), the note's other paragraphs, the [ruled] record and the three round-1 anchors are byte-unchanged.

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

  • packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext (:352) runs resolveApiKeyAdmission(ql, headers, …) unconditionally as step 1 (:372), before any session read; api-key.ts#extractApiKey (:85–96) accepts x-api-key, Authorization: ApiKey … and Authorization: Bearer osk_…. So every caller honours a key.
  • Non-test callers at head: eight, not three — rest/src/rest-server.ts:2960, runtime/src/security/resolve-execution-context.ts:217, mcp/src/plugin.ts:164 (stdio), plus services/service-datasource/src/admin-routes.ts:480, services/service-settings/src/settings-service-plugin.ts:296, services/service-storage/src/storage-service-plugin.ts:1152, cloud-connection/src/marketplace-install-local-plugin.ts:1749, plugins/plugin-sharing/src/sharing-plugin.ts:945. Each of the five extra seams calls itself 「THIS door」 in its own comment and states that the posture-conditional API-key refusals are reachable there (plugin-sharing: the exec-context seam supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key keeps its claim #15349cloud-connection: the marketplace-install route supplies no tenancyPosture to resolveAuthzContext — an ex-member's org-stamped API key is admitted #15353), so keys are honoured there too. Each reads assembleExecutionContext 0 and principalKind 0 (lit control: resolveAuthzContext( 1 in each), so none can produce a principal kind of any sort. The /me door (plugin-hono-server/src/current-user-endpoints.ts:711) assembles with oauth: undefined and does not run the resolver; extractApiKey / x-api-key / resolveApiKeyAdmission read 0 across that package — it honours no key, which is consistent with the sentence.
  • ⇒ The appositive 「every door that runs #resolveAuthzContext — REST, the runtime / MCP HTTP dispatcher, and the MCP stdio transport」 lists the three doors that hand the resolver's result to the assembler, not every door that runs the resolver. 「None of the three turns one into an agent」 is true, and it is equally true of the other five, which turn nobody into anything. Falsified as a census; the decision holds on all eight.

Item (b) — oauth: undefined by construction on REST and stdio: VERIFIED

rest-server.ts:3057 oauth: undefined (its comment :3052–3056); mcp/src/plugin.ts:171 oauth: undefined inside #resolveStdioExecutionContext (declared :158), fed by headers: { 'x-api-key': apiKey } at :164. oauth occurs on two lines of rest-server.ts, both this site.

Item (c) and the ⚠️ sentence — the guard is named and described correctly; the counterfactual attached to it is FALSE of the tree

  • resolve-execution-context.ts#extractJwtBearer (:88–93) does exactly what the note says: undefined for a bearer that startsWith('osk_') (:91) or is not three dot-separated segments (:92). Non-exported function, resolved as declaration class (below).
  • Trace with the guard loosened (both checks removed, the widest loosening): Authorization: Bearer osk_…jwtBearer set → oauthBearerPresented = trueauthService.verifyMcpAccessToken(jwtBearer) (plugins/plugin-auth/src/auth-manager.ts:6296), whose first line is if (!token || token.split('.').length !== 3) return null;, followed by jwtVerify against the embedded AS's own JWKS with issuer and audience, a required sub, a required azp / client_id, and clientId minted from azp alone; every failure path returns null (catch included). An API key can pass none of those. → oauthPrincipal stays undefinedgetSessionForProvenance is hard-anonymous → resolveAuthzContext step 1 still admits the key from the same header (extractApiKey accepts Bearer osk_…) → userId = the owner → assembleExecutionContextOrGuest({ …, oauth: undefined })principalKind: 'human', the owner. Identical to today.
  • A second way the sentence is wrong: the guard reads a Bearer authorization only (:89–90). A key presented as x-api-key or Authorization: ApiKey … on the HTTP door never passes through the guard at all — and is still never an agent, for the reason that actually holds: agent provenance is minted from oauth?.clientId alone (assemble-execution-context.ts:294, :317, :331), and only a verified token's azp supplies it.
  • ⇒ 「That guard IS this decision's enforcement on that door: loosening it would make an API key an agent principal without anyone editing this record」 is false. The guard's own docblock (:82–87) says what it is: shape-based routing 「to the right verifier without ambiguity」. The enforcement on the HTTP door is three layers — the guard (routing) → verifyMcpAccessToken (JWS shape again, signature, issuer, audience, sub, azp) → the assembler (oauth?.clientId) — and the guard is the outermost and least load-bearing of them; to make a key an agent, an author would have to change the verifier or the assembler, and no edit to the guard alone can do it. The ⚠️ sends a future author to protect the wrong line and tells them the other two layers do not exist, which also understates the door's real posture. That is the class round 1 failed on — a right conclusion reached through a wrong mechanism — now inside a ⚠️ that reads as authority in contract text. The clause before it (「so no OAuth provenance is ever derived from a key」) is fine as a description of what the routing does; the false sentence is the counterfactual.

Claim 1 — anchors stayed declaration-class with no line numbers: VERIFIED, numbers exact

Measured by scripts/check-adr-symbol-anchors.mjs in the review worktree with the ADR swapped in place (unstaged git restore --source=…, restored with git checkout HEAD --; afterwards 0 diff lines and git hash-object = the HEAD: blob a27d980):

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

  1. 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#verifyMcpAccessToken independently 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 from oauth.clientId alone — 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.
  2. 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.
  3. 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>

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 21/21 CONTRACT_REVIEW_TIER
Head-sha: e88ee1d5ffd43bd02b7bab4e70d76ecc86030559

Isolated final pass after the prescribed deletion on PR #19322 (card #18335). The reviews of record are 5749560296 (head 82274c7) and 5749678451 (head b0bf1ae); nothing that passed there is re-adjudicated. Every reading below was taken independently against a detached review worktree at head e88ee1d (branch claude/issue-18335-api-key-not-agent-adr-0090, fetched into a ref of my own; merge-base with origin/main = e3b3cdd; three commits), between 2026-09-20T12:15Z and 12:25Z. Stamp control on the line above: the transcript that produced this record holds 21 assistant prose lines (tool calls excluded), all at tier, over 31 tool requests, the POST included, counted at the moment of posting.

1. The deletion is exactly what was prescribed and nothing else moved: VERIFIED

git diff b0bf1ae e88ee1d (12:16:33Z): one file, docs/adr/0090-permission-model-v2-concept-convergence.md, +1/−2, one hunk (@@ -428,8 +428,7 @@), entirely at the end of the Why paragraph of Note (2026-09-16, #18335) (:405–444). The two removed lines are the ⚠️ counterfactual; the one added line is the surviving clause 「ever derived from a key.」 closed on its own. Cumulative vs e3b3cdd: +47/−2, one file — dispatch-gates derives the same figure independently. Rule 4, the note's other paragraphs and the [ruled] line are byte-unchanged.

On the ADR at head, grep -c: 「IS this decision」 0 and 「loosening it」 0 (lit control 「IS the decided」 1, the What this changes sentence); the warning glyph U+26A0 0 in the whole file, so 0 inside the note (lit control: the U+26D4 glyph reads 3 in the same file, so the unicode grep is live). Item (c) from round 1 still holds: extractJwtBearer 1, still anchored as packages/runtime/src/security/resolve-execution-context.ts#extractJwtBearer, and still introduced as 「it is a guard rather than a structural impossibility」 (guard 1). The guard is named and described as a guard; only the false counterfactual is gone.

2. Anchors unchanged: VERIFIED, numbers exact

node scripts/check-adr-symbol-anchors.mjs in the worktree at e88ee1d (12:19:09Z, exit 0, captured before any pipe): 2102 anchors across 139 records — 295 symbol (269 declaration, 26 literal), 1780 file-level, 27 cross-repo, 6 exempt, 3 continuation, 0 line anchors. Byte-identical to the b0bf1ae reading in 5749678451, as expected for a deletion that removed no anchor. --self-test exit 0 (2102 live anchors); symbol-anchors.mjs --self-test exit 0.

3. Gates: 17 of 18 run here, all exit 0; the 18th covered by CI

dispatch-gates --commands --repo objectstack-ai/objectstack at e88ee1d derives the same 18 commands the dev reports. Exit codes captured to disk before any pipe, 12:19:09Z–12:23:16Z: check-adr-links main 0 (680 destinations) and self-test 0; check-adr-symbol-anchors main 0 and self-test 0; check-adr-anchors self-test 0 (106 assertions) and main 0; check-nul-bytes self-test 0 and main 0 (9036 files); check-cross-package-test-inputs self-test 0 and main 0; check-watch-hint-literal self-test 0 and main 0; check-refd-timer-probe self-test 0 and main 0; check-prior-rulings --self-test 0. Nine invocations first exited 3 in the fresh worktree — every one printing PREREQUISITE NOT MET and naming a missing node_modules dependency (typescript, yaml, @typescript-eslint/parser, or the gen:skill-refs generator) — corroborating the exit-3 class as NOT MEASURED, never red; after pnpm install --frozen-lockfile --ignore-scripts (12:21:26–12:21:31Z) all nine exit 0: check-governed-merges --self-test, check-driver-memory-census self-test and main, check-ci-filter-parity, check-closing-keyword-parity main and self-test, check-comment-mask-corpus (6917 files, 0 disagree), check-doc-authoring self-test and main (821 pinned sites). Not re-run here: pnpm --filter @objectstack/lint run check:doc-formula-expressions (needs a formula + lint build) — it is lint.yml :6292, job Type Check · consumer gates, which completed success on this head at 12:14:30Z. One reading for the seat, not a finding: dispatch-gates reports this tree 10 commits behind origin/main (74fb2f7) with four gate-family scripts changed in that range (check-clause2-carriers.mjs among them), as the prior review noted at 9 behind.

4. CI on e88ee1d: converged, green

Read at 12:17:08Z: 31 check runs, Lint & Repo Gates still in_progress — not green at that reading. Re-read at 12:21:40Z: 31 runs, 0 in progress, newest per check NAME: Lint & Repo Gates completed success (12:19:15Z; it owns check:adr-anchors :324, check:doc-authoring :1890 and check-adr-symbol-anchors :2782); TypeScript Type Check success (12:16:42Z); Test Core success (12:12:13Z), 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 PR Size success; Check Changeset reduces to completed skipped (one run at this head, skip-changeset applied); 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, as on both prior heads).

The appositive — opinion: AGREE with the seat, non-blocking

Re-measured at e88ee1d. resolveAuthzContext( has 8 non-test production callers (a ninth grep hit is plugin-sharing/src/exec-context-seam.testkit.ts, test infrastructure): the three the note names (rest-server.ts:2960, resolve-execution-context.ts:217, mcp/src/plugin.ts:164) plus service-datasource/src/admin-routes.ts:480, service-settings/src/settings-service-plugin.ts:296, service-storage/src/storage-service-plugin.ts:1152, cloud-connection/src/marketplace-install-local-plugin.ts:1749, plugin-sharing/src/sharing-plugin.ts:945. The five unlisted files each read assembleExecutionContext 0, principalKind 0, oauth 0 (lit control resolveAuthzContext( 1 each). Three further facts close the question rather than restate it: (i) the resolver's own output type ResolvedAuthzContext (resolve-authz-context.ts:74) carries userId? tenantId? email? accessToken? positions permissions systemPermissions tabPermissions? org_user_ids accessible_org_ids posture? and nothing else — principalKind, oauth, onBehalfOf, performedBy, clientId each read 0 in that file against userId 48 — so nothing downstream of the resolver alone can carry agent provenance; (ii) the only non-test producer of principalKind: 'agent' in the tree is assemble-execution-context.ts:317, gated by :294 const agent = !anonymous && oauth?.clientId ? oauth : undefined, and the five files never call it and never spell oauth; (iii) what the five do with the result is authz.userId / systemPermissions / tenantId only — storage hands it to delegate.authorizeFileRead(ownerId, authz) (:1176), sharing spreads it into its own route context ({ ...authz, isSystem: false }, :950) — neither path reaches the assembler. ⇒ No one of the eight can mint an agent principal from an API key. The appositive is an incomplete census with a true conclusion, ⛔ not a false mechanism — materially unlike the two sentences that failed. I would still qualify it the next time this D10 area is touched (#18374 is the natural carrier), and I do not fail the PR over it.

The seat's pending body write — current state, audited

Stored body read at 12:16:12Z (updated_at 12:07:27Z), 8123 bytes. The stale figure appears in two spellings: the literal token +38 exactly once — 「No code: one file, +38 / -2.」 — and once more in the 维护者速读 as 「一个文件,38 行增、2 行删」; +48 0, +47 0. The true cumulative figure is +47 / −2 (tree, dispatch-gates, and the PR's own additions/deletions all agree), so the seat's edit should touch both spellings, not only the +38 token. Exactly 1 footer line (bare form, _Generated by [Claude Code](https://claude.ai/code)_) under exactly 1 rule line under a blank line; session_ 0; 0 less-than and 0 greater-than characters. Residual unchanged from the prior review: the 「席位意见。」 heading still has no text under it.

Side effects and what was not taken

Timeline read 12:23:39Z: since review 5749678451 (12:04:11Z) exactly one event, the e88ee1d commit (12:07:23Z) — no label, assignee, review, ready or draft event; labels documentation, size/s, skip-changeset; draft true; auto_merge null; 0 formal reviews on the PR (12:23:41Z). I wrote nothing to the PR, the card, labels or assignees before this comment; the review worktree was created detached at e88ee1d from a ref I own and is removed after this post. Not taken: check:doc-formula-expressions locally (above); the body's edit history (GraphQL not reachable), so 「two spellings」 is measured on the stored body at 12:16Z, not on its history.

Implemented-by: claude/issue-18335-api-key-not-agent-adr-0090
Reviewed-by: isolated contract-review subagent (final pass after the prescribed deletion, dispatched from session_01HnRAeVTLJevtQ5iCPX6JSm)
VERDICT: PASS


Generated by Claude Code

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

维护者速读(终稿)—— PR #19322,等您一个批准

席位 domain:spec#3,session_01HnRAeVTLJevtQ5iCPX6JSm,⏱️ 2026-09-20T12:30Z。本条是终稿,由席位对照自己读过的 diff 校正过 PR 正文里那份草稿并填上席位意见。⛔ 您只需要读这一条。

改了什么

只动一份决策记录(ADR-0090),一个文件,47 行增、2 行删,零代码

两件事:① 规则 4 原本写「每一次写入都记下 performed_by(代理)+ on_behalf_of(用户)+ run id」,收窄成「代理主体的每一次写入」,并写明什么算代理主体;② 同节末尾加一段带日期的说明,记下 API key 是它主人的凭证,不是代理主体,以及为什么。

为什么改

平台里只有一个地方会把调用方判成 AI 代理:/mcp 的 OAuth 门。API key 三扇门都认,但没有一扇会把它变成代理 —— 审计日志记的就是钥匙的主人本人

⇒ 所以规则写的是「每一次」,实现做的是「只有代理那一次」。这就是声明了但没兑现,也正是 ADR-0049 那一类。您在 2026-09-16 已裁 A(批 #139 item 1),裁决里同时明写:只维持现状而不改那句话,等于新留一条「说的和做的不一致」。这份 PR 就是把那句话改对,并把理由记在案。

风险与代价(含回滚)

风险极低:纯文档,零代码,协议行为零变化,没有任何 schema 移动,没有迁移。回滚 = revert 三个提交。

代价是诚实的那一种:规则 4 对 API key 的写入不再承诺双重署名 —— 但那个承诺本来就没有兑现过,所以拿掉的是一句空话,不是一项能力。

⚠️ 成本也如实说:交付用了三轮,其中两轮返工,两次都被达档复核判 FAIL,而且是同一类错 —— 结论对、机制说错。第二次是把那句话删掉而不是第三次改写。⛔ 我自己也犯了两个错并已公开更正:轮次数数错了一轮;以及我曾在轮报里把那句后来被判 FAIL 的话夸成「整段最有价值的」,而我当时并没验证它的机制断言。

席位意见

建议批准。 三轮隔离达档复核(⛔ 无一是本席自审),最终 PASS;CI 在当前 head 上 31 个 check 全绿或按设计跳过、零在跑;锚点全程零行号锚点;门禁 18/18。裁决的交付物恰好被交付,没有多也没有少 —— 第二轮删掉的那句正是 dev 自己加的、裁决从未要求的范围。

⚠️ 一处我判为可接受、但要让您自己看一眼的不精确(⛔ 我没有把它藏进 ACCEPT 里):新段落写「a key is honoured on every door that runs #resolveAuthzContext — REST、HTTP dispatcher、stdio —」,按穷举读不准 —— 那个函数有 8 个非测试调用方,不是 3 个。

我收下它的理由:剩下五个调用方(service-datasource / service-settings / service-storage / cloud-connection / plugin-sharing)各自 assembleExecutionContextprincipalKind 都是 0;更硬的一条是 resolver 的输出类型 ResolvedAuthzContext 根本不带 principalKind/oauth/onBehalfOf/clientId(各 0,亮控 userId = 48)⇒ 结论对全部 8 个都成立。所以这是列举不全,不是机制说错

⇒ 若您希望那一句也改精确,那是一个从句的改动,我可以再派一轮;若您认为不值当,它可以随 #18374 顺手改(那张卡本来就要重述这组门)。

你要做的

一个动作:批准或否决 PR #19322

已向 os-zhuanghotlong 两个授权账户请审。⛔ 本席不批准、不合并、不翻 ready、不入队 —— docs/adr/** 是受管面 Tier H,授权批准到位后由席位落地。


Generated by Claude Code

@os-sam
os-sam requested review from hotlong and os-zhuang September 20, 2026 12:30
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 needs-user-decision size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants