Skip to content

Commit 271d6bb

Browse files
os-warrenclaude
andauthored
feat(spec,core,objectql,plugin-audit): record the acting agent on the audit row (ADR-0090 D10 rule 4 dual attribution) (#18371)
Fixes #17022 ADR-0090 D10 rule 4 — 「**Dual attribution**: every write records `performed_by` (agent) + `on_behalf_of` (user) + run id; explain (D6) reports both sides of the intersection.」 — was declared with no writer. A `sys_audit_log` row written by an MCP OAuth client acting for a human was byte-identical to a row that human wrote in the Console. The envelope carried the delegation; the row did not; nothing in between copied it. ## The shape chosen, and why The dispatch left the shape to this round inside three fences. Chosen: **`user_id` = the human (unchanged) + `actor` = the human (unchanged) + `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated write, and nothing at all on a personal one.** - **⛔ Not two new columns.** That needs `packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts`, a **sixth path** outside the declared face, and it pulls in new field labels, the nine i18n bundles and DDL on a shipped system object. The standing order is to stop and report before writing a sixth path, so this round did not write one. - **⛔ Not `actor = the client`**, although the dispatch named it as an option. **ADR-0118 D5 refuses it in terms**: 「actor 维度只有两类:某个用户 / 系统(`null`)。**不引入** `actor_kind` 枚举列;追溯「是哪个自动化干的」用既有关联字段(flow run id、job 名、来源上下文),不在 actor 上重复表达——那是双源」. Its own upgrade path names the remedy: 「将来若出现区分多个非用户 actor 的真实业务需求,以**新增归因字段**(加法)满足,不推翻 null 表示」. So the acting client goes on an **added attribution field**, never onto `actor`. That also discharges triage's additive-only fence verbatim — no existing column changes value, and no historical row is rewritten. - **`metadata` is already the declared home for exactly this.** The column is declared on the shipped object (`'JSON-serialized additional context'`) and the sibling writer in this same package (`auth-event-audit.ts`) already stamps it. Nothing new is declared on the object, so nothing new can be declared-and-unenforced. - **`on_behalf_of` is written from the same `userId` that lands in the `user_id` column**, not from a second carrier. Rule 4 asks for both sides on the row; one source for one fact keeps them from ever disagreeing. - **Distinguishable by absence, not by guesswork** — triage's explicit ⚠️. No key ⇒ the principal in `user_id` performed the write itself. ## Measured file face — five files, four packages, exactly the declared face | # | file | what lands | |---|---|---| | 1 | `packages/spec/src/kernel/execution-context.zod.ts` | `performedBy: { clientId }`, optional | | 2 | `packages/spec/src/data/hook.zod.ts` | `provenance.performedByClientId`, optional | | 3 | `packages/core/src/security/assemble-execution-context.ts` | decided on the existing agent branch; joins `ENTRY_EXECUTION_CONTEXT_FIELDS` | | 4 | `packages/objectql/src/engine.ts` | `buildProvenance`'s fixed copy list | | 5 | `packages/plugins/plugin-audit/src/audit-writers.ts` | the row | Plus two **test siblings** of files 3 and 5 (`assemble-execution-context.test.ts`, `agent-dual-attribution.integration.test.ts`), one changeset, and three regenerated spec artifacts (`authorable-surface/kernel.json`, three `content/docs/references/**` pages, the strictness-ledger count). Thirteen paths total, and `dispatch-gates.mjs` prints the same thirteen from the merge base. **No sixth source path appeared.** Two readings worth recording, both of which could have produced one and did not: - The **closed 9-key hook-context literal needed no new key**: `provenance` is already one of the nine (`engine.ts:9981` and its four siblings), so the entire fix at seam 4 is inside `buildProvenance`'s fixed copy list. Re-measured, not carried from the record. - The **read side needed no edit**, because `actor` is untouched. `read-audit.ts`'s `row.actor = event.actor ?? event.userId` keeps answering exactly what it answered before, and the audit-log browser in the sibling repo is not implicated. ⚠️ Line numbers on the card and in the record had drifted and were re-measured: the writer's row literals are `audit-writers.ts:1345` (`user_id`) and `:1464` (`actor_id`); the three builders are `engine.ts:4043` / `:4104` / `:4167`. ## ⛔ What is NOT delivered: rule 4's third element, the run id Named rather than quietly dropped, and deliberately **not declared**. Nothing on the request path mints a run id today — `ExecutionContext.traceId` is declared but sits in `NonEntryExecutionContextField`, so no transport entry point resolves it, and the only producers in the tree are the observability request-context and the logger. Declaring a carrier nothing populates is the ADR-0049 defect this card exists to close, so the third element is reported as an open gap for a follow-up card rather than half-built here. Both shapes the dispatch named for this round covered the two-sided attribution only. ## Clause-②: `yes` — settled before the first stroke, carrier hung on the card Not re-litigated. Re-measured this round on the two grounds that survive, with the third restated as the correction it is: - **The published tarball.** `npm pack --dry-run --json` over `packages/spec` enumerates **2012** files and `files[]` literally contains `src/**/*.zod.ts`, so the edited `src/kernel/execution-context.zod.ts` **is in the tarball** while three negative controls are on disk and absent from it (`src/index.ts`, `src/kernel/index.ts`, `src/kernel/cli-extension.test.ts`) ⇒ the probe discriminates both ways. - **The authorable-key ledger.** `kernel/ExecutionContext` recorded **31** keys before this change and **32** after — re-measured rather than carried; the record's earlier reading of 30 has drifted. `check:authorable-surface` moved and was regenerated. - ⚠️ **It does NOT rest on `check:api-surface`, and that gate is green here.** The 2026-09-12 correction holds as measured: `api-surface` is a breadth snapshot of exported NAMES. Grepped over `packages/spec/api-surface/`, `ExecutionContextSchema` returns **1** while field names return **0** each — `performedBy` 0, and the existing `onBehalfOf` 0 and `principalKind` 0 ⇒ the probe finds an export name and provably does not find a field name. The gate does not move on a field add. `needs:contract-review` is on the card, applied by the seat before dispatch, and is applied to this PR in the same stroke that opened it. The PR owes an at-tier contract review from its first stroke. ## Changeset — measured per package, not assumed `minor` on all four (`yes` grades above `patch`). `@objectstack/spec` ships the carrier **sources** verbatim, per the tarball reading above. `@objectstack/core`, `@objectstack/objectql` and `@objectstack/plugin-audit` publish `dist`, and the changed text reaches it in **4 / 6 / 2** built files against a negative control marker that returns **0**; `src/index.ts` is on disk and absent from all three tarballs. ## Verification Every heavy run went through `scripts/pm/os-verify-lock.sh`; verdicts are its own `VERDICT command-exit` lines, never a bare exit code. **Tests — all four affected packages, their own suites, all green:** | package | result | |---|---| | `@objectstack/spec` | `Test Files 482 passed (482)` · `Tests 13688 passed (13688)` · `VERDICT command-exit 0` | | `@objectstack/core` | `Test Files 51 passed (51)` · `Tests 1316 passed (1316)` · `VERDICT command-exit 0` | | `@objectstack/objectql` | `Test Files 298 passed (298)` · `Tests 4990 passed (4990)` · `VERDICT command-exit 0` | | `@objectstack/plugin-audit` | `Test Files 24 passed (24)` · `Tests 346 passed (346)` · `VERDICT command-exit 0` | `typecheck` for the same four in one run: `VERDICT command-exit 0`. **The pin** — `packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts`, 5 cases, real `ObjectKernel` + real `ObjectQLPlugin` + real `SqliteWasmDriver` + the real shipped `SysAuditLog` + the real `installAuditWriters`, rows read back through the driver's own SQL. It drives the chain from `assembleExecutionContext` and asserts **both** rows in one run, because a hook-layer pin is vacuous by default: `buildSession` returns `undefined` with no envelope and the writer's `ctx.session ?? {}` then resolves every identity read to `undefined` without throwing. The anti-vacuity control is that `user_id` equals the real human on **both** rows — a value that can only have come through that channel. **Ablation of seam 4**, the step whose omission would be the declared-and-never-populated defect. This suite resolves `@objectstack/objectql` through its `exports` (i.e. `dist/`) by design — a registered pair in `check:test-source-alias`'s `KNOWN_UNALIASED_TEST_IMPORTS` — so the mutation was proven onto disk **and** into the built artifact before its colour was allowed to mean anything: ``` HEAD_BLOB=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7 DISK_BLOB_BEFORE=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7 --- pre-mutation on-disk occurrences of the marker: 1 --- post-mutation on-disk occurrences of the marker: 0 --- post-mutation on-disk occurrences of the injected text: 1 DISK_BLOB_AFTER=e408eba031b340fb7160b305810815c321e42c70 ablation-dist-preflight: ✓ dist/: marker absent from all 14 built files VERDICT command-exit 1 (ablation run) FAIL … > a delegated write records `performed_by` (the client) beside `on_behalf_of` (the human) AssertionError: expected null to deeply equal { performed_by: 'cli_mcp_agent', …(1) } FAIL … > the two rows are no longer identical on the attribution-bearing columns AssertionError: expected { user_id: 'u_sales_manager', …(2) } to not deeply equal { … } Tests 2 failed | 3 passed (5) RESTORE: disk=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7 head=aa6ec36f32eebdbfb9195607bd101ad305a9ffd7 ``` Direction **as predicted: turns red** — and the shape is the informative part. Exactly the two cases that assert the card went red; the three controls stayed green, which is correct, because a control that moves with the fix was never a control. Restore leg: whole-tree `git status --porcelain` empty, `git hash-object` equal to the HEAD blob, objectql rebuilt, and `ablation-dist-preflight` in positive mode exit 0 with the marker back in `dist/core.js`, `dist/core.mjs`, `dist/index.js`. **A second reverse reading, unplanned and worth naming**: the `#6216` parity pin in `assemble-execution-context.test.ts` went red on **64** cases, and they were **all and only** the agent combinations (2 authz shapes × 4 agent OAuth shapes × 4 localizations × 2 request locales). Every non-agent case stayed green. That is an independent measurement that the new key lands on the agent face and on no other. The frozen `legacy*` transcriptions were **not** edited — the file's own header forbids keeping them up to date — so the delta is subtracted at the assertion and then asserted **positively** on the next line: `performedBy` is present exactly when an authenticated principal's token names a client, absent otherwise, carrying that client's id. Every other key still compares byte-for-byte. **Lint** — the whole repo, not a narrowing: `eslint . --no-inline-config --format json` inspected **6783** files (the set decided by `eslint.config.mjs` itself, counted from the JSON report) with **0 errors and 0 warnings**. **Gates.** `dispatch-gates.mjs --repo objectstack-ai/objectstack` derives **119** families from the real 13-path change set; **20 run with exit 0** recorded as `command :: exit N`, **2 NOT MEASURED**, **97 declared to CI**. Run and green: `check:generated` for `packages/spec` (**all 15 artifacts up to date**, `check:api-surface` and `check:authorable-surface` among them) · `check:nul-bytes` · `check:test-source-alias` · `check:durability-log-level` · `check:published-files` · `check:empty-changeset` · `check:changeset-no-major` · `check:adr-0087-registration` · `check:spec-docblock-symbol-anchors` · `check:closing-keyword-parity` · `check:doc-frontmatter` · `check:docs-section-name` · `check:doc-anchors` · `check:docs-spec-enumerations` · `check:quick-reference-counts` · `check:type-check-coverage` · `check:pm-widening-tells` · `check:spec-parsed-alias` · `check:engine-double-contract` · `pnpm lint`. **NOT MEASURED (2), and neither is a pass**: `check:i18n` and `check:type-check-debt` both answered `exit 3 PREREQUISITE NOT MET` — the first wants the built CLI plus a ten-package build closure, the second wants its own built closure. Neither is a finding and neither is green; CI builds both prerequisites. ## Acceptance notes - `check:cross-package-test-inputs` reds **in this worktree**, and the finding names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/`. Neither path is in this diff, `packages/cli` is untouched entirely, and no glob declaration moved. The shared checkout answers exit 0 only because it has **no** `packages/spec/dist` at all, so that green is vacuous for this question rather than a control — the gate's verdict is a function of local build state. Recorded, not filed by this round, not repaired here. - The `sys_activity` mirror row is deliberately left alone. Its `metadata` already carries `{ old, new }`, and the activity timeline is a user-facing feed rather than the compliance ledger this card is about. - `auditRow.metadata` is stamped **unconditionally**, matching `auth-event-audit.ts` in this same package, rather than behind an `objectHasField` probe like `organization_id` / `actor`. Those two are probed because older audit tables predate them; `metadata` does not. A genuinely missing column fails the INSERT loudly through the existing reporting seam instead of silently dropping the delegation. - The ADR-0090 D10 status note still lists 「the agent audit-provenance gap」 as a follow-up with no card number. Amending it rides a **separate docs-only PR after this lands**, per the seat's standing decision: one governed-path hit would make this whole four-package change draft-only with a hand merge. No governed surface is touched by this diff. - The measurement here is still the **in-process** layer. It boots a real kernel, a real engine and a real driver, but **not** a booted MCP OAuth door. ⛔ That remains unmeasured and is not reported as covered. --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fc092b commit 271d6bb

13 files changed

Lines changed: 498 additions & 6 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/plugin-audit': minor
6+
---
7+
8+
Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution
9+
10+
A `sys_audit_log` row written by an MCP OAuth client acting for a human used to
11+
be byte-identical to a row that human wrote in the Console. The envelope carried
12+
the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and
13+
nothing in between copied it: `assembleExecutionContext` consumed the OAuth
14+
`azp` as a boolean and dropped the value, so the acting client did not exist
15+
downstream of the door at all.
16+
17+
The delegation now travels the whole way and lands on the row:
18+
19+
- `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth
20+
door, on the same branch that already decides `principalKind: 'agent'` and
21+
`onBehalfOf`; a member of the closed entry field set like every other.
22+
- `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside
23+
`flowRunId` and `attributedUserId`. Provenance, not `session`: no
24+
caller-gating hook may read the client as the caller.
25+
- `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated
26+
write, and nothing at all on a personal one — the two shapes are told apart by
27+
absence rather than by guesswork.
28+
29+
Additive, and attribution only. `user_id` stays the human, so owner-stamping,
30+
`current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 —
31+
attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps
32+
that column two-valued — a user id, or `null` for the system — and answers
33+
"which non-user acted" with an added attribution field rather than a second
34+
actor vocabulary. No existing row changes meaning, and no historical row is
35+
rewritten.
36+
37+
Rule 4's third element, the run id, is NOT delivered here and is not declared
38+
either: nothing on the request path mints one today (`ExecutionContext.traceId`
39+
is declared but resolved by no transport entry point), and declaring a carrier
40+
nothing populates is the defect this change exists to close.

content/docs/references/data/data-engine.mdx

Lines changed: 12 additions & 0 deletions
Large diffs are not rendered by default.

content/docs/references/data/hook.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
9696
| **submitted** | `Record<string, any>` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image |
9797
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced) |
9898
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
99-
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
99+
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
100100
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
101101
| **transaction** | `any` | optional | Database transaction handle |
102102
| **ql** | `any` || ObjectQL Engine Reference |
@@ -132,6 +132,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
132132
| :--- | :--- | :--- | :--- |
133133
| **flowRunId** | `string` | optional | Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request. |
134134
| **attributedUserId** | `string` | optional | The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as. |
135+
| **performedByClientId** | `string` | optional | [ADR-0090 D10 rule 4] The AGENT that performed this write — the OAuth client (`azp`, a registered sys_oauth_application id) the access token was issued to, when an AI client acted on behalf of the human in `session.userId`. Copied from `ExecutionContext.performedBy.clientId`, which only the /mcp OAuth door sets. ATTRIBUTION ONLY, exactly like `attributedUserId`: the audit writer records it in `sys_audit_log.metadata` as `performed_by` beside `on_behalf_of`; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every non-delegated write, so a hook tells the two apart by the key being there rather than by guesswork. |
135136

136137
### Nested Shape: `HookContext.user`
137138

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const result = ExecutionContextSchema.parse(data);
4141
| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. |
4242
| **authGate** | `{ code: string; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on (PASSWORD_EXPIRED / MFA_REQUIRED) and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. |
4343
| **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | |
44+
| **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. |
4445
| **permissions** | `string[]` | optional (default: `[]`) | |
4546
| **systemPermissions** | `string[]` | optional | |
4647
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ directory rather than per file.
260260
| `api/` | 451 |
261261
| `identity/` | 32 |
262262
| `integration/` | 8 |
263-
| `kernel/` | 256 |
263+
| `kernel/` | 257 |
264264
| `marketplace/` | 29 |
265265
| `qa/` | 6 |
266266
| `shared/` | 20 |

packages/core/src/security/assemble-execution-context.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,27 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-#
235235
authGate: undefined,
236236
});
237237
const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale);
238-
expect(observable(now)).toEqual(observable(before));
238+
239+
// [#17022] The ONE argued delta from the frozen transcription, and
240+
// it is SUBTRACTED here rather than absorbed into the transcription
241+
// itself — the header's rule is that a legacy function is never
242+
// "kept up to date", because the day it needs editing is the day a
243+
// face's output changed and the change owes its own argument.
244+
// Here that argument is ADR-0090 D10 rule 4: the agent face now
245+
// carries the identity of the client that performed the write, and
246+
// no other face carries anything new.
247+
//
248+
// Subtraction alone would be a hole big enough to hide the next
249+
// drift in, so the key is asserted POSITIVELY on the very next
250+
// line: present exactly on the agent branch — an authenticated
251+
// principal whose token names a client — absent everywhere else,
252+
// and carrying that client's id. The parity pin below is otherwise
253+
// unweakened: every other key still compares byte-for-byte.
254+
const { performedBy, ...restOfNow } = observable(now);
255+
expect(restOfNow).toEqual(observable(before));
256+
expect(performedBy).toEqual(
257+
authz.userId && oauth?.clientId ? { clientId: oauth.clientId } : undefined,
258+
);
239259
});
240260
}
241261
}

packages/core/src/security/assemble-execution-context.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [
121121
'isSystem',
122122
'principalKind',
123123
'onBehalfOf',
124+
'performedBy',
124125
'audience',
125126
'userId',
126127
'tenantId',
@@ -315,6 +316,19 @@ function entryFields(
315316
isSystem: false,
316317
principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human',
317318
onBehalfOf: agent ? { userId: authz.userId!, principalKind: 'human' } : undefined,
319+
// [ADR-0090 D10 rule 4 — dual attribution] The same `azp` that made this
320+
// an agent principal, carried as the PERFORMER so the write is attributable
321+
// to the client and not only to the human it acts for. Until now the value
322+
// was consumed as a boolean here and dropped: `onBehalfOf` recorded WHO was
323+
// delegated FROM, and nothing downstream could name WHO acted, which left
324+
// an agent's `sys_audit_log` row byte-identical to the human's own (#17022).
325+
//
326+
// Attribution, not authorization: `userId` still stays the human above, the
327+
// ceiling still travels on `permissions`/`systemPermissions`, and no
328+
// security middleware reads this. `agent?.clientId` rather than `agent` is
329+
// what narrows the optional away — the guard that made `agent` truthy is
330+
// this same property.
331+
performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined,
318332
// [ADR-0090 D10/D11 — P1 shape] No transport resolves an external
319333
// (portal/partner) audience yet; `undefined` reads as 'internal'. Named
320334
// here rather than excluded so the gap is visible in the closed set instead

packages/objectql/src/engine.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4100,14 +4100,27 @@ export class ObjectQL implements IObjectQLEngine {
41004100
* triggered it must reach the audit writer WITHOUT appearing in `session` —
41014101
* where every caller-gating hook would read them as the caller. Attribution
41024102
* here, authorization in `session`/`isSystem`, never the two mixed.
4103+
*
4104+
* `performedByClientId` rides it for the THIRD time on the same reasoning
4105+
* (#17022, ADR-0090 D10 rule 4): an MCP OAuth agent's write authorizes as
4106+
* the human it acts for, so the acting client can only reach the audit
4107+
* writer through a channel that is not `session` — putting it there would
4108+
* make every caller-gating hook read the client as the caller, which is the
4109+
* opposite of what the delegation means. This copy list is the whole reason
4110+
* the field is not inert: `HookContext` is built from a CLOSED literal whose
4111+
* `provenance` value is exactly what this function returns, so a key
4112+
* declared on `hook.zod.ts` and missing here is declared and never
4113+
* populated (ADR-0049).
41034114
*/
41044115
private buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] {
41054116
const flowRunId = (execCtx as any)?.flowRunId;
41064117
const attributedUserId = (execCtx as any)?.attributedUserId;
4107-
if (!flowRunId && !attributedUserId) return undefined;
4118+
const performedByClientId = (execCtx as any)?.performedBy?.clientId;
4119+
if (!flowRunId && !attributedUserId && !performedByClientId) return undefined;
41084120
return {
41094121
...(flowRunId ? { flowRunId: String(flowRunId) } : {}),
41104122
...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}),
4123+
...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}),
41114124
};
41124125
}
41134126

0 commit comments

Comments
 (0)