ModelRig Quickstart Routing & reliability Route bundles Probes Bake-offs & replay How it fits Grade protocol Optimization loop Caching lifecycle Bring your traces (OTLP) Observe a pipeline Migration playbook (T0–T2) Recognition playbooks Tenants & statements Published receipts Provenance & trust Artifact content custody The MCP oracle Use-case templates Template: ticket triage Template: document extraction Template: CS next action Template: lead qualification Template: compliance review Template: catalog cleansing Template: call disposition QA Template: financial classification Template: medical classification Leaderboard

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

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 }

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)

ActionBehaviour
flagreceipt only; dispatch unchanged
stripremove the flagged segment, keep the rest, dispatch; count what was stripped
blockrefuse pre-dispatch (invariant_violation); no request is sent
quarantinedispatch 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

Situationdetectenforce
Policy load error / unknown detectorroute load fails (validate-time)route load fails (validate-time)
Deterministic detector throws at runtimewarn once, receipt degraded, sendinvariant_violation pre-dispatch, no request sent
Optional classifier configured but unavailablenotice once, T0 only, receipt degradedT0 only, receipt degraded; run proceeds on the deterministic tier
Provider lacks tool_choice:none, action quarantinen/adrop + 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:

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 classTagSC-2 detectorDeterministic rule (US-English)Recall
1Namessidecarsidecarfree-text NER (the hardest class)via sidecar
2Geographic < state (street/city/precinct)sidecarsidecarstreet/city need NERvia sidecar
2zZIP (the numeric part of #2)deterministicpii:zipZIP+4, ST 12345, or labelled ZIP: 12345 (bare 5-digit not claimed)pending SC-3 probe
3Dates (except year) incl. DOB/admit/discharge/deathdeterministicpii:dateISO, US-numeric, month-name; a bare year is exempt; per-result cappending SC-3 probe
4Ages > 89 / dates indicating such agedeterministicpii:age_over_89numeric rule: an age phrase whose value is 90–130pending SC-3 probe
5Telephonedeterministicphone (scrub v1)E.164 / US formats(SC-1)
6Faxdeterministicpii:faxlabel-anchored phone shape (fax:)pending SC-3 probe
7Emaildeterministicemail (scrub v1)RFC-ish local@domain(SC-1)
8SSNdeterministicssn (scrub v1)dashed AAA-GG-SSSS (undashed is a stated limit)(SC-1)
9MRNdeterministicmrn (scrub v1)label-anchored MRN: …(SC-1)
10Health-plan beneficiary # / account #deterministicpii:account_idlabel-anchored (account/member/beneficiary/policy …)pending SC-3 probe
11Certificate / license #deterministicpii:license_idlabel-anchored (license/certificate/permit/DL …)pending SC-3 probe
12Vehicle identifiers (VIN)deterministicpii:vin17-char VIN with the check-digit rulepending SC-3 probe
13Device identifiers / serial numbersdeterministicpii:device_idMAC, Luhn-valid IMEI, label-anchored serialpending SC-3 probe
14URLsdeterministicpii:urlhttp(s)://… (also the INJ-6 egress channel)pending SC-3 probe
15IP addressesdeterministicpii:ipv4 (octet-validated) + v6pending SC-3 probe
16Biometric identifiersout-of-scopenot a text shape
17Full-face photosout-of-scopenot text
18Any other unique identifying number/characteristicout-of-scope/sidecarsidecaropen-ended — an honest limitvia 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.