Let a boundary rule ask what started a run - #526
Merged
davidmckayv merged 2 commits intoSep 14, 2026
Merged
Conversation
A routine's turn goes through exactly the path a person's chat turn does, as
the routine's owner: their grants, their connections, their thread. That is the
right design, and it is why `actor.id` cannot tell a scheduled run at three in
the morning from the same person typing.
The trail already draws that distinction. `AuditInitiator` is signed into the
run assertion and written onto the row, with the docstring "what caused a row,
where `actorUserId` is only whose authority it borrowed". The boundary could not
ask the same question, so this was unwritable:
deny: initiator.kind == "routine" && intent == "run_command"
`PolicyContext` now carries `initiator`, the kind and id the trail already
records. The id is there too, so a deployment can name one routine rather than
deciding about scheduled runs as a class, and `handoff` is its own kind.
Required on the type rather than optional, and flattened to two always-present
strings. cel-js throws on an unbound identifier and a throw fails closed, so a
field that were sometimes absent would turn one rule about routines into a
deployment that refused every ordinary click — the failure CopilotKit#115 exists to
prevent. `id` is "" for the kinds that carry none, the neutral `mcp.effect`
already uses.
Set at all three places a context is built. `plugins/store.ts` is where it is
real: a routine's tools run through there carrying the initiator its assertion
was signed with. The computer gateway passes a person, and that is truthful
rather than convenient — a Bot's computer is driven by frontend tools in the
browser, so every action reaching that gateway came from somebody's session.
CopilotKit#298 is the change that makes that untrue, and the comment says so. The
history replay reads the initiator off the row when it is there.
Nothing is refused that was not refused before.
Ten tests: a routine refused by a rule naming it while the identical action with
a person is allowed, one routine named without catching another, a rule naming
the initiator leaving an ordinary click alone, and a replayed row with no
initiator or an unrecognised one reading as a person. 52 pass in
computer-policy, 13 in policy-dry-run, and computer-gateway's 60 unchanged.
Answers the half of CopilotKit#484 that survived — the rest of that issue was my own
misreading, corrected there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hotragn
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso,
mxmzb and
tylerslaton
as code owners
September 14, 2026 07:12
# Conflicts: # CHANGELOG.md
davidmckayv
approved these changes
Sep 14, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Deep-reviewed clean (validation, no secret leak, fail-closed, agrees with existing layers). CI green.
This was referenced Sep 14, 2026
davidmckayv
pushed a commit
that referenced
this pull request
Sep 14, 2026
Both places that list the fields a boundary rule can use — the Boundaries screen and docs/architecture.md — now name `initiator`, the field #526 added, noting that `actor.id` on a scheduled run is the routine's owner and cannot tell an unattended run from a person. Prose only; no behaviour change.
9 tasks
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
A routine's turn goes through exactly the path a person's chat turn does, as the routine's owner:
their grants, their connections, their thread. That is the right design, and it is why
actor.idcannot tell a scheduled run at three in the morning from the same person typing.
The trail already draws that distinction.
AuditInitiatoris signed into the run assertion andwritten onto the row (
audit.ts:426):The boundary could not ask the same question, so this was unwritable:
PolicyContextnow carriesinitiator— the same kind and id the trail already records. Adeployment happy for a Bot to run a shell while somebody watches, and not happy for it to do so
unattended, can now say so. The id is bound too, so one routine can be named rather than scheduled
runs as a class, and
handoffis its own kind for a Bot handing work to another Bot.Nothing is refused that was not refused before. Every path that does not carry an initiator reads
as a person.
Three decisions worth naming
Required on the type, not optional. cel-js throws on an unbound identifier and a throw fails
closed, so a field that were sometimes absent would turn one rule about routines into a deployment
that refuses every ordinary click — the failure #115 exists to prevent. Flattened to two
always-present strings for the same reason, with
idas""for the kinds that carry none, which isthe neutral
mcp.effectalready uses.The computer gateway passes a person, and that is truthful rather than convenient. A Bot's
computer is driven by frontend tools in the browser (
app/src/lib/copilot/computer-tools.tsx), soevery action reaching that gateway came from somebody's session — there is no headless path to it
today. #298 is the change that makes that untrue, and the comment at the call site says so, because
inheriting a hardcoded
personafter that lands would be the trail lying rather than being quiet.plugins/store.tsis where it is real. A routine's tools run through there carrying theinitiator its run assertion was signed with, already plumbed as far as
callTool. This PR is mostlyabout binding something already present rather than introducing something new.
The history replay reads the initiator off the row when it is there, and treats a row that predates
the field — or carries a shape this version does not recognise — as a person. Its own narrow reader
rather than the one in
callback-token.ts: that one narrows a value this deployment signed and cantrust, and a stored row is neither.
Answers the half of #484 that survived. The rest of that issue was my own misreading —
AuditInitiatoralready existed and I had not looked — which I have corrected on the issue.
Where it runs
request already carried.
process state — the initiator arrives on the signed assertion.
is new.
Boundary and audit
only ever refuse more, never less, and a deployment that writes none is unaffected.
rather than discovering the field only in production.
this a binding rather than a new fact.
Changelog
Added under
Unreleased, including that nothing is refused that was not refused before.Proof
Ten tests are new; the 45 existing policy tests and all 60 gateway tests are unchanged, which is what
says no decision moved. The ones worth reading:
actor, differing only in initiator. That is the whole point of the field.this field specifically.
malformed payloads, so a replay cannot be thrown by a row from a later version.
server typecheckclean.biome formatandbiome lintclean on all six changed files.Worth stating: three pre-existing errors show in my local typecheck for
@ag-ui/mastra,@mastra/client-jsandcron-parser. Those are declared inserver/package.jsonand simply absentfrom my
node_modules— my checkout was stale, not this change.What is not covered
When Give a Bot its computer when nobody is watching #298 lands, that call site needs the run's own initiator rather than the neutral, and it is
commented accordingly. I have not pre-built that seam because its shape depends on how Give a Bot its computer when nobody is watching #298 drives
the computer.
dry-run today; a picker for it is a separate change.
initiator.kindis a string a rule compares against. A typo —"routines"— is a rule thatsilently never matches, the same as any other CEL string comparison. The dry-run is the existing
answer to that, and it now binds this field.
🤖 Generated with Claude Code