diff --git a/.changeset/27908-action-invocation-audit-surface.md b/.changeset/27908-action-invocation-audit-surface.md new file mode 100644 index 00000000000..affd1aa5553 --- /dev/null +++ b/.changeset/27908-action-invocation-audit-surface.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/runtime': minor +'@objectstack/plugin-audit': minor +--- + +feat(spec,runtime,plugin-audit): an action run through MCP leaves an audit row naming its surface + +Clause-②: yes (widening) +Metadata-protocol: added + +The audit trail could not tell an action an agent ran over MCP from one a person clicked. Neither action door wrote a row for the invocation itself: the script branch printed a `[action-audit]` log line, the api, flow and declarative-update branches printed nothing, and the `sys_audit_log` rows for the action's own data writes do not say which door started it. + +- **`ExecutionContext.surface`** (`@objectstack/spec`): `'rest' | 'mcp'`, optional. The door an action invocation came through. Set only by the two action doors, from the door's own identity, on the envelope of the invocation they record; never client-supplied, never read by authorization. `@objectstack/core` declares it a non-entry field (identity resolution does not produce it). +- **One `action_invocation` row per invocation** (`@objectstack/plugin-audit`): the `audit` service slot gains `recordActionInvocation`, and `sys_audit_log.action` gains the value `action_invocation`. The row carries the caller (`user_id`, `actor`, `tenant_id`), the action's object and subject record, and in `metadata` the `surface`, `action_name`, `branch` (`script` / `api` / `flow` / `update`), `outcome`, the status and error code of a refusal, the `run_id` of a `resume_run`, and `performed_by` / `on_behalf_of` when an agent performed the call. No new column. +- **Both doors write it on every branch** (`@objectstack/runtime`): REST `POST /actions/:object/:action` (`surface: 'rest'`) and the `/mcp` bridge's `run_action` / `resume_run` (`surface: 'mcp'`), for every call that cleared the door's admission gates, whether the branch then answered or refused. A call refused by an admission gate (not found, `ai.exposed`, the `requiredPermissions` gate, the activation switch, the param contract, the confirmation gate) ran nothing and writes no row. The `api` branch's row is written by the door after the forward answers, so it names the true door although the target re-authenticates the caller. + +Nothing about which actions a door may run changes. The existing `[action-audit]` log line stays. To read the rows: filter `sys_audit_log` on `action = 'action_invocation'` and read `metadata.surface`. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index aa09c61c9dd..34bd3e12f25 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 251 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 252 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **251** | +| 175 write call sites | quoted in the merged changeset | **252** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **173 of 251** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 99 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **174 of 252** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 99 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -217,11 +217,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -144 of 251 (57%) as decidably elevated, with 99 more whose elevation is a +145 of 252 (57%) as decidably elevated, with 99 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 251`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 252`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **251** | -| …whose object name is statically decidable | 173 | +| write call sites on the application surface | **252** | +| …whose object name is statically decidable | 174 | | …whose object name is chosen at run time | 78 | -| …against an object with tenancy ENABLED | 172 | +| …against an object with tenancy ENABLED | 173 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 192 | +| threading a tenant context | 193 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 144 | +| threading a decidably ELEVATED (`isSystem`) context | 145 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 99 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 189 | +| receiver carried a readable engine type | 190 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 115 | +| object name spelled inline | 116 | | object name spelled through a `const` | 58 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 71 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-29 at `07b65dd40`. +Measured on 2026-10-30 at `26e3fb296`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 801 | +| tracked non-test sources scanned | 813 | | engine-shaped types recognised | 95 | -| declared objects in the registry | 122 | -| same-named calls subtracted as non-engine | 185 | +| declared objects in the registry | 123 | +| same-named calls subtracted as non-engine | 190 | {/* END GENERATED: tenant-audit-census */} diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index d6e6d00714a..87007efecc0 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -58,6 +58,7 @@ const result = BaseEngineOptionsSchema.parse(data); | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -112,6 +113,7 @@ Options for DataEngine.aggregate operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -164,6 +166,7 @@ Options for DataEngine.count operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -217,6 +220,7 @@ Options for DataEngine.delete operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -292,6 +296,7 @@ Options for DataEngine.insert operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -350,6 +355,7 @@ Query options for IDataEngine.find() operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -434,6 +440,7 @@ Options for DataEngine.update operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -507,6 +514,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -592,6 +600,7 @@ QueryAST-aligned options for DataEngine.count operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -658,6 +667,7 @@ QueryAST-aligned options for DataEngine.delete operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -720,6 +730,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | @@ -810,6 +821,7 @@ QueryAST-aligned options for DataEngine.update operations | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 177c85c02e8..b23a2479d10 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -64,6 +64,7 @@ const result = AuthGateCodeSchema.parse(data); | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; 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, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. 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. | +| **surface** | `Enum<'rest' \| 'mcp'>` | optional | The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge's run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door's own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row. | | **permissions** | `string[]` | optional (default: `[]`) | | | **systemPermissions** | `string[]` | optional | | | **tabPermissions** | `Record>` | optional | | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index ab3f80a8bd9..7b6f0b423e1 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 251 | -| Object name statically decidable | 173 | +| Write call sites | 252 | +| Object name statically decidable | 174 | | Object name chosen at run time | 78 | -| Against a tenancy-enabled object | 172 | +| Against a tenancy-enabled object | 173 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 192 | +| Threading a tenant context | 193 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 144 | +| Threading a decidably elevated context | 145 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 99 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-29 at `07b65dd40`. +Measured on 2026-10-30 at `26e3fb296`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 801 | +| tracked non-test sources scanned | 813 | | engine-shaped types recognised | 95 | -| declared objects in the registry | 122 | -| same-named calls subtracted as non-engine | 185 | +| declared objects in the registry | 123 | +| same-named calls subtracted as non-engine | 190 | ## Every site @@ -116,6 +116,7 @@ Measured on 2026-10-29 at `07b65dd40`. | `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-approvals/src/display-label-backfill.ts` | `update` | `sys_approval_action` | enabled | elevated | 1 | | `packages/plugins/plugin-approvals/src/display-label-backfill.ts` | `update` | `sys_approval_request` | enabled | elevated | 1 | +| `packages/plugins/plugin-audit/src/action-invocation-audit.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 | | `packages/plugins/plugin-audit/src/activity-changed-fields-backfill.ts` | `update` | `sys_activity` | enabled | elevated | 1 | | `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 | | `packages/plugins/plugin-audit/src/comment-parent-pair-backfill.ts` | `update` | `sys_comment` | enabled | elevated | 1 | diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index 2bb94adee65..f6dae615a99 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -464,6 +464,7 @@ describe('#6216 — the field set is CLOSED', () => { 'skipStateMachine', 'preserveAudit', 'keptOptionValues', + 'surface', ]; it('every ExecutionContext field is either assembled at the entry or declared non-entry', () => { diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index 8ed2056bbd9..442040f156d 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -95,6 +95,9 @@ import type { ResolvedAuthzContext } from './resolve-authz-context.js'; * `skipStateMachine`, `preserveAudit`, `keptOptionValues` — per-write * behaviour flags, server-constructed at the call site (`keptOptionValues` * by the import runner, from the cells one import kept). + * - `surface` — the door an ACTION INVOCATION came through (#27908), set by + * the action doors themselves on the invocation's envelope, never by + * identity resolution: the same credential reaches both doors. */ type NonEntryExecutionContextField = | 'actor' @@ -108,7 +111,8 @@ type NonEntryExecutionContextField = | 'seedReplay' | 'skipStateMachine' | 'preserveAudit' - | 'keptOptionValues'; + | 'keptOptionValues' + | 'surface'; /** * The CLOSED field set every transport entry point must decide. Derived from diff --git a/packages/plugins/plugin-audit/src/action-invocation-audit.test.ts b/packages/plugins/plugin-audit/src/action-invocation-audit.test.ts new file mode 100644 index 00000000000..2ee57f8d90b --- /dev/null +++ b/packages/plugins/plugin-audit/src/action-invocation-audit.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27908 — maintainer ruling 6513869839 on #27681, parameter 3] The + * `action_invocation` writer stores a REAL, READ-BACK ledger row that names the + * door the action came through. + * + * Every assertion reads the row back through a real ObjectQL engine, for the + * reason `auth-event-audit.test.ts` records: every `sys_audit_log` field is + * `readonly` and `validateRecord` skips readonly fields, so "the call did not + * throw" is equally true when the row landed right, landed wrong, or was never + * attempted. The door side — that both action doors hand this writer one event + * per invocation on every branch — is `runtime/src/action-invocation-audit.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { + ACTION_INVOCATION_AUDIT_ACTION, + buildActionInvocationAuditRow, + createActionInvocationAuditWriter, + type ActionInvocationAuditEvent, +} from './action-invocation-audit.js'; +import { SysAuditLog } from './objects/index.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.action-invocation-audit'; + +/** The ledger as every stack registers it since ADR-0131 D7 (no organization column). */ +const ledger = { + name: 'sys_audit_log', + label: 'Audit Log', + systemFields: { tenant: false }, + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + action: { name: 'action', label: 'Action', type: 'text' as const }, + user_id: { name: 'user_id', label: 'User', type: 'text' as const }, + actor: { name: 'actor', label: 'Actor', type: 'text' as const }, + object_name: { name: 'object_name', label: 'Object', type: 'text' as const }, + record_id: { name: 'record_id', label: 'Record', type: 'text' as const }, + old_value: { name: 'old_value', label: 'Old', type: 'textarea' as const }, + new_value: { name: 'new_value', label: 'New', type: 'textarea' as const }, + tenant_id: { name: 'tenant_id', label: 'Tenant', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Metadata', type: 'textarea' as const }, + }, +}; + +/** Minimal in-memory driver — the `auth-event-audit.test.ts` shape. */ +function makeMemoryDriver(opts: { failCreate?: Error } = {}) { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { + s = new Map(); + stores.set(obj, s); + } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { + return true; + }, + async execute() { + return null; + }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + if (opts.failCreate) throw opts.failCreate; + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row: Record = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update() { + return null; + }, + async upsert(object: string, data: Record) { + return this.create(object, data); + }, + async delete() { + return false; + }, + async count(object: string, ast: any) { + return (await this.find(object, ast)).length; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { + return []; + }, + async bulkDelete() {}, + async updateMany() { + return 0; + }, + async beginTransaction() { + return { commit: async () => {}, rollback: async () => {} }; + }, + async commit() {}, + async rollback() {}, + }; + return { driver }; +} + +async function boot(opts: { failCreate?: Error } = {}) { + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(opts).driver, true); + await engine.init(); + engine.registry.registerObject(ledger as any, OWNER_PACKAGE); + return engine; +} + +async function ledgerRows(engine: any) { + return (await engine.find('sys_audit_log', { where: { action: ACTION_INVOCATION_AUDIT_ACTION } }, { + context: { isSystem: true }, + })) as any[]; +} + +const CLICK: ActionInvocationAuditEvent = { + context: { userId: 'usr_1', tenantId: 'org_1', surface: 'rest' }, + actionName: 'close_case', + objectName: 'crm_case', + recordId: 'case_1', + branch: 'update', + outcome: 'succeeded', +}; + +const AGENT: ActionInvocationAuditEvent = { + context: { userId: 'usr_1', tenantId: 'org_1', surface: 'mcp', performedBy: { clientId: 'cli_agent' } }, + actionName: 'close_case', + objectName: 'crm_case', + recordId: 'case_1', + branch: 'update', + outcome: 'succeeded', +}; + +describe('[#27908] the action_invocation row tells an agent\'s call from a click', () => { + it('a click and an agent\'s call of the same action land as two rows that differ by surface and performer', async () => { + const engine = await boot(); + const writer = createActionInvocationAuditWriter({ getEngine: () => engine as any }); + + await writer.recordActionInvocation(CLICK); + await writer.recordActionInvocation(AGENT); + + const rows = await ledgerRows(engine); + expect(rows).toHaveLength(2); + const [click, agent] = rows.map((r) => ({ ...r, meta: JSON.parse(r.metadata) })); + for (const row of [click, agent]) { + expect(row.action).toBe('action_invocation'); + expect(row.user_id).toBe('usr_1'); + expect(row.actor).toBe('usr_1'); + expect(row.object_name).toBe('crm_case'); + expect(row.record_id).toBe('case_1'); + expect(row.tenant_id).toBe('org_1'); + expect(row.old_value ?? null).toBeNull(); + expect(row.new_value ?? null).toBeNull(); + } + expect(click.meta).toEqual({ surface: 'rest', action_name: 'close_case', branch: 'update', outcome: 'succeeded' }); + expect(agent.meta).toEqual({ + surface: 'mcp', action_name: 'close_case', branch: 'update', outcome: 'succeeded', + performed_by: 'cli_agent', on_behalf_of: 'usr_1', + }); + }); + + it('a refused invocation records the status and machine code; a resume records the run', async () => { + const engine = await boot(); + const writer = createActionInvocationAuditWriter({ getEngine: () => engine as any }); + + await writer.recordActionInvocation({ + ...AGENT, branch: 'flow', outcome: 'failed', status: 400, errorCode: 'FLOW_FAILED', runId: 'run_7', + }); + + const [row] = await ledgerRows(engine); + expect(JSON.parse(row.metadata)).toMatchObject({ + surface: 'mcp', branch: 'flow', outcome: 'failed', status: 400, error_code: 'FLOW_FAILED', run_id: 'run_7', + }); + }); + + it('an object-less action names no object and no record', () => { + const row = buildActionInvocationAuditRow({ ...CLICK, objectName: undefined, recordId: 'r1' }); + expect(row.object_name).toBeNull(); + expect(row.record_id).toBeNull(); + }); + + it('an envelope that names no surface records none — the writer never guesses one', () => { + const row = buildActionInvocationAuditRow({ ...CLICK, context: { userId: 'usr_1' } }); + expect(JSON.parse(String(row.metadata)).surface).toBeNull(); + }); + + it('an older ledger with no `actor` column is written without one', () => { + const row = buildActionInvocationAuditRow(CLICK, () => false); + expect('actor' in row).toBe(false); + }); + + it('a lost row is reported once per cause at `error`, and the writer never throws', async () => { + const engine = await boot({ failCreate: Object.assign(new Error('disk I/O error'), { code: 'SQLITE_IOERR' }) }); + const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const writer = createActionInvocationAuditWriter({ getEngine: () => engine as any, logger }); + + await expect(writer.recordActionInvocation(CLICK)).resolves.toBeUndefined(); + await expect(writer.recordActionInvocation(AGENT)).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(String(logger.error.mock.calls[0][0])).toContain('Action-invocation audit write FAILED'); + expect(logger.debug).toHaveBeenCalledTimes(1); + }); + + it('the value it writes is declared by sys_audit_log.action', () => { + const options = ((SysAuditLog as any).fields.action.options ?? []).map((o: any) => (typeof o === 'string' ? o : o.value)); + expect(options).toContain(ACTION_INVOCATION_AUDIT_ACTION); + }); +}); diff --git a/packages/plugins/plugin-audit/src/action-invocation-audit.ts b/packages/plugins/plugin-audit/src/action-invocation-audit.ts new file mode 100644 index 00000000000..4849e404bdf --- /dev/null +++ b/packages/plugins/plugin-audit/src/action-invocation-audit.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27908 — maintainer ruling 6513869839 on #27681, parameter 3] The + * `action_invocation` row in the compliance ledger: one durable row per + * business-action invocation, naming the door it came through. + * + * ## The gap this closes + * + * The ruling's sentence: "The audit row of an action run through MCP records + * the surface, so an administrator can tell an agent's call from a click + * without a second log." Before this writer, neither action door wrote a row + * for the invocation itself. The script branch printed a `console.info + * [action-audit]` line (a log line, not a ledger row), and the api, flow and + * declarative-update branches printed nothing; `sys_audit_log` only held the + * CRUD mirror's rows for whatever the action's body wrote, and those rows do + * not say which door started the action. + * + * ## Who calls it + * + * The two action doors in `@objectstack/runtime` — the REST + * `POST /actions/:object/:action` door and the `/mcp` bridge's `run_action` / + * `resume_run` tools — on every branch (script, api, flow, declarative update), + * once each call has cleared the door's admission gates and reached its + * branch, whether the branch then succeeds or refuses. They resolve this + * writer through the `audit` service slot (beside `recordAuthEvent`) and + * hand over an EVENT, never a row: the row shape is this package's, for the + * reason `auth-event-audit.ts` gives ("a second package hand-assembling + * ledger rows would be a second de-facto definition of that shape"). + * + * ## Where the surface comes from + * + * `event.context.surface` — `ExecutionContext.surface`, which the door sets + * on the invocation's envelope from its own identity. This writer copies it + * and never infers it: a row whose envelope names no surface records none + * rather than guessing one from the principal (an MCP caller may hold a + * human's API key or session, so `principalKind` cannot tell the doors apart). + * + * ## The row + * + * - `action: 'action_invocation'`; `user_id` / `actor` / `tenant_id` from the + * invocation's envelope, with the CRUD mirror's rules (`actor` stays + * two-valued, ADR-0118 D1/D5); + * - `object_name` + `record_id`: the action's object and subject record when + * it has one, so the ledger's record read gate judges the row by the record + * (`audit-log-read-visibility.ts`); an object-less action names neither; + * - no `old_value` / `new_value`: an invocation is not a field diff, and the + * params the caller sent are not recorded (a param can carry a credential); + * - `metadata`: `surface`, `action_name`, `branch`, `outcome`, and on a + * failure the HTTP `status` and the error's machine code; `run_id` for a + * `resume_run`; and `performed_by` / `on_behalf_of` when an agent performed + * the call (ADR-0090 D10 rule 4, the same pair the CRUD mirror writes). + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isMissingTableError } from '@objectstack/types'; +import { + auditFailureCauseKey, + auditFailureCauseSummary, + createFieldPresenceProbe, +} from './audit-writers.js'; +import type { AuthEventAuditLogger } from './auth-event-audit.js'; + +/** The `sys_audit_log.action` value this writer emits. */ +export const ACTION_INVOCATION_AUDIT_ACTION = 'action_invocation' as const; + +/** + * Which dispatcher the invocation reached — the four branches both action + * doors share. `update` is the declarative single-record write + * (`operation: 'update'`). + */ +export type ActionInvocationBranch = 'script' | 'api' | 'flow' | 'update'; + +/** How the branch ended: it answered, or it threw / answered a refusal. */ +export type ActionInvocationOutcome = 'succeeded' | 'failed'; + +/** + * One action invocation, in the vocabulary of what HAPPENED. The door builds + * it; this writer turns it into a row. + */ +export interface ActionInvocationAuditEvent { + /** + * The invocation's envelope: the caller's `ExecutionContext` with + * `surface` set by the door. Read for `surface`, `userId`, `actor`, + * `tenantId` and `performedBy` — nothing else. + */ + context: Pick | undefined; + /** The action's declared name (the routed name for an undeclared one). */ + actionName: string; + /** The action's object; absent for an object-less (global) action. */ + objectName?: string; + /** The subject record, when the call named one. */ + recordId?: string; + branch: ActionInvocationBranch; + outcome: ActionInvocationOutcome; + /** On a failure: the HTTP status the door answered. */ + status?: number; + /** On a failure: the error's machine code, as the door answered it. */ + errorCode?: string; + /** `resume_run` only: the paused run the call continued. */ + runId?: string; +} + +/** What {@link createActionInvocationAuditWriter} adds to the `audit` slot. */ +export interface ActionInvocationAuditWriter { + /** + * Record one invocation in `sys_audit_log`. Never throws — an audit write + * must never change the answer an action call gets — and reports a lost row + * loudly, once per cause. + */ + recordActionInvocation(event: ActionInvocationAuditEvent): Promise; +} + +export interface ActionInvocationAuditWriterOptions { + /** Resolve the data engine at CALL time — see `AuthEventAuditSinkOptions.getEngine`. */ + getEngine(): IDataEngine | undefined; + logger?: AuthEventAuditLogger; +} + +/** JSON that cannot throw on a cyclic/odd value — same rule as the CRUD writer. */ +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function nonEmpty(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +/** + * The row for one invocation — exported so the shape is pinned without an + * engine. `objectHasField` answers whether the deployed ledger has the + * conditionally-present `actor` column (`createFieldPresenceProbe`). + */ +export function buildActionInvocationAuditRow( + event: ActionInvocationAuditEvent, + objectHasField: (objectName: string, field: string) => boolean = () => true, +): Record { + const ctx = event.context ?? {}; + const userId = nonEmpty(ctx.userId); + const performedBy = nonEmpty(ctx.performedBy?.clientId); + const surface = ctx.surface === 'rest' || ctx.surface === 'mcp' ? ctx.surface : null; + + const metadata: Record = { + surface, + action_name: event.actionName, + branch: event.branch, + outcome: event.outcome, + }; + if (event.outcome === 'failed') { + if (typeof event.status === 'number') metadata.status = event.status; + const errorCode = nonEmpty(event.errorCode); + if (errorCode) metadata.error_code = errorCode; + } + const runId = nonEmpty(event.runId); + if (runId) metadata.run_id = runId; + // [ADR-0090 D10 rule 4] The same pair, under the same names, the CRUD mirror + // writes for a delegated write — present only when an agent performed the + // call, so the two shapes stay distinguishable by absence. + if (performedBy) { + metadata.performed_by = performedBy; + metadata.on_behalf_of = userId ?? null; + } + + const objectName = nonEmpty(event.objectName); + const row: Record = { + action: ACTION_INVOCATION_AUDIT_ACTION, + user_id: userId ?? null, + object_name: objectName ?? null, + // A record id with no object to read it under is a pointer nobody can + // judge, so it is dropped with the object. + record_id: objectName ? (nonEmpty(event.recordId) ?? null) : null, + old_value: null, + new_value: null, + // [ADR-0131 D7] The organization the invocation ran in — the attribution + // column the `sys_audit_log_org` row policy scopes readers on. + tenant_id: nonEmpty(ctx.tenantId) ?? null, + metadata: safeStringify(metadata), + }; + if (objectHasField('sys_audit_log', 'actor')) { + // ADR-0014 D2's principal label, by the CRUD mirror's rule: the user, else + // a service principal the host put on the envelope, else null. + row.actor = userId ?? nonEmpty(ctx.actor) ?? null; + } + return row; +} + +/** Create the action-invocation writer for the `audit` service slot. */ +export function createActionInvocationAuditWriter( + opts: ActionInvocationAuditWriterOptions, +): ActionInvocationAuditWriter { + const { getEngine, logger } = opts; + let probe: ((objectName: string, field: string) => boolean) | undefined; + let probedEngine: IDataEngine | undefined; + + /** + * Report a lost row — once per CAUSE (`auditFailureCauseKey`), the rule and + * the reasons `auth-event-audit.ts` records for its own sink. A durability + * degradation, so `error`, falling back to `warn`, never to silence. + */ + const reportedCauses = new Set(); + const reportWriteFailure = (event: ActionInvocationAuditEvent, err: unknown): void => { + const detail = String((err as any)?.message ?? err); + try { + const cause = auditFailureCauseKey('sys_audit_log', err); + if (reportedCauses.has(cause)) { + logger?.debug?.('Action-invocation audit write failed (already reported)', { + action: event.actionName, + err: detail, + cause, + }); + return; + } + reportedCauses.add(cause); + const message = + `Action-invocation audit write FAILED (${auditFailureCauseSummary(err, detail)}) — the compliance trail ` + + 'is now INCOMPLETE. The action call itself was answered exactly as it would have been without the ' + + 'ledger, so nothing downstream looks broken; only the `sys_audit_log` row recording WHICH DOOR ' + + '(REST or MCP) invoked the action, and who called it, never landed, and nothing retries it. Every ' + + 'later invocation failing THIS WAY loses its row the same way (this CAUSE is reported ONCE — raise ' + + 'the log level to `debug` to see the rest; a DIFFERENT cause gets its own `error` line). ' + + (isMissingTableError(err, 'sys_audit_log') + ? 'Fix: confirm `sys_audit_log` is reachable from the connection this write ran on — its ADR-0057 ' + + '§3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered, ' + + 'so a "no such table" here usually means the write ran against a DIFFERENT datasource than the ' + + 'one the table was created in. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on ' + + 'the primary datasource.' + : 'Fix: resolve the driver fault quoted at the head of this line on the connection this write ran ' + + 'on — every action invocation that hits it loses its row until it is resolved.'); + if (logger?.error) { + logger.error(message, err instanceof Error ? err : new Error(detail), { action: event.actionName }); + } else { + logger?.warn?.(message, { action: event.actionName, err: detail }); + } + } catch { + /* logging must never break the action's answer */ + } + }; + + /** + * Write the ledger row. A NAMED callee so `pnpm check:durability-log-level` + * anchors on it (`DURABILITY_CRITICAL_CALLEES`): a later edit cannot walk + * the failure report above back down to `warn`. + */ + const persistActionInvocationAuditRow = async ( + engine: IDataEngine, + row: Record, + ): Promise => { + await engine.insert('sys_audit_log', row, { context: { isSystem: true } }); + }; + + return { + async recordActionInvocation(event: ActionInvocationAuditEvent): Promise { + if (!event || typeof event.actionName !== 'string' || event.actionName === '') return; + const engine = getEngine(); + if (!engine) return; + if (probe === undefined || probedEngine !== engine) { + probe = createFieldPresenceProbe(engine); + probedEngine = engine; + } + const row = buildActionInvocationAuditRow(event, probe); + try { + await persistActionInvocationAuditRow(engine, row); + } catch (err) { + // #5226's class on the action seam: a DURABILITY degradation — the + // action answered, the system looks normal, and the row that says + // which door invoked it is simply absent. + reportWriteFailure(event, err); + } + }, + }; +} diff --git a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts index fecc574ae22..ec778b87505 100644 --- a/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts +++ b/packages/plugins/plugin-audit/src/audit-log-field-redaction.ts @@ -49,7 +49,7 @@ * key by key, so it is dropped whole (fail closed). * - A row of any other action is served as written. Measured per writer on * the tree this landed on: `read`, `login`, `logout`, `login_failed`, - * `account_locked` and `import` rows write no snapshot; a `config_change` row's snapshot is a DIGEST of the + * `account_locked`, `import` and `action_invocation` rows write no snapshot; a `config_change` row's snapshot is a DIGEST of the * setting, never its value (`service-settings`' `config-change-audit.ts`); * a `platform_admin_standing_change` row's is the deployment's * administrator roster (`plugin-security`). None is a parent record's field diff --git a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts index c49da4b4973..c49636dcb4b 100644 --- a/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts +++ b/packages/plugins/plugin-audit/src/audit-log-read-visibility.ts @@ -35,8 +35,9 @@ * `read` rows (`read-audit.ts`), plugin-auth's administrative `create` / * `update` on a `sys_user`, the auth-event sink's `login` / `logout`, * which name the session, and its `login_failed` / `account_locked`, which - * name the account's `sys_user` row (`auth-event-audit.ts`). Judged by the - * record gate; + * name the account's `sys_user` row (`auth-event-audit.ts`), and an + * `action_invocation` row whose action named a subject record + * (`action-invocation-audit.ts`). Judged by the record gate; * the session is in the audit writer's `AUDIT_EXCLUDED_OBJECTS`, so the gate * names it ({@link LEDGER_NAMED_PARENTS}) to keep judging it. * A record that no longer exists is read by no caller, so its rows are @@ -47,7 +48,8 @@ * - **Rows about no record** — they name no `record_id` and their action is * not a record action: the run-level `import` (plugin-auth's user import), * `config_change` (service-settings) and `platform_admin_standing_change` - * (plugin-security) rows, and an auth event that carried no session id. + * (plugin-security) rows, an auth event that carried no session id, and an + * `action_invocation` row of an action invoked with no subject record. * There is no record to judge, and none carries a record's field values * (the per-writer measurement in `audit-log-field-redaction.ts`), so they * are OUTSIDE this gate's class and are served under the ledger's own grant diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 2c67227b4d4..84693c6faaf 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -28,6 +28,7 @@ import { SysAuditLog, SysActivity, SysComment, SysCommentReaction } from './obje import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js'; import { installReadAuditWriter, type ReadAuditWriterHandle } from './read-audit.js'; import { createAuthEventAuditSink } from './auth-event-audit.js'; +import { createActionInvocationAuditWriter } from './action-invocation-audit.js'; import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js'; import { COMMENT_REACTION_REMOVE_ROUTE, @@ -307,25 +308,28 @@ export class AuditPlugin implements Plugin { // hooks, i.e. at request time, so the engine is resolved per call rather // than captured: the service exists from init() while `objectql` only // resolves at kernel:ready, and every caller arrives long after both. - ctx.registerService( - 'audit', - createAuthEventAuditSink({ - getEngine: () => { - try { - return ctx.getService('objectql'); - } catch { - // Same fallback alias `start()` uses below — some kernels register - // the engine as `data`. - try { - return ctx.getService('data'); - } catch { - return undefined; - } - } - }, - logger: ctx.logger, - }), - ); + // + // [#27908] The same slot carries the action-invocation writer + // (`recordActionInvocation`): the two action doors in `@objectstack/runtime` + // resolve it per request and hand it one event per invocation, naming the + // door it came through (ruling 6513869839 on #27681, parameter 3). + const getLedgerEngine = (): IDataEngine | undefined => { + try { + return ctx.getService('objectql'); + } catch { + // Same fallback alias `start()` uses below — some kernels register + // the engine as `data`. + try { + return ctx.getService('data'); + } catch { + return undefined; + } + } + }; + ctx.registerService('audit', { + ...createAuthEventAuditSink({ getEngine: getLedgerEngine, logger: ctx.logger }), + ...createActionInvocationAuditWriter({ getEngine: getLedgerEngine, logger: ctx.logger }), + }); // ADR-0029 D8 — contribute this plugin's object translations to the i18n // service on kernel:ready (the i18n plugin may register after this one). diff --git a/packages/plugins/plugin-audit/src/index.ts b/packages/plugins/plugin-audit/src/index.ts index 0e4a95c21ba..f592a3e24c1 100644 --- a/packages/plugins/plugin-audit/src/index.ts +++ b/packages/plugins/plugin-audit/src/index.ts @@ -10,6 +10,20 @@ export { AuditPlugin } from './audit-plugin.js'; export { createFieldPresenceProbe, installAuditWriters } from './audit-writers.js'; export { createAuthEventAuditSink } from './auth-event-audit.js'; +// [#27908] The `action_invocation` row writer the action doors reach through +// the `audit` slot (ruling 6513869839 on #27681, parameter 3). +export { + ACTION_INVOCATION_AUDIT_ACTION, + buildActionInvocationAuditRow, + createActionInvocationAuditWriter, +} from './action-invocation-audit.js'; +export type { + ActionInvocationAuditEvent, + ActionInvocationAuditWriter, + ActionInvocationAuditWriterOptions, + ActionInvocationBranch, + ActionInvocationOutcome, +} from './action-invocation-audit.js'; export { createReadAuditBatcher, extractDetailReadId, diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts index 8c7f9285004..fc778921d34 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts @@ -26,6 +26,8 @@ describe('sys_audit_log.action option labels', () => { expect(options.map((o) => o.value)).toEqual([ 'create', 'read', 'update', 'delete', 'login', 'logout', 'login_failed', 'account_locked', 'config_change', 'import', 'platform_admin_standing_change', + // #27908: a new value, added writer-first; no existing value moved. + 'action_invocation', ]); }); diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts index 67ac9194c8e..ce3eec298ac 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -94,6 +94,12 @@ const ACTIONS_WITH_WRITERS: ReadonlyArray = { account_locked: "Account Locked", config_change: "Config Change", import: "Import", - platform_admin_standing_change: "Platform Admin Standing Change" + platform_admin_standing_change: "Platform Admin Standing Change", + action_invocation: "Action Invocation" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 6ab813f503d..fcae75c5289 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -37,7 +37,8 @@ export const esESObjects: NonNullable = { account_locked: "Cuenta bloqueada", config_change: "Cambio de configuración", import: "Importar", - platform_admin_standing_change: "Cambio de acceso de administrador de plataforma" + platform_admin_standing_change: "Cambio de acceso de administrador de plataforma", + action_invocation: "Invocación de acción" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 93d77b58f35..73c001b1c68 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -37,7 +37,8 @@ export const jaJPObjects: NonNullable = { account_locked: "アカウントロック", config_change: "構成変更", import: "インポート", - platform_admin_standing_change: "プラットフォーム管理者権限の変更" + platform_admin_standing_change: "プラットフォーム管理者権限の変更", + action_invocation: "アクション実行" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index da09d32f932..c56e5f28ca3 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -37,7 +37,8 @@ export const zhCNObjects: NonNullable = { account_locked: "账户锁定", config_change: "配置变更", import: "导入", - platform_admin_standing_change: "平台管理员权限变更" + platform_admin_standing_change: "平台管理员权限变更", + action_invocation: "动作调用" } }, user_id: { diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 8dfaf574d5e..317e8038efe 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -73,6 +73,7 @@ import type { FlowFailureDetails } from '@objectstack/spec/api'; import { recordNotFoundError, omitInternalFieldsFromWriteResponse } from '@objectstack/metadata-protocol'; import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from './security/actor-user.js'; +import { actionInvocationBranch, actionInvocationFailure, recordActionInvocation, withActionSurface } from './action-invocation-audit.js'; import type { HttpProtocolContext } from './http-dispatcher.js'; import { GLOBAL_ACTION_OBJECT_KEY, @@ -3458,6 +3459,61 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, // `/actions` door emit the same `recordLoadDenied` signal to handlers. const subject = await loadActionSubjectRecord(objectName, recordId, () => callData('get', { object: objectName, id: recordId }, driver, envId, ec)); + + // [#27908 — ruling 6513869839 on #27681, parameter 3] THE INVOCATION ROW. + // Every call that cleared the gates above reaches its branch below; one + // `action_invocation` row is written for it whether the branch answers or + // throws (the subject-record verdict, a target's refusal, a body's throw), + // naming this door (`surface: 'mcp'`, set from the door's identity on a + // copy of the caller's envelope; nothing on the request can move it). A + // call a gate above refused ran nothing and writes no row. The REST + // `/actions` door writes the same row with `surface: 'rest'`. See + // `action-invocation-audit.ts`. + const invocation = { + context: withActionSurface(ec, 'mcp'), + actionName: typeof action?.name === 'string' && action.name ? action.name : name, + objectName: isObjectLessActionKey(objectName) ? undefined : objectName, + recordId, + branch: actionInvocationBranch(action, isDeclarativeUpdateAction(action)), + }; + let answer: any; + try { + answer = await dispatchAdmittedBusinessAction(deps, requestContext, name, { + action, objectName, recordId, params, subject, ec, driver, envId, callData, + }); + } catch (err) { + await recordActionInvocation(deps, requestContext, envId, { + ...invocation, outcome: 'failed', ...actionInvocationFailure(err), + }); + throw err; + } + await recordActionInvocation(deps, requestContext, envId, { ...invocation, outcome: 'succeeded' }); + return answer; +} + +/** + * The MCP `run_action` dispatch, once every admission gate in + * {@link invokeBusinessAction} has passed: the declarative update, the + * subject-record verdict, the api forward, the flow dispatch and the + * script/body dispatch. Split out so the door can write the invocation row + * for whatever this answers or throws (#27908); the code is unchanged. + */ +async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, + name: string, + admitted: { + action: any; + objectName: string; + recordId: string | undefined; + params: Record; + subject: ActionSubjectRecordLoad; + ec: any; + driver: any; + envId?: string; + callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: ExecutionContext) => Promise; + }, +): Promise { + const { action, objectName, recordId, params, subject, ec, driver, envId, callData } = admitted; const record = subject.record; // ── declarative update ── [#15079] BEFORE the `type` switch below, and diff --git a/packages/runtime/src/action-invocation-audit.test.ts b/packages/runtime/src/action-invocation-audit.test.ts new file mode 100644 index 00000000000..8c75b48b8a0 --- /dev/null +++ b/packages/runtime/src/action-invocation-audit.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27908 — maintainer ruling 6513869839 on #27681, parameter 3] "The audit + * row of an action run through MCP records the surface, so an administrator + * can tell an agent's call from a click without a second log." + * + * The DOOR-side pins: every branch an action dispatches to (script, api, + * flow, declarative update) × both doors that invoke one — REST + * `POST /actions/:object/:action` (`HttpDispatcher.handleActions`) and the + * real `/mcp` bridge (`buildMcpBridge` → `runAction` / `resumeRun`) — hands + * the `audit` slot exactly ONE invocation event, naming the door on the + * invocation's `ExecutionContext.surface`, whether the branch answered or + * refused. A call an admission gate refused ran nothing and hands none. + * + * The `audit` slot is a spy here so each case asserts the event the door + * built; the row the plugin-audit writer turns it into, read back through a + * real engine, is `plugin-audit/src/action-invocation-audit.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { API_FORWARD_MARK_HEADER } from './action-execution.js'; +import { withActionSurface, actionInvocationBranch, actionInvocationFailure } from './action-invocation-audit.js'; + +const OBJECT = 'aud_lead'; +const RECORD = 'lead_1'; +const ai = (description: string) => ({ exposed: true, description }); + +const SCRIPT = { name: 'nudge_lead', label: 'Nudge', objectName: OBJECT, type: 'script', target: 'nudge_lead', ai: ai('Nudge the lead.') }; +const API = { + name: 'ban_lead', label: 'Ban', objectName: OBJECT, type: 'api', + target: '/api/v1/aud/ban', recordIdParam: 'userId', ai: ai('Ban the lead.'), +}; +const FLOW = { name: 'escalate_lead', label: 'Escalate', objectName: OBJECT, type: 'flow', target: 'escalate_flow', ai: ai('Escalate the lead.') }; +const UPDATE = { name: 'close_lead', label: 'Close', objectName: OBJECT, operation: 'update', patch: { status: 'closed' }, ai: ai('Close the lead.') }; +/** Gated on a capability no caller below holds — the pre-dispatch refusal. */ +const GATED = { ...SCRIPT, name: 'purge_lead', target: 'purge_lead', requiredPermissions: ['aud.purge'] }; + +type Branch = 'script' | 'api' | 'flow' | 'update'; +const ACTION_OF: Record = { script: SCRIPT, api: API, flow: FLOW, update: UPDATE }; +const BRANCHES: Branch[] = ['script', 'api', 'flow', 'update']; + +/** How each branch is made to refuse AFTER dispatch, and the status that refusal answers. */ +interface Failing { script?: boolean; api?: boolean; flow?: boolean; update?: boolean } + +function makeRig(fail: Failing = {}) { + const events: any[] = []; + const audit = { recordActionInvocation: vi.fn(async (e: any) => { events.push(e); }) }; + const row = { id: RECORD, status: 'open', owner: 'u_member' }; + + const objectDef = { + name: OBJECT, + fields: { status: { type: 'text' }, owner: { type: 'text' } }, + actions: [SCRIPT, API, FLOW, UPDATE, GATED], + }; + const schemaOf = (n: string) => (n === OBJECT ? objectDef : undefined); + const ql: any = { + executeAction: vi.fn(async (_object: string, key: string) => { + if (fail.script && key === SCRIPT.target) { + throw Object.assign(new Error('The lead cannot be nudged now.'), { status: 409, code: 'CONFLICT' }); + } + return { ran: key }; + }), + getSchema: schemaOf, + registry: { getObject: schemaOf, getItem: () => undefined }, + find: vi.fn(async (_object: string, q: any) => (q?.where?.id === RECORD ? [{ ...row }] : [])), + update: vi.fn(async (object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + if (fail.update) { + throw Object.assign(new Error(`You do not have permission to update ${object}`), { code: 'PERMISSION_DENIED', status: 403 }); + } + Object.assign(row, data); + return { id: row.id }; + }), + insert: vi.fn(), + delete: vi.fn(), + }; + const request = vi.fn(async () => new Response( + JSON.stringify(fail.api + ? { success: false, error: { code: 'FORBIDDEN', message: 'The target refused.' } } + : { success: true, data: { banned: true } }), + { status: fail.api ? 403 : 200, headers: { 'content-type': 'application/json' } }, + )); + const automation = { + execute: vi.fn(async () => (fail.flow + ? { success: false, error: 'The escalation flow failed.' } + : { success: true, status: 'completed', output: {} })), + getFlow: vi.fn(async (name: string) => ({ name, type: 'screen' })), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async (n: string) => schemaOf(n)), + }; + const services: Record = { + objectql: ql, data: ql, metadata, automation, audit, + 'http.server': { getRawApp: () => ({ request }) }, + }; + const resolve = (n: string) => services[n] ?? null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const dispatcher = new HttpDispatcher(kernel); + + const ctx = (ec: Record, headers: Record = {}): any => ({ + request: { headers: { host: 'app.example.test', authorization: 'Bearer tok', ...headers } }, + environmentId: 'platform', + executionContext: { tenantId: 'org_1', systemPermissions: [], ...ec }, + }); + + /** REST `POST /actions/:object/:action` — the console's click, the SDK's call. */ + const viaRest = async (action: any, ec: Record = { userId: 'u_member' }, headers?: Record) => { + const res: any = await dispatcher.handleActions(`/${OBJECT}/${action.name}`, 'POST', { recordId: RECORD }, ctx(ec, headers)); + return res.response; + }; + /** The real `/mcp` bridge's `run_action` tool — an agent's call. */ + const viaMcp = async (action: any, ec: Record = { userId: 'u_member' }, headers?: Record) => { + const bridge = (dispatcher as any).buildMcpBridge(ctx(ec, headers)); + try { + return { ok: true, value: await bridge.runAction(action.name, { objectName: OBJECT, recordId: RECORD }) }; + } catch (err: any) { + return { ok: false, status: err?.status, code: err?.code }; + } + }; + return { events, audit, viaRest, viaMcp, ql, request, automation, dispatcher, ctx }; +} + +describe('[#27908] every branch × both doors writes ONE invocation row naming its door', () => { + it.each(BRANCHES)('%s — REST /actions: one event, surface rest, succeeded', async (branch) => { + const rig = makeRig(); + const res = await rig.viaRest(ACTION_OF[branch]); + + expect(res.status).toBe(200); + expect(rig.events).toHaveLength(1); + const [event] = rig.events; + expect(event.context.surface).toBe('rest'); + expect(event).toMatchObject({ + actionName: ACTION_OF[branch].name, objectName: OBJECT, recordId: RECORD, branch, outcome: 'succeeded', + }); + expect(event.context.userId).toBe('u_member'); + expect(event.status).toBeUndefined(); + }); + + it.each(BRANCHES)('%s — MCP run_action: one event, surface mcp, succeeded', async (branch) => { + const rig = makeRig(); + const out = await rig.viaMcp(ACTION_OF[branch]); + + expect(out.ok).toBe(true); + expect(rig.events).toHaveLength(1); + const [event] = rig.events; + expect(event.context.surface).toBe('mcp'); + expect(event).toMatchObject({ + actionName: ACTION_OF[branch].name, objectName: OBJECT, recordId: RECORD, branch, outcome: 'succeeded', + }); + }); + + it.each(BRANCHES)('%s — a refusal AFTER dispatch still writes the row, on both doors, with status and code', async (branch) => { + const rest = makeRig({ [branch]: true }); + const res = await rest.viaRest(ACTION_OF[branch]); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(rest.events).toHaveLength(1); + expect(rest.events[0]).toMatchObject({ branch, outcome: 'failed', status: res.status }); + expect(rest.events[0].context.surface).toBe('rest'); + expect(rest.events[0].errorCode).toBe(res.body?.error?.code); + + const mcp = makeRig({ [branch]: true }); + const out = await mcp.viaMcp(ACTION_OF[branch]); + expect(out.ok).toBe(false); + expect(mcp.events).toHaveLength(1); + expect(mcp.events[0]).toMatchObject({ branch, outcome: 'failed', status: out.status }); + expect(mcp.events[0].context.surface).toBe('mcp'); + expect(mcp.events[0].errorCode).toBe(out.code); + }); + + it('the subject-record verdict (a record the caller cannot read) is a refusal after admission: one failed row per door', async () => { + for (const door of ['rest', 'mcp'] as const) { + const rig = makeRig(); + rig.ql.find.mockImplementation(async () => []); + if (door === 'rest') { + const res = await rig.viaRest(SCRIPT); + expect(res.status).toBe(404); + } else { + const out = await rig.viaMcp(SCRIPT); + expect(out.status).toBe(404); + } + expect(rig.events).toHaveLength(1); + expect(rig.events[0]).toMatchObject({ outcome: 'failed', status: 404 }); + expect(rig.events[0].context.surface).toBe(door); + expect(rig.ql.executeAction).not.toHaveBeenCalled(); + } + }); + + it('a call an admission gate refused (ADR-0066 D4) ran nothing and writes NO row, on either door', async () => { + const rig = makeRig(); + const res = await rig.viaRest(GATED); + expect(res.status).toBe(403); + const out = await rig.viaMcp(GATED); + expect(out.ok).toBe(false); + expect(rig.ql.executeAction).not.toHaveBeenCalled(); + expect(rig.events).toEqual([]); + }); +}); + +describe('[#27908] the surface is the DOOR\'s — nothing on the request moves it', () => { + it('an outside REST request that forges the forward mark, a surface header and an upstream `surface: mcp` still records `rest`', async () => { + const rig = makeRig(); + // The forward mark carries no trust: any caller can send it. On an api + // action it buys the loop refusal, and the row still names the door + // the request actually came through. + const res = await rig.viaRest(API, { userId: 'u_member', surface: 'mcp' }, { + [API_FORWARD_MARK_HEADER]: 'aud_lead/other_action', + 'x-objectstack-action-surface': 'mcp', + }); + expect(res.status).toBe(400); + expect(rig.request).not.toHaveBeenCalled(); + expect(rig.events).toHaveLength(1); + expect(rig.events[0].context.surface).toBe('rest'); + expect(rig.events[0]).toMatchObject({ branch: 'api', outcome: 'failed', status: 400 }); + }); + + it('an MCP call whose envelope arrives saying `surface: rest` (an agent posing as a click) still records `mcp`', async () => { + const rig = makeRig(); + const out = await rig.viaMcp(SCRIPT, { userId: 'u_member', surface: 'rest' }, { 'x-objectstack-action-surface': 'rest' }); + expect(out.ok).toBe(true); + expect(rig.events).toHaveLength(1); + expect(rig.events[0].context.surface).toBe('mcp'); + }); + + it('the api branch\'s row names the true door after the in-process forward re-authenticates at the target', async () => { + const rig = makeRig(); + await rig.viaMcp(API); + // The forward reached the target and carried no surface: the row is + // written by the door from its own envelope, after the target answered. + expect(rig.request).toHaveBeenCalledTimes(1); + const forwarded = (rig.request.mock.calls[0] as any[])[1] as RequestInit; + expect(Object.keys(forwarded.headers as Record).some((h) => h.includes('surface'))).toBe(false); + expect(rig.events).toHaveLength(1); + expect(rig.events[0].context.surface).toBe('mcp'); + expect(rig.events[0]).toMatchObject({ branch: 'api', outcome: 'succeeded' }); + }); + + it('the request\'s own envelope is left as identity resolution built it (the door stamps a copy)', async () => { + const rig = makeRig(); + const context = rig.ctx({ userId: 'u_member' }); + await rig.dispatcher.handleActions(`/${OBJECT}/${SCRIPT.name}`, 'POST', { recordId: RECORD }, context); + expect(context.executionContext.surface).toBeUndefined(); + expect(rig.events[0].context.surface).toBe('rest'); + }); + + it('an agent\'s MCP call carries the ADR-0090 D10 performer beside the surface', async () => { + const rig = makeRig(); + await rig.viaMcp(SCRIPT, { + userId: 'u_member', principalKind: 'agent', performedBy: { clientId: 'cli_agent' }, + onBehalfOf: { userId: 'u_member', principalKind: 'human' }, + }); + expect(rig.events[0].context.surface).toBe('mcp'); + expect(rig.events[0].context.performedBy).toEqual({ clientId: 'cli_agent' }); + }); +}); + +describe('[#27908] MCP resume_run continues a run and writes its own row', () => { + function resumeRig(resumeAnswer: any) { + const rig = makeRig(); + const automation: any = rig.automation; + automation.getRun = vi.fn(async (runId: string) => ({ + id: runId, flowName: FLOW.target, status: 'paused', + trigger: { type: 'manual', userId: 'u_member', object: OBJECT, recordId: RECORD }, steps: [], + })); + automation.getSuspendedScreen = vi.fn(async () => ({ nodeId: 'screen_1', fields: [] })); + automation.resume = vi.fn(async () => resumeAnswer); + const bridge = (rig.dispatcher as any).buildMcpBridge(rig.ctx({ userId: 'u_member' })); + return { rig, bridge }; + } + + it('a resumed run records surface mcp, the admitting flow action, the run id, succeeded', async () => { + const { rig, bridge } = resumeRig({ success: true, status: 'completed', output: {} }); + await bridge.resumeRun('run_7', { values: {} }); + expect(rig.events).toHaveLength(1); + expect(rig.events[0].context.surface).toBe('mcp'); + expect(rig.events[0]).toMatchObject({ + actionName: FLOW.name, objectName: OBJECT, recordId: RECORD, branch: 'flow', outcome: 'succeeded', runId: 'run_7', + }); + }); + + it('a resume the engine refuses records a failed row with the refusal\'s status', async () => { + const { rig, bridge } = resumeRig({ success: false, code: 'INVALID_SCREEN_INPUT', error: 'missing' }); + const thrown: any = await bridge.resumeRun('run_7', { values: {} }).catch((e: unknown) => e); + expect(thrown).toBeInstanceOf(Error); + expect(rig.events).toHaveLength(1); + expect(rig.events[0]).toMatchObject({ outcome: 'failed', status: thrown.status, errorCode: thrown.code, runId: 'run_7' }); + }); +}); + +describe('[#27908] the helpers', () => { + it('withActionSurface copies and overwrites, never mutates', () => { + const ec = { userId: 'u', surface: 'rest' as const }; + const out = withActionSurface(ec, 'mcp'); + expect(out.surface).toBe('mcp'); + expect(ec.surface).toBe('rest'); + expect(withActionSurface(undefined, 'rest')).toEqual({ surface: 'rest' }); + }); + + it('actionInvocationBranch reads operation before type', () => { + expect(actionInvocationBranch({ type: 'flow', operation: 'update' }, true)).toBe('update'); + expect(actionInvocationBranch({ type: 'api' }, false)).toBe('api'); + expect(actionInvocationBranch({ type: 'flow' }, false)).toBe('flow'); + expect(actionInvocationBranch(undefined, false)).toBe('script'); + }); + + it('actionInvocationFailure reads a declared status and code, and a bare throw as 500', () => { + expect(actionInvocationFailure(Object.assign(new Error('x'), { status: 409, code: 'CONFLICT' }))).toEqual({ status: 409, errorCode: 'CONFLICT' }); + expect(actionInvocationFailure(new Error('x')).status).toBe(500); + }); +}); diff --git a/packages/runtime/src/action-invocation-audit.ts b/packages/runtime/src/action-invocation-audit.ts new file mode 100644 index 00000000000..d014ccafe5b --- /dev/null +++ b/packages/runtime/src/action-invocation-audit.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27908 — maintainer ruling 6513869839 on #27681, parameter 3] The action + * doors' half of the `action_invocation` audit row: "The audit row of an + * action run through MCP records the surface, so an administrator can tell an + * agent's call from a click without a second log." + * + * Both action doors — the REST `POST /actions/:object/:action` door + * (`domains/actions.ts`) and the `/mcp` bridge's `run_action` + * (`invokeBusinessAction`) and `resume_run` (`resumeActionRun`) — call + * {@link recordActionInvocation} once per call that cleared the door's + * admission gates and reached its branch (script, api, flow, declarative + * update), whatever the branch then answered. A call a gate refused ahead of + * that (not found, the `sys_*` / `ai.exposed` / ADR-0066 D4 / ADR-0126 + * activation gates, the param contract, the confirmation gate) writes no row: + * nothing ran. + * + * ## The surface is the door's, never the caller's + * + * {@link withActionSurface} sets `ExecutionContext.surface` on a COPY of the + * caller's envelope, from the door's own identity, overwriting whatever an + * earlier hop left there. Nothing read off the request — no header, no body + * member, no upstream envelope value — reaches it. In particular the `api` + * branch's forward mark (`API_FORWARD_MARK_HEADER`) carries no trust: any + * outside caller can send it, and its only effect is the loop refusal. + * + * ## Why the forward needs no surface header + * + * The `api` branch forwards in-process and the target re-authenticates the + * caller from its credentials, so the TARGET's envelope never carries what + * this door knew. The row does not need it to: it is written HERE, after the + * forward answered, from this door's own envelope, which the hop never + * replaces. The row therefore names the true door on the `api` branch exactly + * as on the other three. + * + * ## The writer + * + * `@objectstack/plugin-audit` owns the row shape and registers the writer on + * the `audit` service slot (`recordActionInvocation`, beside + * `recordAuthEvent`). It is reached through the structural surface below, + * so neither package depends on the other. A host with no audit plugin has + * no ledger at all, so the call is skipped silently — the same as every other + * ledger row on such a host. ⛔ The `console.info [action-audit]` line the + * script branch prints is NOT this row and is not replaced by it. + * + * Attribution only: nothing here gates, narrows or widens what a door runs. + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { resolveThrownHttpError } from '@objectstack/types'; +import type { HttpProtocolContext } from './http-dispatcher.js'; + +/** The door an action invocation came through — `ExecutionContext.surface`. */ +export type ActionSurface = NonNullable; + +/** The dispatcher the invocation reached; `update` is the declarative write. */ +export type ActionInvocationBranch = 'script' | 'api' | 'flow' | 'update'; + +/** The event the doors hand the `audit` slot's `recordActionInvocation`. */ +export interface ActionInvocationAuditEvent { + /** The invocation's envelope, `surface` set by the door. */ + context: ExecutionContext | undefined; + actionName: string; + objectName?: string; + recordId?: string; + branch: ActionInvocationBranch; + outcome: 'succeeded' | 'failed'; + status?: number; + errorCode?: string; + runId?: string; +} + +/** The `audit` slot member this file calls — declared locally, structurally. */ +interface ActionInvocationAuditSurface { + recordActionInvocation(event: ActionInvocationAuditEvent): Promise; +} + +/** The service resolution the two doors' deps both offer. */ +interface AuditSlotLookup { + resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any; +} + +/** + * The caller's envelope as THIS door's invocation: a copy with `surface` set + * from the door's identity. A copy, so the request's own envelope — which the + * gates and the dispatch read — is left exactly as identity resolution built + * it. + */ +export function withActionSurface(ec: unknown, surface: ActionSurface): ExecutionContext { + const base = ec && typeof ec === 'object' ? (ec as ExecutionContext) : ({} as ExecutionContext); + return { ...base, surface }; +} + +/** The branch an admitted action dispatches to, read in the doors' order. */ +export function actionInvocationBranch(action: any, isDeclarativeUpdate: boolean): ActionInvocationBranch { + if (isDeclarativeUpdate) return 'update'; + if (action?.type === 'api') return 'api'; + if (action?.type === 'flow') return 'flow'; + return 'script'; +} + +/** + * A thrown refusal's HTTP status and machine code, read by the ONE shared + * resolver the doors' envelopes are built from (`resolveThrownHttpError`), + * never re-derived here. A thrower that declares no status reads as 500. + */ +export function actionInvocationFailure(err: unknown): { status: number; errorCode?: string } { + const { status, code } = resolveThrownHttpError(err, 500); + return typeof code === 'string' && code.length > 0 ? { status, errorCode: code } : { status }; +} + +/** + * Hand one invocation to the `audit` slot. Never throws and never changes the + * door's answer: the writer reports its own lost rows (`plugin-audit`). + */ +export async function recordActionInvocation( + deps: AuditSlotLookup, + requestContext: HttpProtocolContext, + environmentId: string | undefined, + event: ActionInvocationAuditEvent, +): Promise { + let sink: Partial | undefined; + try { + sink = await deps.resolveService(requestContext, 'audit', environmentId); + } catch { + return; // no audit slot on this kernel: no ledger to write to + } + if (!sink || typeof sink.recordActionInvocation !== 'function') return; + try { + await sink.recordActionInvocation(event); + } catch { + // The writer never throws by contract; a host's own sink that does + // must not turn an answered action into a failed one. + } +} diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 41f3c3c4c87..b0d625af81c 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -70,6 +70,7 @@ import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage, driv // activation write below asks to name the ledger row's package. import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; import * as actionExec from '../action-execution.js'; +import { actionInvocationBranch, recordActionInvocation, withActionSurface } from '../action-invocation-audit.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; // [#25976] The one value-shape check the run doors share — the trigger and @@ -767,6 +768,61 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string await resolveActorDisplayName(() => ql, ec), ); + // [#27908 — ruling 6513869839 on #27681, parameter 3] THE INVOCATION ROW. + // Every call that cleared the gates above reaches its branch below, and + // whatever the branch answers — a result, the subject-record 404, a + // target's refusal, a body's throw mapped by the catch — is answered as + // ONE response. One `action_invocation` row is written for it, naming this + // door (`surface: 'rest'`, set from the door's identity on a copy of the + // caller's envelope; nothing on the request can move it). A call a gate + // above refused ran nothing and writes no row. See + // `../action-invocation-audit.ts`. + const answer = await dispatchAdmittedRestAction(deps, _context, { + objectName, actionName, actionDef, actionType, subject, record, recordId, reqParams, ql, ec, userFromAuth, + }); + const answered: any = answer.response; + const answeredStatus: number = typeof answered?.status === 'number' ? answered.status : 500; + const answeredOk = answeredStatus >= 200 && answeredStatus < 300; + const answeredCode: unknown = answered?.body?.error?.code; + await recordActionInvocation(deps, _context, _context?.environmentId, { + context: withActionSurface(ec, 'rest'), + actionName: typeof actionDef?.name === 'string' && actionDef.name ? actionDef.name : actionName, + objectName: actionExec.isObjectLessActionKey(objectName) ? undefined : objectName, + recordId: recordId !== undefined && recordId !== null && recordId !== '' ? String(recordId) : undefined, + branch: actionInvocationBranch(actionDef, actionExec.isDeclarativeUpdateAction(actionDef)), + outcome: answeredOk ? 'succeeded' : 'failed', + ...(answeredOk ? {} : { status: answeredStatus }), + ...(!answeredOk && typeof answeredCode === 'string' ? { errorCode: answeredCode } : {}), + }); + return answer; +} + +/** + * The REST action door's dispatch, once every admission gate in + * {@link handleActionsRequest} has passed: the declarative update, the + * subject-record verdict, the api forward, the flow dispatch and the + * script/body dispatch, with the one catch that maps a thrown refusal to the + * route's answer. Split out of the handler so the handler can write the + * invocation row for whatever this answers (#27908); the code is unchanged. + */ +async function dispatchAdmittedRestAction( + deps: DomainHandlerDeps, + _context: HttpProtocolContext, + admitted: { + objectName: string; + actionName: string; + actionDef: any; + actionType: string; + subject: actionExec.ActionSubjectRecordLoad; + record: any; + recordId: any; + reqParams: Record; + ql: any; + ec: any; + userFromAuth: ReturnType; + }, +): Promise { + const { objectName, actionName, actionDef, actionType, subject, record, recordId, reqParams, ql, ec, userFromAuth } = admitted; try { // ── declarative update ── [#15079] FIRST, ahead of the `type` switch // and ahead of the trusted-mode plumbing below. Contract point 1 reads diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 71c4b8382f7..457938d97a0 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -16,6 +16,7 @@ import type { AIActionConfirmation, IExternalDatasourceService } from '@objectst import { FEDERATION_READ_CAPABILITY, FEDERATION_WRITE_CAPABILITY } from '@objectstack/rest'; import { buildApiError } from '../error-envelope.js'; import * as actionExec from '../action-execution.js'; +import { actionInvocationFailure, recordActionInvocation, withActionSurface } from '../action-invocation-audit.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; import { readDispatchedBody } from '../deferred-request-body.js'; @@ -1130,9 +1131,61 @@ export async function resumeActionRun( ); } const { action, objectName } = admitted; + const recordId = typeof trigger?.recordId === 'string' && trigger.recordId !== '' ? trigger.recordId : undefined; + + // [#27908 — ruling 6513869839 on #27681, parameter 3] THE INVOCATION ROW, + // as `run_action` writes it: the call cleared steps 1–3, so from here on + // one `action_invocation` row is written whether the resume answers or is + // refused, naming this door (`surface: 'mcp'`, set from the door's + // identity), the admitting flow action, and the run it continued. A + // refusal in steps 1–3 resumed nothing and writes no row. + const invocation = { + context: withActionSurface(ec, 'mcp'), + actionName: typeof action?.name === 'string' && action.name ? action.name : String(flowName), + objectName: actionExec.isObjectLessActionKey(objectName) ? undefined : objectName, + recordId, + branch: 'flow' as const, + runId, + }; + let answer: any; + try { + answer = await continueAdmittedRun(deps, automation, runId, values, { + action, objectName, recordId, driver, envId, ec, callData, + }); + } catch (err) { + await recordActionInvocation(deps, context, envId, { + ...invocation, outcome: 'failed', ...actionInvocationFailure(err), + }); + throw err; + } + await recordActionInvocation(deps, context, envId, { ...invocation, outcome: 'succeeded' }); + return answer; +} + +/** + * `resume_run` steps 4–6 for the run {@link resumeActionRun} admitted: the + * subject record read again as the caller, the screen-pause check, and the + * resume itself. Split out so the door can write the invocation row for + * whatever this answers or throws (#27908); the code is unchanged. + */ +async function continueAdmittedRun( + deps: DomainHandlerDeps, + automation: any, + runId: string, + values: Record | undefined, + admitted: { + action: any; + objectName: string; + recordId: string | undefined; + driver: any; + envId?: string; + ec: any; + callData: (action: string, params: any, dataDriver?: any, scopeId?: string, ec?: any) => Promise; + }, +): Promise { + const { action, objectName, recordId, driver, envId, ec, callData } = admitted; // ── 4. the subject record, read again in the caller's own scope ────────── - const recordId = typeof trigger?.recordId === 'string' && trigger.recordId !== '' ? trigger.recordId : undefined; const subject = await actionExec.loadActionSubjectRecord(objectName, recordId, () => callData('get', { object: objectName, id: recordId }, driver, envId, ec)); actionExec.refuseDeniedSubjectLoad(objectName, recordId, subject); diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 5a9747c566a..700f4f2c986 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -193,6 +193,7 @@ "kernel/ExecutionContext:skipAutomations", "kernel/ExecutionContext:skipStateMachine", "kernel/ExecutionContext:skipTriggers", + "kernel/ExecutionContext:surface", "kernel/ExecutionContext:systemPermissions", "kernel/ExecutionContext:tabPermissions", "kernel/ExecutionContext:tenantId", diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 6047c6fb9fa..71105d8adb3 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -313,7 +313,42 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ 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.'), - + + /** + * [#27908 — ruling 6513869839 on #27681, parameter 3] WHICH DOOR an action + * invocation came through, so the audit row of that invocation tells an + * agent's call from a click without a second log. + * + * A closed vocabulary of the doors that invoke a business action today: + * + * - `rest` — the REST `POST /actions/:object/:action` door, which the + * console's action buttons and the SDK call. + * - `mcp` — the `/mcp` bridge's `run_action` and `resume_run` tools, which + * an AI client calls. + * + * A new member arrives with the door that sets it, never ahead of it. + * + * Set by those two doors, and only by them, on the envelope of the + * invocation they hand the audit writer; absent on every other operation. + * The door sets it from its own identity and overwrites whatever an earlier + * hop left there, so no request header, body member or upstream envelope + * can move it. Unlike {@link performedBy} it is set for every principal, + * a human's API key or session on `/mcp` included: the surface is a fact + * about the door, the performer a fact about the credential. + * + * **ATTRIBUTION ONLY**, exactly like {@link performedBy}: no security + * middleware reads it, it widens and narrows nothing, it never becomes an + * authorization subject, scope or gate, and which actions a door may run + * is decided by the gates each door already runs (ADR-0011 `ai.exposed`, + * ADR-0066 D4, ADR-0126). Server-constructed only, never client-supplied — + * exactly like {@link isSystem}. + * + * Recorded by the audit writer as `sys_audit_log.metadata.surface` on the + * `action_invocation` row, beside `performed_by` / `on_behalf_of` when an + * agent performed the call. + */ + surface: z.enum(['rest', 'mcp']).optional().describe('The door an action invocation came through: `rest` (the REST POST /actions/:object/:action door — console buttons, the SDK) or `mcp` (the /mcp bridge\'s run_action and resume_run tools — an AI client). Set only by those two doors, on the envelope of the invocation they hand the audit writer, from the door\'s own identity (an upstream value, header or body member never moves it); absent on every other operation. ATTRIBUTION ONLY — no security middleware reads it and it is never an authorization subject, scope or gate. Server-constructed only, never client-supplied. Recorded by the audit writer as sys_audit_log.metadata.surface on the action_invocation row.'), + /** Aggregated permission names (resolved from PermissionSet) */ permissions: z.array(z.string()).default([]), diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 447778aa919..a13cfebf4f3 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -400,6 +400,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistAuthEventAuditRow', 'The compliance audit row for a sign-in / sign-out was never written — the auth request itself succeeded and the user holds a valid session, so the API, the cookie and every counter read clean, while the `sys_audit_log` row recording WHO signed in is simply absent and nothing retries it. The shipped `auth_events` list view and the system-overview widgets read exactly those rows, so the screen an operator checks stays empty and healthy-looking (#8144, the #5226 shape on the auth seam).', ], + [ + 'persistActionInvocationAuditRow', + 'The compliance audit row for a business-action invocation was never written — the action itself answered exactly as it would have without the ledger, so the API, the data and every counter read clean, while the `sys_audit_log` row recording WHICH DOOR (REST or MCP) invoked it, and who called it, is simply absent and nothing retries it. An administrator asking whether an agent or a person ran it finds no answer (#27908, the #5226 shape on the action seam).', + ], [ 'deleteMetaItemFromLoader', 'The metadata definition was never deleted from the authoritative store — `unregister()` still resolves and still announces `deleted`, the in-memory registry entry is gone, and the surviving row is read straight back out of storage by the very next `list()`/`get()`, so the "deleted" item reappears and survives every restart. Nothing retries it (#5259).', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3b60ed0b921..13b3704a87b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4436,6 +4436,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/action-invocation-audit.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/api-key-lifecycle.pin.test.ts", "verb": "update", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index e8753b5ebb8..410238a447e 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -327,6 +327,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistAuditTrailRow', 'gate-vocabulary'], ['persistReadAuditRows', 'gate-vocabulary'], ['persistAuthEventAuditRow', 'gate-vocabulary'], + ['persistActionInvocationAuditRow', 'gate-vocabulary'], ['deleteMetaItemFromLoader', 'gate-vocabulary'], ['persistPackageCommitRow', 'gate-vocabulary'], ['persistSeedTenancyReceiptRow', 'gate-vocabulary'],