Artifact content custody
ModelRig records a metadata graph of the work products your pipeline produces — each artifact's identity, lineage, evaluations, and an integrity hash of its bytes. By default the bytes themselves are never stored: the hash proves what existed, and the content stays wherever you already keep it.
Content custody (E2-C) stores the bytes too — and it is on by default for every new organization (MD-1), because it is what turns your captured prompts, outputs and evidence into analysis over the whole graph. It stays consented and opt-out in one setting: every signup presents the published content-custody terms and records the customer's affirmative acceptance, and any org can dial it back from account settings — the console or the set_org_settings MCP tool — by setting posture off (capture nothing) or metadata (hashes only), or with a zero_retention route. The account posture is the customer control; there is no deployment env var to set.
Managed is the unconditional default
The ratified product direction (Matt, 2026-08-27) is that managed content custody is THE default as every customer signs up — unconditionally, with no operator switch. A new org is created managed, and the acceptance is recorded as an immutable tos_acceptances row (document version + timestamp, context = 'signup') — the lawful-basis artifact, on the same append-only consent log as the ToS clickwrap. The customer can move the org to metadata at any time in settings.
The default lives in the database (migration 00069: the orgs.artifact_posture column default and the signup-trigger fallback both default managed), so it holds for every org-creation path, including magic-link signups. Only the default changes; the isolation guarantees, the PII/PHI scrub floor, the zero_retention carve-out, and the account-settings opt-out (posture off or metadata) are unchanged. Existing orgs are untouched by the migration. The two axes below stay exactly as precise.
What the console cannot do yet. You can see and export every byte in the console; there is no self-service delete button yet — opting out (postureofformetadatain account settings, or azero_retentionroute) stops future capture rather than removing what is already stored.
Two independent axes
Custody and telemetry are separate switches. Turning one on says nothing about the other.
| Axis | Question | Values | Where it lives |
|---|---|---|---|
| Telemetry posture | Is telemetry captured for optimization at all? | optimized · pure_router | orgs.telemetry_posture (00015) |
| Content custody posture | May artifact bytes be stored, and where? | metadata · managed (default) | orgs.artifact_posture (00032, default flipped to managed in 00069) |
A pure_router org can still enable managed custody; an optimized org can stay metadata-only. Neither implies the other.
Content-custody postures
| Posture | Behaviour |
|---|---|
metadata | Hash + metadata only. No content object is ever created. The upload-grant endpoint refuses fail-closed (403). The one-setting opt-out from the managed default. |
managed (default) | Content may be uploaded to the ModelRig-owned object store, isolated under a per-org key prefix ({org_id}/{artifact_id}). Retention is enforced by the store's lifecycle rules per classification. New orgs default here (00069); existing orgs are unchanged by that migration. |
Changing posture between managed and metadata is an org-owner action. Adding a posture value is a database migration — a new place bytes may live is reviewed like the authority it is.
How content moves (managed tier)
Content is stored through a posture-gated, consented, write-once path — the bytes never touch ModelRig's control-plane database, and they never flow through the telemetry exporter (which has no path to content, structurally and permanently).
1. Your pipeline saves an artifact. Its metadata row is recorded as always. 2. If the route has opted content on and the org posture is managed and the run is not zeroRetention, the SDK requests an upload grant from POST /v1/artifacts/uploads (a rig_sk_ key with the artifact-write scope). 3. The server authorizes the grant against the artifact's own org (never the caller's assumed org), enforces the size ceiling, and refuses a second upload for the same artifact (content is immutable — it writes once). It returns a short-lived presigned PUT. 4. The SDK uploads the bytes directly to the object store. On any refusal or failure it degrades to metadata-only — a counted, warned skip; it never throws into your run and never retries a deterministic refusal.
Reading content back (the console panel, or an artifact-read key) mints a short-lived presigned GET the same way. Presigned URLs and content hashes are never logged.
On by default, and opt out in one setting
Content custody is on by default — every new org is created managed, having accepted the published terms at signup. The gate is your org content posture (managed → stores; metadata → stores nothing), so the opt-out is always one setting. On the SDK side there is nothing extra to configure for the common case:
# Capture on/off is your ACCOUNT POSTURE, not an env var — the artifact namespace
# is on by default once a control plane (a MODELRIG_API_KEY) is configured, and
# you opt out by setting org posture `off` (capture nothing) or `metadata` (no
# content) in the console or the set_org_settings MCP tool. There is no
# MODELRIG_ARTIFACTS / MODELRIG_ARTIFACTS_CONTENT env gate.
# Scrub mode is ALSO an account/task setting resolved from the control plane —
# off | detect | redact | block, set per task in the console or via
# set_org_settings. There is no scrub env var (MODELRIG_ARTIFACTS_SCRUB was
# removed); a rig with no control plane uses the built-in default (`detect`).
# MODELRIG_CONTENT_UPLOAD_URL # OPTIONAL — defaults to the ingest host +
# # /v1/artifacts/uploads; set only to override.
# MODELRIG_CONTENT_API_KEY # OPTIONAL — falls back to MODELRIG_API_KEY; the
# # key must carry the `artifact-write` scope.
The SDK attempts upload when the namespace is on and a key is present; the server's org posture decides (a metadata/off org 403s → the SDK degrades to metadata-only). A half-configured transport (no scoped key) degrades the same way — never silently half-on.
Scrubbing (before upload)
When content is on, a deterministic scrub pass runs on the bytes before they are uploaded. Findings are recorded as counts by type, never the matched values — recording a value would move the PII into the control plane, the exact thing custody contains.
| Mode | Effect |
|---|---|
off | No scan; upload verbatim. |
detect (default when on) | Scan; count findings by type; upload verbatim. |
redact | Upload a masked copy as a superseding version; the original keeps its own hash and stores no content. |
block | If anything is found, refuse the upload (counted). The artifact keeps its metadata + hash; it stores no content. |
Detectors (v1): US SSN (delimited), PAN (Luhn-valid), email, E.164/US phone, labelled MRN.
The same scrub pass also runs on bake-off review samples before they are persisted: a --keep-outputs bake-off scrubs each sample's input and output with the task's mode (detect counts onto the sample's arm_meta.scrub, redact masks, block stores a metadata-only sample), so retained review content never carries un-scrubbed PII either. The mode a sample gets is the rig's per-task content-custody scrub setting, resolved from the control plane (a direct-mode rig has no control plane to consult, so a console-set per-task mode does not reach its samples); a direct-mode rig uses the built-in default — detect when content custody is configured, off otherwise, and samples then persist verbatim. Scrub is an account/task setting, not an env var (the MODELRIG_ARTIFACTS_SCRUB override was removed).
Scrub floor forpii/phiartifacts. An artifact saved withclassification: "pii"or"phi"resolves an effective scrub mode of at leastredact, regardless of the task's configured mode — so PII/PHI content is never uploaded verbatim just because the mode wasoff/detect. A strongerblockstill wins (it refuses the upload entirely).data_classificationis a fixed enum —public,internal,confidential,pii,phi— validated at the SDK save gate, at server ingest, and by a DB CHECK; an unknown value is dropped with a teaching warning rather than silently stored.
Deterministic ≠ DLP. These are regex/checksum detectors, not a model. They catch well-formed identifiers and will miss obfuscated or free-form PII. A clean detect result is not a guarantee of no PII. Model-assisted scrubbing is a later version.
Zero-retention still wins
A run marked zeroRetention: true (or a route requiring zero_retention) stores nothing — no metadata, no hash, and no content. The refusal is structural: it returns from the save gate before scrub or any upload grant is even reached. Custody being on does not weaken it.
Retention
Retention is enforced by the object store's lifecycle rules, keyed to each artifact's data classification and prefix — native TTL, not a cron. expires_at on the metadata row records the intended horizon; the store is what deletes the bytes.
What metadata we record, and why it's not content
The attempt envelope (AttemptMeta@v1, on inferences.meta) and the run envelope (RunMeta@v1, on runs.meta) record the metadata that answers analytical questions — what finished how, how much it cost, did the model change under us — without ever recording the content. Every field is one of five classifications: enum · number · boolean · hash · identifier. There is deliberately no free-text field. This is the structural reason the self-describing analytics API (GET /v1/analytics/meta, POST /v1/analytics/query) is metadata only — enums, numbers, hashes, identifiers; never content: a caller cannot name a column that could carry a prompt or a completion, and the one thing that would be content — the rendered request and the raw output — is stored as a sha256 hash, never the text.
The list below is the same one GET /v1/analytics/meta returns (it is derived from the grammar, so this doc and the endpoint cannot drift).
Attempt envelope (inferences.meta, AttemptMeta@v1)
| Field | Classification | What it is |
|---|---|---|
finish_reason | enum | stop / length / tool_calls / content_filter / refusal / error / other |
refusal | boolean | provider-signalled or adapter-derived (documented per adapter) |
streaming | boolean | whether the caller streamed deltas |
model_snapshot | identifier | provider-reported model id (e.g. gpt-5.2-2026-06-10) — a drift signal, not proof |
context_bucket | enum | ≤4k / ≤16k / ≤64k / ≤200k / >200k |
error_code | enum | coarse provider error slug (never the message) |
region | identifier | only when the provider reports it |
http_status | number | coarse transport status |
reasoning_tokens | number | thinking tokens, when reported |
response_chars | number | length of the output (not the output) |
cost.in / cost.out / cost.cache_read / cost.cache_write / cost.other | number | USD cost components (omitted when the model is unpriced — never an invented $0) |
input_hash | hash | sha256 of the rendered request (system + user + schema) — a fingerprint, never the text |
output_hash | hash | sha256 of the raw output text |
arm | identifier | the live A/B experiment arm this call was drawn onto (mode='experiment', LRE-2) — present only on experiment traffic; absent on a pin / non-experiment call, so the row stays byte-identical. Lets the console's A/B readout group outcomes by arm |
sampling (temperature / top_p / max_output_tokens, all numbers), tool_calls (declared tool names — an undeclared name is hashed — plus a count), and grounding (citation count + a search-provider identifier) are recorded the same way: numbers, enums, identifiers. A provider_request_id identifier is kept for support correlation.
Reserved dimensions (inferences.tags)
subject, feature, user, session, cost_center are ordinary tag keys (GIN-indexed; no schema change) that the grammar exposes as first-class group-by columns. subject is the customer/tenant the work is for — an opaque customer-side id, hashed by you if it looks like PII. They are business dimensions you attach; ModelRig never derives them from content.
Run envelope (runs.meta, RunMeta@v1)
| Field | Classification | What it is |
|---|---|---|
pipeline_sha | hash / identifier | git sha, auto-filled when available |
config_hash | hash | hash of the run's config |
trigger | enum | cron / api / manual / replay / other |
actor | identifier | an opaque identifier, not a display name — a free-text actor is dropped, counted, and warned |
input_hash | hash | sha256 of the run input |
Both envelopes are sanitized at production (the SDK producer) and at the boundary (server ingest for the wire, buildRunMeta for the run): any string that is not an enum/identifier/hash — too long, or containing whitespace — is dropped and counted. A property test feeds content-shaped strings into every field and proves none survive, in the attempt envelope, the run envelope, the wire, the OTLP span attributes, and the /query result. Zero-retention still wins: a zeroRetention run records no envelope at all.