Motivation
A language model can propose an action; it must never be able to authorize one. Two failure modes make that more than a slogan.- The proposal is attacker-influenced text. Anything the model reads —
a retrieved chunk, a web page, a tool result — can steer the arguments it
proposes. If the same structure that carries the arguments also carries
tenant_id,principal_idorgrant, a prompt injection can ask for a different tenant or a stronger grant. - Approval and effect are separated in time. A person approves at 10:00 and the effect runs at 10:07, or on a queue worker at 14:00. In between, the arguments can be swapped, the approver’s grant can be revoked, the approval can lapse, and the worker can be running for another tenant. An approval that is merely “a token that was clicked” authorizes none of those cases correctly.
Theory & background
Three well-known ideas are combined.- The confused deputy. A privileged component (here: the server executing a tool) acts on instructions from a less-privileged one (the model). The cure is to make the authority — tenant, principal, grant, assurance level — a property the server attaches, never a field the proposer can set.
- Time-of-check to time-of-use (TOCTOU). Authorization checked at approval time is not authorization at effect time. The cure is to re-check at the last moment before the effect against current state, not to trust a stored decision.
- Canonical form. To say “this approval is for exactly this action” you need one byte-exact representation of the action, so equal actions hash equal and different actions hash different. The canonicalizer sorts object keys recursively, preserves list order and hashes UTF-8 JSON with SHA-256, in the spirit of JSON canonicalization schemes such as RFC 8785 (it is not a full implementation of that standard).
Design
An action travels through three stages. Each stage re-derives what it needs from server state instead of trusting what the previous stage carried. The approval service has three operations whose order is enforced by state, not by the caller’s good behaviour.Data model / contract
The intent
App\Actions\ActionIntent is a readonly value object, schemaVersion
action-intent.v1.
Classification lives in
config/actions.php. A tool absent from the registry,
or present without a non-empty effect and executable => true, is
non-executable: the widget /exec-tool path answers 422 with
error: tool_not_classified after the existing skill allowlist and
session/tenant gates have already passed.
What the approval is bound to
The approval digest is the SHA-256 of one canonical JSON document with these fields:action_id, schema_version, tenant_id, principal_id, resource,
effect, args_digest, target, grant, assurance. Two properties matter:
- the arguments are digested from
argsitself each time; the publicargsDigestfield is not trusted, so an intent rebuilt with other arguments and the old digest does not match; - the document is structured JSON, not a delimiter-joined string, so a character
inside
resourceortargetcannot slide into a neighbouring field.
flow_approvals, step action-intent-approval) stores
only digests and routing data — execution_id, action_id,
schema_version, tenant_id, principal_id, action_digest, target_digest.
The arguments are never persisted in the approval. The row’s own
tenant_id column, stamped by the host from the ambient tenant, must equal the
tenant in the payload and the intent’s tenant.
The audit trail
Every denial that can be attributed to an approval row is appended toflow_audit (append-only) on that row’s run and tenant:
The payload never contains the token, the arguments or free text from the
proposal. A failed audit write propagates instead of being swallowed.
Knobs
There is no feature flag: the primitive is inert until a caller adopts it. The one setting that shapes behaviour is Flow’s approval lifetime,LARAVEL_FLOW_APPROVAL_TOKEN_TTL_MINUTES (config key
laravel-flow.approval.token_ttl_minutes, default 1440). A receipt is valid
until the same expiry as the approval it came from. LARAVEL_FLOW_STORAGE
selects the Flow connection; the service uses it for the run lookup and the
audit write exactly as the host’s run repository does.
Decision rationale (ADR-style)
Reuse Flow’s approval tokens instead of a new table. Flow already provides hash-at-rest tokens and an atomic single-use consume, and the approval inbox already exists. A parallel store would duplicate the one invariant that is hard to get right (atomic consume) and fragment the audit. The cost is coupling to Flow’s row shape, accepted because the service reads it only through the token manager and its model. Persist digests, never arguments. The approval is evidence that a specific action was approved, not a copy of the action. Storing arguments would put model-proposed, possibly sensitive data into an inbox and an audit table with different retention. The cost: the executor must present the intent again, and a mismatch means refusal, not repair. Digest the structure, derive the argument digest. Joining fields with a delimiter, or trusting a caller-supplied argument digest, are the same mistake — treating a representation as the thing it represents — and either lets a different action share an approval. Canonical JSON over the real fields removes the class of bug rather than one instance of it. Re-verify at the last moment, and refuse by default.approve and
verifyForExecution both call the caller’s current-ACL callback and accept only
an exact true: a denied Response object or a message string is truthy and
therefore not an allow, and a callback that throws is a refusal. Expiry is
enforced on the approved row because Flow only expires rows that are still
pending.
Three-way tenant agreement. Flow stamps the approval row from the ambient
tenant while the intent carries its own. Trusting either alone lets a row owned
by one tenant carry a payload bound to another, or lets a queue worker with a
stale ambient tenant verify another tenant’s receipt. The service therefore
requires the ambient tenant, the persisted row and the payload to agree, and
requires the parent run to belong to that tenant before issuing.
Verification is a pure check; exactly-once is the gateway’s job.
verifyForExecution does not record that the execution identity was spent. A
unique reservation of execution_id belongs next to the effect, in the same
transaction as the side effect, which is where the gateway sits. Putting it in
the service would claim a guarantee it cannot honour for effects it does not
perform.
Alternatives that do not fit: a signed, self-contained receipt (it would not see
revocation or an ACL change after signing), and a check expressed in the model
prompt (a prompt is guidance, not authorization). The human-gated boundary this builds on is the one in
ADR 0003;
the widget identity it sits behind is in
ADR 0027.
Worked example
A tool call proposes searching one project’s knowledge base. The server builds the intent, the approver confirms, and the gateway verifies before running.approve and verifyForExecution,
or the arguments are altered, or the approval has lapsed, verifyForExecution
returns false and one row is appended:
Gotchas & operations
- Not wired yet. No MCP, Flow or widget executor calls the service today.
The only enforced piece in production is the classification gate on the widget
/exec-toolpath. Treat the approval service as a contract the gateway adopts. - A receipt verifies repeatedly. Within its validity window the same receipt
returns
trueevery time. Until the gateway reservesexecution_idunder a database uniqueness constraint, no caller may treat one successful verification as permission to run an effect more than once. - The ACL callback must return exactly
true. Return a boolean from your policy check; do not return aResponseobject or a string. - Match the ambient tenant. On a queue worker, set
TenantContextfrom the job’s immutable tenant before callingapproveorverifyForExecution; a mismatch is refused and audited asbinding_mismatch. - Pin the run and step in the gateway. Flow’s engine and cockpit consume path can replace an approval’s stored payload; the service then denies (fail closed) but the approval is spent.
- Do not wrap denials in a transaction you may roll back. The denial audit row is written on the same connection; a rollback discards it.
- Unknown tokens are not audited by the service. A token Flow does not know has no run to attribute a denial to; the gateway records that refusal.
evidenceRefsand key order. Evidence references are not part of the digest, and object key order does not change it, but list order does.