How it works / Technical docs

Architecture and flow

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
  1. 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.
  2. 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.
  3. Runner. One machine runs one job and is destroyed. Job types in the reference runner: quantify (sizes and counts, no content leaves), pull (source to raw/), scrub (raw/ to clean/), manifest, deliver (and a buyer-pull variant), reconcile and trace. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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_report travels 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
PDF – 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():

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
PDF 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. The evasion count 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, part2Over at 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.

Engine docs at commit 1c5af8f39ebb. Plain-language versions: What happens to your data, the workflow score.