diff --git a/.changeset/17022-agent-dual-attribution.md b/.changeset/17022-agent-dual-attribution.md new file mode 100644 index 00000000000..88dceaf2783 --- /dev/null +++ b/.changeset/17022-agent-dual-attribution.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/objectql': minor +'@objectstack/plugin-audit': minor +--- + +Record the acting agent on the audit row — ADR-0090 D10 rule 4 dual attribution + +A `sys_audit_log` row written by an MCP OAuth client acting for a human used to +be byte-identical to a row that human wrote in the Console. The envelope carried +the delegation (`principalKind: 'agent'` + `onBehalfOf`), the row did not, and +nothing in between copied it: `assembleExecutionContext` consumed the OAuth +`azp` as a boolean and dropped the value, so the acting client did not exist +downstream of the door at all. + +The delegation now travels the whole way and lands on the row: + +- `ExecutionContext.performedBy` (`{ clientId }`) — decided at the `/mcp` OAuth + door, on the same branch that already decides `principalKind: 'agent'` and + `onBehalfOf`; a member of the closed entry field set like every other. +- `HookContext.provenance.performedByClientId` — the hook-layer carrier, beside + `flowRunId` and `attributedUserId`. Provenance, not `session`: no + caller-gating hook may read the client as the caller. +- `sys_audit_log.metadata` gains `{ performed_by, on_behalf_of }` on a delegated + write, and nothing at all on a personal one — the two shapes are told apart by + absence rather than by guesswork. + +Additive, and attribution only. `user_id` stays the human, so owner-stamping, +`current_user.*` RLS and the `sys_user` join are untouched (ADR-0073 D3 — +attribution is not ownership). `actor` is untouched too: ADR-0118 D1/D5 keeps +that column two-valued — a user id, or `null` for the system — and answers +"which non-user acted" with an added attribution field rather than a second +actor vocabulary. No existing row changes meaning, and no historical row is +rewritten. + +Rule 4's third element, the run id, is NOT delivered here and is not declared +either: nothing on the request path mints one today (`ExecutionContext.traceId` +is declared but resolved by no transport entry point), and declaring a carrier +nothing populates is the defect this change exists to close. diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index d4b71ee1eab..266477b513f 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -56,6 +56,7 @@ const result = BaseEngineOptionsSchema.parse(data); | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -108,6 +109,7 @@ Options for DataEngine.aggregate operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -183,6 +185,7 @@ Options for DataEngine.count operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -255,6 +258,7 @@ Options for DataEngine.delete operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -434,6 +438,7 @@ Options for DataEngine.insert operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -504,6 +509,7 @@ Query options for IDataEngine.find() operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -797,6 +803,7 @@ Options for DataEngine.update operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -909,6 +916,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -977,6 +985,7 @@ QueryAST-aligned options for DataEngine.count operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -1028,6 +1037,7 @@ QueryAST-aligned options for DataEngine.delete operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -1088,6 +1098,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -1176,6 +1187,7 @@ QueryAST-aligned options for DataEngine.update operations | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index fd5a929e8e1..10a2e73b8ed 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -96,7 +96,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **submitted** | `Record` | optional | What the caller submitted, as sent (update only) — diagnostics only, never the persist image | | **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced) | | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | -| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | +| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **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. | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | @@ -132,6 +132,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | :--- | :--- | :--- | :--- | | **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. | | **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. | +| **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. | ### Nested Shape: `HookContext.user` diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 5f97168c87f..f41e86c9403 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -41,6 +41,7 @@ const result = ExecutionContextSchema.parse(data); | **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. | | **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. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | +| **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. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 594ea836943..adffc71d3f5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -260,7 +260,7 @@ directory rather than per file. | `api/` | 451 | | `identity/` | 32 | | `integration/` | 8 | -| `kernel/` | 256 | +| `kernel/` | 257 | | `marketplace/` | 29 | | `qa/` | 6 | | `shared/` | 20 | diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index 94aed7bcd5c..93c58a85ecb 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -235,7 +235,27 @@ describe('#6216 — runtime/dispatcher face: byte-for-byte parity with the pre-# authGate: undefined, }); const before = legacyDispatcherAssembly(authz, oauth, localization, requestLocale); - expect(observable(now)).toEqual(observable(before)); + + // [#17022] The ONE argued delta from the frozen transcription, and + // it is SUBTRACTED here rather than absorbed into the transcription + // itself — the header's rule is that a legacy function is never + // "kept up to date", because the day it needs editing is the day a + // face's output changed and the change owes its own argument. + // Here that argument is ADR-0090 D10 rule 4: the agent face now + // carries the identity of the client that performed the write, and + // no other face carries anything new. + // + // Subtraction alone would be a hole big enough to hide the next + // drift in, so the key is asserted POSITIVELY on the very next + // line: present exactly on the agent branch — an authenticated + // principal whose token names a client — absent everywhere else, + // and carrying that client's id. The parity pin below is otherwise + // unweakened: every other key still compares byte-for-byte. + const { performedBy, ...restOfNow } = observable(now); + expect(restOfNow).toEqual(observable(before)); + expect(performedBy).toEqual( + authz.userId && oauth?.clientId ? { clientId: oauth.clientId } : undefined, + ); }); } } diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index 28994af91ce..5dac8581f1b 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -121,6 +121,7 @@ export const ENTRY_EXECUTION_CONTEXT_FIELDS = [ 'isSystem', 'principalKind', 'onBehalfOf', + 'performedBy', 'audience', 'userId', 'tenantId', @@ -315,6 +316,19 @@ function entryFields( isSystem: false, principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human', onBehalfOf: agent ? { userId: authz.userId!, principalKind: 'human' } : undefined, + // [ADR-0090 D10 rule 4 — dual attribution] The same `azp` that made this + // an agent principal, carried as the PERFORMER so the write is attributable + // to the client and not only to the human it acts for. Until now the value + // was consumed as a boolean here and dropped: `onBehalfOf` recorded WHO was + // delegated FROM, and nothing downstream could name WHO acted, which left + // an agent's `sys_audit_log` row byte-identical to the human's own (#17022). + // + // Attribution, not authorization: `userId` still stays the human above, the + // ceiling still travels on `permissions`/`systemPermissions`, and no + // security middleware reads this. `agent?.clientId` rather than `agent` is + // what narrows the optional away — the guard that made `agent` truthy is + // this same property. + performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined, // [ADR-0090 D10/D11 — P1 shape] No transport resolves an external // (portal/partner) audience yet; `undefined` reads as 'internal'. Named // here rather than excluded so the gap is visible in the closed set instead diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5408facd915..aa6ec36f32e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4100,14 +4100,27 @@ export class ObjectQL implements IObjectQLEngine { * triggered it must reach the audit writer WITHOUT appearing in `session` — * where every caller-gating hook would read them as the caller. Attribution * here, authorization in `session`/`isSystem`, never the two mixed. + * + * `performedByClientId` rides it for the THIRD time on the same reasoning + * (#17022, ADR-0090 D10 rule 4): an MCP OAuth agent's write authorizes as + * the human it acts for, so the acting client can only reach the audit + * writer through a channel that is not `session` — putting it there would + * make every caller-gating hook read the client as the caller, which is the + * opposite of what the delegation means. This copy list is the whole reason + * the field is not inert: `HookContext` is built from a CLOSED literal whose + * `provenance` value is exactly what this function returns, so a key + * declared on `hook.zod.ts` and missing here is declared and never + * populated (ADR-0049). */ private buildProvenance(execCtx?: ExecutionContext): HookContext['provenance'] { const flowRunId = (execCtx as any)?.flowRunId; const attributedUserId = (execCtx as any)?.attributedUserId; - if (!flowRunId && !attributedUserId) return undefined; + const performedByClientId = (execCtx as any)?.performedBy?.clientId; + if (!flowRunId && !attributedUserId && !performedByClientId) return undefined; return { ...(flowRunId ? { flowRunId: String(flowRunId) } : {}), ...(attributedUserId ? { attributedUserId: String(attributedUserId) } : {}), + ...(performedByClientId ? { performedByClientId: String(performedByClientId) } : {}), }; } diff --git a/packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts b/packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts new file mode 100644 index 00000000000..cb343d97ae8 --- /dev/null +++ b/packages/plugins/plugin-audit/src/agent-dual-attribution.integration.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17022] ADR-0090 D10 rule 4 — 「Dual attribution: every write records + * `performed_by` (agent) + `on_behalf_of` (user)」 — has a writer. + * + * ## What was measured before this file existed + * + * Two writes by the same human, one through an OAuth-connected MCP client and + * one through a plain session, produced `sys_audit_log` rows that were + * IDENTICAL on every attribution-bearing column: + * + * via OAuth MCP client : {"user_id":"u_sales_manager","actor":"u_sales_manager","metadata":null, …} + * via plain session : {"user_id":"u_sales_manager","actor":"u_sales_manager","metadata":null, …} + * attribution-bearing columns identical? true + * any column naming the client? false + * + * `assembleExecutionContext` consumed the OAuth `azp` as a BOOLEAN and dropped + * the value, so the acting client did not exist downstream of the door at all. + * This file is the inverse of that reading, asserted on both rows in one run. + * + * ## Why it drives the chain from `assembleExecutionContext` + * + * A pin written at the hook layer is VACUOUS by default. `buildSession` + * returns `undefined` when the context yields nothing session-worthy, and the + * writer's `ctx.session ?? {}` then resolves every identity read to + * `undefined` WITHOUT throwing — so an assertion that only says "the client is + * not the human" passes on the early-out, before any of this code runs. The + * control below (`user_id` is the human on BOTH rows) is what proves the + * identity channel is open, and the fixture starts at the real door so the + * envelope under test is the one a transport actually builds. + * + * ## The four seams this crosses, in one run + * + * 1. `@objectstack/core` — `assembleExecutionContext` decides + * `performedBy` on the same branch that decides `principalKind: 'agent'`; + * 2. `@objectstack/spec` — `ExecutionContext.performedBy` and + * `HookContext.provenance.performedByClientId` declare it; + * 3. `@objectstack/objectql` — `buildProvenance`'s fixed copy list carries it + * into the CLOSED hook-context literal. ⚠️ This package resolves + * `@objectstack/objectql` through its `exports` (i.e. `dist/`) by design + * — it is a registered entry in `check:test-source-alias`'s + * `KNOWN_UNALIASED_TEST_IMPORTS` — so a change to that copy list only + * reaches this suite after `pnpm --filter @objectstack/objectql build`; + * 4. this package — the row. + * + * Omitting seam 3 would leave a key declared on a published schema and + * populated by nothing, which is the ADR-0049 defect this card exists to + * close, and every assertion here would still be about a real row. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, assembleExecutionContext } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { installAuditWriters } from './audit-writers.js'; +import { SysAuditLog } from './objects/index.js'; + +/** The compliance ledger under test — the REAL shipped object, not a stand-in. */ +const LEDGER = 'sys_audit_log'; +const AUDITED_OBJECT = 'contact'; + +/** The one human on both sides. Same person, two doors — that is the whole point. */ +const HUMAN = 'u_sales_manager'; +const ORG = 'org_1'; +/** The registered OAuth client (`azp`) the MCP access token was issued to. */ +const CLIENT = 'cli_mcp_agent'; + +const AGENT_RECORD = 'c_via_agent'; +const HUMAN_RECORD = 'c_via_console'; + +/** Owning package for the harness objects — `registerObject` requires one. */ +const HARNESS_PACKAGE = 'com.objectstack.audit.test'; + +/** The shared authorization envelope. Byte-identical on both doors. */ +const AUTHZ = { + userId: HUMAN, + tenantId: ORG, + email: 'manager@example.com', + positions: ['sales_manager'], + permissions: ['contact_read', 'contact_write'], + systemPermissions: [], + org_user_ids: [HUMAN], + accessible_org_ids: [ORG], +}; + +const contactObject = { + name: AUDITED_OBJECT, + label: 'Contact', + fields: { + full_name: { name: 'full_name', label: 'Name', type: 'text' as const }, + }, +}; + +/** knex wraps some results as `[rows]`; normalize both shapes. */ +function rows(result: unknown): Record[] { + const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; + expect(Array.isArray(list)).toBe(true); + return list as Record[]; +} + +/** + * The columns the card is about: everything on a `sys_audit_log` row that can + * name WHO. `object_name` / `record_id` / `action` are excluded on purpose — + * they differ between any two writes and would make the inequality below true + * for reasons that have nothing to do with attribution. + */ +function attributionOf(row: Record) { + return { + user_id: row.user_id ?? null, + actor: row.actor ?? null, + metadata: row.metadata ?? null, + }; +} + +describe('[#17022] an MCP OAuth agent’s write is attributable to the agent, not only to the human', () => { + let kernel: ObjectKernel; + let driver: SqliteWasmDriver; + let engine: ObjectQL; + let sql: (statement: string, bindings?: unknown[]) => Promise; + + /** The two envelopes, built at the real door rather than hand-written. */ + let agentCtx: Record; + let humanCtx: Record; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + sql = (statement, bindings) => + (driver as unknown as { execute(s: string, b: unknown[]): Promise }).execute( + statement, + bindings ?? [], + ); + + engine.registry.registerObject(contactObject as any, HARNESS_PACKAGE); + engine.registry.registerObject(SysAuditLog as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + + // The REAL shipped record-level writer, not a stand-in for it. + installAuditWriters(engine, HARNESS_PACKAGE); + + // Door 1 — the `/mcp` OAuth dispatch door. `clientId` present ⇒ agent. + agentCtx = assembleExecutionContext({ + authz: { ...AUTHZ }, + oauth: { + userId: HUMAN, + scopes: ['data:read', 'data:write'], + clientId: CLIENT, + scopePermissions: ['agent_data_read', 'agent_data_write'], + delegatesActions: false, + }, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }) as unknown as Record; + + // Door 2 — the same human in the Console. No OAuth provenance at all. + humanCtx = assembleExecutionContext({ + authz: { ...AUTHZ }, + oauth: undefined, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }) as unknown as Record; + + await engine.insert( + AUDITED_OBJECT, + { id: AGENT_RECORD, full_name: 'Wei Zhang' }, + { context: agentCtx as any }, + ); + await engine.insert( + AUDITED_OBJECT, + { id: HUMAN_RECORD, full_name: 'Li Na' }, + { context: humanCtx as any }, + ); + }, 120_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + /** Both ledger rows, keyed by the record they describe. */ + async function ledgerRows(): Promise>> { + const list = rows( + await sql( + `SELECT record_id, user_id, actor, metadata FROM "${LEDGER}" WHERE object_name = ? AND action = ?`, + [AUDITED_OBJECT, 'create'], + ), + ); + const byRecord: Record> = {}; + for (const row of list) byRecord[String(row.record_id)] = row; + return byRecord; + } + + /** + * CONTROL — the door decided the field, and decided it only for the agent. + * + * Read off the envelope rather than the row, so a failure here separates + * "`assembleExecutionContext` never carried it" from "it was carried and + * something downstream dropped it". Absence is spelled as a MISSING KEY + * because `emit()` drops an `undefined` decision from the closed set. + */ + it('control — the OAuth door decides `performedBy`, and the Console door decides against it', () => { + expect(agentCtx.principalKind).toBe('agent'); + expect(agentCtx.performedBy).toEqual({ clientId: CLIENT }); + + expect(humanCtx.principalKind).toBe('human'); + expect('performedBy' in humanCtx).toBe(false); + + // The premise the card rests on: same person, both doors. If these ever + // diverged, the rows below would differ for a reason that is not the + // delegation. + expect(agentCtx.userId).toBe(HUMAN); + expect(humanCtx.userId).toBe(HUMAN); + }); + + /** + * ANTI-VACUITY CONTROL, and the one that matters most here. + * + * `buildSession` returns `undefined` for a context with nothing + * session-worthy, and the writer's `ctx.session ?? {}` then makes every + * identity read resolve to `undefined` without throwing — so a suite can go + * green on the early-out while measuring nothing. A `user_id` equal to the + * real human, on BOTH rows, can only have come through that channel. + */ + it('control — both writes reached the writer with a live identity channel', async () => { + const byRecord = await ledgerRows(); + + expect(Object.keys(byRecord).sort()).toEqual([AGENT_RECORD, HUMAN_RECORD].sort()); + expect(byRecord[AGENT_RECORD]!.user_id).toBe(HUMAN); + expect(byRecord[HUMAN_RECORD]!.user_id).toBe(HUMAN); + }); + + /** + * THE CARD. The delegated row names the agent that acted AND the human it + * acted for — rule 4's pair, on the row, in the D10 vocabulary. + */ + it('a delegated write records `performed_by` (the client) beside `on_behalf_of` (the human)', async () => { + const row = (await ledgerRows())[AGENT_RECORD]!; + + expect(JSON.parse(String(row.metadata))).toEqual({ + performed_by: CLIENT, + on_behalf_of: HUMAN, + }); + + // ADR-0073 D3 — attribution is not ownership. The human stays the subject + // the write was authorized as, so the `sys_user` lookup still joins. + expect(row.user_id).toBe(HUMAN); + // ADR-0118 D1/D5 — `actor` stays two-valued (a user id, or null for the + // system). "Which non-user acted" is answered by the ADDED attribution + // field above, never by a second actor vocabulary. A reader of historical + // rows keeps reading this column exactly as before. + expect(row.actor).toBe(HUMAN); + }); + + /** + * The other half of the contract: the two shapes are distinguishable by + * ABSENCE rather than by guesswork. A personal write claims no delegation. + */ + it('a personal write records no delegation at all', async () => { + const row = (await ledgerRows())[HUMAN_RECORD]!; + + expect(row.metadata ?? null).toBeNull(); + expect(row.user_id).toBe(HUMAN); + expect(row.actor).toBe(HUMAN); + }); + + /** + * The measured defect, inverted. This is the assertion the 2026-09-10 + * probe's `attribution-bearing columns identical? true` would have failed. + */ + it('the two rows are no longer identical on the attribution-bearing columns', async () => { + const byRecord = await ledgerRows(); + const viaAgent = attributionOf(byRecord[AGENT_RECORD]!); + const viaConsole = attributionOf(byRecord[HUMAN_RECORD]!); + + expect(viaAgent).not.toEqual(viaConsole); + // And named positively, so the failure message says WHICH column carries + // the difference rather than only that one does. + expect(viaAgent.metadata).not.toBeNull(); + expect(viaConsole.metadata).toBeNull(); + expect(viaAgent.user_id).toBe(viaConsole.user_id); + expect(viaAgent.actor).toBe(viaConsole.actor); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 37e6bf2a895..50277afe52e 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -1256,6 +1256,19 @@ export function installAuditWriters( ? (ctx as any).provenance.attributedUserId : undefined; const userId: string | undefined = sess.userId ?? attributedUserId; + // [#17022, ADR-0090 D10 rule 4] The AGENT that performed this write, when + // an MCP OAuth client acted for the human above. A THIRD channel, and the + // reason it is a separate one: `session.userId` is the delegator — the + // write authorizes as them and the record stays theirs (ADR-0073 D3) — so + // before this the delegated row was byte-identical to a row that human + // wrote in the Console, and 「这是 AI 代替人类执行」 was not recoverable + // from the ledger at all. Stamped from `provenance`, never from `session`, + // because no caller-gating hook may read the client as the caller. + const performedByClientId: string | undefined = + typeof (ctx as any).provenance?.performedByClientId === 'string' && + (ctx as any).provenance.performedByClientId.trim() + ? (ctx as any).provenance.performedByClientId.trim() + : undefined; // Principal label for attribution. Prefer the real user id; otherwise fall // back to a service/automation principal the host put on the context // (`ExecutionContext.actor`, e.g. `svc:`). This is what makes a @@ -1367,6 +1380,29 @@ export function installAuditWriters( if (objectHasField('sys_audit_log', 'actor')) { auditRow.actor = actorLabel; } + // [#17022] ADR-0090 D10 rule 4's dual attribution, recorded ADDITIVELY. + // + // Present ONLY on a delegated write, so the two shapes are distinguishable + // by ABSENCE rather than by guesswork: no key ⇒ the principal in `user_id` + // performed the write itself. + // + // ⛔ Deliberately NOT `actor`. ADR-0118 D1/D5 keeps that column + // two-valued — a `sys_user` id, or `null` for the system — and rules that + // "which non-user acted" is answered by an ADDED attribution field, never + // by a second actor vocabulary (「不在 actor 上重复表达——那是双源」). Nor + // `user_id`, which stays the human so the row keeps joining to `sys_user` + // and historical rows keep reading the same way. + // + // `on_behalf_of` is written from the same `userId` that lands in the + // `user_id` column rather than from a second carrier: rule 4 asks for both + // sides on the row, and one source for one fact is what keeps them from + // ever disagreeing. + if (performedByClientId) { + auditRow.metadata = safeStringify({ + performed_by: performedByClientId, + on_behalf_of: userId ?? null, + }); + } // [#6656] Masked, but computed fields KEPT: `recordLabel` reads // `name`/`title`/… and an object whose label field is a formula would diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 096518f82a6..82de536d174 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -180,6 +180,7 @@ "kernel/ExecutionContext:oauthScopes", "kernel/ExecutionContext:onBehalfOf", "kernel/ExecutionContext:org_user_ids", + "kernel/ExecutionContext:performedBy", "kernel/ExecutionContext:permissions", "kernel/ExecutionContext:positions", "kernel/ExecutionContext:posture", diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 8e872e9de61..4579c86434e 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -1030,10 +1030,14 @@ export const HookContextSchema = lazySchema(() => z.object({ * or the audit writer — can tell "the run / the person this write belongs * to" from "an unrelated caller". * - * Two marks, both non-authorizing, for two different questions: + * Three marks, none authorizing, for three different questions: * - `flowRunId` — WHAT produced the write (a machine origin, no person); * - `attributedUserId` — WHO is CREDITED for a write the system authorized - * (a person, but never the subject the write was authorized as). + * (a person, but never the subject the write was authorized as); + * - `performedByClientId` — WHICH AGENT performed a write authorized as the + * human in `session.userId` (ADR-0090 D10 rule 4's `performed_by`; the + * `on_behalf_of` half is that same `session.userId`, so it is not carried + * a second time here). * * Kept OUT of `session` on purpose. A writer can have provenance and no * identity at all — a schedule-triggered flow run resolves no principal — and @@ -1047,6 +1051,7 @@ export const HookContextSchema = lazySchema(() => z.object({ provenance: z.object({ flowRunId: z.string().optional().describe('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.'), attributedUserId: z.string().optional().describe('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.'), + performedByClientId: z.string().optional().describe('[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.'), }).optional().describe('Server-stamped write provenance (never client-supplied, never an authorization input)'), /** diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index edc468ad407..4c09edd7041 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -192,6 +192,55 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ userId: z.string(), principalKind: z.enum(['human', 'agent', 'service', 'guest', 'system']).optional(), }).optional(), + + /** + * [ADR-0090 D10 rule 4 — dual attribution] WHO performed this operation, + * when the performer is not the principal the write is authorized as. + * + * Set at the ONE door that resolves an agent — the `/mcp` OAuth dispatch + * door, whose access token names an authorized client (`azp`) — on exactly + * the branch that already decides `principalKind: 'agent'` and + * {@link onBehalfOf}. Absent on every other provenance, and the ABSENCE is + * the record: a write carrying no `performedBy` was performed by the + * principal itself, so the delegated and the personal write are told apart + * by a key being there rather than by guesswork. + * + * The pair rule 4 names is deliberately asymmetric on this envelope, because + * the envelope already carries one half: {@link userId} stays the human, so + * owner-stamping and `current_user.*` RLS keep resolving to them (ADR-0073 + * D3 — attribution is not ownership), and this names the client that acted + * for them. {@link onBehalfOf} states the same delegation from the + * AUTHORIZATION side (it is what the confused-deputy intersection reads); + * this states it from the ATTRIBUTION side, which is the side the audit + * writer reads. + * + * **ATTRIBUTION ONLY**, exactly like {@link actor} and + * {@link attributedUserId}: nothing in the authorization path reads it, it + * neither widens nor narrows what the write may touch, and it never becomes + * the subject the write is authorized as. The agent's own ceiling travels on + * `permissions` / `systemPermissions`, which the door already replaces. + * + * Server-constructed only, never client-supplied — exactly like + * {@link isSystem}. + * + * Surfaced to hooks as `HookContext.provenance.performedByClientId` and + * recorded by the audit writer in `sys_audit_log.metadata` as + * `performed_by` beside `on_behalf_of`. ⛔ It does NOT move `actor`: + * ADR-0118 D1/D5 keeps that column two-valued (a user id, or `null` for the + * system) and answers "which non-user acted" with an ADDED attribution + * field — this one — never with a second actor vocabulary. + * + * A one-key object rather than a bare string so the API-key door (#18335, + * blocked on this carrier) can name its own identifier as a sibling key if + * it is ruled an agent, without re-shaping a field that already shipped. + */ + performedBy: z.object({ + /** + * The OAuth client (`azp`) that performed the operation — the id of the + * registered `sys_oauth_application` the access token was issued to. + */ + clientId: z.string(), + }).optional().describe('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.'), /** Aggregated permission names (resolved from PermissionSet) */ permissions: z.array(z.string()).default([]),