Part 3 of 40 · 7 min read
An agent may propose a decision; an action decides whether it can happen
Experimental · Work in progress · Internal R&D
How a proposed action moves through validation, policy and approval, with stale-version checks and replay-safe effects.
Arunank Sharan · Kuzushi Labs ·
In this article
“Send the customer a revised quote” sounds like one tool call. It can also change a commercial commitment, disclose pricing or contradict a negotiated exception. I want the assistant to help prepare that action without letting its confidence become permission.
The platform's action dispatcher is the shared checkpoint between agent reasoning and a recorded business change. It is already exercised by the local loop pipelines. Its production hardening is one of the main items still open in this R&D project.
The action request is explicit
The existing request model separates actor, tenant, target and arguments:
class ActionRequest(BaseModel):
action: str
tenant_id: str
principal: Principal
provenance: Provenance
object_type: str | None = None
object_id: str | None = None
args: dict[str, Any] = Field(default_factory=dict)
precondition: str | None = None
A dispatcher looks up the declared action, checks the allowed object type, checks the principal's roles, resolves an authority tier, evaluates policy, and either executes, denies or opens a human-review task. Execution uses the object runtime; allowed state transitions remain enforced there.
The distinction between deny and escalate is useful. A missing approval can be supplied. A request to operate on the wrong tenant's object should not become approvable merely because it reached a human inbox.
Six authority levels describe business intent
| Level | Example in a solo studio |
|---|---|
| Read | Inspect the accepted scope |
| Draft | Prepare a customer reply |
| Prepare | Assemble a payment batch for review |
| Execute under policy | Update a lead's qualification record |
| Execute with approval | Send a negotiated commercial commitment |
| Human only | Decide a major change to the business's authority policy |
These levels are independent of the model provider. Swapping a language model must not silently increase what the agent can do. The current repository also has an earlier action registry with read/write/destructive trust levels. That is a separate implementation surface, not an interchangeable encoding of these six levels. Consolidating the two is part of the harness work.
What an approval must bind to
An approval should mean “this actor accepted these exact consequences.” My target record includes the action, target version, canonical argument hash, tenant, approver identity, expiry and current policy version. The sketch below is a contract for the next implementation, not a claim that every field is already enforced:
{
"action": "send_quote",
"target_id": "quote-017",
"target_version": 3,
"argument_hash": "sha256:canonical-arguments",
"tenant_id": "studio-demo",
"approved_by": "founder-account",
"policy_version": "sales-2026-09",
"expires_at": "2026-09-21T09:00:00Z"
}
On resume, I would reload the object and re-evaluate policy. If the price changed, the approval is stale. If the customer opted out of that communication channel, an old approval does not overrule the new fact. Approval consumption also needs an atomic check so two workers cannot spend the same authorisation twice.
The prototype reveals where the boundary is incomplete
The current dispatcher can select a conditional authority branch from a supplied precondition string. Some custom effects omit an object type when operating across several records. The basic approval-resume path executes a held request rather than rerunning every original check. These are workable local demonstrations of the control flow, but they are not sufficient boundaries for an untrusted model or public API.
The next version should compute preconditions from stored facts, require typed effect contracts for all targets, authenticate the principal outside the request body, and revalidate held work on resume. A human label supplied by the caller is not proof of a human decision.
Retried actions need more than a cache
The dispatcher records successful idempotent results; the earlier harness also detects reuse of a key with different parameters. But an external action can succeed just before the process crashes. If the local result was not persisted, a retry can duplicate the effect.
I separate three controls: reserve an operation durably, use the provider's idempotency support where available, and reconcile ambiguous outcomes before retrying. Stripe's API documents provider-side idempotency keys and parameter comparison; that is useful external support, not a replacement for the platform's own operation record. Stripe's idempotent-request semantics.
The operator should see AWAITING_APPROVAL, DISPATCHED, CONFIRMED and OUTCOME_UNKNOWN as different states. A timeout belongs in the last category until evidence resolves it. That is how an action layer keeps the founder informed instead of producing a confident but unverified “done.”
Walk through a stale approval
The founder approves quote version 3 at S$6,000. Before the send worker wakes up, the quote changes to version 4 at S$6,500. The old approval must not cover the new message. It also must not cause the system to send the old quote after a newer version has become current.
The original small lab in the kit makes this failure executable:
from operating_lab import Lab, ReviewRequired
lab = Lab()
lab.seed("studio", "quote-17", 600000)
lab.prepare("studio", "send-17", "quote-17", 1, 600000, "sales-agent")
lab.approve("studio", "send-17", "founder", expires=200)
lab.db.execute(
"UPDATE quotes SET version=2, amount=650000 "
"WHERE tenant=? AND id=?", ("studio", "quote-17")
)
try:
lab.execute("studio", "send-17", now=100)
except ReviewRequired as error:
assert str(error) == "quote changed after approval"
The identities here are trusted fixture values. In an application, the review endpoint obtains the actor from its authenticated session and looks up the relevant permission. It must not accept actor="founder" from arbitrary request JSON. The point of the lab is to isolate the binding between a decision, its arguments and the version of the target.
Define the operation before opening the approval screen
I would bind an approval to tenant, action name, target identity, target version, canonical argument hash, policy version and expiry. The preview should be generated from those same arguments. Otherwise the founder can approve one screen while the worker executes a subtly different request.
Changing a recipient, amount, beneficiary or commercial condition changes the operation. A spelling correction inside a non-binding draft may be permitted without a new approval if the policy explicitly allows it, but that is a separate policy decision; the agent should not decide which changes “probably do not matter.”
| Event while approval is held | Required result |
|---|---|
| Quote version advances | New review against current terms |
| Approval expires | Stop; expose the expired decision |
| Actor's permission is revoked | Deny execution even if the old screen was approved |
| Provider receipt is already confirmed | Return the existing result |
| Same operation ID arrives with different arguments | Reject the conflicting reuse |
Use a ledger of attempts around the external call
There are three separate failure windows. Before the request leaves, retry may be safe. After the provider accepts it but before the acknowledgement arrives, the outcome is unknown. After the receipt is stored, replay should return that receipt. A cache of successful results only handles the last case.
The expanded kit records an UNKNOWN operation before crossing the simulated provider boundary. On restart, it queries the fake provider by operation identity and confirms the receipt without sending again. A real connector needs an equivalent provider lookup, a documented idempotency key or an operator-led reconciliation path. No database transaction can retroactively make an external send atomic.
from company_lab.store import Store
from company_lab.connectors import FakeProvider, dispatch
store, provider = Store(), FakeProvider()
payload = {"quote_version": 3, "recipient": "[email protected]"}
first = dispatch(store, provider, "studio", "send-17", payload,
lose_ack=True)
second = dispatch(store, provider, "studio", "send-17", payload)
assert first["status"] == "UNKNOWN"
assert second["status"] == "CONFIRMED"
assert len(provider.effects) == 1
Treat rejection as useful information
A refusal should explain which precondition failed and what can resolve it. “Quote changed from version 3 to 4; review the new fee” is useful. A generic tool error is not. The harness should receive a typed outcome such as requires_review, conflict, unknown_effect or denied, along with a safe explanation.
This also constrains recovery. A reasoning agent can gather missing context after requires_review; it cannot turn denied into a new tool name and try again. A new approval is a new piece of authority, not a conversational reformulation of the old request.