ADR-0145: Model Envelope — a privacy-and-authority firewall at every LLM call
Date: 2026-07-26 Status: Accepted Deciders: Adrian Birlogeanu (Soft4U), Claude Opus 4.8 Review provenance (added 2026-07-27, #795): the controlling spec's §21 was reviewed via the implementation PRs' bot review (Codex on #747/#752/#754/#755/#756/#759/#760) — which surfaced and resolved real findings (e.g. #791/#792/#793/#794) before/at merge. The spec's §21 status was reconciled to "implemented" to match this Accepted record (they had drifted: the spec still read "pending review" after the code landed).
Context
The OSINT agents send data to LLMs and read structured decisions back. Two risks live at that boundary, and neither was controlled centrally:
- Outbound over-exposure. Agents interpolated whole records (person rosters, registry blobs, document summaries) into prompts — including identifiers the model does not need to do its job: registration/VAT/LEI numbers, addresses, dates of birth, national IDs, LinkedIn URLs, WHOIS registrant identity, content digests, internal finding/claim IDs. This is a GDPR data-minimization gap at the model boundary and needlessly enlarges the blast radius of any prompt/response leak or replayed workflow history.
- Inbound authority-smuggling. A model response is untrusted input. Nothing stopped a response from carrying a field only the deterministic layer may write —
risk_score,sanctions_status,is_sanctioned,verdict,source_winner— which downstream code could then honor, letting an LLM (or a prompt-injection) silently move a compliance decision.
This decision was taken while finalizing the #512 golden-record survivorship claim contract (epic #511): the model-visibility of each claim field belongs in the contract from day one, not retrofitted per prompt-builder. It composes with #516 (protected-field allowed-writer set) and #514 (typed absence/gap outcomes).
Decision
Introduce a Model Envelope — one boundary, three mechanisms — in a dependency-free module backend/app/services/osint/model_envelope.py, and wire every OSINT agent's LLM call through it, dark-launched behind settings.model_envelope_enabled (default False, flag-off byte-identical).
- Allowlist projection (
project_for_model(record, spec) -> (payload, alias_map)). The outbound payload is built by projection through an explicitProjectionSpecallowlist of semantic fields (category, severity, summary, dates, jurisdiction), never by passing a record and deleting keys. A complementary forbidden-key set hard-fails (ForbiddenKeyError) if any identifier class survives to the payload; scrubbing regexes (UUIDs, emails, URLs, ≥8-digit runs, 64-hex digests, control chars) + a free-text length cap are the backstop. The allowlist is the control; scrubbers are the backstop — denylist-only always leaks. - Run-scoped opaque aliasing (
AliasMap). Where the model must reference specific records, real identifiers are replaced with opaque per-run aliases; the alias→identifier map lives only in the calling activity's memory.AliasMapuses__slots__(no__dict__) and raises on__reduce__/__getstate__, so it cannot be serialized into a prompt, log, or durable history — a structural guarantee, property-tested. - Authority-key rejection (
reject_forbidden_authority,validate_model_response). Every response is recursively scanned; a forbidden authority key at any depth, an unknown alias, or an out-of-set enumerated choice raises aModelEnvelopeError, which the caller maps toOutcome.MODEL_OUTPUT_REJECTED— the strongest gap inGAP_PRECEDENCE(claims.py). The whole LLM output is discarded (never strip-and-accept); the rejection is a typed data-gap, never a swallowed exception (fail-closed, ADR-0067).
The authority-rejection set is bound to the SAME frozenset the merge layer uses — PROTECTED_FIELD_NAMES = frozenset(PROTECTED_FIELDS) exported from survivorship.py (#516) — so the outbound firewall and the survivorship allowed-writer set can never drift apart.
Wired into all four OSINT agents in migration order (most personal data first): adverse-media (#752), synthesis (#754, eval-measured on the OB Holding oracle — no recall regression, no allowlist widening), social-intelligence (#755), registry (#756).
Consequences
Positive
- PII minimization at the model boundary: identifiers are projected away; only semantic fields + the irreducible search/lookup key egress.
- Prompt-injection / authority-smuggling defense: a model response cannot move a compliance decision — a forbidden authority key discards the whole output, fail-closed.
- Property-proven invariants (
hypothesis): no identifier-shaped string ever egresses; alias maps never serialize; authority rejection is recursive and fail-closed. - One control surface: the allowlist lives in the claim contract, imported by both the merge layer and the response validator (single source of truth with #516).
Negative
- Adds a rejection surface. It must never become a silent-pass path — mitigated by making a rejection a typed
MODEL_OUTPUT_REJECTEDdata-gap that rides through the existing gap machinery. - Residuals, recorded honestly (never silently claimed covered):
- Irreducible egress: the subject's screened name / registry lookup key must go outbound (you cannot search or look up an opaque alias). Everything around it is projected/scrubbed.
- MCP tool-loop: the agentic MCP agents (registry→NorthData, social→BrightData) pull records mid-run; those raw tool RESULTS re-enter model context without passing the envelope. The envelope covers the fixed prompt + the response, not mid-loop tool results — tracked as issue #757.
- Aliases are sequentially minted (opaque, but not per-run-salted); cross-run non-correlation via a per-run salt is a cheap future hardening.
- Projection can, in principle, remove context a model reasoned over (e.g. raw name variants). Per the spec, any such regression must be measured on the eval set and the allowlist widened only with a recorded reason — never quietly. Synthesis was measured clean.
Neutral
- Dark-launched: flag-off is byte-identical; flipping
model_envelope_enabledon is Calibration-Review-gated. - The demoted top-level
risk_score(ADR-0123/0124) is exempted from authority rejection on the synthesis path (it is expected there and already demoted to a labelled synthesis value).
Alternatives Considered
Alternative 1: Denylist-only scrubbing
- Scrub known-sensitive patterns from whatever the agent already sends.
- Why rejected: a denylist always leaks the next unmodeled identifier. Projection through an allowlist is the control; scrubbers are only the backstop.
Alternative 2: Trust the model not to echo authority fields
- Rely on the prompt telling the model not to output
risk_score/verdict/etc. - Why rejected: a prompt is not a guarantee, and a prompt-injection defeats it. Recursive authority-key rejection is fail-closed regardless of what the model returns.
Alternative 3: Send real identifiers (no aliasing)
- Simpler; the model sees real IDs.
- Why rejected: enables cross-run correlation and exposes identifiers in any leaked/replayed prompt or history. Run-scoped opaque aliases (that cannot serialize) remove that exposure.
Decision context:
- Latency: negligible — projection/validation are in-process dict/regex passes per LLM call, no added I/O.
- Dependency surface: dependency-free runtime module (stdlib only) +
hypothesisas a dev-only dep for the property tests. - Debuggability: every failure is a typed
ModelEnvelopeError→Outcome.MODEL_OUTPUT_REJECTEDwith a reason; a rejected response surfaces as a visible data-gap finding, not a silent drop. - Reversibility: single config flag (
model_envelope_enabled), default off; flag-off byte-identical; revert = flip the flag. - Blast radius: additive across four agents; flag-off changes nothing; the shared
PROTECTED_FIELD_NAMESfrozenset couples this to #516 by construction. - Alternative considered: denylist-only scrubbing — rejected because a denylist always leaks the next unmodeled identifier.