The Global-tier AI inference path
TL;DR. Every AI call now passes through one default-deny choke-point. A firm picks a residency tier; the resolver proves an eligible, in-policy route or refuses; the OpenRouter adapter calls with ZDR on and pins to a brokered host; and a provenance row records run → PRM-AI-NNNNN → route → tier → policy_version, resolvable to the named processor. Only the Global tier is live; US/EU/India return "coming soon" with no silent fallback.
Why this exists
pramaan-docs ADR-120 splits inference quality from data residency and makes OpenRouter a routing tool, not the legal trust boundary (D3). The boundary is PRAMAAN's own custody plus the named processors and their terms. This Story is the build-side keystone (D14): the single resolver choke-point that makes an out-of-policy inference call structurally unreachable, not merely discouraged — plus the firm policy, the provider-eligibility matrix, the immutable PRM-AI registry, the ZDR adapter, the provenance audit, and the app contract the settings page codegens against.
What changed
Migration (0100)
Four tables. Two firm-scoped with full RLS (business.firm_inference_policies, business.inference_provenance); two platform-global reference tables, SELECT-only for the app role (ops.inference_provider_eligibility, ops.inference_model_registry). Up/down/up clean; alembic check reports no drift.
Default-deny resolver
resolve_inference_route(firm, matter, classification) loads policy → tier-gates → picks an active registry record → cross-checks the eligibility matrix. Any gap raises a typed PramaanInference* error. It returns a frozen ResolvedRoute — never a free-form model string.
Immutable PRM-AI registry
ops.inference_model_registry is append-only: GRANT SELECT only, REVOKE UPDATE,DELETE, plus a BEFORE UPDATE OR DELETE trigger that raises. A material change mints a new PRM-AI-NNNNN id; an old audit row always resolves to what was true at run time (D13).
OpenRouter ZDR adapter
The only module that calls a provider. Sets provider:{zdr:true, data_collection:"deny", only:[host]}, reads OPENROUTER_API_KEY from the runtime env, and captures provenance (model, usage.cost, provider_responses[], GET /generation?id=). HR-21: responses are parsed through Pydantic, never cast.
Provenance audit (D11)
record_inference_provenance writes one append-only row per run: run → PRM-AI-NNNNN → route type → tier → policy_version, joined to the registry for named processor facts — resolvable in one click, never opaque.
App contract
GET/PUT /app/inference/policy (set requires ToS acceptance) and GET /app/inference/providers (the versioned approved list). OpenAPI snapshot regenerated so pramaan-app codegens against it. CI ban test forbids direct provider calls outside the adapter.
Build-against-reality: ZDR routes via brokered hosts, not first-party
The Pramaan OpenRouter account enforces account-wide ZDR. Enforcing ZDR on a frontier model disables its first-party endpoint and routes via Bedrock / Vertex / Azure — same weights, same quality, different host. So the seed reflects the real serving route, not a fiction:
| PRM-AI | Processor (host) | Family | Route | provider.only pin |
|---|---|---|---|---|
| PRM-AI-56701 | Amazon Bedrock | Claude | brokered · ZDR | amazon-bedrock |
| PRM-AI-56702 | Google Vertex AI | Gemini | brokered · ZDR | google-vertex |
| PRM-AI-56703 | Azure OpenAI | GPT | brokered · ZDR | azure |
The resolver pins provider.only to the chosen processor's brokered-host slug so a request can never silently fall back to a non-ZDR first-party endpoint. Provenance records the real route (e.g. "Claude · via Bedrock · brokered · ZDR"), not "Anthropic first-party".
First-run safety (the system-boundary gate)
Verified with real requests on a freshly provisioned empty firm (no policy row) through the real ASGI pipeline + RLS + DB — not a green unit test on a seeded fixture.
| First-run state | Behaviour exercised end-to-end |
|---|---|
Empty firm, GET /app/inference/policy | 200; synthesized Global default, policyVersion 1, all four tiers with live/coming-soon status |
PUT Global with acceptedTermsVersion | 200; ToS acceptance stamped (acceptedByUserId, acceptedAt) |
PUT a coming-soon tier (eu) | 409 pramaan.inference.tier_unavailable — rejected, never downgraded |
GET /app/inference/providers | the three migration-seeded brokered-ZDR processors, each brokered + training-excluded |
| Resolve against the seeded matrix + write provenance | route resolves to a real PRM-AI-NNNNN; provenance row joins back to processor facts |
| UPDATE / DELETE on the PRM-AI registry | blocked by the append-only trigger (verified live) |
Scope boundary — foundation in, precise follow-ups out
This lands the coherent Global-tier slice: schema + resolver + registry + eligibility + ZDR adapter + provenance + the app contract. Deferred (named in the PR): (1) wiring the adapter into a live brain call path — the brains live in pramaan-agents; the adapter + resolver are the contract they will call. (2) Platform-admin endpoints to mint registry / eligibility rows — seeded by migration today, append-only by construction. (3) Envelope-scoped re-consent (D8) beyond ToS-acceptance capture. (4) US/EU/India live routing — coming-soon until the EU enterprise endpoint or direct provider contracts land (ADR-120 Constraints). (5) Lambda env injection of OPENROUTER_API_KEY — the secret + IAM exist (pramaan-infra Story-1284); if the function env wiring is not yet attached, that is the one small infra touch, flagged in the PR.
How it was verified
ruff checkon every changed/new file — clean.- App + admin apps import cleanly;
make openapiregenerated the app snapshot (admin untouched, deterministic). New paths:/app/inference/policy,/app/inference/providers; operation idsappGetInferencePolicy,appSetInferencePolicy,appListInferenceProviders. - Unit + contract tests green: resolver default-deny + tier gating (5 cases), adapter request-body knobs + provenance capture + HR-21 rejection, tier facts, migration statics, the CI provider-call ban, and the inference-policy OpenAPI contract.
- DB-backed (isolated Docker test DB): migration up → down → up clean,
alembic checkreports no drift, registry UPDATE/DELETE blocked live, the empty-firm route test, and a resolver+provenance round-trip against the seeded matrix. CI Postgres is the final gate. alembicsingle head (0100) chained off0099.
Engineering outcome: an out-of-policy inference call is now structurally unreachable. The model adapter cannot be handed a free-form model string — it receives an eligibility-checked, ZDR-pinned, immutable route, and every run leaves an audit trail that resolves to a named processor. The settings page (separate app-lane ticket) now has a true contract to build against.