Chapter 5 of 9 · 8 min read
A hook enforces only what a machine can decide
On this page
The requirement arrived as one sentence in a compliance ticket: EU customer data must not be sent to non-EU processors. The agent it applied to had three tools — a summariser that called a US-hosted model, a search tool over a global index with shards in Virginia and Frankfurt, and a shell. The ticket was assigned to a platform engineer with a two-week estimate and the note "same pattern as the infra/prod guard".
The first draft took an afternoon, because the shape is familiar:
# PreToolUse — the version that gets written first, everywhere
event = json.load(sys.stdin)
dest = DESTINATION_REGION.get(event["tool_name"]) # "us-east-1" / "eu-west-1"
payload = json.dumps(event["tool_input"])
if dest and not dest.startswith("eu-") and contains_eu_personal_data(payload):
print("Blocked: EU personal data may not leave the EU (POL-DR-1).", file=sys.stderr)
sys.exit(2)
sys.exit(0)Every line of that is straightforward except contains_eu_personal_data, which is the entire requirement wearing a function name. Deciding whether an arbitrary blob of text contains personal data belonging to an EU data subject — any information relating to an identifiable living person, which the tables below shorten to EU-PII — is a judgement about meaning, provenance and jurisdiction, and there is no implementation of it that is both correct and deterministic.
Two implementations, both of which fail in a way that gets reported as coverage
The pattern-matching version is the one that ships. Regexes for email addresses, IBANs, phone numbers in E.164 form, and the national identifier formats for the eight member states the company operates in. The team tested it against 500 payloads sampled from a week of real tool calls, hand-labelled by two people.
| Regex predicate | Model-based classifier | Destination-only predicate | |
|---|---|---|---|
| Recall on labelled EU-PII payloads | 71% | 94% | not applicable |
| False positives on 500-payload sample | 14% | 3% | 0% |
| p50 added latency per call | 3 ms | 1,240 ms | 0.4 ms |
| Cost per 10k calls | €0 | €4.10 | €0 |
| Failure is visible | no | no | yes, as a block |
| Answer is stable across runs | yes | no | yes |
The latency rows are percentiles of the time each predicate adds to a call, so p50 is the median call once they are sorted by latency and p95, which appears throughout the rest of this topic, is the slowest call in twenty. The 29% the regexes miss is not a random 29%. It is free-text support transcripts where a customer describes their own circumstances without a single formatted identifier, German addresses written across three lines, names alone, and internal customer IDs that are personal data by the regulation's definition because the company holds the mapping. Those are the payloads a compliance officer would care about most, and they pass. The 14% false positives are mostly order references that happen to match an IBAN checksum shape, and their cost is an agent blocked from doing ordinary work with an error it cannot act on.
The model-based classifier fixes the recall and breaks something more important. A hook exists to convert a probability into a guarantee, which is the whole argument of chapter 1; putting a model inside it converts the guarantee back into a probability, adds 1.24 seconds to every outbound call, and makes the answer unstable, so the same payload can be blocked on Tuesday and allowed on Wednesday. It also introduces a delightful recursion, in that the classifier is itself a US-hosted model that has just been sent the payload in order to decide whether the payload may be sent to a US-hosted model.
Both versions produce the same organisational outcome, which is a dashboard showing POL-DR-1 enforced on 100% of outbound calls, with no way for anyone reading it to know that the hard cases are the ones going through.
Decidable means the answer is a lookup, not an opinion
The distinction that matters is whether the predicate can be answered from facts the system already holds in a form it controls, or whether answering it requires interpreting content.
| Predicate | Decidable | Decided from |
|---|---|---|
| Is the destination host in an EU region? | yes | service registry, one lookup |
Does this path resolve under infra/prod/? | yes | filesystem, after realpath |
| Is a fresh plan attached to the change request? | yes | change-request API |
Does this SQL statement contain a WHERE clause? | yes | parser |
| Is this SQL statement safe to run? | no | judgement about intent and data |
| Does this payload contain EU personal data? | no | judgement about meaning and provenance |
| Is this change well designed? | no | judgement, and reviewers disagree with each other |
The last row is worth a number, because "reviewers disagree" is the sort of claim that sounds like an excuse. A team that tried to build a design-quality gate had four senior engineers independently label 20 agent-authored diffs as acceptable or not; pairwise agreement was 61%, which after correcting for chance is a Cohen's kappa of 0.41, on a scale where 1.0 is perfect agreement and 0 is what coin-flipping would produce. A predicate that four experts agree on three times in five is not a predicate a program can enforce, and the only two ways to ship it are to encode one reviewer's opinion as though it were the rule, or to call a model and inherit its variance. Design quality belongs to review, where a human disagreeing with the verdict is a normal event rather than an outage.
Enforce the topology, then say plainly what is left
The residency requirement does have a decidable core, and finding it means moving the question from the content of the payload to the route it travels. Whether a blob contains EU personal data is a judgement; whether this session belongs to an EU tenant, and whether this tool's endpoint terminates outside the EU, are both lookups against configuration the platform already owns.
Three layers do the work, and only the middle one is a hook. The routing layer gives EU-tenant sessions a toolset in which the US summariser does not appear, which removes most of the risk before any interception happens, because a tool that is not in the model's tool list is not a tool the model can call. The hook sits at PreToolUse and asserts a predicate it can actually decide: the resolved endpoint region for this tool call must be eu-* when the session's tenant is EU-resident, and a mismatch is a hard rejection naming POL-DR-1 and the EU-region alternative. It never looks at the payload.
The shell is the part that cannot be decided at all, since a shell command's egress destination is not knowable from its text — chapter 6 is about why trying anyway is a losing structure. It is handled by running the shell inside a network namespace — a private network stack the kernel hands the process, whose routes and firewall rules the process itself cannot change — with an egress allowlist of EU endpoints, so the enforcement happens in the kernel rather than in a predicate over strings. That is a heavier mechanism than a hook and it is the only one whose coverage does not depend on anticipating syntax.
What you then owe the policy owner is a sentence that says what holds, in the terms the mechanism actually enforces rather than the terms the policy was written in. The honest version is: no tool call from an EU-tenant session reaches an endpoint outside the EU, and no process spawned by the shell can open a connection outside the EU allowlist. That is not the same claim as "EU customer data never reaches a non-EU processor", and the difference is the residual risk, which is also worth stating explicitly rather than leaving as an implication. EU personal data still reaches a non-EU processor if a user pastes it into a session tagged as a non-EU tenant, if a store containing EU records is misclassified in the registry, if the model reproduces from its own context something it read earlier in an EU-routed call and the session was later re-tagged, or if a shell process reaches an allowlisted EU endpoint that itself forwards onward. Each of those has an owner somewhere else: tenant tagging, the data catalogue, session lifecycle, and third-party processor agreements. Naming them is how the hook stops being asked to carry the whole policy.
The coarseness is a real cost and pretending otherwise is how these designs get rejected in review. Routing by tenant means an EU-tenant session summarising a document with no personal data in it at all — a public regulatory filing, say — is still confined to the Frankfurt model, which at the time was a smaller model that scored 6 points lower on the team's summarisation rubric. Roughly 40% of EU-tenant summarisation calls were of that harmless kind, so the design pays a quality cost on all of them to guarantee an outcome on the rest. That trade is defensible and it is a trade; the classifier design was attractive precisely because it promised to avoid paying it, which it did by not enforcing the thing it claimed to enforce.
The model is not banned from the loop, only from the mechanism
None of this says a model can never participate in enforcement. It says a probabilistic component must not sit where a deterministic guarantee is being claimed, and the test is what happens when the component is wrong. A classifier that flags suspicious payloads into a queue a human reviews the next morning fails by generating work. A classifier wired to exit 2 fails by silently permitting the payloads it did not understand, while the dashboard reports full coverage. The same code, the same accuracy, and only one of the two arrangements lets the failure be seen.
Verifier-first design already puts model-based checking where it belongs, as a stage whose verdict is itself evidence to be examined rather than a gate whose silence is taken for a pass. A useful way to keep the two apart in a codebase is that a hook's decision is allow or deny and must be derived from a lookup, while a verifier's output is a report with a confidence and a rationale that something downstream consumes. When someone proposes a hook whose decision requires a rationale to explain, the rationale is the signal that it is a verifier being installed in the wrong slot.
The residency design above rests on a claim that is easy to state and harder to keep true: that the enforcement sits at the layer where the effect actually occurs. The shell got a network namespace for exactly that reason. What happens when a boundary is drawn one layer too high, and the agent — with no intent to evade anything — finds its way past it, is the next thing to work through.
Hooks and deterministic interception — read the current version at https://daviddominguez.dev/learn/en/hooks-and-deterministic-interception/a-hook-enforces-only-what-a-machine-can-decide