Increment 0A — removed five false compliance guarantees from the request path

2026-08-05

routersecuritygovernance

LOCKSTEP TRACEABILITY MATRIX --- api_endpoints: ["POST /v1/chat/completions"] sdk_methods_updated: ["none"] mcp_tools_updated: ["none"] ---

What We Built

Nothing new. This removes claims the platform was making but could not keep — the precondition for building compliance enforcement on top of the routing engine.

The registry manufactured retention facts. inferDataRetention() hardcoded anthropic/google"zero" and openai/bedrock"no-training". Retention is a property of a specific account, product and feature, not of a vendor: Anthropic's standard API retention is 30 days absent a separate zero-retention agreement, and Vertex ZDR depends on account configuration. Downstream, checkEndpointConstraints treated those labels as satisfying dataPolicy: "zero", so the router honored a contractual promise nobody had made. Retention now defaults to "unknown", which never satisfies a requirement; operators may assert it explicitly via ModelProviderConfig.dataRetention.

We shipped a ZDR flag that did nothing. ProviderFilter.zdr is documented in the TS SDK as "only route to providers with ZDR support". It was validated and then discarded — applyProviderFilter, the function that would enforce it, has zero callers. Its fallback list also named anthropic/azure as "verified". That list is now empty (the platform cannot attest ZDR on a customer's behalf; only a tenant's own attestation counts), and the route returns a structured unsupported_policy / zdr_not_configured error rather than appearing to honor the request.

Raw prompts were disclosed to a third-party embedder. Semantic caching embeds the last user message via a hosted provider (OpenAI, else Google) on _both_ lookup and store, before the completion provider is selected — gated only on a provider API key being present. A key configured for routing is not consent to add a processor. External embeddings now require an explicit opt-in; without it the cache is inert in both directions. Separately, bypassCache guarded only the lookups, so cache: false requests were still embedded and persisted after completion — in both the streaming and non-streaming paths.

Untenanted cache lookups scanned every tenant. relevantPartitions() scanned all partitions when no tenant was supplied, so any caller omitting the optional RouteCompletionParams.tenantId could be served another tenant's cached response. Absent tenant now means the "system" scope — the same bucket untenanted writes go to.

The DLP baseline emitted nothing, three times over. The check registers as "dlp" but both callers looked up "dlp_check"; the param is default_action (not action, and "warn" isn't a valid DlpAction); and under a log/redact policy the check returns pass: true, so callers testing !pass observed nothing even once wired. CheckVerdict now carries findings — observations reported independently of the pass/fail policy decision.

Strict privacy did not stop content persistence. recordRequestContent wrote full plaintext messages, system prompts and responses gated only on contentLogging.enabled, independent of privacy.mode. Strict mode already stripped decision-lineage fields, so content was the inconsistent surface.

Why It Matters

Every governance claim rests on constraints actually holding. We emit tamper-evident decision lineage, chaining a lineageDigest into each audit row's provenanceDigest. That evidence is an asset only while what it records is true — a signed record of a guarantee we never kept is not neutral, it is a durable, timestamped, discoverable record of a breach. Removing unkeepable claims has to precede building on them.

How It Works

The governing rule is that absence of evidence is never evidence of compliance:

// Retention cannot be derived from a provider id.
export function inferDataRetention(_provider: string): DataRetentionPolicy {
  return "unknown";
}

"unknown" is not a weaker "standard" — it means no attested fact exists, and it fails every retention constraint. The constraint messages distinguish the two cases, because an operator seeing "unattested" needs to declare a fact, whereas a known-insufficient endpoint genuinely cannot serve the request.

The Numbers

  • 4 false guarantees removed; 6 live defects closed.
  • 19 tests added; full unit suite 9238 passing, 0 failures (was 9219).
  • 4 tests previously _pinned_ the false guarantees — including one asserting Anthropic

is zero-retention and one named "getZdrProviders returns non-empty". Rewritten to assert the honest contract.

  • Cross-tenant isolation verified by reverting the fix: 2 of 4 new tests fail against the

old code with the untenanted read returning another tenant's row, and pass after.

Competitive Edge

Portkey (now Palo Alto Prisma AIRS) and OpenRouter ship governance as controls; Credo AI produces evidence but has no runtime. The intersection — enforcing inside the routing decision _and_ proving it — is only defensible if the enforcement is real. This increment is the unglamorous half of that claim: we now assert less than we did yesterday, and everything we still assert is true.

Lockstep Checklist

  • [x] API Routes: POST /v1/chat/completions now rejects provider.zdr: true with a

structured unsupported_policy error. No request/response schema fields added.

  • [x] TS SDK: no change required — ProviderFilter.zdr already exists; its behavior

changes from silently-ignored to explicitly-rejected. Normalizing the only/ignore/zdrproviderAllow/providerDeny spellings is Increment 3.

  • [x] Python SDK: no change required (same reasoning).
  • [x] MCP Schemas: not agent-facing.
  • [x] Master Record: docs/architecture/master-capability-record.mdx — "Data-protection

policy resolution" added to Tier 1, and the facts-are-attested-never-inferred rule recorded under "Router invariants".

Increment 0B — observability

A 2xx response cannot demonstrate that no text left the process, so egress and persistence decisions are now counted rather than inferred:

  • br_embedding_egress_total{provider} — incremented in SemanticCache.embed(), the single

point where request text is handed to an external embedder. Counted _before_ the call, so a provider error does not erase the fact that the disclosure already happened. Mirrored in-process as cache.embeddingEgressCount so tests can assert "this path disclosed nothing" directly.

  • br_semantic_cache_writes_total{outcome}stored, skipped_bypass,

skipped_cache_not_ready, skipped_admission, skipped_cache_replay. The opt-out is now an observed fact rather than an absence of evidence, on both the streaming and non-streaming store paths.

  • br_content_persistence_total{outcome}persisted, skipped_privacy_strict,

skipped_not_enabled. The gate reports _which_ constraint was binding.

  • Registered cache_admission_skipped_total, which model-router.ts had been incrementing

since the admission gate shipped — incCounter drops unknown names, so it silently no-opped.

Increment 1 — processor facts, requirements, evaluation

src/security/data-protection-policy.ts establishes the type model. Three separate concepts, deliberately not conflated:

type FactValue<T> = { status: "known"; value: T } | { status: "unknown"; reason: string };

Not T | unknown — that union collapses to unknown in TypeScript and erases the type. The discriminant also carries _why_ a fact is missing, which is what an operator needs in order to go and obtain it.

  • ProcessorFact — what an ACCOUNT promises (per tenant, credential, service, region, with

effectiveFrom/effectiveTo, evidenceRef, assertedBy). unattestedFact() is the default for anyone nobody has attested.

  • DataProtectionRequirement — what the tenant demands. retentionMaxDays and

customerDataTraining are independent: the old standard < no-training < zero ladder conflated how long data is kept with whether it may be trained on.

  • EvaluationResult — computed per request, never memoised as a label on a processor.

Composition is tighten-only, following effective-scope.ts — retention takes the minimum, regions intersect (and an empty intersection cannot be re-widened), training prohibition is sticky, frameworks union, evidenceMode: "required" wins. Every operation is a meet on its lattice, so composition is order-independent and a less-trusted source can never widen a more trusted one. One deliberate inversion from effective-scope: an absent source falls back to the platform floor, not to "unrestricted".

Fact currency is live-at-request: facts outside their window are excluded from resolution entirely rather than treated as unknown, and snapshotDigest() commits the resolved requirement plus resolver version to that request's evidence. The snapshot is historical evidence for one request and authorizes nothing future — a revocation closes effectiveTo and takes effect on the next request.

Follow-ups

  • Blocking is still off. The inbound DLP baseline had a high-risk (SSN / credit-card)

400 branch that could never execute. Switching it on during a wiring repair would be a silent behavioral change with real false-positive risk (ssn and aba_routing are broad patterns). Enforcement belongs in the warn→enforce rollout with per-tenant opt-in.

  • Egress counters (Increment 0B) — a 200 response cannot prove no outbound embedding

occurred. The D1 tests verify the cache is inert without an embed function; they do not yet assert zero fetch calls from the init path.

  • Existing deployments using dataPolicy: "zero" will now fail closed until they

declare dataRetention in provider config. That is the intended consequence of no longer lying, but it is a live behavior change.

Increment 1 (wiring) — resolution in the request path

dataProtectionMiddleware (src/api/middleware/data-protection.ts) resolves the effective requirement once per request and stashes it on the context, mounted on /v1/* after apiKeyAuth (server.ts:1027) and before both guardrails and the prompt cache (server.ts:1107). Placement is load-bearing: Hono runs middleware in registration order and the exact prompt cache can return a hit before anything downstream executes, so a policy resolved later would not govern cache reads.

Sources compose platform floor → tenant → request. Strict privacy mode is projected as a zero-retention requirement so the two settings cannot drift apart. Resolution failure leaves no snapshot, and consumers treat a missing snapshot as "no attested requirement" — never as permission. The digest is returned as X-BR-Data-Protection-Digest so a caller can see which policy governed their request without evidence-store access.

Increment 2 — derivation, persistence, and policy-bound cache

Boundary predicates in data-protection-policy.ts:

  • mayDerive — an embedding is disclosure to a processor, not storage. A tenant that has

declared _any_ constraint may not derive against an unattested processor, which is precisely the semantic-cache embedder's status (selected at boot, no per-tenant agreement).

  • mayPersist — a zero-retention requirement forbids content at rest in any store,

including ours. Wired into the content-persistence gate as a skipped_zero_retention outcome.

  • mayReadCache — cache entries now carry the policyDigest in force when they were

produced, and a constrained request is served only on an exact match. A hit computed under one posture served under another would launder content across policies. Entries predating policy binding carry no digest and are never served to a constrained request; unconstrained tenants keep prior behavior exactly, so binding cannot regress cache hits for anyone who configured nothing.

Threaded end to end: RouteCompletionParams.dataProtection → both semanticCache.lookup sites and cacheStorestore(..., policyDigest).

Increment 3 — endpoint eligibility at the actually-selected endpoint

Fact source without a migration. Processor facts live in the tenant settings jsonb (parseProcessorFacts, processorFactsFromTenantSettings) — they are per-customer legal assertions read on a path that already loads settings. Anything unparseable is dropped rather than coerced, so a malformed assertion can never become a permissive one, and undeclared fields stay unknown rather than defaulting.

Credential identity without a signature refactor. Eligibility depends on _which account_ will see the data, but resolveAuth yields only secret material and threading an identity through every caller would be a large refactor. credentialRefFor() derives a stable, domain-separated, truncated digest of the secret: the secret never enters evidence, the reference may. applyAuthOverride stamps it onto the endpoint clone. selectFact prefers an exact credential match over a "*" wildcard, because an account-specific agreement is more precise than a vendor-wide one.

Enforcement at B3. assertEndpointEligible runs after selection, after BYOK, _and_ after BYOK-alternative fallback — the only point at which the endpoint that will actually be called is known. Ineligibility raises DataProtectionIneligibleError (403, not 503: retrying will not help), carrying reasons that name the unmet requirement and never contain request content. The check is passed as a predicate so the router stays free of policy imports, and no-ops entirely unless the tenant declared a constraint.

Lockstep. ProviderFilter.zdr is now @deprecated in the TS SDK with the reason stated, and DataProtectionRequest documents the replacement path. The Python SDK exposes no chat or provider-filter surface, so there is nothing to mirror there.

Increment 4 — classification obligations (warn mode)

obligationsFromDetections maps detected PII classes to obligations (cardholder_data, health_data, personal_data), and tightenWithDetections folds them into the requirement — health identifiers imply a training prohibition, cardholder data implies zero retention.

Directionality is the whole point: detection may only ever TIGHTEN. A regex miss is not proof that content is free of PHI or card data, so a clean scan can never relax a declared obligation. Wired into the now-live DLP baseline as warn mode only — headers (X-BR-Data-Obligation), logs, and br_data_obligation_total{implies,mode} — because the pattern library has real false-positive risk. Enforcement stays per-tenant opt-in.

Remaining

  1. Exact-cache relocation. prompt-cache.ts:519-520 still returns before

runApiBeforePromptBuild(), so its hash need not represent the final prompt. The policy now resolves before it, but the lookup itself must move into the handler after final prompt construction to fully satisfy the canonical ordering.

  1. Evidence-required mode. evidenceMode: "required" composes and is carried in the

snapshot, but the receipt protocol is not implemented — including the streaming provisional-then-final receipt with an evidence-failure SSE event.

  1. Artifact projection. defaultArtifactControls still emits 3 hardcoded SOC2 controls

rather than projecting the resolved policy and its per-constraint origins.

  1. Admin surface for processor facts (they are settings-editable today, with no validation

endpoint).