Put the Guardrail in the Validator, Not the Prompt
A mid-market UK wealth manager wanted an assistant to triage its shared operations inbox, not a model it had to trust with judgement calls. The build that stuck
Put the Guardrail in the Validator, Not the Prompt
A mid-market UK wealth manager wanted an assistant to triage its shared operations inbox, not a model it had to trust with judgement calls. The build that stuck asked the model for one thing only: a proposal. Every guardrail that mattered for audit sat outside it, in code that runs every time, in the same order, whether or not the model behaved.
Sector: Wealth management, UK · Team: 4-person operations desk · Scope: 2 shared mailboxes · Time to pilot: 3 weeks
The inbox nobody owned
The client is a mid-market UK wealth manager, FCA-regulated, growing through a run of small acquisitions rather than one big platform migration. Nothing in the back office had been rebuilt to match; email was still where operational work actually happened. Invoices, pension paperwork, supplier correspondence, CVs, IT requests, and the odd phishing attempt all landed in the same handful of shared Google Workspace inboxes, and a rotating cast of four operations staff worked through them by hand each morning.
Nobody owned the inbox in the sense of being accountable for what was in it. Everybody owned it in the sense of being expected to glance at it between other work. That is a common enough failure mode in regulated back offices: the queue is too important to ignore and too undifferentiated to delegate cleanly, so triage quietly becomes everyone's fifth priority.
The brief was narrow by design. Read the inbox, work out what kind of email it is, apply the right label or forward it to the right place, and leave a record a compliance reviewer could check without having to ask anyone what happened. No auto-replies drafted by a model. No unsupervised sending. Categorisation first; everything with more consequence, later, once trust had been earned.
What was actually breaking
The operations desk was processing on the order of 150 to 200 emails a day across the two mailboxes at peak. Most of that volume, automated notifications, order confirmations, routine supplier correspondence, cleared in under a minute with a glance and a label. A narrower band, roughly one email in ten, needed real attention: four to six minutes once you counted opening it, working out which of a dozen internal categories it belonged to, and either labelling it, forwarding it, or walking it over to someone who could, for a recruitment application, an ambiguous invoice, or anything that read slightly wrong. That narrower band did most of the damage. Between the two, the desk was losing twelve to fifteen hours a week of a four-person team's attention to reading and routing rather than deciding, and almost all of it sat in that one email in ten.
The sharper problem was underneath that number. Two of the categories flowing through the same queue, recruitment applications and supplier payment requests, routinely carried personal data or financial detail: names, phone numbers, national insurance numbers, sort codes, card fragments pasted into a CV by mistake. Compliance had a reasonable question for us before any of this touched a third-party model: what leaves this building, and can you prove it.
That question shaped the architecture more than anything else in the brief. Compliance wasn't asking us to promise nothing would ever go wrong. They were asking a narrower, more answerable question: if something did go wrong, could we show exactly what the system saw, what it decided, and why, without anyone having to trust our word for it after the fact. Any design that couldn't answer that in an audit, however accurate it was day to day, was a non-starter regardless of how well it triaged mail.
Why the guardrail doesn't live in the prompt
The obvious first design writes a long, careful system prompt: read this email, tell me the category, and while you're at it, flag anything that looks like a compromise attempt or personal data. We built a version of that early, threw a deliberately awkward set of test emails at it (a spoofed supplier invoice, a CV with a card number pasted into the covering note, an email in a language the policy didn't cover), and watched it behave inconsistently across near-identical inputs. Not because the model was bad. Because a system prompt is a request, and a request can be followed loosely, forgotten under a long enough context, or reasoned around by whatever the model decided the email was really asking for.
A compliance reviewer cannot audit "the model was asked nicely." So the guardrail moved out of the prompt entirely and into a small piece of deterministic code that runs after every model call, regardless of what the model returned. The model's only job became producing a proposal: which scenario this looks like, how confident it is, and what it extracted. A second, separate stage decides whether that proposal is allowed to stand, in a fixed order that never changes per scenario: a suspected compromise attempt beats everything else, personal data beats language and attachment issues, and only once all of that clears does the model's own proposal get to run. The order itself is part of what compliance signed off, once, rather than something that could drift piece by piece as new scenarios were added.
We considered, and rejected, building a dedicated personal-data detection model, either fine-tuned in-house or bought in as a specialist NLP library, to sit ahead of the triage call. It was the more thorough answer and the wrong one for this stage: it added a second system with its own false positive and negative rate to monitor, for a problem that was mostly structured (email addresses, phone numbers, card-like digit runs) rather than the harder case of a name or address embedded in free text. We settled on a cheap local pattern filter for the structured cases, on the model's own classification as the backstop for everything else, and on being honest in the documentation that the second layer is a floor under the risk, not a guarantee against it.
The other rejected alternative was operational rather than architectural: one long-running process watching every mailbox at once, for efficiency. We turned that down too. A slow or stuck mailbox would have stalled the others, and at this scale the isolation of one process, one mailbox, one audit log was worth more than the infrastructure it saved.
How it actually runs
Each mailbox is watched by its own scheduled run, every five minutes, doing the same seven steps regardless of what arrives:
Inbox (new mail only)
-> Local PII filter (regex, no model call)
-> Triage call (masked copy)
-> Guardrail gate (fixed precedence)
|-- fails a check --> Escalate to human
|-- covers_pii ------> 2nd triage call (unmasked, PII-scenarios only) -> Dispatch
`-- clean pass -------------------------------------------------------> Dispatch
-> Audit log (one line per email)Every run does the same seven steps in the same order. Only a scenario that genuinely needs personal data (recruitment applications, in this build) ever earns a second, unmasked call.
The precedence check itself is the smallest and most important piece of code in the system. It does not classify anything; the model already did that. It only decides whether the model's proposal is allowed through, and in what order competing concerns get checked:
# pseudo-code: guardrail precedence
# Order is fixed and never varies per scenario, so a reviewer can
# reconstruct why an email landed where it did without reading a prompt.
def route(email, proposal):
if email.looks_like_compromise_attempt:
return escalate("compromise-attempt") # checked first: cost of a miss is highest
if email.contains_structured_pii and not proposal.scenario.covers_pii:
return escalate("pii") # model doesn't see raw PII beyond this point
if email.language not in SUPPORTED_LANGUAGES:
return escalate("unsupported-language")
if email.unreadable_attachments:
return escalate("unreadable-attachment") # named, never guessed at
if proposal.confidence < CONFIDENCE_FLOOR:
return escalate("low-confidence")
return apply(proposal.scenario)Two consequences fell out of putting the check here instead of in the prompt. First, adding a new category of email never touches the guardrail at all; it is purely a new entry in a policy document, reviewed on its own. Second, the audit log can record which check an email passed or failed as a plain fact about the code path taken, not as a summary of what the model claimed it did.
Dispatch, the step after the gate, got the same treatment. An email that both needs a label and needs forwarding gets the label applied first, forward second, and if the forward fails, the label stands. We deliberately didn't build automatic retry for the block as a whole: a forward is not something you can safely repeat if you're not sure whether the first attempt actually went out, and guessing wrong in either direction, a duplicate email to a supplier or a silently dropped one to compliance, is worse than a loud failure a human has to look at once. A failed dispatch raises an alert immediately rather than getting swallowed into the next scheduled run's queue.
One pitfall is worth naming because it is easy to miss until it bites: several of the forwarding rules in this build send mail back to an address on the same domain the pipeline is watching. Left alone, that creates a loop, the forwarded copy lands back in the inbox, looks unlabelled, gets triaged again, and gets forwarded again, indefinitely. The fix is a one-line exclusion on anything the mailbox itself just sent, but it only shows up as a problem the first time a self-addressed rule goes live, and by then it's already running. We now write it into the test plan for every new forwarding rule rather than trusting anyone to remember it.
What changed once it went live
The first week ran conservatively on purpose. The confidence floor was set high enough that a third of proposals still went to manual review, which felt closer to a second opinion than automation. By week three, with the floor tuned down against real traffic rather than test emails, that had settled to roughly one in eight.
| Metric | Value |
| Median hands-on time per email | 4–6 min → <1 min |
| Mail carrying structured PII | ~9% |
| Redacted emails needing the 2nd, unmasked call | 1 in 20 |
| Supplier-impersonation attempts caught, first 6 weeks | 3 |
The compromise-attempt guardrail earned its place in the first month. A supplier-impersonation email, close enough to a real invoice thread to have fooled a tired reviewer at nine in the morning, was pulled out before it reached the same queue as genuine payment requests and routed straight to the security mailbox instead. That is the outcome the whole design was aimed at: not a smarter model, a boring, reliable place for the dangerous ten percent of mail to land differently from the other ninety.
The genuine surprise came from outside the pipeline entirely. The scheduled job ran cleanly by hand every time anyone tested it, and then sat silently broken for the better part of a week once handed over to the client's own scheduler, because that scheduler's environment was not the same shell a person gets when they log in: a bare execution path with none of the usual tool locations on it, and file-access permissions on the client's desktop that quietly blocked a background job from touching files under a folder an interactive session could read without noticing. Nothing in the pipeline itself was wrong. It had simply never been run the way it was actually going to be run.
What we'd do differently
- Test under the scheduler, not just interactively, from day one. The gap between a login shell and a cron-style environment is an old lesson that still costs a week when you skip it. It now sits on the checklist for every pilot before handover, not after.
- The confidence floor started too cautious. A third of week-one volume going to manual review was safe, and it undersold what the system could already do. We'd start the tuning pass against a short window of shadowed real traffic before go-live, rather than after.
- Forwarding didn't preserve the original thread. A handful of forwarded emails landed in the receiving mailbox as new conversations rather than continuations, which is a minor annoyance for a human reader and an avoidable one. Small, unglamorous, and still on us.
The pattern, generalised
The reusable idea is not "add guardrails." Every vendor pitch already says that. It is where the guardrail is allowed to live. Anything a compliance reviewer will eventually ask about, an ordering of checks, a decision about what data reaches a third party, what counts as too uncertain to act on alone, belongs in code that runs the same way on every email, independent of whatever the model was asked or how it chose to answer. The model stays useful for the part it is actually good at, reading an ambiguous email and proposing what it might be. The part that has to be defensible on a Tuesday morning to someone who wasn't in the room sits outside the model entirely, in a handful of lines short enough to read end to end in one sitting.

Rohit Varshney
Principal · AI Native Infrastructure and Operations
Principal for AI-native infrastructure and operations at Bugni Labs. GCP-native, event-driven, DevSecOps-hardened platforms with SRE discipline. Multi-year delivery at a UK Tier-1 bank across Cloud CoE, PSD2 and Open Banking, and commercial onboarding.
The Engineering Notebook
Once a month, a long read on what we're learning building governed AI for regulated enterprises. No hot takes, no roundups.
You might also enjoy
The question that started PDLC: why does this feature exist
A field note on losing traceability, and why I built a lifecycle engine instead of shipping faster.
Field NoteTurning Database Schema Changes into Release Artefacts with Flyway
How a small Spring Boot Flyway service turned PostgreSQL schema changes into versioned, reviewable release artefacts across regulated Kubernetes environments.
Field NoteAI Code Review in Regulated CI/CD
AI code review became useful only after we made it policy-aware, evidence-led, and subordinate to human ownership.