Architecture and flow
The path data takes
intake ──► custody ──► runner: pull ──► runner: scrub ──► gate ──► release ──► runner: deliver ──► buyer
owner control raw/ in the raw/ → clean/ inline control watermark, encrypt
connects plane, deal's vault one source per per file plane: to the buyer's key,
or deal key prefix job + re- output Merkle root
uploads wrapped check sample
under KEK review
- Intake. The owner connects a source (database, bucket, SaaS account, mailbox, repository) with a credential they create in their own account, or uploads files. The control plane seals the credential and creates the deal's data key, wrapped under that product's key-encryption key (KEK). Neither the package nor the runner ever sees the KEK.
- Custody. One control plane per product owns the deal, its vault prefix, its keys and every job. It hands a runner one job at a time through a versioned claim and receives counts, hashes and reports back in the finish call. The claim and finish JSON is the only interface between runner and control plane.
- Runner. One machine runs one job and is destroyed. Job types in the reference runner:
quantify(sizes and counts, no content leaves),pull(source toraw/),scrub(raw/toclean/),manifest,deliver(and a buyer-pull variant),reconcileandtrace. Boot order: lock outbound traffic to the control plane only, claim, re-lock to the hosts this job type needs, pin them, drop DNS and IPv6, drop to an unprivileged user with no-new-privileges, then work. Details in threat-model.md. - Scrub. The engine (this package) does detection and rewriting; the runner does IO, parsing of hostile formats in a sandbox, media, models and the report. One scrub job covers one source.
- Gate. Every byte the scrub writes passes the inline residual gate before it is uploaded, then the independent re-check, then (when the deal names owner terms) the owner-identity check. A hit holds that file; nothing is ever "fixed and shipped" without passing again. Held files are listed by path hash and reason.
- Release. The control plane's release gate: the desk reviews a random sample of the delivered output and the version releases only when the profile's thresholds hold (measurement.md).
- Delivery. A deliver job watermarks per buyer and version, encrypts to the buyer's recipient key, and
records a Merkle root over every delivered file. The signed
deid_reporttravels with it.
Engine and runner
| Concern | Engine (datacool_scrub, shared) |
Reference runner (custody-runner, jobs/) |
|---|---|---|
| Rules, stand-ins, dates, profiles, coverage | patterns.py, patterns.json, dates.py, profiles.py, coverage.json |
passes the profile, the salt and the date key |
| Decoded view of every string | textnorm.py |
– |
| Tables, JSON, mail, chat | formats.py |
file IO, streaming, the gate writer |
| Office and OpenDocument rewrite | docs.py (XML parts) |
jobs/docs.py: legacy conversion in the sandbox, read-back check |
| – | jobs/docs.py, jobs/pdfclean.py (sandboxed rebuild), OCR redaction |
|
| Images | vision.py (decisions), media.py (boxes) |
jobs/media.py, jobs/vision.py (face model, code reader, OCR) |
| Audio and video | av.py (policy, limits, counts) |
jobs/av_redact.py (speech model, bleeps, frame boxes, subtitles) |
| Git history | gitid.py (identity rewrite, local git only) |
mirror, live-key scan, credential scan, filter-repo in the sandbox |
| Healthcare formats | health/ (HL7 v2, X12, C-CDA, FHIR, DICOM action table, fax, PHI signal) |
jobs/health.py and jobs/health_worker.py (sandboxed child, limits) |
| Paths | paths.py |
applies it to every shipped path |
| Detection ensemble | ensemble.py, thresholds.json |
jobs/detectors.py loads the pinned members |
| Re-check | recheck/ (pure) |
jobs/recheck.py (streams every shipped byte), jobs/verifier.py (optional) |
| Sensitive attributes | sensitive.py, sensitive.json |
jobs/sensitive_media.py (media detectors; none registered yet) |
| Identity graph | identity.py |
jobs/identity_map.py (seal, store, retry) |
| k-anonymity | kanon.py |
sets the table per file |
| Business ids, workflow score | bizids.py, workflow/ |
jobs/workflow.py (reads clean and raw files) |
| Output sample | review.py |
feeds it every shipped byte |
| Report | report/ (build, bands, schema, sign) |
jobs/deid.py (inputs, signing key) |
| Canaries | bench/files.py (fixtures) |
jobs/canaries.py |
A runner that vendors the package gets the engine column on every sync. The runner column is each runner's own code: which of those features another runner wires, and with which switches, is that product's documentation. Everything these pages call "the reference runner" is custody-runner at the same commit as this package.
Scrub order (reference runner)
jobs/scrub.py run():
- Verify every baked model against its pinned hash (
jobs/models.py); under a profile with no fallback, a mismatch or a missing baked model fails the job. - Activate the profile, load the ensemble members, run the canary self-test on every scrub path (measurement.md). Under random stand-ins the canaries run on a throwaway graph.
- Open the deal's identity map when the claim carries one, then set the k-anonymity tracker, the stand-in salt, the date rule and the business-id map.
- Per file, in order: the owner's never-share path rules (before download) → line formats stream from the vault through the scrub and the gate writer with no disk; everything else downloads to the work directory under a hashed name → second credential scanner (counts providers so the owner knows what to rotate; inside the sandbox it has no network, so nothing is verified against a provider) → malware scan → macro check → kind → healthcare format check → the kind's path (below) → upload through the gate.
- After the last file: normalized tables (when the claim asks), the workflow score (when the claim asks), the
linkage table and sealed map, the scrub report, the
deid_report, and the manifests (scrub-report.json,forensics.json,held.jsonl.gz).
Modalities
| Modality | Path | What ships |
|---|---|---|
| Text, logs, Markdown, code | streamed line by line; prose lines batched 64 at a time to the ensemble | every line scrubbed; NDJSON and logs filtered per record by the never-share content rules |
| Tables (CSV, TSV, spreadsheets after rewrite) | formats.scrub_table: header-driven column rules first, then cell text; column inference samples values when the header says nothing |
cells replaced in place; ZIP cut to 3 digits and ages capped where the profile says; quasi-identifiers counted |
| JSON and JSONL | formats.scrub_obj: key-driven rules (drop_key), FHIR datatypes, then every string value |
same structure; dropped keys removed |
| Mail (mbox) | parsed per message (formats.scrub_mbox): never-share rules per message; junk-labelled mail skipped; a message about password resets or credentials, or with a risky attachment name, dropped as quarantined; quoted history, signatures and sign-offs cut; headers paired into the identity graph; subject and body scrubbed |
one scrubbed JSON record per message (.ndjson.gz); attachment contents don't ship |
| Chat exports | per message and per user record (scrub_messages, scrub_slack_json) |
messages scrubbed; user ids kept as account ids |
| Git (bundles) | mirror in the sandbox; both credential scanners over all history; one history rewrite (gitid.py): authors, committers, taggers, trailers and emails in messages, and people in file contents (AUTHORS, CODEOWNERS, package manifests, copyright lines, every version) become per-deal stand-ins; credential formats replaced in contents and messages; credential files (keys, .env, keystores) removed from every commit; commit signatures stripped; commit dates follow the date rule; owner terms after the identity pass. Then the whole rewritten history (log --all -p) goes through the residual gate, the re-check and the owner-identity check |
rewritten bundle; any hit holds the repo; a never-share match anywhere in history drops it. Under random stand-ins git is held (random_stand_ins_git): the history rewrite only makes keyed stand-ins |
| Office and OpenDocument | every XML part rewritten (text across runs, tracked changes accepted, comments, notes, headers, charts, sheet names), metadata blanked, risky parts dropped, embedded packages rewritten recursively; legacy formats converted first by a headless office suite in the sandbox; read back and re-detected | rewritten file, or held |
| text and info fields checked; any hit or any picture: pages rendered, boxed (PDF word boxes and OCR), re-read until clean, rebuilt with a text layer from the redacted pages; no hits: rebuilt without info, XMP, annotations, forms, attachments, scripts, outlines or structure tree; read back for leftovers | rebuilt PDF, or held | |
| Images | OCR, boxes over detected text, faces boxed when the face model is present, QR and barcode payloads read and checked, metadata stripped, re-encoded | re-encoded image without metadata, or held |
| Audio and video | default: held (media_unreviewed) unless the claim allows media. redact: speech transcribed by the speech model, detected spans toned out, frames sampled and boxed (OCR text, faces), subtitles scrubbed, transcript scrubbed and shipped beside it; voices held under strict and HIPAA profiles (the transcript still ships). deliver: tags stripped only, content unchanged |
redacted media plus transcript, or held |
| Healthcare (HL7 v2, X12 837/835, C-CDA, DICOM; FHIR inside JSON) | parsed field by field in a sandboxed child under CPU, memory, size and wall limits; DICOM by the PS3.15 basic profile action table with keyed UIDs and a burned-in-text hold | rewritten message or file, or held |
| Fonts | shipped as-is after the byte gate (no text content to read) | as-is, or held when a content rule can't read it |
| Archives (zip, tar, and like) | extracted with path and size checks (safe_extract), members through their own kind's path, two levels deep |
members, each under <archive>/<member> |
| Anything else binary (sqlite, parquet, executables) | no reader | held (binary_unscanned) |
Paths that ship (folders, file stems, archive members) are scrubbed like text under every profile
(paths.py), extensions kept. File times follow the profile's date rule.
Unpacking and normalization
- Decoded view (
textnorm.view). Every detector also reads a normalized view of each string: NFKC, zero-width and bidi controls dropped, combining marks dropped, homoglyphs in mixed-script words mapped, percent-encoding, HTML entities, quoted-printable and bracketed at/dot undone, base64 runs that decode to text decoded. Each replacement maps back to the original span, and the stand-in is keyed on the decoded value, so an encoded email and its plain form get one stand-in. Theevasioncount says how many values only the view found. - Containers. Archives, mailboxes, git bundles, Office packages and PDFs are opened and every inner part goes through its own kind's path. Nesting is bounded (archives 2 levels, Office packages and decoded views by their own depth limits below).
- Normalized tables (claim switch, off by default). At the end of a scrub, CSV and TSV become typed JSONL
tables and JSON arrays of objects become JSONL tables, each with a stats-only schema (column, inferred type,
null rate, stand-in rate, distinct-count estimate; never values). They go through the same gate; a table
that trips it is held (
normalize_residual).
Limits
- Images: at most 512 MiB and 150,000,000 pixels, 30 frames; scanned PDFs up to 80 pages; 3 OCR redaction rounds with 3 px padding.
- Video: frames sampled at 1.0 fps plus scene changes over 0.3, at most 3600 frames at 1920 px wide; face boxes padded 0.15; bleep padding steps (0.15, 0.4, 0.8) s; at most 2000 bleeped ranges and 1500 boxes per file.
- Decoded view: up to 3 nested decodings and 256 decoder calls per string.
- Re-check: 1 MiB chunks with 512 characters of overlap; a verifier hit counts for review at score 0.9 or more.
- Healthcare PHI signal: threshold 25; weights (points per hit, cap) files 25/4, mrn 3/10, npi 3/10, icd 2/15, cpt 2/15, terms 1/20; Part 2 terms counted apart,
part2Overat 3 or more.
| A/V profile | Audio cap | Video cap | File cap | Strictest frame interval |
|---|---|---|---|---|
| strict | 2 h | 45 min | 8 GiB | 1.0 s |
| standard | 4 h | 90 min | 16 GiB | 2.0 s |
The reference runner's sandbox adds per-tool CPU, file-size and output limits and a wall clock; the
healthcare worker runs each file under 600 CPU seconds, 3 GB of memory, 2 GB of output and 900 seconds of wall
time. A file over any limit is held with a limit_* reason; the job carries on.