Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/27908-action-invocation-audit-surface.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 17 additions & 17 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
Expand Down Expand Up @@ -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 */}
Loading
Loading