Lockrail - the agent was sure. The money moved anyway.
An agent working a support queue calls refund(order_id="ORD-8871", amount=1500).
The order id is real. It belongs to a different customer. The tool refunds it.
Nothing malfunctioned. The model emitted a well-formed call, the schema accepted it, the processor did what it was asked, and the audit log recorded all of it faithfully - about a second after the money was gone.
One valid call, one wrong customer, and no layer in the stack whose job was to ask.
- What it is. A transactional runtime that sits between an LLM agent and the tools it can call.
- What it does. Every tool call runs an ordered gate pipeline - idempotency, evidence, policy, human approval - and reaches the executor only if it survives. Every gate decision is event-sourced into Postgres, so a blocked call leaves the same forensic trail as an executed one.
- What the evals say. Of 140 scenarios, 33 are labeled unsafe: all 33 execute without Lockrail, none do with it. Of 200 replayed webhook submissions, 190 never reach the executor.
- The decision I'd defend hardest. The runtime imports neither MCP nor FastAPI nor any agent framework. It takes a
Callable[[ToolCall], Awaitable[dict]]. Everything else is a caller.- Get it.
docker compose -f docker/docker-compose.yml up -d && uv run python evals/run.py --metric all- four numbers in about five seconds. github.com/egehazar/lockrail.
Here is the whole argument in one run of the demo script, unedited. Watch for the executor line - it appears once, in step 4, after a human has granted the call:
$ uv run python scripts/demo_transaction.py
--- 1. Submit a $1500 refund (over the $500 approval threshold) ---
status=pending_approval tx_id=25383631-cc8e-4af6-9f33-c08059cf074e
halted by gate='policy' reason='amount=1500 gt 500.0'
--- 2. List pending approvals (operator view) ---
approval=0ec87298-e427-4218-bc2d-9d2a5cfa7ddb tool=refund
--- 3. Grant the approval (operator action) ---
status=granted resolver=ops-cli
--- 4. Resume the transaction ---
[executor] refunding $1500 for order ORD-42
status=executed output={'refunded': True, 'amount': 1500}
new tx_id=9d9d0fc9-46c7-4675-a026-a1edd2c0c46b resumed_from=25383631-cc8e-…
Why this isn't a prompting problem
You can prompt a model into being careful. You cannot prompt it into being deterministic, and the two failure modes that actually bite production agents are both determinism problems.
The first is the hallucinated write: a call that is syntactically perfect and semantically wrong. The second is the accidental retry - a webhook redelivered, a job requeued, a timeout that was secretly a success. Neither is a generation-time failure. The first has already been generated; the second doesn't involve the model at all.
So the layer that catches them can't be made of instructions. It has to be code that doesn't care what the model said, and asks only whether this call, from this actor, with these arguments, is safe to execute right now. Given the same actor, tool, arguments, gates, and world state, Lockrail returns the same decision every time. A prompt structurally cannot promise that.
SKIP is not ALLOW
A gate returns one of four decisions, and the fourth took me a while to see the need for.
| decision | meaning |
|---|---|
ALLOW | this gate checked the call and is satisfied |
DENY | this gate checked the call and refuses it |
REQUIRE_APPROVAL | not this gate's call to make - a human decides |
SKIP | this gate has no opinion; no rule here applies |
SKIP exists because ALLOW is a claim. If the policy registry holds no
rules for send_email and the gate returns ALLOW, the audit log now contains a
row asserting that policy evaluated the call and approved it. It didn't. It had
nothing to say. Six months later, mid-incident, "policy allowed this" and "policy
had no rules for this" are very different sentences and only one of them is true.
So a gate with nothing to say says nothing.
The pipeline runs in priority order and halts on the first blocking decision. The alternative - run everything, aggregate the reasons - is tempting, and I decided against it because the audit log is the reason the system exists. A halt gives every blocked call exactly one cause: this gate, this rule, this reason. Aggregation produces rows where three explanations are simultaneously true and nobody can tell which one actually stopped the call.
That choice has a real cost. Sometimes you want the complete set of reasons - for
tuning policy, or for answering "what else would have caught this?" The fix isn't
to weaken the enforcement path; it's a separate analyze mode that runs every
gate deliberately. That's written down as a follow-up rather than quietly folded
into production behavior.
Gates also can't take the runtime down with them. The base class wraps
_evaluate and converts any exception into a DENY carrying the exception type,
so a gate that crashes fails closed - and the call that tripped it gets an
audit row naming the broken gate, which beats a stack trace in a log file
somewhere.
Idempotency is a canonicalization problem
The idempotency gate hashes (agent_id, tool_name, args) into a SHA-256
fingerprint and looks it up in Redis. Hit: the agent gets the prior result and the
tool is never invoked. Miss: the call proceeds and the result is stored against
that fingerprint with a TTL.
The part that matters is one line - the fingerprint is computed over sorted-key
canonical JSON. Without it, refund(amount=100, order="X") and
refund(order="X", amount=100) hash differently: the same call, twice, counted as
two. Models reorder keyword arguments constantly and for no reason. Canonicalizing
makes that quirk stop mattering, and it is the entire reason the replay number is
95% rather than something embarrassing.
The subtler decision is what doesn't get cached. Only EXECUTED results do:
- Blocked calls aren't cached. The policy that blocked it may have changed. Caching a denial freezes a rule that no longer exists.
- Failed calls aren't cached. A failure the agent could retry through shouldn't be made permanent by the safety layer.
- Pending-approval calls aren't cached. Caching one would let the second identical call sail past the human review the first is still waiting on - the approval gate defeating itself.
Each is one condition in the code, and each is a different incident that doesn't happen.
Approval is two calls, deliberately
When a policy returns REQUIRE_APPROVAL, the transaction stops at
PENDING_APPROVAL and an approval row is persisted beside it. An operator calls
POST /approvals/{id}/grant. Separately - possibly much later, possibly from a
different process - something calls POST /transactions/{id}/resume, which
verifies the grant, reconstructs the original tool call, and executes it.
The obvious simplification is one endpoint that grants and executes. Two reasons I didn't:
The approver and the executor are different roles. Whoever is authorized to say "yes, refund this" is frequently not the process that should decide when the side effect fires. Folding them assumes one human workflow and bakes it into the API.
An approval is durable; an execution is replayable. A grant on Monday can be resumed on Tuesday, after the agent crashed or the downstream tool came back. One endpoint throws that away: if execution fails, the authorization dies with it and a human gets asked the same question twice.
A detail I got wrong first time around: resume() reuses the original
tool_call_id but mints a new transaction_id. Call identity belongs to the
agent - it submitted this one call, and the call survives the pause. Transaction
identity belongs to the runtime, and each pipeline run is its own. Collapsing them
meant the resumed transaction aliased the original's primary key, which either
overwrites the audit history or crashes on insert. Neither is a good outcome for a
system whose whole value proposition is the audit history.
One honest limitation: resume() doesn't currently re-run the gates. It trusts
the evaluation that produced the approval, because the approval was granted
against that exact transaction. Defence in depth says production re-evaluates,
since policy may have moved in the interval. Documented as a v2 item, not buried.
The runtime doesn't know what MCP is
Runtime.__init__ takes gates, an executor callable, an idempotency store, a
session factory, and an approval-repository factory. That's the entire surface. No
MCP import, no FastAPI import, no LangGraph import.
The executor is whatever the caller needs - an MCP client call, an HTTP request to an internal service, a plain function. That's what stops Lockrail from being "the MCP thing" or "the FastAPI middleware." It's a runtime, and the three integrations in the repo are all callers of the same object.
Where the decision earns out is the MCP surface, where a single Pydantic model per
tool is the source of truth for both the inputSchema the agent sees and the
contract the evidence gate validates against. Same class, two projections.
Being DRY is the small reason. The real one: any drift between the advertised schema and the enforced schema is precisely the bug this project exists to prevent. If the server advertised one shape and the gate enforced another, an agent could send arguments that satisfy the protocol and fail validation - and the carefully structured per-field error the gate produces would never reach it, because the SDK would have rejected the call first. Or the inverse: arguments that pass validation but violate the advertised schema, so the agent's self-correction aims at a shape nobody checks. One model makes both unrepresentable.
That's also why the SDK's own validation is switched off with
validate_input=False. Not because it's bad - because two validators means two
answers to "why was this rejected," and the gate's answer is the one structured
for JSONB persistence and for the agent to retry against. Letting the SDK reject
first would leave the gate's richest code path untested on exactly the calls it
was written for.
Four bugs that never show up in an architecture diagram
isinstance(True, int) is True. bool subclasses int, so an
amount-threshold check written as a plain numeric comparison happily accepts
amount=True - which models do emit when a schema is ambiguous. True > 0 holds,
and a boolean walks through a financial threshold. The fix is one
not isinstance(x, bool). The bug is invisible until it isn't.
Pydantic's ValidationError.errors() is not JSONB-safe. Each error dict can
carry model classes, callables, or raw input in its input and ctx fields.
Writing that straight into a JSONB column passes every happy-path test and fails
the first time a real tool sees an unusual argument. Projecting to
{loc, msg, type} fixes it - and that projection is also exactly what the agent
needs in order to retry. Everything past those three keys was noise.
Alembic doesn't drop named Postgres enums on downgrade. Autogenerate writes
the create_table for a column typed sa.Enum(..., name="approval_status") and
silently omits the matching drop. The downgrade appears to succeed. The next
upgrade head fails with "type already exists," pointing at the wrong migration.
Hand-edit the downgrade.
ASGITransport doesn't run FastAPI's lifespan.
httpx.AsyncClient(transport=ASGITransport(app=app)) never fires the startup
handler, so app.state.runtime is never wired and every request 500s in a way
that looks like a routing bug. async with app.router.lifespan_context(app):
fixes it. TestClient does this for you and AsyncClient does not - the kind of
asymmetry that costs an hour exactly once.
None of these are interesting architecture. All four are the actual work.
What the evals measure - and what they don't
Four numbers from two synthetic scenario sets, re-run against a clean stack while writing this:
| metric | baseline | with Lockrail | what's ablated |
|---|---|---|---|
| unsafe writes | 23.6% (33/140) | 0.0% | all gates vs. full pipeline |
| duplicate prevention | 0 of 200 stopped | 95.0% (190/200) | idempotency gate only |
| task completion (dedicated set) | 61.0% | 81.0% (+20pp) | evidence gate only |
| task completion (standard set) | 76.4% | 85.7% (+9.3pp) | evidence gate only |
Three things about that table matter more than the numbers in it.
Each metric ablates exactly one gate. The baseline for duplicate prevention is the full pipeline minus the idempotency gate - not "no Lockrail." Otherwise the number measures the system rather than the component, and the attribution is a guess. The one exception is unsafe writes, whose baseline strips every gate on purpose, because that claim genuinely is about the system versus its absence.
61 and 81 are pinned, not observed. A test asserts those exact integers against the dedicated set. If a scenario drifts - an unrecoverable one becoming policy-permitted, a recoverable one accidentally becoming safe - the suite fails before the commit lands. Numbers in a README rot silently. These can't.
Two completion measurements exist because one distribution can't serve both metrics honestly. Unsafe-writes needs roughly a quarter of scenarios labeled unsafe; completion needs a population where an evidence-driven retry loop can demonstrably help. Forcing both onto one set compromises one of them, and in the original 150-scenario design safety won that fight. So there's a dedicated 100-scenario set: 61 trivially passing, 20 recoverable through evidence-driven retry, 19 unrecoverable by construction. Those 19 are the ceiling that stops the metric being gameable to 100%. Both numbers are published because they answer different questions - and reporting only the flattering one would have been a choice too.
The suite is 98 tests, 69 unit and 29 integration, running in about a second against live Postgres and Redis. One of them is the regression test that pins the constants above.
Limitations
- Every scenario is synthetic. The evals show the gates behave as designed against a failure surface I chose. They prove nothing about production agent traffic, and I'd distrust anyone claiming otherwise from a suite like this.
- The "smart agent" retry loop is hard-coded, so the completion numbers are an upper bound on what an evidence-aware agent should achieve, not a measurement of one. Real model behavior adds interpretation noise that closes some of the gap. Until the suite is replayed with a model in the loop, +20pp should be read as a ceiling.
resume()skips gate re-evaluation.- Audit and approval writes commit separately. If the second fails, the transaction row is durable with no approval row behind it - partial atomicity, logged as a known reconciliation risk rather than papered over. The fix is one outer transaction or an idempotent backfill.
resolver_idis free text. The request layer rejects empty strings, and that is the entire authorization story on the approval surface. Fine for a demo; nowhere near enough for anything real.- Policies are Python objects, so changing a threshold means a deploy.
What I'd build next
Policies as data - Pydantic models behind a discriminator, loaded from YAML - so an ops team can change a limit without shipping code. Real auth on the approval endpoints. Gate re-evaluation on resume. One transaction spanning the audit and approval writes. And above all, the same eval replayed against real agent traces with a model actually in the loop, because that's the number I don't have and it's the one that would tell me the most.
Where everything lives
The runtime, the four gates, the MCP server, the FastAPI approval surface, both scenario sets, the harness, the methodology write-up, and the demo at the top of this page are at github.com/egehazar/lockrail.
Four steps, and the executor runs in only one of them. The gap between the agent asking and the money moving is the entire project.