Prompts are suggestions. Guardrails are architecture. How a loan-acquisition agent layers a deterministic flow, an MCP contract, ownership gates, a pure state machine, and idempotent writes so that the LLM can be wrong safely.
Every "agent gone rogue" postmortem has the same shape: the LLM did something surprising, and there was nothing between the surprise and the consequence. The prompt said "only disburse after the customer accepts" — and the prompt lost.
The fix is not a better prompt. It's accepting a design rule:
A guardrail is only a guardrail if the LLM cannot route around it. Anything the model can skip by phrasing a tool call differently is a convention, not a control.
This article walks the guardrail stack of a production-grade conversational loan agent — LangGraph agent, MCP tool layer, Node orchestrator, banking core — from the outside in. The whole system runs offline with docker compose up, and the guardrails are proven by end-to-end tests that attack them.
Reading the diagram in order:
- The proposal — The LLM proposes a command — «disburse the loan». From here it has no more power: the proposal must survive five gates, each owned by a different module with a different reason to change.
- Gate 1 — conversation — Are the slots valid and the terms accepted? If not, the answer is "keep talking" — the flow stays in intake and never advances on a terse follow-up the router misread.
- Gate 2 — identity — Is this a proven caller acting on their own party? No verifiable token, no journey: the gate returns 401 and nothing is written.
- Gate 3 — ownership & rules — Is it your loan, is the party still alive, does policy pass? A "no" here is a 403, 404, or 422 — a machine-readable refusal, not an exception.
- Gate 4 — state machine — Is this a legal step right now? A pure XState machine answers canFire(state, DISBURSE). Out of order gets a 409 — the model cannot talk its way past the lifecycle.
- Gate 5 — idempotency — Is this the first time this exact command has run? A duplicate replays the first answer instead of moving money twice. Ask twice, pay once.
- Money moves — Five yeses, and only then does the money move — exactly once. One straight road to the money, with five places it can be stopped and only one where it can complete.
- Every "no" is data — And every refusal returns to the agent as a code plus a reason it explains in the chat — never an exception, never a write. The LLM can be wrong, safely.
The original article has an interactive, scroll-driven version of this diagram that lights up each step as you read.
One proposal, five yes/no gates, one irreversible action. A "yes" moves the command one step closer to the money; every "no" comes back as data — a code plus a reason the agent explains in the chat, never an exception, never a write. Each gate is owned by a different module with a different reason to change. Let's walk them outside-in.
Ring 1 — the agent itself is deterministic where it matters
The LangGraph agent does not run a free ReAct loop over the loan tools. The loan lifecycle is a deterministic, turn-resumed intake node; the LLM is quarantined to two narrow jobs — proposing an intent classification, and phrasing help text. Neither can advance the flow.
The most instructive piece is the sticky intake routing guardrail:
"""
Route based on state + the classified intent from the router node.
- intake_active (sticky): once we're collecting a loan, STAY on the intake path
regardless of how the router classifies a terse follow-up answer (a bare "24").
This is the flow's "State Enforcement — the orchestrator overrides the LLM".
"""
if state.get("intake_active"):
logger.debug("Routing to intake (intake_active sticky guardrail)")
return "intake"
Without this, a customer answering "24" to "how many months?" gets misclassified as small talk and the flow derails. With it, the router's opinion is advisory once a journey is underway.
The intake node also pre-validates slots against real policy before any tool call — and here's the decomposition detail worth stealing: the agent and the orchestrator call the same shared policy function, so the conversational check and the authoritative check can never drift apart:
// Pure policy check, shared so the agent and the orchestrator apply IDENTICAL rules. Returns the
// list of human-readable violations (empty = admissible), so the orchestrator can put them in a
// BUSINESS_RULE_VIOLATION detail and the agent can echo them conversationally.
export function checkAgainstRules(input, rules: LoanRules): string[] {
const violations: string[] = [];
if (input.amountMinor < rules.minAmountMinor) { /* … */ }
if (input.amountMinor > rules.maxAmountMinor) { /* … */ }
if (!rules.allowedInstallments.includes(input.termMonths)) { /* … */ }
Finally, the agent shows personalized disclosures and an explicit "I accept / Cancel" gate before acceptance — deterministic template renders of the customer's exact numbers, deliberately not retrieval. A disclosure that an LLM paraphrases is a disclosure a lawyer can't sign off on.
Ring 2 — the MCP layer: a contract designed for an unreliable caller
The FastMCP server (loan-mcp) is where agent-facing design lives: docstrings carry ordering hints ("Call this FIRST", "Legal only after accept_terms"), and — crucially — errors are data, not exceptions:
"""
Tools pass through the orchestrator's value object on success. On failure we
return {"error": <RFC 9457 problem>} instead of raising so the agent receives
a usable message — the whole problem (type/title/status/detail/code/instance/
traceId) rides along, and a 409 STATE_TRANSITION reads as an out-of-order call
the guardrail refused, not an opaque exception.
"""
except OrchestratorError as exc:
if exc.is_state_transition:
logger.warning(f"[loan-mcp] {action_label} refused by guardrail: {exc.message}")
return {"error": exc.as_dict()}
A refusal that reaches the model as structured data becomes a conversational event: "we can't disburse yet — you haven't accepted the terms." A refusal that dies as an exception becomes a stalled chat.
This layer is also the identity seam: with LOAN_MCP_AUTH_ENFORCED=true, no verifiable token means no journey — and a valid token binds the journey to the caller's own party, resolved from the verified email. Fail closed:
"""
* No token + enforcement ON -> refused (401): the composed stack fails closed, so a
journey can never open against an unproven identity.
* A token -> verified against Keycloak's JWKS (signature/issuer/expiry/email_verified),
then bound to the CALLER'S OWN party.
"""
Ring 3 — the orchestrator's gates: ownership, party, rules
Every command that reaches the orchestrator's REST edge passes three gates in order, before any state machine question is even asked:
-
Ownership — the forwarded token is independently re-verified (a second, deliberately separate implementation from loan-mcp's), and the token-derived party must match the
customerRefbeing operated on. Errors are coarse on purpose: specifics go to logs, an attacker learns nothing. - Party gate — the party must exist and be live. An erased (crypto-shredded) party yields a 422; unknown yields 404. If party-api is unreachable, the gate returns 503 — fail closed, never "assume it's fine."
- Rules gate — the authoritative policy check (same shared function as Ring 1), returning 422 with the human-readable violations.
The trade-off is explicit and named: ORCH_AUTH_MODE runs off / verify-when-present / require. The compose stack uses verify-when-present so token-less e2e and internal tools keep working; a real deployment runs require. The point is that the posture is a configuration decision, not an accident of middleware.
Note the asymmetry with Ring 1: identity and policy dependencies fail closed; conversational helpers fail open. The agent's draft check degrades gracefully because "the orchestrator still enforces authoritatively on create" — but the orchestrator's gates never degrade, because there is nothing behind them.
Ring 4 — the pure state machine: the only authority on "may this happen"
The heart of the system is an XState machine that is pure to the point of asceticism:
// The loan-acquisition lifecycle as a PURE, deterministic XState machine. It is the
// guardrail: given the current durable state and a candidate event (proposed by the
// non-deterministic LLM, or projected from a loans-api domain event), it answers "is this
// transition legal?" and "what is the next state?". It performs NO side effects — the
// synchronous command handlers and the SQS backstop execute the actual step and then
// project this machine only on success. Coupling points one way: everything depends on the
// machine; the machine depends on nothing. SECRET: the legal state/event transition graph.
The disburse handler shows what "layered refusal" looks like in practice — each check has its own status code, so the caller learns exactly which ring refused:
if (!canFire(current.currentState, { type: 'DISBURSE' })) {
return fail('STATE_TRANSITION', `cannot disburse from state ${current.currentState}`, 409);
}
if (current.snapshot === null) {
return fail('BUSINESS_RULE_VIOLATION', 'no simulation snapshot to disburse against', 422);
}
if (isSnapshotExpired(current.snapshot, nowIso())) {
return fail('BUSINESS_RULE_VIOLATION', 'the simulation snapshot has expired; re-simulate before disbursing', 422);
}
Because the machine is pure and depends on nothing, the same legality function serves three callers: the synchronous command handlers, the SQS backstop's projection applier (so an out-of-order replayed event is skipped, never applied), and a non-mutating /signal check endpoint that lets you measure classifier→guardrail precision without touching state.
Ring 5 — loans-api: even a legal command must be safe to repeat
The innermost ring assumes everything above it failed. The system of record wraps every write in an idempotency envelope (same transaction as the domain change), and adds a relational belt-and-suspenders check — the acceptance being disbursed must actually belong to the active simulation. A retried or duplicated command replays the recorded response; it never writes twice.
Underneath all five rings sits durability: the Control Record is persisted via DBOS on Postgres, advanced only through a version compare-and-swap plus per-event dedup. The version-CAS detail is a war story in itself: the CAS used to key on state, and a re-simulation is a self-loop — simulated → simulated — so the CAS silently matched stale rows. Monotonic version fixed it. If your state machine has self-loops, a state-keyed CAS is a bug you haven't met yet.
The attack, end to end
Here's the full detail of the five rings — who checks what, in which order, and what the happy and rejected paths look like:
What happens when something — a creative LLM, a curious tester, a direct REST call — tries disburse while the journey is still simulated?
-
canFire('simulated', DISBURSE)→false. Nothing mutates; loans-api is never called. - The orchestrator returns RFC 9457
application/problem+json, status 409, codeSTATE_TRANSITION. - loan-mcp passes it through as a value:
{"error": {code, status, title, …}}. - The agent reads the code and explains conversationally — "let's review the terms first."
- Durable state remains
simulated;acceptanceIdanddisbursementIdremain null.
Every step of that path is asserted by e2e tests, over both REST and MCP — including that the MCP tool call resolves (rather than throws) and that the durable record is untouched. A guardrail without a test that attacks it is a hope.
The soft outer filter, and why it's allowed to be soft
Outside the five rings sits one more layer: a Bedrock Guardrails config for model-level content filtering. Its deny-topics are domain-specific — unlicensed financial advice ("should I put this loan into crypto?") and lending fraud and evasion ("how can I inflate my income to qualify?") — alongside harmful-content filters and PII masking. The customer-facing refusal copy lives in a single module-level constant, so the guardrail definition sent to Bedrock and the local fallback message can never drift apart.
Unlike everything inside it, this filter fails open on error — acceptable only because it is not, and must never be, the authoritative control. One operational note: guardrails are created-or-fetched by name, so a policy change in the repo reaches an existing environment only when the guardrail is recreated or a new version is published.
That's the real lesson of the ring model: you can afford a soft, imperfect outer filter precisely because the inner rings are hard. Put your rigor where the irreversibility is.
Conclusion
The five rings are not redundancy for redundancy's sake — each one hides a different secret and refuses a different class of mistake:
| Ring | Refuses | Mechanism |
|---|---|---|
| Agent flow | conversational derailment | sticky routing, shared pre-validation, explicit confirm |
| MCP contract | unusable failures, unproven identity | errors-as-data, fail-closed party binding |
| Orchestrator gates | wrong caller, dead party, bad policy | ownership + party + rules, fail closed |
| XState machine | illegal ordering | pure canFire, 409 as data |
| loans-api | duplicated execution | idempotency envelope + relational check |
The LLM sits outside all of them, free to be brilliant and wrong. You don't make an agent safe by prompting it better — you make it safe by building rings it cannot route around.


Top comments (0)