The requirement usually arrives worded like this: inference for this workload must not be subject to third-country disclosure obligations, and you must be able to demonstrate it per request.
That is two problems. Stopping the traffic from going somewhere it should not, and being able to prove afterwards that it did not. This recipe covers both.
The shape of the answer
| Layer | What it does | Applies to |
|---|---|---|
| Blocked provider jurisdictions | Which providers your team may reach at all | Every request, including pinned models |
| Team sovereignty floor | Minimum ladder level a routing decision may select | Routed requests |
| Named router floor | Raises the floor further for one workload | Requests addressed to that router |
| Response header + request log | Per-request evidence of what actually served | Every request |
The jurisdiction list is the wall. The floors shape routing inside it. The log is the proof.
Step 1 — Decide the level
| Requirement | Floor |
|---|---|
| EU/EEA data residency | L1 |
| Residency plus certified European operation | L2 |
| No third-country control over the serving operator | L3 |
| No exceptions of any kind | L4 |
For most regulated-sector work the answer is L3: the concern is not where the disk is, it is who can be compelled. See Sovereignty for what each rung means.
Availability narrows as you climb. Check what your team can actually reach before you commit to a number in a contract — the providers page groups the catalogue by level.
Step 2 — Set the team boundary
In the Customer Portal, Team → Routing:
- Add every jurisdiction you cannot accept to blocked provider countries. This applies to every request from your team, including one that pins a model by name — it is the only control that does.
- Set the minimum sovereignty level to your floor.
The order matters. The jurisdiction list is the wall; the floor is how routing behaves inside it. Setting only the floor leaves an explicitly pinned model governed by that list alone — which is fine if it is tight, and a gap if it is not.
Step 3 — Give the workload its own router
Create a named router in Team → Routers for the regulated workload:
- Minimum sovereignty level: your floor (or higher — a router may raise the team floor, never lower it)
- Allowed models: the two or three you have qualified
- Weights: whatever the workload needs; quality-heavy is common here since the pool is already small
Now your application code carries a stable address:
resp = client.chat.completions.create(
model="@acme/regulated",
messages=[{"role": "user", "content": case_text}],
)
Re-qualifying a model becomes a portal change, not a release. And a second workload cannot accidentally inherit the policy, because it is addressed explicitly.
Step 4 — Verify, in both directions
Positive test — the level is what you expect:
raw = client.chat.completions.with_raw_response.create(
model="@acme/regulated",
messages=[{"role": "user", "content": "ping"}],
)
assert raw.headers["X-Stav-Sovereignty-Level"] in ("L3", "L4")
Negative test — the floor actually refuses. On a scratch router, raise min_sovereignty_level to L4, or combine your floor with an allowed_models list that nothing at that level satisfies. Then confirm the request fails:
import openai
try:
client.chat.completions.create(
model="@acme/floor-test", # scratch router, floor deliberately unsatisfiable
messages=[{"role": "user", "content": "ping"}],
)
raise AssertionError("expected the floor to refuse")
except openai.NotFoundError as e:
assert e.body["error"]["code"] == "no_eligible_models"
A floor you have never seen refuse anything is a floor you have not tested. no_eligible_models is the correct outcome — a named router does not fall back below its own policy.
Keep the positive assertion in your production code path, not just in tests. It is one header comparison, and it converts a silent configuration drift into an alert.
Assert on the header rather than on the configuration screen. The floor lookup degrades to no floor if the configuration cannot be read — an availability trade — so the header is the only per-request evidence that the policy actually held. Treat UNCLASSIFIED as a failure of the assertion too.
Step 5 — Produce the evidence
Every request is logged with the model, the provider, the effective sovereignty level, the token counts and the cost. In Monitor → Requests, filter by the key or the app and you have a per-request ledger.
What that gives an auditor is a different kind of answer than a policy document: not "we configured a rule", but "here are 4.2 million rows, each stating which rung served it".
Two practices make that ledger much more useful:
- One key per workload. Filtering by key is then filtering by workload.
- Send
X-Title(or register the app and sendX-Stav-App-Id). Attribution turns "something ran at L1" into "the nightly enrichment job ran at L1".
Step 6 — Handle the sensitive-domain gate
Stav classifies every routed request and detects those touching sensitive domains. Choose your team's fallback behaviour deliberately:
warn(default) — the request proceeds and the response carriesX-Stav-Sensitive-Domain. Surface it in your UI.refuse— the request fails rather than being served by a model with no recorded fit for the domain.
For a regulated workload with a human in the loop, warn plus a visible banner is usually right. For an automated decision path, refuse is the safer default.
What this does not cover
- A floor is not a data-processing agreement. It constrains where inference runs; your DPA, retention terms and subprocessor list are separate.
- Levels are indicative and versioned. They are Stav's assessment, dated, not a regulatory recognition. Cite them as such.
- A tighter floor costs something. Usually price, sometimes frontier quality. Make the trade once, at the team level, with the numbers in front of you — the routers page shows what your routed traffic costs against a frontier baseline.