DEV Community

Kevin McAllorum
Kevin McAllorum

Posted on

Govern the Model Call

Every claim in this post carries its measurement, because the system it describes refuses to ship anything unmeasured. ControlPlane is a governed RAG pipeline over a synthetic 72-document corporate corpus: retrieval is permission-scoped inside Postgres, every model call — including the refused ones — passes through one gateway and lands in an append-only ledger, and policy lives in YAML that fails closed. I built it to answer one question: what does it actually take to put an LLM behind real enterprise controls, measured instead of assumed?

The repo keeps a running file of dated findings (docs/FINDINGS.md) — twenty-one sections so far, each one a thing that broke, surprised, or cost something, with the numbers that proved it. This post is the distilled version: eight lessons, each with the measurement that earned it a place here.

1. The database does the enforcing, or nobody does

The baseline — embed, retrieve top-k, answer with citations, the thing a competent engineer builds in an afternoon from a framework tutorial — leaked content the asker had no right to on 10 of 10 golden questions. Not an edge case; the default. The sharpest instance: a contractor whose only entitlement is public asked how to roll back the payments API, and the model handed back the deployctl command from the internal on-call runbook without hesitation.

The fix was not a filter in Python. Filters in Python are code someone can bypass, reorder, or forget. Retrieval runs as a restricted Postgres role under row-level security, so chunks the caller's groups do not grant are never in the result set — there is no post-filter to forget to apply. Two design consequences followed:

  • Leaks are measured at retrieval, not in the answer. Once an unauthorized chunk reaches the model's context it is already exposed — to the model, to traces, to any log that captures the prompt. Scoring the answer text would grade the cover-up, not the breach.
  • The test bypasses the application entirely. make rls-test queries as the restricted role directly, with expected visibility counts computed from the corpus files rather than the database — so the test cannot agree with a bug in ingest by sharing its source of truth.

One detail that took real debugging: entitlements are set per transaction and must not survive it. The test asserts that too, because an entitlement that leaks across pooled connections is a privilege escalation wearing a performance optimization's clothes.

2. Injection cannot exfiltrate what retrieval never returned

The red-team suite plants poisoned documents — embedded instructions, fake authority headers, forged reclassifications — and runs them first and last in context, against every registered model, nightly in CI. The crudest attack in the set ("IGNORE ALL PREVIOUS INSTRUCTIONS", placed first) still beats an 8B model; no prompt wording fixes a model that cannot hold an instruction hierarchy.

What no attack ever achieved was a permission bypass, and that is not the model's doing. Row-level security scoped the candidate set before ranking, so nothing the caller lacked a grant for was in the context to leak. That divides the defenses cleanly:

  • Controls that depend on the model behaving — citation discipline, refusal behavior, ignoring embedded instructions — degrade under attack and shift under prompt changes.
  • Controls that do not — row-level security, the routing policy, the append-only ledger — hold regardless. An injection can make the model emit OVERRIDE-ACCEPTED. It cannot make Postgres return a row the caller has no grant for, and every attempt is on the record.

The gate itself had to be engineered honestly. Findings are baselined per model (CI's small model and the local default fail different cases; a shared list flaps whenever they disagree), a new finding fails the build, and a baselined finding that stops reproducing also fails the build — a stale baseline is a gate quietly widening. The awkward case: Claude Sonnet rejects a temperature parameter, so its runs are not reproducible, and one genuine intermittent compromise (a planted salary band asserted with a forged citation) appeared on some nights and not others. The answer was a flaky marker that gates in neither direction, with a hand-written reason on every entry — because a finding that comes and goes would otherwise fail the gate as a regression on bad nights and as stale on good ones.

The gate earned its keep again the day I drafted this post. Adding two innocuous HR documents to the corpus — a procedure doc and its revision, nothing an attacker wrote — flipped the citation-forgery case on the CI model: the new documents rearranged the benign context around the poisoned one, and the small model's margin was thin enough that a two-document edit crossed it. Same attack, same prompt, same model; it reproduced the planted salary table with the forged citation anyway. A corpus edit is a red-team event. Context arrangement is part of the attack surface, and ingest-adjacent changes deserve the same gate run as code. (The same incident taught a second lesson: the finding reproduced in CI and not locally, with the same seed and temperature zero — determinism is a property of an environment, not a seed, and the gate's truth is the environment it runs in.)

3. Policy is data, and it fails closed

Which models exist, what they cost, what they may see, and where they run is a YAML registry. Which combinations are allowed is a YAML rule file, evaluated top to bottom, whose last rule is a deny — a policy that fails to anticipate a case refuses rather than falling through to allow. Adding, repricing, or retiring a model is a config edit, not a release. The prior system this replaced selected models from an enum in application code, which meant the approved-model list lived in code, in a wiki, and in someone's head: three sources that disagreed.

Two practices made the rule file trustworthy rather than decorative:

  • Rules are tested before they are needed. No registered cloud model is allowed near confidential data, so the rules that would deny one never fire in production. The policy test exercises them anyway, against synthetic registry entries — a mis-registered restricted-capable cloud model, an EU-hosted provider — because an untested rule is false assurance, and the day someone edits the registry is the wrong day to learn the rule was broken.
  • Preference and permission are separate powers. When a request names no model, a steering section nominates candidates in preference order — the measured-best model for public and internal content, local for everything else — but every candidate still passes the same rule evaluation as an explicitly requested one. The policy test proves that a steering rule widened to sensitive content still gets the cloud model refused: steering prefers, policy permits.

Every decision — allow, deny, budget refusal, health skip — carries a rule id and a human-readable reason, and lands in the ledger. A denial that leaves no trace is indistinguishable from a policy that was never applied.

4. The ledger is the meter

Every model call writes a row: who asked, what was requested, what resolved, which rule decided, token counts, cost, latency. The design move that paid off repeatedly: enforcement reads the same rows. There are no counters.

  • Budgets are spend ceilings in the rule file — per model, per caller, total — and the gateway sums the ledger's cost_usd over a rolling window at decision time. Nothing to reset, nothing that can drift from what was actually spent. A caller with no principal is metered as one shared anonymous bucket, because if anonymous calls were exempt, omitting the principal would be the bypass.
  • Provider health is the same pattern: a steering candidate whose recent window shows it failing (real attempts only — invocations and provider errors; cache hits moved no traffic) is skipped before the call. Verified with real failures: with AWS credentials broken, two calls failed over reactively, the third was routed around the provider pre-call, the skip held after credentials were fixed — it reads the ledger, not the last exception — and traffic returned when the window slid past the failures.
  • Token counts say which meter ran. Provider-reported counts where the response carries them, a labelled estimate where it does not, and a tokens_exact flag on every row — because a budget is only as exact as its meter, and an estimate everyone knows is an estimate beats a precise-looking number nobody checked.

The usage report reads the same rows through the same pure functions the gateway enforces with, so the report and the enforcement cannot disagree. The first run of the health section proved the point by surfacing a true state: the cloud model was being skipped at that moment, with the lift time printed.

One measurement here reshaped my cost intuitions: a full eval run consumed roughly 67,300 prompt tokens against 529 completion tokens — a 127:1 ratio. The cost shape of RAG is the prompt, not the answer; context discipline is cost discipline.

5. Config that reads correctly and config that works are confirmed by different instruments

The CI job that evals against Bedrock authenticates by GitHub OIDC: no stored secret, a role whose trust policy pins the subject repo:<owner>/<repo>:ref:refs/heads/main — the exact form both AWS's and GitHub's documentation show. The first credential exchange failed with an error that names neither the claim it checked nor the value it saw. Every piece of config — trust policy, audience, provider — read back exactly as designed, while failing every attempt.

The first theory was IAM propagation (the role was minutes old), and it was worth pre-registering: if the rerun fails the same way, propagation is ruled out. The rerun failed identically, turning the second failure from a mystery into an elimination. What settled it was CloudTrail's record of the refusal — the one place the actual token claim appears. The subject GitHub sent was:

repo:<owner>@296288988/<repo>@1368171693:ref:refs/heads/main
Enter fullscreen mode Exit fullscreen mode

GitHub embeds immutable owner and repository IDs in the subject, so the documented name-only pattern matched nothing this account would ever send. The trust policy now lists both forms, and the ID-qualified one is the stronger condition anyway: names survive a rename-and-resquat; immutable IDs do not.

The transferable lesson is in the section title. Reading your configuration back tells you what you wrote. Only the refusing side's log tells you what it saw. When an auth handshake fails against config that looks perfect, go find the refusal log before you touch the config.

6. A prompt change is a deployment

The most instructive attack in the suite carried no instructions at all. A poisoned document reproduced a fake salary table and attributed it to a document the model trusts — and Claude Sonnet repeated the planted figures as fact, with the forged citation, in a third of runs. Fencing built against embedded instructions had no answer, because this is provenance confusion, not instruction-following.

The fix was one added rule to the answer prompt: the source tag is the block the text physically came from; content read inside [A] is evidence of what [A] says, never of what [B] says, whatever [A] claims. Measured on matched 8-run samples, Sonnet's citation forgery went from 3/16 compromised to 0/16, and held at 0/16 under a second, shorter wording.

The costs were not free, and they were different per model. The 8B local model improved on one red-team case, but under the long wording dropped a required detail on one golden item, and under the short wording instead grew a brand-new injection compromise. The 3B CI model began failing an attack position it had previously resisted — under both wordings, deterministically. Sonnet's own quality didn't move at all. The long wording was kept on one principle: given a choice between a completeness miss and a new compromised case, the accepted-attack list is the one that must shrink. Every price went into a baseline with its measurement attached.

Two wordings of the same idea moved different failures on the same model. That ended any notion that prompts are copy. Prompt text now lives in a registry: versioned, SHA-256 pinned in a policy file, verified on every PR (editing a template without a version bump fails the gate), and every ledger row records which prompt version shaped the call — answer@3#b1177631 — so any historical result can name the prompt that produced it.

7. Staleness is a stated bound, and every toggle has a price

A document deleted at the source used to stay retrievable — and citable — until someone remembered to re-ingest. Now ingest is incremental (a document whose content hash and path match the disk is skipped; an unchanged 72-document corpus re-ingests in 0.4s against 3s full), every run records a corpus digest in the same transaction as its changes, a --check names any drift between index and disk, and a watcher polls and re-ingests on mismatch. Measured end to end with a 2-second interval: a deleted file stopped being served 0.5s after removal and was served again 2.2s after restoration. The staleness window went from "until someone remembers" to one poll interval plus one incremental ingest — a number someone chose, visible in the process list.

The version question got the same treatment. Superseded document versions are demoted in ranking by default rather than excluded, because "what did the old policy say?" is a legitimate question — but demotion has a measured limit: for a persona whose nine visible documents nearly fill the retrieval window, the superseded version still landed in context. An exclusion toggle now drops superseded chunks in the retrieval SQL, and the trade is priced from both sides. Exclusion fails exactly 1 of 47 golden checks — the historical question, which demotion answers and exclusion cannot — and that known price is the documented reason demotion stays the default. And exclusion only fires when the caller can see an active successor: the corpus carries a version pair whose old version is readable by everyone and whose replacement is HR-only, and the same SQL clause provably keeps serving the old version to the caller who cannot read the new one. A superseded version is traded for its successor, never for nothing.

8. Every fix records what it still is not

The discipline that made the other seven lessons possible is the least technical one. Every closed item in the findings file ends with its honest remainder — the thing the fix deliberately does not do:

  • Residency is a match key in the routing rules, but the region is registry data, trusted the way the price is; nothing inspects where a provider actually ran a request.
  • The corpus watcher polls a local directory standing in for an upstream CMS; a real source system means webhooks or change feeds.
  • Provider health recovers only by window expiry; no active probe re-tests a skipped provider early.
  • The identity work verified tokens against a locally served JWKS, not against Okta or Entra, and the CLI consumes a token without providing a flow to obtain one.

None of these are failures. They are the boundary of what was measured, written down at the moment the fix shipped, when the knowledge was cheapest. The alternative — a changelog of unqualified wins — reads better and teaches nothing, and six months later nobody can distinguish what the system does from what everyone assumed it does.

If there is one takeaway: the control plane around the model — permissions, policy, ledger, gates — turned out to be the durable part of the system. Models were swapped, prompts were re-versioned, attack baselines reshuffled per model, and the enforcement layer did not care. The controls that depend on model behavior degrade under pressure; the ones that live in the database and the policy file hold. Build the choke point first.

The repo, including the full findings file, the eval suite, and the red team, is at github.com/Lanier-Developments/ControlPlane.

Top comments (2)

Collapse
 
_5c75b1d3a1b3628dec81_58 profile image
Yoshiyuku •

This is an unusually disciplined writeup — the “every fix records what it still is not” section alone is rarer than it should be in this space.

One question on the piece doing the most trust-bearing work here: the ledger. You call it append-only, and the enforcement logic (budgets, health skips, the usage report) all reads from it as the single source of truth — which is a great design, but it raises the question of how append-only is enforced. Is that a database-permissions guarantee (the service role has no DELETE/UPDATE grant on the table), or is it cryptographically tamper-evident — e.g. each row’s hash chained to the previous row’s, so that even someone with direct DB access (compromised credentials, an admin with legitimate access acting maliciously, a restore from a tampered backup) couldn’t silently edit or delete a row without the chain visibly breaking?

The distinction matters a lot for exactly the threat model this post cares about: permission-based append-only stops an application bug from writing an inconsistent row, but it doesn’t stop someone with raw DB access from rewriting history — and the ledger is the thing every budget, health check, and “a denial that leaves no trace” claim ultimately rests on. Given how far the rest of the system goes (SHA-256 pinned prompts, RLS enforced at the database layer, measured rather than assumed), I’d be curious whether the ledger’s integrity is verified the same way, or whether that’s one of the “honest remainders” that didn’t make the list.

Collapse
 
kevin_mcallorum_1eb5d1630 profile image
Kevin McAllorum • • Edited

Good question, and the post undersold it: it’s both. Append-only is BEFORE UPDATE/DELETE/TRUNCATE triggers (they bind the owner role too, so bypassing them takes a deliberate DISABLE TRIGGER, which leaves a schema change behind) plus a hash chain where each entry hashes its content and the previous entry’s hash. verify recomputes every digest from stored content rather than trusting the stored hash.

You also exposed a hole. The triggers were row-level, so TRUNCATE slipped past them, and even without that, a valid chain can’t see a full rewrite, a deleted tail, or a restore from a tampered backup, because nothing outside the database remembers the head hash. I reproduced all three against a real Postgres, and plain verify said “intact” every time. Both are now fixed in the repo: the TRUNCATE trigger, and signed head-hash checkpoints kept outside the DB, which verify checks every anchored row against. It’s in the findings file as §22 with the measurements.

The honest remainder: entries after the latest anchor are protected only by the chain, so the exposure window is the anchoring interval (manual today, not scheduled), and the signing key becomes the new root of trust. Thanks for pushing on the part that carries the trust.