I keep seeing the same failure mode in AI tooling: a model generates an action, and the same
code path that generated it also executes it. Draft an email, send it. Suggest a database
update, run it. There's no seam between "the model thinks this is a good idea" and "this
actually happened."
That seam matters more than it looks like it does, so here's the pattern I ended up building
around it, and why.
The problem with "the model decided"
If you've shipped an LLM-backed feature that does anything consequential, you've probably hit
this: the model is right most of the time, wrong occasionally, and confidently wrong often
enough to be a real problem. The usual fix is "add a human in the loop," but in practice that
gets bolted on as a confirmation dialog in the UI, which means the authorisation logic lives
in the frontend, not the system. Skip the dialog (a different client, a direct API call, a
bug), and the action just runs.
That's not a staged action. That's an unstaged action with a confirmation dialog in front of
it sometimes.
What an actual execution boundary looks like
The pattern I use treats "propose" and "execute" as genuinely separate operations with a hard
boundary between them, enforced server-side, not in whatever client happens to be calling in.
propose(action) -> staged_action_id
# writes an immutable record: what was proposed, by what, when
# does NOT perform the action
# returns an id, nothing else
authorise(staged_action_id, actor) -> bool
# checks actor against policy for THIS action type
# writes who approved it and when
# independent of propose(); a different principal can call this
execute(staged_action_id) -> result
# only runs if authorise() succeeded for this id
# the action handler never sees a raw "do this" call,
# only "execute this specific staged action"
Three things fall out of structuring it this way, and they're the actual point:
1. The model never has execute access. It calls propose(). That's the entire surface
area it has. There's no code path from "model output" to "side effect" that doesn't pass
through authorise(), because execute() refuses to run anything without a valid
authorisation record attached to that specific staged action id.
2. The audit record is a side effect of the architecture, not a logging statement someone
remembered to add. propose(), authorise() and execute() each write their own
immutable entry. You don't need a separate "make sure we log AI actions" effort, because
there's no way to execute one without the record existing first.
3. You can authorise by policy, not by UI. Since authorise() is a real function call
with its own identity and its own argument (which staged action, which actor), you can wire
it to anything: a human clicking approve, an automated policy for low-risk action types, a
second model doing a narrower risk check. The execution boundary doesn't care how
authorisation happened, only that it did, and that it's recorded against the right id.
Where this gets interesting: sealing the record
The part above is a reasonably standard staged-command pattern, nothing novel there. Where it
got more interesting for us is what backs the audit record once an action has run.
A log table that a sufficiently privileged user can edit isn't an audit record, it's a claim.
For environments where you actually need to prove what happened, not just assert it, we seal
each attestation under post-quantum signatures at write time, so an entry can be
independently verified by anyone holding the public key, without needing to trust whoever
operates the system that wrote it. We've written that part up separately if you want the
mechanism in detail: The Offline Attestation Record.
What I'd do differently if starting over
Two things bit us during implementation that are worth flagging if you're building something
similar:
-
Don't let
propose()accept anything other than a closed set of action types. We initially let it accept a free-form action descriptor for flexibility. That meantauthorise()had to parse and interpret arbitrary structures to apply policy, which is exactly the kind of ambiguity you don't want between "what was approved" and "what runs." Closed, versioned action types fixed this. -
execute()needs its own idempotency key, separate from the staged action id. A staged action can legitimately be authorised twice if your approval flow retries, and you do not want that to mean "run twice."
This is one piece of a larger pattern we use across a sovereign, on-premise AI system (every
studio, email, CRM, meetings, calls the same substrate rather than implementing its own
authorisation), but the propose/authorise/execute split stands on its own in any AI system
where "the model decided" is currently also "the model did it."
Happy to go deeper on any part of this in the comments, particularly interested if anyone's
hit different failure modes with the UI-confirmation-dialog approach I mentioned at the top.
Top comments (0)