Say what started a run in the audit trail, not only whose authority it had - #386
Open
zopeVaibhav wants to merge 3 commits into
Open
Say what started a run in the audit trail, not only whose authority it had#386zopeVaibhav wants to merge 3 commits into
zopeVaibhav wants to merge 3 commits into
Conversation
zopeVaibhav
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
September 5, 2026 18:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Every audit row now names what caused it, alongside the actor it already recorded.
actor_user_idanswers on whose authority an action was taken. A routine borrows that from its owner and a hop borrows it from whoever began the conversation, so that column alone cannot say whether the person was there. Two columns are added toaudit_events:initiator_kindinitiator_idpersondeploymentroutinehandoffThe value travels inside the signed run assertion, beside
depth, for the reasondepthdoes: a hop is one run on one pod handing to another on another, and the process that knows a routine began it is the one that claimed the routine, not the one writing the row. Anything holding the assertion holds the answer, so every writer a run reaches says the same thing without each being told separately: everymcp.call_*row throughplugins/store.ts, a hop offered or refused at the desk inhandoff.ts, a hop retried, delivered or failed inhandoff-runner.ts, a Bot stopping to ask its person inescalation.ts, and a stream that stalls install-guard.ts. A Bot cannot relabel its own run, because the assertion is signed by the deployment, and a kind this deployment does not write is read back as a person rather than kept.deploymentexists so the column never overclaims. Four rows have no person behind them at any point: the boundary and isolation rows written at start-up, and the two refusals written when a caller cannot be identified at all. Without a truthful value those would take thepersondefault and the screen would assert somebody was there.The Audit screen gains a Started by column and a Nobody watching filter, which is
initiatorKind=routine,handoff. That filter asks what ran on somebody's authority while they were away, sodeploymentis deliberately outside it.Nothing about existing rows changes.
initiator_kindis NOT NULL with a default ofperson, so every row already written reads as a person, which is what it was.Closes #385.
What carries no initiator, and why that is correct
The
computer.action_*rows. The computer tools are browser actions inapp/src/lib/copilot/computer-tools.tsx, executed by the person's own session against routes guarded byrequireUser, so a headless run has no way to drive the computer today. A row there is a person's because a person's browser wrote it. When a Bot is given its computer with nobody watching, the server-side actor that path constructs is where the initiator belongs, and it is one more field on a type this change already defines.Every route behind a login that writes a row, credentials, sessions, component publishing, channels and routing, likewise keeps
person, because the actor comes from the session and a person is there.Where it runs
audit_events. No process-local state is added: the initiator is a value passed down the same call stack that already carriesactorId, and written in the sameINSERTthe row was already doing.initiator_kind = 'routine'because the value comes from the claimed row'sroutineId, not from anything held in the process that scheduled it. A hop delivered on a different replica from the one that offered it reads the initiator out of the signed assertion it was handed, so it writes what the offering replica would have. Every replica writes its owndeploymentrows at its own start-up, which is what those rows are for.audit_eventsremains append-only by trigger, which is what stops the columns being re-attributed after the fact rather than any application-level check./api/admin/audit-eventsendpoint, which returns the new fields because the reader selects the whole row.Boundary and audit
mcp.call_rejectedandmcp.call_failednow carries its initiator, the desk'sagent.handoff_refuseddoes, and the two unauthenticated boundary refusals say the deployment refused them rather than borrowing a person.initiator_kindis derived on the server at each write site and never accepted from a request body. Inside the assertion it is signed, so a Bot cannot present a run as a person's; on the way back in, a kind this deployment does not write reads as a person rather than being stored.routines.dispatch_refusedis written before the body is parsed, so that refusal cannot be made to name a routine of the caller's choosing. The one client-supplied value is the read filter, validated against the four known kinds before it reaches SQL.One choice worth naming: an unrecognised
initiatorKindfilter is ignored rather than matched, so it widens the result instead of emptying it. On an audit trail a filter that silently returns nothing reads as "nothing unattended happened", which is a false negative nobody can see. It has its own test.Changelog
CHANGELOG.mdunderUnreleased, plus a "What started a run" section indocs/architecture.mdand an update todocs/routines.md, which had this recorded as a known gap and now points at the answer.Proof
Migration
0028applied to a fresh local Postgres and checked with\d audit_events: both columns present,initiator_kindNOT NULL defaulting to'person',audit_events_initiator_time_idxcreated, and both append-only triggers still bound.deploymentneeded no second migration, since the column is text.Run end to end against a live deployment, not seeded rows. A person asked a Bot to create a routine through the browser. The worker later swept it, took the lease, dispatched it through
/internal/routines/run, and the server ran a real turn that called a granted tool:Same Bot, same plugin. A person created the routine; the routine ran itself. Before this change those rows were identical on the actor column.
The first two rows a brand-new deployment writes, on a database created from nothing:
Without the fourth kind, a fresh install would open its audit trail asserting a person acted before anyone had signed in.
The hop path, with the initiator carried through the signed assertion into the desk, on a real database across sixteen runs:
The filter, over the same data:
initiatorKind=deploymentreturns only deployment rows,initiatorKind=persononly person rows, andinitiatorKind=routine,handoffreturns routines and hops with zero deployment rows in it.Twenty new tests, the ones that touch the column against a real database because the column, its default and the filter are all SQL:
audit-initiator.integration.test.ts(9): a person is the default and needs nothing passed; a routine names the routine; a hop names the Bot that handed on; the deployment acting as itself is not filed as a person; a boundary refusal is the deployment and is not swept up by Nobody watching;initiatorKind=routine,handoffreturns both and only both; a single kind narrows; an unrecognised kind is ignored rather than returning nothing; anUPDATEre-attributing a stored row is refused by the append-only trigger.plugin-store.integration.test.ts(2): a refusal raised insidecallToolcarries the routine that asked; a call with no initiator given is still filed as a person's.agent-callback-token.test.ts(3): all four kinds survive a signed round trip; an assertion that says nothing reads as a person; a forged, malformed or unknown kind reads as a person rather than being kept.agent-handoff.test.ts(3) andagent-escalation.test.ts(2): the offered, refused and escalated rows carry what the assertion says, and a run that says nothing leaves them filed as a person's.stall-guard.test.ts(1): a stalled stream says what started its run.Existing routine and handoff tests were extended to assert the value is carried rather than dropped in transit.
The failures on both sides are in the two handoff integration files and reproduce identically on bare
main: those files do not clean up the queue rows they create, so a database that has run them before fails them on the next run. Not this change, and not something CI sees, since it gets a fresh database per run.One consequence of the trail being append-only: rows written before this change still read
A personand always will. Only rows written from here on carry the true value.