Pattern: Deterministic Rails Around a Stochastic Core
5 min read · updated August 3, 2026
You cannot make a model reliable. You can make the system around it reliable, and the difference between those two projects is most of the engineering. The pattern is to arrange the call graph so that the stochastic part proposes and only deterministic code commits.
Propose, do not commit
Consider two designs for the same feature. In the first, the model is given a set of tools and asked to resolve a customer’s refund request; it calls the refund tool with an amount it decided on. In the second, the model is given the request and the order record and asked to return a structured proposal — refund, amount, reason code — which is then evaluated by ordinary code against the refund policy, which decides whether to execute.
The model does the same work in both. What differs is who holds the authority to change the world. In the first design, a bad output is an incorrect refund. In the second, a bad output is a rejected proposal and a log line, because the policy is code and code does not have a bad day.
This is the whole pattern, and its value comes from a property of the dependency rather than from any distrust of it: model output is a sample from a distribution, and a system that lets samples commit irreversible actions has an irreducible failure rate equal to the model’s. A system in which samples are proposals has a failure rate set by its policy code, which you can test.
The four rails
Four constraints, at four different points, and they are not alternatives — a serious feature has all four.
| Rail | Description |
|---|---|
| Input rail | What the model is allowed to see. Retrieval scoped to this user's data, untrusted content marked as data rather than instruction, secrets never in context. This rail is what stops the lethal trifecta from assembling by accident. |
| Output rail | What shape an answer may take. A closed enum rather than free text, a schema rather than prose, a number in a stated range. Every degree of freedom removed here is a class of failure that cannot occur rather than one you must detect. |
| Action rail | What may be done with the answer. Policy evaluated in code, permissions checked against the acting user, limits enforced server-side. The model proposes; this decides. Never both in the same component. |
| Blast-radius rail | How much can go wrong before something stops it. Rate limits, spend caps, per-run budgets, transactional writes that roll back, and reversibility windows. This is the rail that assumes the other three failed. |
The output rail is the one with the best return per hour of work and the one most often skipped in favour of asking nicely in the prompt. If the answer must be one of six labels, make it one of six labels — via an enum in a schema, via constrained decoding where the provider supports it, or via a rejection in your own code where it does not. A prompt that says “respond with exactly one of the following” is a request. A validated enum is a guarantee.
Where the model goes: a placement rule
How much freedom a given call should have is decidable from one property of what its output touches. Ask what happens if this specific output is wrong in the worst plausible way, and route by the answer.
- Reversible by the system, invisible to anyone — an internal tag, a routing hint, a cache key. Give the model wide latitude; the blast radius is a recomputation. Constraint here is wasted effort.
- Reversible by the user, visible to them — a draft, a suggestion, a pre-filled field. Output rail only. The user is the action rail, and that is a legitimate design as long as the interface makes review genuinely easy rather than nominally possible.
- Reversible by staff, visible to a customer — a sent message, a published summary, a status change. Output and action rails, plus a record of what was proposed and by which prompt version, because you will be reconstructing this later.
- Irreversible or externally visible — money moved, data deleted, an email sent to a third party, anything a regulator reads. The model may propose and may never commit. Either code decides or a person does. If neither can, the feature is not ready, and that is a legitimate conclusion rather than a failure of nerve.
The rule’s usefulness is that it is about the action, not about the model. It gives the same answer regardless of how good the model got last month, which is what you want from a design rule in a field where the dependency is replaced twice a year.
What it looks like
// OUTPUT RAIL: a proposal is a closed type. There is no free-text field
// through which an unanticipated instruction can arrive.
type Proposal = {
action: "refund_full" | "refund_partial" | "replace" | "decline" | "escalate";
amount_cents?: number;
reason_code: ReasonCode; // enum, validated
evidence: string[]; // ids of order events, not prose
};
// ACTION RAIL: pure, deterministic, unit-testable, and the only thing
// that is allowed to say yes. Note what it does NOT do: it does not
// consult the model, and it does not trust any field it was handed.
function authorise(p: Proposal, order: Order, actor: Actor): Authorised | Denial {
if (!actor.can("issue_refund")) return deny("permission");
if (p.action === "refund_partial") {
if (p.amount_cents == null) return deny("malformed");
if (p.amount_cents > order.paid_cents) return deny("exceeds_paid");
if (p.amount_cents > POLICY.auto_refund_ceiling_cents) return deny("needs_human");
}
if (order.age_days > POLICY.refund_window_days) return deny("outside_window");
if (!p.evidence.every((id) => order.hasEvent(id))) return deny("bad_evidence");
return authorised(p);
}
// BLAST-RADIUS RAIL: even an authorised action is bounded in aggregate.
await withinDailyCap("refunds", p.amount_cents, () => execute(p));The evidence check is the detail worth copying. Requiring the proposal to cite identifiers that must exist in the order record converts a justification — which a model can produce for any conclusion — into a claim that is mechanically falsifiable. It is cheap, it catches fabrication directly, and it works for the same reason spans work in extraction: a pointer into real data can be checked, and prose cannot.
Note also that authorise returns a reason for denial rather than a boolean. The distribution of denial reasons over time is one of the most informative metrics an AI feature produces — a rising rate of exceeds_paid means something changed in how the model reads orders, and it is visible long before a customer complains.
Rails that are not rails
Four things are commonly deployed as constraints and do not constrain.
- Instructions in the prompt. “Never refund more than the order total” is a strong hint and not an invariant. It is worth including — it improves the proposal rate — but it is not a rail, and treating it as one is the single most common version of this mistake.
- Client-side enforcement. A limit applied in the interface that assembles the request is not applied at all, for the same reason it never was in any other system.
- A model checking the model. A guard model can lower an error rate and cannot bound one. Use it where no deterministic check exists, as verification rather than as authorisation, and never as the last thing between a sample and an irreversible action.
- Output filtering as security. Scanning generated text for bad content is a useful hygiene layer and a poor boundary. The boundary that works is the action rail: if the dangerous thing requires a capability the component does not have, no output can invoke it. That is the same reasoning behind capability-scoped tool calls, and it is the only version of this that holds under an injection attempt.