Provenance-first assembly & screening (SC-0 + SC-1)
ModelRig assembles the prompt it sends to a model from segments of different provenance: your route's own instructions, your caller-supplied variables, and third-party content such as web-search results. The most-trusted slot — the system prompt — should carry only your instructions. Provenance-first assembly is the deterministic control that keeps it that way: every segment is stamped with a source and a trust tier, and the assembler — not a classifier — decides where each tier is allowed to appear.
This page covers the SC-0 assembly seam and the SC-1 screening engine — the screen: policy, the injection pattern pack, the optional in-process classifier, the per-tier actions, the fail-closed matrix, and the counts-only receipt.
Trust tiers → slots
Each segment carries {source, tier}. The assembler places a segment by its tier; the one structural rule is untrusted content never occupies the system slot — it is spotlighted into an isolated, JSON-encoded block in the user slot, wrapped in a per-run random delimiter, with a policy sentence in the system slot telling the model the block is data, not instructions.
source default tier slot
───────────── ──────────── ─────────────────────────────
template trusted system ───────────────┐
variable semi system (default) ├─► SYSTEM (trusted only)
│ + policy sentence when
│ anything is spotlighted
variable untrusted* user (spotlighted) ─┐ │
search untrusted user (spotlighted) ├────┴─► USER
tool_result untrusted user (spotlighted) │ directive
proxy_user untrusted user (spotlighted) ─┘ + [[UNTRUSTED-INPUT <nonce>]]
{"blocks":[{source,content}]}
[[/UNTRUSTED-INPUT <nonce>]]
* only when the variable is annotated `x-trust: untrusted` (S2, below)
The block is delivered per the serving adapter's untrusted_block capability: tool_result (a synthetic tool-result envelope — Anthropic's guidance for untrusted content) or user_json (a JSON block in the user turn — the default). Unicode tag characters (U+E0000–U+E007F), an invisible channel for smuggling instructions past a human reviewer, are stripped from untrusted text.
S1 — grounding assembly knob
Web-search grounding results are third-party content. The route-level knob chooses how they are assembled:
grounding:
query: "recent earnings for {{ticker}}"
assembly: legacy # legacy (default) | spotlight
legacy(default, and the behaviour when the key is absent): search results are concatenated into the system prompt, exactly as before. Every existing grounded route is byte-identical.spotlight: search results are routed through the assembler as an untrusted user-slot block.validatewarns onlegacyonce spotlight is proven for your route.
The flip from legacy to spotlight changes the assembled prompt, so it goes through the bake-off like a model swap — the shadow-equivalence verifier replays your route old-vs-new and reports Δ conformance / value-accuracy with CIs. The security win is available per route, proven per route.
S2 — x-trust per-variable opt-in
By default a caller variable is semi and renders in the system slot (unchanged). A variable that carries third-party content — a scraped document, a pasted email — can be annotated so it is spotlighted out of the system slot instead:
prompt:
system: ./system.txt
variables:
- topic # stays in the system slot (default)
- { name: document, x-trust: untrusted } # spotlighted into the user block
modelrig validate emits an advisory when a variable whose name looks like third-party content renders in the system slot without the annotation. The move is opt-in and bake-off-gated per route — nothing flips globally.
S5 — citation allowlist
When a grounded route extracts citations from the model's output, only URLs that appear in the injected sources are kept; a URL the model emitted that was not among the grounded sources is dropped and counted (RunMeta.grounding.citations_dropped). A model cannot smuggle an attacker-controlled link into your citation set.
The screen: policy (SC-1)
Screening is opt-in per route. Absent, or mode: off, and the route is byte-identical — no screening runs. A route opts in with a screen: block:
screen:
mode: enforce # off (default) | detect | enforce
sources: { search: untrusted, variable: semi } # may only TIGHTEN a tier, never loosen
detectors: [patterns@v1, classifier:defender@int8] # T0 always; T1 only if the peer + weights are present
on: { untrusted: quarantine, semi: flag } # action per tier when a finding meets the threshold
threshold: 0.8
output: { canary: true, strip_unicode_tags: true, citations: allowlist }
mode: detectscans and records a receipt; it never rewrites the request.mode: enforceacts on findings per theon:actions.
An unknown detector id, an unknown action, or a sources key that loosens a default tier is a load error (teaching message), never a runtime surprise.
Actions (per source tier, when a finding meets the threshold)
| Action | Behaviour |
|---|---|
flag | receipt only; dispatch unchanged |
strip | remove the flagged segment, keep the rest, dispatch; count what was stripped |
block | refuse pre-dispatch (invariant_violation); no request is sent |
quarantine | dispatch with tool_choice:none where the model supports it, else drop + count tool calls (A7); mark the step quarantined |
Quarantine honesty: quarantine governs the tool calls in ModelRig's own dispatch response — it stops the flagged turn from actuating a tool call through ModelRig and records the count. Your downstream agent loop is yours; the receipt makes the boundary explicit rather than implying end-to-end containment.
Fail-closed matrix
| Situation | detect | enforce |
|---|---|---|
| Policy load error / unknown detector | route load fails (validate-time) | route load fails (validate-time) |
| Deterministic detector throws at runtime | warn once, receipt degraded, send | invariant_violation pre-dispatch, no request sent |
| Optional classifier configured but unavailable | notice once, T0 only, receipt degraded | T0 only, receipt degraded; run proceeds on the deterministic tier |
Provider lacks tool_choice:none, action quarantine | n/a | drop + count tool calls; step quarantined |
A required deterministic screen that throws fails closed under enforce; a missing optional classifier degrades to T0. Both are visible on the receipt (degraded), never silent.
Hosted (mirror) lane
The hosted lane rebuilds the route from a mirrored bundle. The raw screen: policy is carried across the mirror and re-resolved on the gateway, so a screened route is never silently inert on the hosted lane. Because the mirror cannot guarantee a T1 classifier's local weights are present on the gateway, a route that declares screen: enforce with a classifier detector is refused at export with a teaching error — self-host it, or drop the classifier from the enforce policy (the T0 pattern tier re-resolves deterministically on the mirror). A pure-T0 enforce route mirrors safely.
The receipt (counts-only)
Every screened inference records a ScreenReceipt on inferences.meta.screen (zero migration). It is counts only — never a matched value:
policy_hash,detectors(id + version)findings: counts by detector × type × source (never a span or a value)risk_bucket(enum, derived from max confidence — the raw score is never stored)action,quarantined_tool_calls,canary_leaked,citations_dropped,degraded
The receipt rolls up per route at GET /v1/routes/:route/screen-stats (findings by type × source, quarantined/blocked counts, degraded rate, canary-leak count, policy-hash history) and renders in the console's route Control card, the per-step chip, and the /coverage screened-share column — all counts only.
SC-2 — PII/PHI Safe Harbor coverage
SC-2 adds deterministic HIPAA Safe Harbor identifier detectors beside scrub v1, over the same Detector interface, plus a customer-run de-id sidecar for the classes that need free-text NER (names, street addresses). It is PII/PHI screening with published detector coverage; recall is measured on synthetic PHI, not assumed — it is a coverage-and-measurement tool, not a compliance claim and not an audit. No detector erases PHI or makes a route safe by itself. Measured coverage, not a compliance guarantee.
Coverage table (the 18 Safe Harbor identifier classes)
Each row is tagged deterministic (in-process shape/checksum), sidecar (needs the customer-run NER model), or out-of-scope. Detectors are US-English-shaped; multilingual detection is delegated to the sidecar. Recall is "pending SC-3 probe" for every deterministic row until the SC-3 probe measures it on synthetic PHI — we publish a coverage table and measured recall, never a guessed number.
| # | Safe Harbor class | Tag | SC-2 detector | Deterministic rule (US-English) | Recall |
|---|---|---|---|---|---|
| 1 | Names | sidecar | sidecar | free-text NER (the hardest class) | via sidecar |
| 2 | Geographic < state (street/city/precinct) | sidecar | sidecar | street/city need NER | via sidecar |
| 2z | ZIP (the numeric part of #2) | deterministic | pii:zip | ZIP+4, ST 12345, or labelled ZIP: 12345 (bare 5-digit not claimed) | pending SC-3 probe |
| 3 | Dates (except year) incl. DOB/admit/discharge/death | deterministic | pii:date | ISO, US-numeric, month-name; a bare year is exempt; per-result cap | pending SC-3 probe |
| 4 | Ages > 89 / dates indicating such age | deterministic | pii:age_over_89 | numeric rule: an age phrase whose value is 90–130 | pending SC-3 probe |
| 5 | Telephone | deterministic | phone (scrub v1) | E.164 / US formats | (SC-1) |
| 6 | Fax | deterministic | pii:fax | label-anchored phone shape (fax:) | pending SC-3 probe |
| 7 | deterministic | email (scrub v1) | RFC-ish local@domain | (SC-1) | |
| 8 | SSN | deterministic | ssn (scrub v1) | dashed AAA-GG-SSSS (undashed is a stated limit) | (SC-1) |
| 9 | MRN | deterministic | mrn (scrub v1) | label-anchored MRN: … | (SC-1) |
| 10 | Health-plan beneficiary # / account # | deterministic | pii:account_id | label-anchored (account/member/beneficiary/policy …) | pending SC-3 probe |
| 11 | Certificate / license # | deterministic | pii:license_id | label-anchored (license/certificate/permit/DL …) | pending SC-3 probe |
| 12 | Vehicle identifiers (VIN) | deterministic | pii:vin | 17-char VIN with the check-digit rule | pending SC-3 probe |
| 13 | Device identifiers / serial numbers | deterministic | pii:device_id | MAC, Luhn-valid IMEI, label-anchored serial | pending SC-3 probe |
| 14 | URLs | deterministic | pii:url | http(s)://… (also the INJ-6 egress channel) | pending SC-3 probe |
| 15 | IP addresses | deterministic | pii:ip | v4 (octet-validated) + v6 | pending SC-3 probe |
| 16 | Biometric identifiers | out-of-scope | — | not a text shape | — |
| 17 | Full-face photos | out-of-scope | — | not text | — |
| 18 | Any other unique identifying number/characteristic | out-of-scope/sidecar | sidecar | open-ended — an honest limit | via sidecar |
Turn deterministic detectors on per route via the screen policy detectors: list — the safe-harbor@v1 pack enables all ten at once, or name individual pii:<slug>@v1 ids. This is opt-in and never widens the custody scrub floor (scrub v1's five-detector scrubContent behaviour is unchanged): a Safe Harbor detector runs only when the screen policy lists it.
screen:
mode: enforce
detectors: [patterns@v1, safe-harbor@v1] # or e.g. [patterns@v1, pii:date@v1, pii:ip@v1]
on: { untrusted: flag }
Findings ride the existing counts-only ScreenReceipt as pii:* count keys ({detector: "pii:date@v1", type: "pii:date", source, count}) — never a value. Zero migration: the receipt is still inferences.meta.screen.
The customer-run sidecar
For names and street addresses (free-text), configure a sidecar — your own de-id container (GLiNER Guard / GLiNER2-PII / Presidio). ModelRig ships only the adapter and the HTTP contract; it never bundles or re-hosts a model — you run the container and own its weights and license.
screen:
mode: enforce
detectors: [patterns@v1, safe-harbor@v1]
on: { untrusted: quarantine }
sidecar:
digest: img-sha256-abc123 # receipt id becomes sidecar:<digest>
endpoint: https://deid.internal/scan
timeout_ms: 2000
The contract is deliberately tiny and content-out-only-to-your-own-service:
POST <endpoint> { text, source, tier }
200 { spans: [ { start, end, type, confidence } ] } ← spans only, never text back
The sidecar's request body, its response body, and the span offsets never reach a receipt, log, telemetry span, or failure message — only per-type counts, under the receipt id sidecar:<digest>. If the sidecar is unreachable/timed-out it fails closed: under detect the receipt is degraded: true and the run proceeds on the deterministic tier; under enforce on a phi route the run fails pre-dispatch (invariant_violation) — an unscreened phi request is never sent.
The gated phi route posture
A route classified phi (route-level classification: phi) must satisfy, at load, all of:
1. mask effective mode ≥ redact (mask.mode: mask or block), AND 2. require: [zero_retention] OR a sidecar detector present in screen.detectors.
Else the route refuses to load with a teaching error naming the missing requirement. modelrig validate prints the resolved posture (mask≥redact · zero_retention · sidecar) and the specific unmet requirement. This makes no de-id claim — it enforces that PHI never rides an unmasked, un-screened, content-retaining route.
Claim ceiling (SC-2)
Allowed: "PII/PHI screening with published detector coverage", "recall is measured on synthetic PHI, not assumed", "deterministic identifiers in-process; names/addresses via a customer-run sidecar", "measured coverage, not a compliance guarantee". Banned (enforced by the CL-SC2-* rules in apps/modelrig-www/scripts/claim-lint.mjs): any HIPAA / Safe-Harbor compliance or de-id claim; any "erases / removes-all / makes-it-safe" absolute about PHI; any "scrub-it-to-nothing" claim; any "detects-all" claim; and any recall/coverage number stated without its measurement source (measured on synthetic PHI, or pending the SC-3 probe).
Honest limits
Deterministic assembly, a pattern pack, and an optional in-process classifier. They isolate untrusted content and flag known injection shapes; they do not prevent injection — no detector can. We publish the measured false-positive rate instead of a guarantee.
Provenance-first assembly is a deterministic control: it isolates untrusted content from your instructions and stops a flagged turn from actuating a tool call through ModelRig's dispatch. It is not a firewall and it does not sanitize input. The pattern pack and classifier that add a risk signal on top of this seam are the SC-1 screening engine; they are signals, never guarantees.