# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Purpose

> Provenance note: llm-redact was developed privately before its public
> v1.0.0 debut; the public version numbering starts there. This document
> and the docs/ tree deliberately avoid the private lineage's internal
> version numbers.

llm-redact is an LLM information redactor: it prevents private information from being sent to LLMs by agentic tools. It substitutes placeholders for private information on outgoing requests, then restores the real values in incoming responses, transparently to the agentic tool's user.

## Open-core placement rule (owner's standing instruction)

Paid features (Pro tier and above) live in the separately-distributed
**llm-redact-pro** package, and stay there to the greatest extent possible.
This repository keeps only the redaction/rehydration product, generic seams
(`plugin_api` + `registry`), and the security floors that must hold even
without llm-redact-pro, such as never forwarding a proxy credential. When
adding or changing a Pro feature, put the implementation in llm-redact-pro.
**Before writing any Pro-feature code in this repository — a new seam, a
config shape, a user-facing surface, or a guard — ask the owner first**,
explaining why it belongs here. Never add authentication, user or seat
logic here.

## Commands

```bash
uv sync                                        # install deps (incl. dev group)
uv run pytest                                  # all tests (live tests deselected)
uv run pytest tests/test_rehydrate.py -x      # run one file
uv run pytest tests/test_vault.py::test_reverse_lookup   # run one test
uv run ruff check . && uv run ruff format --check .      # lint + format check
uv run ruff format .                           # apply formatting
uv run mypy                                    # strict type-check of src/
uv run llm-redact serve [--config PATH] [--port N] [--session NAME] [--log-format json]  # run the proxy
uv run llm-redact serve --check                # full startup build, no socket: the deploy/reload gate
uv run llm-redact config show [--path]         # effective config as TOML; env overrides named
uv run llm-redact status [--json] [--ca CA --cert C --key K]  # query a running proxy (mTLS flags)
uv run llm-redact sessions list|prune --older-than 90d   # vault lifecycle
uv run llm-redact lookup «EMAIL_001» | --value V         # local secret lookup
uv run llm-redact override CODE --once|--always | list [--json] | revoke ID   # refusal overrides (confirmation typed on /dev/tty)
uv run llm-redact vault gen-key                # LLM_REDACT_VAULT_KEY value
uv run llm-redact vault set-key                # store the key in the OS keychain (keyring extra)
uv run llm-redact audit verify [--json]        # verify the tamper-evident audit hash-chain
uv run llm-redact audit decrypt FILE [--out P]  # decrypt a downloaded .ndjson.fernet backup object
uv run llm-redact license show|verify [--key K]  # decoded license payload / exit 0 valid, 1 invalid, 2 expired
uv run llm-redact users invite|verify|list|revoke  # named seats — supplied by llm-redact-pro via the CLI seam (absent without it)
uv run python scripts/issue_license.py gen-key|sign …  # vendor-side signing (cryptography dev-dep; llm-redact-pro docs/licensing.md)
uv run llm-redact init [--yes --tools claude,codex --vault sqlite]  # setup wizard
uv run llm-redact run [--tools claude,ollama] -- claude -p "hi"  # env-injecting wrapper; ephemeral proxy if none running
uv run llm-redact doctor [--json]              # read-only diagnostics incl. detector-build dry-run; non-zero exit on FAILs
uv run llm-redact service install|uninstall|status [--print-only]  # launchd/systemd user unit
uv run llm-redact plugin install|uninstall|status claude|codex|opencode|cursor [--print-only --force --proxy-url URL --install-proxy]  # agent slash-command plugins
./scripts/oracle_smoke.sh [docker|podman]      # opt-in Oracle RDBMS-vault battery (needs vault-oracle extra + a container engine)
uv run python scripts/render_plugins.py       # re-render plugins/llm-redact + .claude-plugin/marketplace.json from plugin_assets.py (pinned by test)
uv run llm-redact completions bash|zsh|fish    # shell completion scripts
uv run llm-redact fips-check                   # host FIPS posture report
scripts/render_diagrams.sh                     # re-render docs/diagrams/*.mmd -> committed PNGs + animated SVG/GIF for '%% animate' diagrams (needs node + uv; README embeds the PNGs, docs embed the SVGs)
uv run python scripts/capture_plugin_screenshots.py       # re-render docs/screenshots/plugins/*.svg terminal shots (fixture traffic only)
uv run python scripts/history_sweep.py         # audit ALL git history with the production detectors (docs/history-hygiene.md)
uv run python scripts/fake_upstream.py --port 9999 [--mangle]  # fake provider for manual e2e
uv run python -m llm_redact.bench --check      # recall + fp-corpus precision gates
uv run coverage run -m pytest && uv run python scripts/complexity_gate.py --check  # every CC>1 function must be executed (assurance gate; allowlist scripts/complexity_allowlist.py)
uv run python -m llm_redact.bench --latency --check   # + in-process overhead benchmark (CI form)
uv run python scripts/live_smoke.py            # live-API tests (needs keys, costs money)
LLM_REDACT_DOGFOOD=1 uv run python scripts/dogfood_claude.py  # real claude CLI e2e (needs auth, costs money)
./scripts/container_smoke.sh docker            # container e2e (needs a container engine)
```

`pyproject.toml` is the single source of project metadata and tool configuration (ruff, pytest, mypy). Runtime dependencies are deliberately only httpx, starlette, and uvicorn; config/CLI use stdlib (`tomllib`, `argparse`, dataclasses). Do not add pydantic or FastAPI — the proxy must forward unknown JSON fields verbatim, never validate or reshape them.

## Architecture

One request flows: agentic tool → proxy (`proxy.py`) → provider adapter redacts → upstream LLM API → adapter rehydrates → tool.

- **Outbound (redaction)**: `detection/` (regex rules + validators; optional NER behind the same `Detector` protocol via `[detection.ner]` — backends spacy (`ner` extra), gliner, and presidio (`presidio_ner.py`: `PRESIDIO_TYPE_MAP` folds EMAIL_ADDRESS/PHONE_NUMBER/US_SSN/IBAN_CODE/CREDIT_CARD into the built-in placeholder types so one value never gets two token identities; `score_threshold` valid for gliner+presidio, rejected for spacy)) finds values; `redactor.py` resolves overlapping detections (longest/highest-priority wins; NER runs at priority 120 so structured rules win ties) and substitutes vault-issued placeholders. Per-rule modes (`[detection.modes]`): configured by RULE NAME, dispatched by DETECTOR TYPE via `build_modes` (rules sharing a type must agree; unknown names are build-time errors, deferred past `parse_config` like `enabled`). Dispatch happens AFTER overlap resolution — the winner's mode governs. USER DENY STRINGS (`[detection] deny` sugar / `[[detection.deny_strings]]`, `detection/deny.py`): literal substrings, case-insensitive unless flagged, tier 0 — a two-phase `_resolve_overlaps` (each candidate's only possible deny overlap found by `bisect_right` over the chosen deny spans' sorted ends, `lo=` the previous answer — no loop a mutant can spin) makes them win EVERY overlap (a rule match merely starting earlier cannot claim the span), they bypass the allowlist, and they ALWAYS redact (modes never apply, even on type collision — structural in `redact_text`, not config). With no deny entries the old sweep runs unchanged (property-tested). Deny values must not contain guillemets; error messages name positions, never values. PER-TYPE ALLOWLISTS (`[detection.allowlist_by_type]` TYPE = [values], `Allowlist.allows_for`): exact values allowed for ONE detector type only; deny (tier 0) bypasses these too (pinned by test). LANGUAGE SCOPING (`[detection] languages`, unset = ALL = historical behavior): national-id rules carry ISO 639-1 tags (`RegexRule.languages`; None = universal — emails/IPs/vendor/credit_card/iban/phone always run); a tagged rule with no overlap is NOT BUILT (`active_rule_names` is the single list build_detectors instantiates AND the editor/status report — `language_inactive_rules`), the NER type-suppression set is computed from the ACTIVE list (a type whose only rule is scoped out stays out), and an enabled NER whose `language` is outside the list is a parse-time ConfigError; codes are lowercased+sorted at parse. Validated national-id rules (ALL grouped-display-form + checksum; bare digit runs never fire): `canadian_sin` (grouped 9 digits, Luhn; only area 8 rejected — area 0 stays allowed because 046 454 286 is the government's own example), `uk_nino` (HMRC grammar + invalid-prefix blacklist {BG,GB,NK,KN,TN,NT,ZZ}; the QQ reserved prefix is excluded by the grammar itself), `aadhaar` (4-4-4, Verhoeff, lead 2-9; the corpus derives Verhoeff from the D5 group law so table typos fail the recall gate), `australian_tfn` (3-3-3, ATO mod-11 — SAME grammar as canadian_sin: SIN wins exact-span ties by registration order, and TFN corpus values lead with 8 so _sin_ok rejects them), `spanish_dni` (DNI/NIE mod-23 control letter; solid form safe because the letter validates; declares no prefilter literals like credit_card), `french_nir` (spaced display form incl. Corsican 2A/2B, mod-97 key; priority 90 because the loose 13-19-digit card grammar covers a spaced NIR and 1/10 of NIRs are Luhn-valid — the tie must go to the NIR-specific grouping), `german_steuer_id` (spaced 2-3-3-3, repeat-structure + ISO 7064 MOD 11,10), `brazilian_cpf` (dotted 000.000.000-00, dual mod-11, all-same-digit rejected), `italian_codice_fiscale` (16-char grammar + day range incl. +40 female form + mod-26 check letter; the corpus transcribes the odd-position table INDEPENDENTLY and RSSMRA85T10A562S pins both; omocodia out of scope), `belgian_nn` (Rijksregisternummer dotted YY.MM.DD-NNN.CC, two check digits = 97 - body mod 97 with the post-2000 '2' century prefix accepted, month/day 0 allowed; 93.05.18-223.61 pins it), `finnish_hetu` (henkilötunnus DDMMYY + century sign + NNN + mod-31 check char; the sign letter makes the solid form safe like codice_fiscale — the corpus builds the check alphabet by FILTERING out G/I/O/Q so a typo fails recall, 131052-308T pins it), `nhs_number` (UK 3-3-4 SPACED display form only, mod-11 with 10-invalid; the space form is disjoint from the phone grammar — the hyphenated form is a phone shape, not NHS; 943 476 5919 pins it), `norwegian_fnr` (fødselsnummer DDMMYY-NNNNN spaced/hyphenated, DOUBLE mod-11 with either check landing on 10 = invalid; weights transcribed independently in the corpus), `korean_rrn` (resident registration number YYMMDD-SBBBBNC hyphenated 6-7 form ONLY, weighted mod-11 + month/day/gender-digit gates; priority 90 like french_nir because the 13-digit hyphenated span is also a valid credit_card grammar and ~1/10 pass Luhn — the RRN grouping must win the exact-span tie), `singapore_nric` (NRIC/FIN [STFG]+7digits+check letter, S/T and F/G share weight-2765432 tables with T/G adding 4; the bracketing letters make the solid form safe, S1234567D pins it). Dutch BSN deliberately absent (customarily bare 9 digits; the 11-test passes 1/11 of random runs). CRYPTO WALLET ADDRESSES (`detection/wallet_checksums.py`, all checksum-vetoed, priority 20): `eth_address` (ETH_ADDRESS, `0x`+40hex — EIP-55 mixed-case keccak-256 checksum; all-lower/all-upper accepted on shape, single-nibble null/burn rejected; keccac-256 is VENDORED because hashlib only ships NIST SHA3 which uses a different pad byte — pinned to the empty/`abc` Keccak vectors), `btc_address` (BTC_ADDRESS, base58check P2PKH `1…`/P2SH `3…`, double-SHA256, version 0x00/0x05, declares no anchor like iban), `btc_bech32` (BTC_ADDRESS shared, `bc1…` segwit — BIP-173 bech32 for witver 0, BIP-350 bech32m for v1+ taproot). The corpus reuses the ENCODE side of the same primitives, so correctness rests on the independent published spec vectors in test_wallet_checksums.py (a self-consistent keccak bug would otherwise pass recall). Vendor tokens are prefix-anchored ONLY — Cloudflare/Vercel have no prefix and are deliberately excluded; `sb_publishable_` and similar non-secret forms are deliberately unmatched; the GitLab routable-token family beyond `glpat-` (`gitlab_token`: glrt/glcbt/gldt/glptt/glagent/glimt/glsoat), `google_oauth_client_secret` (GOCSPX-), `sentry_token` (sntrys_/sntryu_), `xai_key` (xai-), `perplexity_key` (pplx-), `hashicorp_vault_token` (hvs./hvb.), `langsmith_key` (lsv2_pt_/lsv2_sk_), `replicate_token` (r8_), `pinecone_key` (pcsk_), `new_relic_key` (NRAK-), `grafana_service_account` (glsa_…_hex), `jina_key` (jina_), and `telegram_bot_token` (8-10 digit id : 35-char secret, no single anchor) are covered; `aws_access_key_id` matches ASIA (STS temporary) as well as AKIA; the legacy unprefixed GitLab CI_JOB_TOKEN and 64-hex Sentry token stay out (no anchor). NER is MULTI-BACKEND: `[detection.ner] backends = [..]` runs any combination of spacy/gliner/presidio/stanza/hf concurrently (stanza = `stanza_ner.py`, Stanford Stanza `Pipeline(lang, processors="tokenize,ner")`, multilingual, NO confidences like spacy; hf = `hf_ner.py`, transformers `pipeline("token-classification", aggregation_strategy="simple")`, any Hub model, emits confidences so score_threshold applies — `_confidence_backends = gliner/presidio/hf`; both build-dispatched in `build_detectors` and validated in `known_backends`) (legacy single `backend` = one-element form; `[detection.ner.models]` per-backend model overrides — the legacy `model` key applies only when exactly one backend is active; score_threshold rejected only when NO confidence backend is active). PER-TYPE SINGLE SOURCE OF TRUTH: a placeholder type disabled at the rule level is suppressed for NER emissions too (TypeFilteredDetector wraps every NER backend; PERSON has no builtin rule so never suppressed); the dashboard NER card has per-backend checkboxes/models and a live folded-type state line. NER language/model (`[detection.ner] language`, `model`): spaCy pipeline name for spacy/presidio (default en_core_web_sm), HF id for gliner (default urchade/gliner_small-v2.1); presidio wires language through NlpEngineProvider and `analyze(language=...)`; presidio's EmailRecognizer validates TLDs against the public-suffix list, so `.example` addresses score zero (real-analyzer smoke test uses `.com`). HOT-PATH PREFILTER (`regex_rules.py`): rules declare `required` literal CNF (necessary conditions; skipped entirely when unsatisfied — one shared `PreparedText` caches the lowered haystack) and `anchors` (every match starts with the literal → find-then-match via `pattern.match(text, pos)`, which keeps \b/lookbehinds correct; CI anchors fall back to plain finditer when lowering changes string length, e.g. 'İ'). Equivalence is machine-checked: per-rule soundness tests over the recall corpus plus a differential fast-vs-naive suite — a wrong literal declaration is a silent recall bug, so never add one without its generator coverage. credit_card/iban deliberately declare nothing. PER-STRING GATE (`engine.DetectorPlan`/`plan_for`, a WEAK cache — a plan lives exactly as long as a Redactor holds it, so a reload's old detectors (NER models included) die with the last request using them; every Redactor copy shares its plan): for a text of at most `GATED_MAX_CHARS` (1024) only candidates run — a literal rule when a literal of one of its CNF groups occurs in its own haystack (indexed by first character: exactly its prefilter's test), the literal-less rules with no capturing group and no flag through ONE combined alternation (it matches nowhere exactly when none of them does), everything else (deny strings, NER, plugin and other custom detectors) always — in list order through the unchanged per-detector code, so detections are identical; longer texts run every detector. tests/test_detector_plan.py proves it against the full scan over the recall corpus, its non-ASCII-digit rewrites and the fp corpus at every length, and dropping any gate literal or combined member must fail it. Haystacks are Unicode-exact: case-sensitive literals test `PreparedText.folded` (decimal digits of every script folded to ASCII, same offsets — `\d` matches them all), case-insensitive ones `PreparedText.lower` (`lower_for_ci` maps İ/ı/ſ first, keeping CI anchor offsets 1:1); user literals are folded alike, and validators indexing ASCII tables (codice fiscale, CURP) fold digits first. CUSTOM-RULE VALIDATORS (`detection/validators.py`, `[[detection.custom_rules]] validator=`): a named checksum/format gate resolved in `build_detectors` and hung on the RegexRule's `validator` — `luhn`/`mod97` (accepts plain MOD-97-10 AND the IBAN move-first-4 layout)/`verhoeff`/`jwt` (base64url header+payload decode to JSON objects)/`entropy` (≥3.5 bits/char, ≥16 chars, no whitespace — the loose-pattern-plus-check recipe made user-facing); an unknown name is a build-time ValueError (deferred past parse_config like modes). Custom rules also accept `required` (each literal → a single-alternative CNF clause) and `anchors` passed straight through to RegexRule — a wrong hint is the user's silent-recall risk, opt-in and off by default; all three round-trip through the emitter and the editor view. `warn` counts the type and deliberately forwards the value upstream (no vault write); `block` raises `BlockedRequest` (type only, never the value) which `handle()` turns into a provider-shaped **400** (not 403 — SDK UX) before any upstream contact, on CHAT and REDACT_ONLY routes alike. `jsonwalk.py` applies redaction to every string value in the request body (skipping only SCALAR values of `STRUCTURAL_KEYS` like `model`/`role`/base64 `data` — objects/arrays under a structural key are WALKED (a tool result nested under `data`/`name`/`id` once skipped its whole subtree: a leak); `OPAQUE_POSITIONS`/`OPAQUE_ANYWHERE` (tool-call args, tool results, `documents`, keyed by (parent key, key), list items inheriting their list's key — and the maps whose keys the CALLER chooses: `metadata`/`requestMetadata`/`attributes` (vector-store file attributes) anywhere, (`prompt`,`variables`), a body's top-level (None,`instances`)/(None,`parameters`); a value under a caller key named `id`/`name`/`type`/`data` once went upstream unredacted) are walked with NO skips at all; `ENUM_LIST_POSITIONS` skips the modalities enum arrays; `MEDIA_POSITIONS` (`source.bytes`, Bedrock base64 media) skip STRING values like base64 `data`; plaintext documents with `source.type == "text"` have their `data` scanned; image responses' `b64_json` stays skipped) so system prompts, nested content blocks, and tool results are covered without hardcoded body shapes. REPEATED KEYS: request JSON is parsed with `jsonwalk.loads_request` (an `object_pairs_hook` flags a repeated key at any depth; a second plain parse runs only for a flagged document). The parse keeps the LAST occurrence, so a flagged body never takes `handle()`'s no-op short-circuit (the raw bytes still hold the earlier occurrences, which a first-wins upstream reads) and is re-serialized from the walked object — `detection = false` included, where exactly what the ownership check read is sent; uploaded JSONL lines likewise (`openai._parse_request_line`), a Bedrock count-tokens blob with a repeat is `UnredactableRequest` (400: the envelope short-circuit cannot see inside it), and realtime client frames are always re-serialized (`_dump_frame`). SERIALIZATION: re-serialize JSON only through `jsonwalk.json_text`/`json_bytes` (exactly `json.dumps(ensure_ascii=False)` output, a lone surrogate re-escaped instead of failing the UTF-8 encode) — every request, answer, listing, stream-adapter, realtime-frame, JSON-log and doctor `--json` site does; never `json.dumps(..., ensure_ascii=False).encode()`. Unchanged bodies still forward byte-identical. BOUNDED DEPTH: every client/upstream JSON parse goes through `jsonwalk.loads_bounded`/`loads_request` (`JsonTooDeep` past `MAX_JSON_DEPTH` 128, or a RecursionError): the client side is refused (scanned-body 400, upload line/field 400, Bedrock blob 400, realtime 1008), the upstream side forwarded verbatim, never a 500; a realtime client frame the parser refuses for anything but its syntax (an integer past the int-digit limit: a ValueError that is no JSONDecodeError, `realtime._UnreadableJson`) is refused 1008/row 400 like a too-deep one — never relayed as "not JSON" (which key auth forwards unread); keep the limit well below 1000/4 (mutmut's trampolines overflowed at 256). Oversized redactable bodies are rejected 413 fail-closed (`max_body_bytes`), and so are bodies with more strings to redact than `max_body_strings` (default 100,000, hot, snapshotted per request with `max_body_bytes`; redaction runs string by string on the event loop, which bytes alone did not bound): `Redactor.with_budget` charges every `redact_text` (JSON strings, form fields, file names; floor copies share the count) and the OpenAI multipart hook charges each uploaded file's lines (a byte count) before any split; `TooManyStrings` (an `UnredactableRequest`, so an unaware path still refuses) becomes the same recorded 413 via `_body_too_large`; a multipart body's parts are bounded by its delimiter count before any parse (`_multipart_parts_over`); realtime gives each client frame its own budget (close 1009, row 413). `detection = false` providers and pass-through redact nothing and are not counted.
- **Vault** (`vault.py`): deterministic — same `(session, type, value)` always yields the same `«TYPE_NNN»` token (format in `placeholders.py`); the mapping never leaves the machine. `InMemoryVault` (default) dies with the process; `SqliteVault` (`[vault] backend = "sqlite"`, `--session`) persists across restarts with 0600/0700 permissions, WAL + `synchronous=FULL` (counter reuse after a lost write would silently rehydrate the wrong secret) + `busy_timeout` (concurrent WAL writers wait, not error), and a write-through cache keeping reverse lookups off the DB on the streaming hot path. WRITE-FAULT FAIL-CLOSED: `placeholder_for` rolls back and re-raises on ANY exception (a sqlite write error, a cipher fault — not just IntegrityError) so a disk-full/IO fault under `isolation_level=None` can't leave the open `BEGIN IMMEDIATE` wedging the next request (EncryptedInMemoryVault encrypts before changing state); caches write only after COMMIT (nothing poisoned) and n is read fresh inside the write transaction, so a retry reissues the SAME number (test_vault_faults.py). TOKEN FLOORS: every session numbers from 001, so a request can carry tokens its session never issued (a compacted history forked into a fresh session, a pasted answer, another proxy's token). `Vault.placeholder_for(type, value, *, floor=0)` numbers a NEW value `next_number` = max(MAX(n), retired, floor)+1 (a mapped value keeps its token; the gaps a floor asks for are the only gaps, never a reuse); past `MAX_TOKEN_NUMBER` (999999999) `PlaceholderSpaceExhausted` → the Redactor's `PlaceholderLimitReached` (an `UnredactableRequest`: 400 / realtime close, nothing written). Floors come from `placeholders.token_floors`/`json_floors` (every key and string of the decoded body; a superset of `PLACEHOLDER_RE` and the fuzzy grammar plus escaped guillemets) behind the byte gate `may_carry_tokens` (0xAB, `\u00`, NUL for UTF-16/32, `%AB`); the Redactor holds them immutably (`with_floors` returns itself or a thin copy, so the shared static redactor is never floored; `floor=` only for a type the request carries). Sources: `handle()` JSON bodies, `OpenAIAdapter.redact_multipart` (`_multipart_floors`: the whole upload before any part), the decoded Bedrock count-tokens blob, realtime `frame_floors` (a running floor over CLIENT frames only — a `session.updated` echo carries the note). The system note's example token is «EMAIL_000», a number never issued. PER-REQUEST BATCH (sqlite): `vault.run_batched(vault, work)` wraps `prepare_request` (Bedrock count-tokens included), `redact_multipart` and each realtime `redact_message` — `BEGIN IMMEDIATE` lazily at the first new value, staged rows visible to the request's own lookups, ONE COMMIT (one fsync) before anything is forwarded, caches only after it; any exception (a write or COMMIT fault, a block, a string cap, a cipher fault, a vault error the work swallowed — `_Batch.failure`) rolls the whole batch back with caches untouched, so a retry reissues the same numbers. The callable form is deliberate — `work` cannot await; never turn it into a context manager. Views on one connection share the batch (`_Connection`, re-entrant); `_revalidate` never runs inside one; other processes wait on `busy_timeout` and fail closed. RDBMS and memory vaults run per call (the RDBMS reconnect and UNIQUE retries replay ONE self-contained allocation). A vault fault there is a recorded, provider-shaped 503 before any upstream contact (`_vault_fault_refused`, bookkeeping stage `vault`, logged by exception type; realtime closes 1011, row 503): `proxy.vault_fault_types` = `sqlite3.Error` plus the manager's optional `fault_types` (RDBMS: the driver's Error and `RdbmsAllocationError`), resolved once. RETIRED NUMBERS: every whole-session delete (the TTL prune, `POST /__llm-redact/sessions/prune`, the CLI prune, `SessionStore.forget`) raises the session's retired number (sqlite `retired_numbers`, RDBMS `llm_redact_retired`; never lowered, never deleted) in its own transaction, and allocation reads max(MAX(n), retired) in one statement — no (session, type, n) ever carries two values, so another instance's stale cache or a live realtime view restores a token to its own value or passes it through. The retired number is also the epoch: views load it first, re-check every `CACHE_CHECK_SECONDS` (1 s, injectable `clock=`) and rebuild on change; a `_revalidate` that cannot read the database keeps the caches and retries next interval (safe: retired numbers mean a cached token restores only its own value; `vault.CheckFaults` logs once per outage by type and counts bookkeeping stage `vault_check` through the optional `bind_fault_counter`), `_load` assigns all-or-nothing, and a fault while `context_for` opens a session is the vault-fault 503 (WS 1011). `VaultManager`s hand out per-session views over one shared connection, ONE live view per session (a weak `_live` registry behind the ~64-entry LRU; a local prune/forget rebuilds every live view of the deleted sessions); the in-memory manager forgets in place (`forget_mappings`, numbering kept) and, like the persistent ones, counts and lists only sessions holding mappings. The prune's idle check and its delete are one transaction (sqlite: inside the write lock; RDBMS: a per-session re-check, `DELETE … n <= M`). Managers persist the Responses `response_id → session` map (`response_sessions`; the ~10k cap trims ONLY rows whose session holds no mappings — a router reads a missing row as "pruned", and resuming a LIVE session's chain in a fresh one would reissue «EMAIL_001» for a new value; live sessions' rows leave with the session via prune); stored-object owner records mirror there through the optional `record_object_session` (kind `object`, bounded apart from Responses rows by `_MAX_OBJECT_ROWS`, 10,000), and `lookup_response_sessions` answers many ids in one query per `LOOKUP_CHUNK` (500). LIVE RESUMPTION HANDLE MAP (for plugins; the proxy itself never calls it): the sqlite and RDBMS managers offer the OPTIONAL `record_handle_session(handle_digest, session_id, *, replaces=())` + `lookup_handle_session(handle_digest)` (documented on `VaultManager`'s docstring, pinned in OPTIONAL_MEMBERS; llm-redact-pro reaches the manager through the durable lookup's `__self__`) — a separate table (sqlite `handle_sessions`, `seq INTEGER PRIMARY KEY` = rowid; RDBMS `llm_redact_handle_sessions`, `seq` allocated MAX(seq)+1 inside the write under the PK + UNIQUE(handle_digest) + UNIQUE(session_id, seq) as the trim index, collisions retried `_ALLOCATION_ATTEMPTS` then `RdbmsAllocationError`), created on open (sqlite `CREATE IF NOT EXISTS` in both schemas, `ensure_side_tables` for the CLI prune; RDBMS `_create` → a ConfigError naming table + DDL, never the DSN); digests only (the plugin's domain-separated hash; never a raw handle). One transaction per write (`write_handle` / `RdbmsStore._write_handle`): `replaces` deleted (same session only), the digest made the NEWEST row (re-written moves up), the session trimmed to its newest `MAX_SESSION_HANDLES` (1024) every write, and every `_RESPONSE_PRUNE_EVERY` writes rows beyond the newest `MAX_HANDLE_ROWS` (10,000) whose session holds NO mappings — insertion order (`seq`), never `created_at` (1-second ties). `delete_sessions` / `RdbmsStore._delete_side_rows` remove a session's handle rows with its response rows on every whole-session delete, so a handle into a pruned-and-recreated session is unknown. Faults contained in the manager (`HandleWriteFaults` + `HandleReadFaults`, `CheckFaults` subclasses — two separate outages, so writes failing while reads work (a missing grant) neither repeat the WARNING nor log a false recovery: stage `handle_map` through `bind_fault_counter`, one WARNING per outage + one INFO on recovery, by type): a write records nothing (a sqlite BEGIN outside the try, so a write landing inside an open batch never rolls it back), a read answers None. The in-memory manager's members are no-ops answering None (`durable_response_map = False`: never read as "pruned"). Pinned by tests/test_vault_handles.py (every persistent backend) and the RDBMS `_battery`; the fake oracledb translates `OFFSET :o ROWS FETCH NEXT 1 ROWS ONLY`. `[vault] encryption = "fernet"` (`crypto` extra, `LLM_REDACT_VAULT_KEY`) stores a domain-separated HMAC index + Fernet ciphertext (schema v3); the MEMORY backend honors fernet too (EncryptedInMemoryVault: HMAC index + ciphertext in RAM, decrypt per reverse lookup, deliberately NO plaintext cache; key resolution and fail-closed behavior shared); migration is one-transaction encrypt-in-place followed by `wal_checkpoint(TRUNCATE)` + `VACUUM` (else plaintext lingers and the at-rest claim is false); wrong/missing key fails closed AT OPEN; caches stay plaintext-keyed so the hot path is unchanged. KEY RESOLUTION (`vault_crypto.from_env`): env var (`LLM_REDACT_VAULT_KEY`) → key command (`LLM_REDACT_VAULT_KEY_CMD`, `key_from_command`: run via shell, stdout stripped, for service units with no `$(...)` shell — a failing command logs by exception TYPE only since CalledProcessError carries captured output that could hold the key, timeout `_CMD_TIMEOUT_S`) → OS keychain (`keyring` extra, `llm-redact vault set-key`, service "llm-redact"/item "vault-key") → fail closed; every non-env source that errors counts as key-absent (never a silent downgrade, never a traceback), and error messages name the key SOURCE without echoing key material. Lifecycle CLI (`vault_cli.py`): `sessions prune` deletes WHOLE sessions only (partial deletion would let MAX(n)+1 reissue a live token number), retires their numbers and repeats the idle check inside its transaction; `vault verify` fails a token/number mismatch or a live row reusing a retired number and only NOTES gaps (floors create them); for RDBMS backends `sessions list|prune` and `lookup` are CONFIG-driven (an explicit --db always means a sqlite file) while `vault verify/rotate-key/backup` refuse with guidance (server engines bring their own tooling; MVCC makes rotate-in-place dishonest). RDBMS BACKENDS (`vault_rdbms.py`, llm-redact-pro docs/vault-rdbms.md, Pro): `[vault] backend = postgresql|mysql|oracle|dbapi` + `[vault.rdbms]` (dsn URL-form; LLM_REDACT_VAULT_DSN env override; password from `password_env`; `module` names the DB-API 2.0 driver for dbapi; `cloud` declares managed placement) — ONE RdbmsStore over DB-API 2.0 with a paramstyle adapter (named/pyformat/qmark/format/numeric) and a portable SQL subset (beyond it the generic `dbapi` backend needs `TEXT`/`BIGINT` and `LIMIT n OFFSET m` paging — the response-map trim and EVERY handle write page that way, only `oracle` spells `OFFSET … FETCH`; without it every handle write fails contained and every Live resumption is refused: llm-redact-pro docs/vault-rdbms.md says so): NO upserts, NO FOR UPDATE — counters are max(MAX(n), retired, floor)+1 read inside the transaction, resolved by the UNIQUE (session,type,n) constraint + bounded retry (still colliding after `_ALLOCATION_ATTEMPTS` = 3 → `RdbmsAllocationError`, a 503), caches write only after COMMIT, one reconnect-retry per self-contained op (a lost commit-ack re-finds the committed row). `_create` makes a table the database user may not create (`llm_redact_retired` or `llm_redact_handle_sessions` on an older schema) a startup ConfigError naming the table, its DDL and the privileges the user then needs on it (`_GRANTS`: DELETE on the mappings, response map and handle map — every whole-session delete empties all three in one transaction; never on the retired numbers), never the DSN; `_ensure_schema` adds the owner-record `kind` column, and a user that may not ALTER keeps the shared bound with a WARNING (`owner_bound_shared`: /status `vault.owner_bound_shared`, a status posture line, a doctor WARN naming the ALTER statement). Schema deltas from sqlite, both deliberate: fixed-width `original_key` (HMAC index when encrypted, SHA-256 otherwise) replaces raw values in the PK (MySQL/Oracle can't index unbounded text), and the ENCRYPTION MODE IS FIXED AT CREATION (llm_redact_meta marker; server MVCC keeps old row versions no rewrite could scrub — no migration, no rotation; fresh schema to change). OFF-BOX RULE: non-local DSN (incl. recognized managed hosts and /cloudsql/ sockets) without fernet = startup ConfigError; LLM_REDACT_VAULT_REMOTE_PLAINTEXT=1 is the hatch, surfaced in /status (vault.remote_plaintext), posture, doctor; dbapi DSNs are opaque → doctor WARNs locality unverifiable. Drivers are extras (vault-postgres=psycopg3, vault-mysql=PyMySQL utf8mb4-forced, vault-oracle=oracledb thin with fetch_lobs=False); missing extra = ConfigError with hint. Tests: the battery runs against stdlib sqlite3-as-dbapi, fake psycopg/pymysql/oracledb over sqlite3 (fault injection + reconnect), and REAL postgres:16 + mysql:8 CI services (the `rdbms` job); scripts/oracle_smoke.sh is the Oracle opt-in. DSNs never logged/echoed. IAM DB AUTH (`[vault.rdbms] auth = "identity"` + aws-only `region`; `config.rdbms_identity_error` runs at parse time on the file DSN and again on the effective/env DSN): postgresql|mysql only, `cloud` required, no `password_env` (raw-key check) and no DSN password, postgres `sslmode` ∈ `PG_TLS_SSLMODES` and ALWAYS passed explicitly (`_pg_sslmode`: the DSN's, else a strong PGSSLMODE, else require — explicit beats a service file), MySQL gets an SSLContext (`?ssl_ca=` ⇒ verified). `vault_rdbms.identity_tls_problem` (at connect-build, so startup and doctor) requires a VERIFIED server certificate off loopback/sockets (pg verify-ca|verify-full, MySQL ssl_ca — the server asks for the token in the clear inside TLS) unless `LLM_REDACT_VAULT_TLS_UNVERIFIED=1` (surfaced: /status `vault.tls_unverified` via `identity_tls_unverified`, doctor WARN, status posture), and for postgres refuses `service`/`hostaddr`/a TCP `host=` query override and PGSERVICE/PGSERVICEFILE/PGHOSTADDR; PyMySQL ≥ 1.2 (older ones fall back to plaintext) and `_TlsOnlyClearPassword` sends the token only over an SSLSocket. SEAM: `Registry.build_db_password(vault_config) -> plugin_api.DbPasswordProvider | None` (Free default: None for password auth, ConfigError naming llm-redact-pro for identity); `RdbmsStore(..., password_provider=)` defaults to the registry and calls it at EVERY connect incl. the `_run` reconnect-retry (tokens expire — never replay one); `validate_connector` (doctor) never builds a credential source. KMS-WRAPPED KEY (`[vault.kms]`, `VaultKmsConfig`: provider aws|gcp|azure|hashicorp + `key_id` shape-checked per provider + exactly one of `wrapped_key_env`/`wrapped_key_file` (never LLM_REDACT_VAULT_KEY/_CMD) + hashicorp-only `address` (https unless loopback, `kms_address_problem`)/`mount`/`auth` token|kubernetes/`role`/`auth_mount`/`service_account_token_file`; requires fernet; restart-only): the key then comes ONLY from the KMS. `Registry.resolve_vault_key(VaultConfig) -> bytes|None` is the seam (Free default: None without kms, ConfigError naming llm-redact-pro with it); `vault_crypto.resolve_cipher` is THE cipher path (pro's `build_vault_manager` and every vault CLI command — the CLI resolves from the loaded config even with `--db`); `require_vault_key_source` (ProxyState, before the vault is built) refuses LLM_REDACT_VAULT_KEY/_CMD set alongside and a registry still holding the Free resolver (an older pro would build from the env key); doctor `_check_vault_kms` is posture only (never calls the KMS); /status `vault.key_source` = `kms:<provider>`|`local`|null.
- **Sessions** (`sessions.py`, `[vault] session_mode = "per-conversation"`): OPEN-CORE BOUNDARY — Free `sessions.py` ships only `StaticSessionRouter` (one shared namespace, `mode="static"`, inert `resolve`/`record_response_id` stubs) plus a fail-closed `build_session_router` (per-conversation without the `llm-redact-pro` package is a `ConfigError`, never a silent downgrade to the shared namespace); the per-conversation `SessionRouter` lives in `llm_redact_pro.sessions` (registered through the registry, reusing the Free `StaticSessionRouter` for static mode). The proxy binds only to the `plugin_api.SessionRouter` Protocol (`mode`/`resolve`/`record_response_id` — which MAY return False to VETO the proxy's durable `record_response_session` mirror (a refused cross-namespace re-home, or a router that never reads the map; None/True mirror as before) — plus the OPTIONAL `record_object_id(object_id, session_id)` (same veto contract, read once, reported in EVERY mode — static included: pro serves unattributed traffic on the static path next to named users and must know what that shared session created; Response ids stay unrecorded in static mode): `_deliver` asks `ProxyState.object_tracker` (the routed adapter, else the pass-through provider's by name) whether `adapter.tracks_object_ids(method, path, body=)` (the parsed request body) claims a 2xx answer and reports `object_ids_from_body` — OpenAI POST files/batches/conversations, completed Uploads (`_completed_upload_file`: never one of purpose `batch` or none — its parts were forwarded unread), fine-tuning jobs, batch status and job reads (output/error files, `result_files`), videos + `videos/{id}/remix`, chat completions with `store: true` (strictly a bool), Responses code-interpreter container files (`container_file_ids`), Anthropic POST messages (code execution output files, `generated_file_ids`, and the code execution container the answer names, `container_ids`: the message's `container.id`, streamed in `message_start`'s message and `message_delta`'s delta; the event prefilter admits `"file_id"` or `"container"`; a container the request names is dropped by `_uncited`)/batches/Files uploads, Gemini cachedContents (the `name`, which later bodies cite as `cachedContent`), Files API creates (`file_object_ids`: a create's `{"file"}` only; the `files` array only for `files:register` — a create answering with one is a listing), batch and Veo operations and a finished batch's output file, Vertex `:predictLongRunning`, Bedrock `POST /async-invoke`; streams through `object_ids_from_event` (`_StreamedObjects` reports each id once, reading to the end unless `reports_object_ids_once`); `_uncited` drops any id the request body itself carries (an existing object the answer echoes); extraction and recording are contained (bookkeeping stages `response_id`/`object_ids`) — so pro keeps another user's read of a stored object out of the creator's namespace — plus the OPTIONAL `object_access_refusal(adapter_name, method, path, body, *, identity)` (read once): asked for EVERY forwarded HTTP request after the routing `plan()` and the scanned-body rule and BEFORE `context_for`/redaction/audit START/`authorize()`/upstream; `identity` = the request spends a credential the proxy holds (the identity snapshot, or `_lends_credential(plan)`); `body` = the parsed JSON, or what an upload cites (`_ownership_body` → `upload_view.read_upload`: file-part JSON lines and form fields nested along their names, read before redaction on every matched route, `detection = false` included — only a line whose first byte past whitespace and one BOM is `{` is parsed (`_OBJECT_LINE`, one possessive scan: blank/prose lines never cost a parse), each counted against `max_body_strings` before its parse (`too_many_lines` → 413 under a proxy-held credential), and `_reclassified` classifies only the parts a normalized reading rewrote; a file line is read only as UTF-8 text (a leading UTF-8 byte-order mark dropped, never a bytes parse that guesses UTF-16/32) and a repeated-key line re-serialized only in a UTF-8 file (`classify_file(...).codec == "utf-8"`: JSONL or UTF-8 text), and `_ownership_body` refuses 400 (`_RECLASSIFIED`, whatever the credential) a re-serialized upload whose parts `classify_file` would read as another kind; under a proxy-held credential an upload it cannot read, a content coding or a second Content-Type is refused 400/413/415, `_unchecked_body_refused`; a SINGLE-REQUEST upload whose first part is the created file's JSON metadata (`ProviderAdapter.upload_metadata_boundary`: the Gemini API's multipart/related upload) → `body` = that metadata OBJECT, exactly what the provider reads as the create's body, its chosen `file.name` included (`upload_view.read_upload_metadata`: the FIRST part, declared `application/json` or undeclared (another declared type is `METADATA_TYPE`; a multipart/related `start` parameter makes `_related_boundary` None: the root need not be first), one with an EMPTY header block too (ANY part — the media too — with no header block that does not open with CRLF is AMBIGUOUS, for the form view as well: `_require_header_block` over `header_block_found`, which the redaction part loop applies to every part too), strict UTF-8 behind one BOM, `loads_request`, `MAX_JSON_DEPTH`; blank or no part = `{}`; a repeated key read last-wins and the part sent re-serialized (only the JSON text's span: the bytes around it — an empty header block's CRLF, whitespace, a BOM — kept) — `_reclassified` still applies, so a pretty-printed one is refused; the media part never read) — metadata it cannot read is refused wherever the scanned-body rule holds (a proxy-held credential: the credential-bound 400; the client's key with detection on: a 400 naming no credential), unchecked only with the client's key and `detection = false`, as an unparseable JSON body); a reason → recorded provider-shaped 403 (`_object_access_refused`), an exception or non-string → 403 `_OBJECT_ACCESS_FAULT` (type-only log); the OPTIONAL `listing_item_session(object_id)` / batched `listing_item_sessions(object_ids)` (asked once per listing): `_restore_listing` restores each item of a 2xx listing an adapter claims (`lists_objects`/`listing_items`: OpenAI `{"object":"list","data":[…]}` GET files|batches|videos|chat/completions, tail-matched so Azure/custom inherit) as a WHOLE object from the provider's bytes in the named session — only when it exists and holds mappings (`has_session`/len; never created) and, with a durable map, only while it records the object in exactly that session (`_recorded_sessions`: a pruned-and-recreated session holds new values under the same names); an empty, absent or unverified session → the provider's bytes; None → as the listing's own session delivers it; a router exception or miscount → every item it failed for as the provider sent it (`_UNANSWERED`, counted `listing`), an owner session that cannot be read → its items as sent; listings never record ownership; the OPTIONAL `sealed(session_id) -> bool | str` (asked right after `resolve`, so never in static mode): the context's redactor gets a read-only `_SealedVault` whose `placeholder_for` raises `SealedSessionError` → a recorded 403 (the router's string, else `_SEALED_REFUSAL`; `RequestContext.sealed`) before any upstream contact, nothing written; realtime refuses the connection; an exception seals (all pinned by tests/test_object_access_seams.py, test_sealed_sessions.py and the OPTIONAL_MEMBERS snapshot in test_plugin_api_surface.py; plugin_api's SessionRouter docstring is the contract) — plus the OPTIONAL `response_observer(context) -> ResponseObserver | None` (read once via getattr; `ProxyState.observes_responses` is the one per-answer test when absent, pinned by tests/test_response_observer.py): asked in `_deliver` for EVERY upstream answer handed to the client (routed or not, every mode; never a refused redirect or a transport fault) with a frozen `plugin_api.ResponseContext` (adapter_name, method, path, status, content_type, `request_body` = the parsed body AS SENT UPSTREAM — `outbound_obj`, placeholders — session_id, `identity` = a proxy-held credential was spent); a returned callable gets, synchronously, each JSON value of the answer AS THE PROVIDER SENT IT — before rehydration, each its own `loads_bounded` parse of the upstream bytes (`_observe`), so it never sees a restored value and cannot change the client's bytes: a buffered JSON body once (after `_restore_buffered` succeeded), SSE per event, NDJSON per line (tail included), eventstream per frame payload (Bedrock invoke chunks: the `{"bytes"}` wrapper); non-JSON/too-deep skipped; realtime not observed; a factory or callable exception is `_bookkeeping_fault` stage `response_observer` (type-only log, answer delivered) and ends that answer's observation (llm-redact-pro learns compaction item → session from it) — plus the OPTIONAL `realtime_frame_refusal(adapter_name, path, frame, *, identity, session_id)` (read once, `ProxyState.checks_realtime_frames` per connection; the realtime twin of `object_access_refusal`, pinned by tests/test_realtime_frame_check.py): asked synchronously for EVERY client frame that parses as JSON (text or binary; all four realtime adapters; every mode; `detection = false` included) in the relay's `client_to_upstream` right after the revoked check and BEFORE the frame's floors, redaction or send (no await in between — the reload invariant holds), in the connection's own context (the gate's contextvars are visible in the frame task); `identity` = the connection's upstream is authorized with the proxy's identity, `session_id` = the connection's session; `_checked_frame` parses once and hands the parse to `redact_message(parsed=)`; a string → `_FrameRefused` → `close_on_policy` 1008 with the router's fixed reason (cut to 123 bytes, never logged), row 403, nothing sent or numbered; an exception or any answer but None/non-empty str → 1008 `REALTIME_FRAME_FAULT`, bookkeeping stage `realtime_frame` (type-only log); with the member present a too-deep frame is refused 1008/400 on every connection and a non-JSON one under identity even with `detection = false`, and `detection = false` sends the checked value re-serialized (a repeated key's earlier occurrence never leaves) — plus the OPTIONAL `realtime_server_frame(adapter_name, path, frame, *, identity, session_id)` (read once, `ProxyState.observes_realtime_server_frames` per connection; the server-side twin, pinned by tests/test_realtime_server_frame.py): handed, synchronously, EVERY upstream frame of every realtime adapter that parses as JSON within MAX_JSON_DEPTH (`_observe_server_frame`: its own `parse_json_text` of the provider's bytes — placeholders, never a restored value) in `upstream_to_client` right after the revoked check and BEFORE `rehydrate_message`/send (no await added), in the connection's context; read-only (return ignored, its parse never reaches the client); an exception is contained — bookkeeping stage `realtime_server_frame`, type-only log, the frame delivered; llm-redact-pro records Live `sessionResumptionUpdate.newHandle` owners there — plus the OPTIONAL `is_durable(session_id)` read via getattr: `_prune_exclusions` keeps the configured static session AND every current session the router marks durable out of the TTL loop and `POST /sessions/prune` — a recreated session would reissue the same placeholder numbers for new values while the provider still holds the old tokens; FAIL-SAFE: only an explicit False releases, an exception or non-bool keeps; llm-redact-pro marks each named user's `user:<namespace>:<static>` copy). A vault manager with `durable_response_map = False` (InMemoryVaultManager) hands the router `durable_lookup=None`: its always-unknown answer would read as "pruned" and orphan every Responses chain. The pro router lives in the separately-installed `llm-redact-pro` package; its mutation coverage (`resolve`/`_canonical`/`orphan_session_id`) runs in the pro repo's CI, and this repo's mutmut config covers only the Free codecs, vault and `upload_view`. The pro `SessionRouter` derives `conv-<16hex>` ids from a domain-separated SHA-256 of the first user message (whole `input` for Responses; only the hash is ever stored/logged; `model` excluded so mid-conversation switches don't fork the namespace). Isolation is strict by design — there is NO fallback lookup across sessions: token names collide across sessions by construction (every session has an «EMAIL_001»), so any fallback hit would silently restore another conversation's secret. ORPHAN SESSIONS (pre-debut security fix): an UNMAPPED Responses `previous_response_id` or unmapped `GET /v1/responses/{id}` routes to a UNIQUE, empty session derived from the response id (`orphan_session_id`, domain-separated `_ORPHAN_DOMAIN`), NOT the configured static fallback — in per-conversation mode the static session is itself populated by the conversations/batch/no-anchor paths, so a provider-echoed token could rehydrate a DIFFERENT secret bound there (a never-wrong-value violation); the orphan session is empty by construction so an echoed stale token misses and passes through verbatim (compaction stance). That holds for a body-less read; a continuation carrying content of its own would redact into the orphan, so llm-redact-pro's router SEALS it (`sealed` → its `CHAIN_UNKNOWN` reason): a placeholder-only turn is served, a turn that would redact a value is refused 403 ('earlier turns no longer known … start a new conversation'), and the served turn's Response is not recorded, so the chain stays sealed. Static mode (default) uses one prebuilt `RequestContext`, so the hot path is unchanged; per-conversation costs one hash + dict lookups (thin Redactor/Rehydrator wrappers share detectors/allowlist/counters). COMPACTION FORKS: a NEW session (first seen by this process, EMPTY in the vault, and not marked `is_durable` — `_is_compaction_fork`; a persisted session resumed after a restart owns its tokens, and a durable per-user static copy has no first-message anchor to fork) whose first message already contains placeholder tokens is the history-compaction signature — counted and surfaced (`compaction_forks` in /status, `llm_redact_compaction_forks_total`, dashboard pill, specific INFO line). Relinking the fork was spiked and REJECTED — every design fails the never-wrong-value bar; `docs/compaction-relink.md` is the record, do not re-attempt without reading it. A fork never issues a token its history carries (TOKEN FLOORS, Vault bullet: the summary is every request's anchor); the residual (compaction-relink.md) is provider-side history no request carries, a number issued before the foreign token arrived, and other requests sharing the session.
- **Inbound (rehydration)** (`rehydrate.py`): non-streaming bodies via jsonwalk (with a `key_overrides` escape-aware path for OpenAI `arguments`, which is raw JSON source); streaming via `StreamingRehydrator`, which buffers a bounded partial-token suffix across chunk boundaries (`«EM` → `«EMAIL_` → `«EMAIL_001»`). `json_source=True` channels normalize `«`/`»` guillemet escapes (even-backslash positions only; split escapes held back ≤5 chars) and re-escape restored values so the argument stream stays valid JSON source. Fuzzy matching (`[rehydration] fuzzy`, default on) restores mangled tokens (`«email_001»`, `«EMAIL-1»`, `« EMAIL_001 »`) via canonicalize-then-vault-lookup — a miss always passes through verbatim; bracket swaps are deliberately out of scope. `substitute_tokens()` is the single substitution path shared by streaming and non-streaming.
- **SSE** (`sse.py`): incremental byte-level parser/serializer. The proxy always operates on the raw byte stream — never assume typed objects; this is the known failure mode that broke other proxies.
- **Providers** (`providers/`): `ProviderAdapter` maps paths to routing kinds (CHAT / REDACT_ONLY / NONE) and maps SSE events to rehydrator channels. Anthropic: `text_delta`/`thinking_delta`/`input_json_delta` per block index; flush on `content_block_stop` (leftovers become synthetic deltas *before* the stop event). MESSAGE BATCHES: POST /v1/messages/batches is REDACT_ONLY (requests[].params walked; note injected per entry); GET .../results is CHAT rehydrated line-by-line through the NDJSON path (`handles_ndjson`, content types in proxy `_JSONL_CONTENT_TYPES`; whole-string restoration — lines are complete); poll/list/cancel/delete are recognized REDACT_ONLY no-ops (pinned). `GET /v1/models[/{id}]` is Anthropic's only when `anthropic-version` is the request's sole provider marker (the OpenAI adapter declines it when another provider's marker is present; `ClaudeVertexAdapter` overrides `matches_request` so it never inherits the claim); Anthropic's `wants_system_note` is an explicit allowlist. OPENAI FILES/BATCHES: POST /v1/files is CHAT multipart (`multipart.py`, byte-faithful codec, `parse` offset-based and linear — None outside the canonical CRLF grammar, which the scanned-body rule refuses; `parse_boundary(content_type, media_type)` — form-data, or Gemini's multipart/related — is None unless the content type has ONE reading: no control, the strict parameter grammar (a repeated `boundary` is refused: a last-wins reader parses with the other), no `;` or escape inside a quoted value (a naive split reads it differently), RFC 2046 bchars; JSONL file-part lines redacted per line — the request-body structural reading ONLY for what a provider runs (`_REQUEST_LINE_KEYS`: purpose `batch` → a line's `body`, `fine-tune` → the example's conversation keys; the purpose field exactly as sent, one value, never on a container's or another provider's upload), every other value of every other JSONL line walked with NO skip set (`jsonwalk.transform_all_strings`: a data file's `id`/`name`/`type`/`data` is content), batch `body` and fine-tune lines noted, BlockedRequest anywhere rejects the WHOLE request 400 via the shared blocked_response closure). The proxy always passes `redact_multipart(require_scanned=True)`: every plain form field is scanned as UTF-8 text (the lenient reading's `_STRUCTURAL_FORM_FIELDS` as-sent exception serves only a caller that does not require scanning), and `UnredactableRequest` (kind only → recorded 400) classifies each file part by `upload_content.classify_file` (jsonl / text / binary; UTF-16/32 by BOM; a listed signature or a NUL means binary even if it decodes, so an all-ASCII PDF is never text-redacted; `_SIGNATURES` holds only `%PDF-` and control-byte ones (ZIP, RAR, Matroska, ELF) — a printable word like `ID3`/`RIFF`/`GIF89a`/`ftyp` opens ordinary text too and is NOT a signature, the real formats already fail the strict decode or carry a NUL): JSONL is redacted per line, other text as ONE text re-encoded as it came (one string against `max_body_strings`), and a binary part is forwarded byte-identical (file name still redacted) only through `redact_multipart(forward_binary=callable)` — the proxy passes it when no proxy-held credential is spent and `[detection] binary_uploads = "forward"` (default; hot), counting `ProxyState.unscanned_uploads` (/status `unscanned_uploads_total`, `llm_redact_unscanned_uploads_total{provider}`, a status posture line, an INFO line with path + count) — else `UnredactableRequest`; UPLOAD INSPECTION (`upload_inspection.py`; `plugin_api.UploadInspector`/`UploadPart`/`Inspection`, built once by `Registry.build_upload_inspector(config, tier)` — the Free default is the core's own `[extraction]` inspector when enabled (DOCUMENT EXTRACTION, below), else None; restart-only; an enabled `[extraction]` whose (plugin-replaced) factory builds none is a startup ConfigError; its declared `timeout` (capped at `MAX_INSPECT_SECONDS` 300) and `max_bytes` are validated at startup, a ConfigError otherwise): with an inspector, `_inspect_upload` reads the upload ONCE (`adapter.read_multipart` → `openai.PartsReading`, charged like redaction, every part's headers checked first — `checked_reading`/`require_plain_headers`: a header block every reader finds, the strict file-name grammar and `_require_plain_encoding` — and, when a binary part is to be inspected, every scanned part's format (`require_readable`: a non-UTF-8 form field, a JSONL line `_parse_request_line` refuses — it re-parses JSONL, so only then; the Gemini metadata is read in `read_related_upload`), so a request the redaction would refuse for its FORM never reaches an inspector that may send the file to a service; with a rule in block mode (`Redactor.blocks`) `_inspect_upload` also runs `reading.require_unblocked(_block_check(...))` first — every string the part loop will redact (file names, form fields, a text file, JSONL lines through `_redact_line` with the reading's `request_keys`, which the redaction itself uses, the Gemini metadata) checked by `Redactor.blocked_type` (the type `redact_text` would block for: nothing issued, counted or charged) under its own `StringBudget(max_body_strings)`, so a block-mode value anywhere refuses before the inspection; what needs the redaction itself (`max_body_strings` without a block-mode rule, a sealed session, placeholder exhaustion, a vault fault) and the upstream authorizer (it signs the final bytes) and a routed plan's `begin()` (the budget 402) can still follow the inspection; what refuses the request whatever the redaction finds runs right before the inspector is called and only for an upload with a binary part to inspect (`before_inspection` → `_RefusedBeforeInspection`: the legacy path's missing upstream 502 and `_legacy_target` 400, a routed plan's `local_refusal()`, then the `[audit] required` START row with no detections, so a failed START refuses 503 before any file is read) — the START row is held in `_EarlyAudit` (contextvar `_EARLY_AUDIT`, set by `handle()`): every row the request records without a token finalizes it (the refusals after the inspection are its END rows), the later START sites (`_handle_routed`, the legacy send) take it instead of writing another — unless the redaction found values (counts or warn-mode values, which ARE forwarded) or the request passed a refusal on an override (`_COMMITTED_OVERRIDE`: an approved value or binary part goes out as sent): then `_start_after_early`, before upstream contact, either AMENDS the early row (`_EarlyAudit.amend` → `ProxyState.amend_audit` → the log's OPTIONAL `amend(token, entry)`, `WriteAheadAudit`'s docstring, re-exported by `plugin_api` and pinned in OPTIONAL_MEMBERS; resolved once at startup into `write_ahead_amend`, None unless callable: the request keeps ONE START row, the token stays held so the END row — or the `_audit_unavailable` 503 when `amend` raises `AuditWriteError` or answers an awaitable (closed unrun: the member is synchronous, the core never awaits it) — finalizes it) or, for a log without the member, commits a second START row carrying them (its failure is the 503, whose row ends the early one) and `_EarlyAudit.supersede` ends the early row straight through `write_ahead_audit.finalize` (status None, no detections; never `record_request`, so the request counts once; a fault logs CRITICAL by type) — so what is durable before contact says what leaves, and `handle()`'s `finally` closes one still held (status None on an exception), so START/END stay paired (tests/test_upload_fate.py); without an inspector, or with no binary part, the order is unchanged), awaits `inspect_parts` on its binary file parts BEFORE `run_batched` — the one await inside `handle()`'s per-request count diff, so `_CountWindow.restart()` runs as the inspector returns (before `judge`, whose warn counts are the request's own; other requests redacting meanwhile were once counted on the upload's row) (≤ `MAX_INSPECTED_PARTS` 16, `INSPECT_CONCURRENCY` 4, one deadline for all, what still runs is cancelled and never awaited; a part over `max_bytes` is `not_inspected`, an exception or a non-`Inspection` answer `error`), then `judge` scans every extracted text with `Redactor.scan_text` (detection only — allowlists, per-type allowlists, modes, deny strings; no placeholder, no vault write; one string against `max_body_strings`, at most `max_body_bytes` characters of text per request): block → BlockedRequest, a redact-mode value → `BinaryValuesDetected` (400 naming TYPES only) whether or not the reading was complete; a COMPLETE clean reading CLEARS the part (`InspectedUpload(reading, cleared)` → `redact_multipart(inspected=)`: sent byte-identical, not refused even under `"refuse"`, not counted unscanned; under a proxy-held credential only with `Inspection.proxy_credential`); every other part keeps the unscanned-binary rules; every scanned text's token floors raise the upload's redactor (a placeholder inside a compressed PDF stream bounds the request's new numbers). Counted in `ProxyState.inspected_uploads[(provider, outcome)]` by `_count_inspections` once the upload's fate is known — `handle()` is a thin wrapper holding an `_UploadFate` for `_handle`: the upload block `hold`s `_settle_upload` (inspection outcomes + the binary parts forwarded unscanned: `unscanned_uploads` and its INFO line), settled `sent` right before the legacy `client.send` or the first routed hop actually issued (a send failing in transit counts as sent), and settled refused by the wrapper's `finally` on every other way out — so `clean` only when handed to the upstream, a clean part of an upload refused before that (a value in another part, the authorizer, a routed 402) `clean_refused` (no upstream configured and a failed START refuse before the inspection: nothing inspected, nothing counted), and nothing counted unscanned; `judge` gives every part one outcome even when the extracted texts run out of `max_body_strings` (`Judgement.over_budget`: that part and the later ones `incomplete`, raised by `_inspect_upload` after counting) (/status `inspected_uploads_total` + the `upload_inspector` block, `llm_redact_inspected_uploads_total{provider,outcome}`, a status posture line for clean forwards); `aclose()` at shutdown. A clean scan covers the EXTRACTED text only — docs must say so (tests/test_upload_inspection.py on every upload route, test_scan_text.py). DOCUMENT EXTRACTION (core since the owner moved it from llm-redact-pro; docs/extraction.md): `[extraction]` is a CORE section (`config.ExtractionConfig`/`ExtractionService`/`parse_extraction`, emitter `config_write.emit_extraction`, in `CORE_SECTION_KEYS` and `RESTART_ONLY_KEYS`, free on every tier); `extraction.ExtractionInspector` only READS (`read(data) -> FileReading(text, complete, extractor, display)`): LOCAL = a fresh `python -I -m llm_redact.extract_worker SETTINGS` per file (imports nothing else from llm_redact; empty env (Windows: only SYSTEMROOT/WINDIR, `worker_env`); `apply_limits` RLIMIT_AS/DATA/CPU/FSIZE/CORE/NOFILE/NPROC before reading stdin — on Windows NONE (no `resource`): only the wall-clock kill and the quarter-of-`worker_memory_mb` input bound hold, docs/extraction.md says so; killed+reaped at `timeout_seconds`; output capped; `max_workers` per loop; the honest-`complete` rules of the worker — polyglots via `carries_another`, the PDF walk, OOXML/ODF shown/split readings, markup charsets, RTF — moved unchanged), then SERVICES in order until complete: self-hosted tika/docling/unstructured and cloud `textract` (SigV4 via the core's own `sigv4.py`, pinned by the AWS doc vector; synchronous DetectDocumentText: images + one-page PDFs), `documentai` (`token_env` access token — static, NEVER refreshed: once expired every call 401s → no reading, fail closed until the operator rotates it and restarts, pinned by test_an_expired_documentai_access_token_fails_closed_until_rotated — or `credentials_file` service-account key → RS256 JWT (crypto extra, in a thread) exchanged at the pinned `GOOGLE_TOKEN_URI`, cached until 60 s before expiry; key read once at build) and `azure_docintel` (`:analyze` 202 + `Operation-Location` polled with a clamped Retry-After, same-https-origin only, all inside the service `timeout_seconds`); a cloud reading is complete only when its reported page count (`_Read.pages`: Textract `DocumentMetadata.Pages`, Document AI `document.pages`, Azure `analyzeResult.pages`) EQUALS the file's own (`_PAGED`: the worker's PDF `pages`, 1 for `_one_image` PNG-without-acTL/JPEG/BMP; unknown → never complete, counted `pages_unverified` — Azure F0 silently reads 2 pages) AND OCR must see everything left unread (else `unseen_content`): for a PDF the worker's `"ocr"` (`Reading.ocr_completes`: `Reading.complete` is a property over `_unread` — every `complete = False`, fail closed — and `_ocr_gap` — ONLY a page-drawn image (every image XObject of the file named by a `Do` of a page's or a page-drawn form's content, + masks, and inline images: `_page_images`; `_PdfWalk.images`) holding pixels only (`_image_unseen`/`_inline_unseen`: `_PDF_PIXEL_FILTERS`, or DCT/JBIG2 as the ONE filter with `_jpeg_text_free`/`jbig2_text_free` (globals too); JPX never; no `_PDF_IMAGE_ASIDES` /Metadata /OC /OPI /Alternates; no catalog /OCProperties — the same JPEG once completed inside a PDF that its own upload could not), U+FFFD or paints-nothing page text; every string object `_PdfWalk.value` meets, every `/Metadata` stream (any object's), every /V /RV /RC /JS text stream and `_content_asides` (content-stream comments via the `_comments` lexer, strings of operators outside `_SHOWS`) are added to the FULL reading only; every stream must be `consumed` — a page's /Contents, a form/pattern/appearance (read as drawn), an image, a glyph procedure, metadata/text, `_PDF_MACHINERY_KEYS` or `_PDF_COLOR_FAMILIES` arrays (rendered with, bytes NOT read: a documented residual), `_PDF_STRUCTURE`, a linearized file's hint stream (`/H` offsets) — else `_unread` (an unreferenced stream, /PieceInfo data, a /Thumb); attachments/JS/XFA/undrawn images/unmappable fonts/drawn streams/the text cap stay `_unread`), for a single picture `extract_worker.image_text_free` (PNG of `_PNG_PICTURE_CHUNKS` / JPEG of `_jpeg_segment_text_free` headers + thumbnail-less JFIF, each held to its specified length or table layout — an oversized tIME or APP14 once passed —, nothing after the end marker; BMP never), off the loop via `to_thread`; cloud kinds need `trusted = true` and https always, credentials only from NAMED env vars (`ExtractionService.credential_env`); no redirects, no `trust_env`, bounded answers; any failure = no reading; logs name kind/host/fixed kind or exception TYPE. `inspect` clears `complete` when the declared type (`declared_class`) OR the file-name extension (`extension_class` over `UploadPart.extension`, core-computed by `upload_inspection.file_extension` through `redact_filenames(strict)`: every name must agree, `""` = names disagree → `other`) names another class than `sniff`. CONVERT MODE (`[extraction] convert` = true or classes): the worker also returns a DISPLAY reading (`Reading.show`: PDF page text + filled field values, package content parts' shown text minus `_UNSHOWN_PART`, markup data outside script/style/template — also added to the FULL reading when not already in it, so a value a script/style/comment splits is scanned whole — RTF shown; none for a workbook or past `max_chars`; only when complete) → `Inspection.convert_text` for a complete reading of a convert class; `judge(..., convertible=adapter.converts_upload(path, reading))` makes a DETECTED (never blocked) part `converted` when convertible and (not identity or `proxy_credential`), merging the CONVERT text's token floors too (it can join a placeholder the full reading keeps apart) and taking the scan's warn counts back out (`_uncount`: the redaction of the sent text counts them once); `InspectedUpload.converted` → `openai.convert_part` replaces the part (display text through `redact_text` as ONE text, `text/plain; charset=utf-8` via `with_content_type`, file names `.txt` via `text_file_name`, remembered like a text upload through `_CONVERTED` for `record_raw_texts`). `converts_upload`: OpenAI/Azure/custom `/files` with ONE purpose `assistants`|`user_data` and container files; Anthropic Files; never media routes, other purposes or the Gemini related upload (base False). Outcomes `converted`/`converted_refused` (`_count_inspections`). Doctor `_check_extraction` (value-free, offline: the `extract` extra, env names, key file, services off-machine WARN, convert WARN; a plugin-REPLACED factory is built as serve would and closed by `_check_inspector_factory` — None or a ConfigError is a FAIL, the ProxyState refusal's twin). Tests: test_extract_worker.py, test_extraction.py, test_extraction_cloud.py, test_extraction_convert.py, test_extraction_e2e.py, test_extraction_doctor.py, test_sigv4.py (fixtures tests/document_fixtures.py). `redact_multipart(require_scanned=True)` refuses a too-deep JSONL line, a non-UTF-8 field, a non-blank preamble/epilogue, a part header without one reading, a part without a header block every reader finds (`openai._require_header_block` = `upload_view.header_block_found`: no CRLF CRLF separator, neither empty nor opening with CRLF — parsed with no header at all, a bare-LF reader found a file name or a transfer encoding in it), a Content-Transfer-Encoding other than 7bit/8bit/binary on any part or a charset other than utf-8/us-ascii (Content-Type or `_charset_`) on a scanned part (`_require_plain_encoding`); media parts of the prompt-field routes (`/images/edits`, `/videos`) are the non-goal; `redacts_multipart` (paths ending `/files`, `/images/edits`, `/videos`; Azure/custom inherit) names the routes. FILENAMES: every part's Content-Disposition `filename`/`filename*` is redacted by `MultipartPart.redact_filenames` (strict header grammar: only `\"`/`\\` escapes; obs-fold, a repeated header or parameter, any other backslash, a control other than HTAB anywhere in a header line (a bare CR/LF: a lenient reader's header block ends there), non-UTF-8 and malformed RFC 8187 raise `AmbiguousHeaders`; only the value bytes are spliced, `filename*` re-encoded under its own charset tag, a non-UTF-8 `filename*` raising under strict); routing reads (`name`/`filename`) use the strict grammar with the lenient reader as fallback, and a `filename*`-only part is a file. POST/GET /v1/files and GET /v1/files/{id} (the Azure twins; custom via the tail rule) are CHAT so echoed names are restored in the request's session; GET /v1/files/{id}/content is CHAT via `rehydrate_raw_body` (`rehydrate_text_file`: per FILE — EVERY text upload redacted as one text is remembered by the SHA-256 of the bytes sent (`openai.RAW_TEXT_FILES`, newest 1024, this process only — the proxy passes `redact_multipart(remember_text=)` and remembers them in `_settle_upload` only once the request is handed to the upstream, so a refusal after redaction never evicts a sent upload's entry) and its download restored raw as one text (byte-exact); otherwise a JSONL file line by line as JSON with NO skip set (`_restore_file_value`: the upload redacts a data line's every value; `arguments` restored as JSON source), JSON-escaping restored values; a text file that is ONE JSON document (`_is_json_document`: a model-written JSON file) through `rehydrate_json_source_text` (formatting kept, keys restored, values escaped — raw restore once broke a PEM's newlines inside a string); any other text raw as one text — residual: a remembered-no-longer JSON text upload comes back escape-aware); `wants_system_note` is False for `/v1/files…`. BATCHES: POST /v1/batches, GET /v1/batches[/{id}] (the LIST too) and POST …/cancel are CHAT — the caller's free-form `metadata` redacted out and every batch echo restored in the request's own session (one shared namespace holds every batch's tokens; pro's named users read a listing in an empty session and `listing_item_session` attributes each item); no note on any batch route; structural fields pinned untouched by the built-in detectors (tests/test_openai_batches.py); Azure's `/openai[/v1]/batches` likewise. Batch flows use the static session (async fetches have no anchor). MCP CONNECTOR: mcp_servers[] (Messages) and tools[].type=="mcp" (Responses/Realtime) are provider-directed config — stripped BEFORE redaction (strip_mcp_tools/restore_mcp_tools in providers/base.py) so the provider receives the real credential and nothing in the block is counted; MCP call CONTENT still flows (Responses gained response.mcp_call_arguments.delta/done channels + mcp bookkeeping events in KNOWN_EVENT_TYPES). STORED-OBJECT APIS (OpenAI; Azure both families via `_AZURE_STORED`, containers v1 only; custom providers via the tail rule): fine-tuning jobs (create/list/get/cancel/pause/resume/events CHAT, checkpoints REDACT_ONLY, checkpoint permissions pass-through), vector stores (store, search, files, file content, file batches CHAT; deletes REDACT_ONLY) and code interpreter containers (container + files CHAT; a multipart container-file upload goes through `redact_multipart`/`classify_file` like `/v1/files`, the download through `rehydrate_raw_body` like a Files download; deletes REDACT_ONLY); never a system note. VERBATIM FIELDS: `ProviderAdapter.verbatim_fields(method, path)` + `providers.base.prepare_route_request` (THE proxy's redaction entry point, wrapping `prepare_request`) hold identifier fields out of the walk and scan them alone — a value that would be redacted refuses 400 (`VerbatimFieldRedacted`, never forwarded, never rewritten; warn counts and forwards, block blocks): fine-tune `suffix` (part of the fine-tuned model name later cited in the structural `model`), `training_file`/`validation_file`, `integrations`; store/attach/file-batch/container file ids; a vector store search filter's `key` (positions: "*" = list items, "**" = any depth). The hold-out is LINEAR and budgeted (it runs on the event loop): held slots go into a trie built as each traversal reaches them (`_Cursor`: a trie node made only when a field is held at or below it, so no field costs its depth — a slot is never materialized as a tuple and re-walked; a field inside a held one goes back with it, first held wins, pinned against the slot-by-slot reference by a hypothesis differential), each verbatim field is charged 1 against `max_body_strings` as found (then per string when scanned) and each label when redacted, so a body of too many is refused 413 before they are all collected, `_rebuilt` copies each container on a held path once to take them out and once to put them back, and label slots are redacted in slot order then written in one pass — never a per-field scan or list copy (it was quadratic: ~30 s for 16,000 search filters; tests/test_verbatim_fields_cost.py). LABEL FIELDS: `label_fields` (a store's create/modify and a container's create: `name`, a key the walk skips as structural; a fine-tuning job create: every grader `name` under `method`, `("method", "**", "name")`, nested multi-graders included) are redacted after the walk, and `OpenAIAdapter.rehydrate_body` restores the `name` of every `object: vector_store|container` and every `name` under a `fine_tuning.job`'s `method` (alone or in a list envelope, `_restore_labels`). Tracking: store/container creates, container files, and the `container_id` a Responses code interpreter call or `container_file_citation` names; `lists_objects` = the job/store/container collections, never nested file lists (`_NESTED_LISTING_RE`). Vector store filter values stay consistent with stored attributes because every such route uses the static session. The Uploads API stays unrecognized (opaque byte-range parts; api-coverage.md honest gaps). docs/api-coverage.md is the endpoint matrix, pinned by tests/test_api_coverage.py in BOTH directions (route + doc sync). OpenAI chat: `delta.content`, reasoning-model chain-of-thought (`delta.reasoning_content` — DeepSeek/vLLM/Groq/xAI — and `delta.reasoning` — OpenRouter — on their own per-choice channels, `_REASONING_FIELDS`), and `tool_calls[].function.arguments` per choice; flush on `finish_reason`/`[DONE]` (leftover reasoning tokens map back via `_leftover_to_delta`). Non-streaming reasoning fields are already covered by the generic jsonwalk — only the streaming deltas needed the per-channel path. OpenAI Responses (`openai_responses.py`, Codex CLI): `output_text`/`refusal`/`function_call_arguments` deltas per item; `*.done` events flush and rehydrate their repeated full value; `response.completed` flushes all and rehydrates the embedded response; `GET /v1/responses/{id}` is also rehydrated. `POST /v1/responses/compact` is CHAT (note injected — a compaction must carry tokens exactly; never streams; the opaque encrypted compaction item returned as sent; not tracked) and `/v1/responses/input_tokens` REDACT_ONLY with the note (a count must match the request it counts) — also on Azure, custom providers and Gemini's `/v1beta/openai/`. Its event shapes are pinned by hand-authored fixtures — verify against the live API when possible. CONVERSATIONS API (the stateful item store paired with Responses): POST /v1/conversations + POST/GET /v1/conversations/{id}/items + GET /v1/conversations/{id} are all CHAT (item content redacted out, stored/echoed content rehydrated back); DELETE (and an item's DELETE) is a recognized REDACT_ONLY no-op. Conversations use the STATIC vault session (async reads have no first-message anchor — the batch/realtime stance, forced in sessions.py `resolve`), so redact/rehydrate always agree (never wrong-value) at the cost of a per-conversation namespace. The list-items response rides the `object:list` jsonwalk envelope walk above; note injection is suppressed for conversations (bodies carry `items`, not `messages`). Gemini (`gemini.py`): channels keyed `(candidate, text|thought)`; NO [DONE] sentinel — `finishReason` is the only flush point (leftovers append to the candidate's last matching part; `_stream_rehydrated` discards stream-end leftovers); `functionCall.args` is a parsed object (plain walk, not JSON source); the no-`alt=sse` form returns a buffered JSON *array* whose elements split tokens → list-aware `rehydrate_body` runs streaming channels across elements; SSE and array chunks (and Live serverContent/toolCall) are walked from their ROOT like the buffered body, only channel-fed text held out (`gemini.StreamedText`), so streaming == buffered for function-call args, generated code and grounding (an unchanged SSE chunk forwards byte-identical); drift detector = KNOWN_CHUNK/CANDIDATE/PART_KEYS subsets (events are unnamed). CONTEXT CACHING + BATCH MODE: `POST /v1beta/cachedContents` (create, `_GEMINI_CACHED_CREATE`) and `models/{m}:batchGenerateContent` are REDACT_ONLY — the cached prompt and inlined batch requests are content walked by the generic jsonwalk, the responses carry only a cache/operation name (nothing to rehydrate); both are stored/async (no first-message anchor) so `sessions.py resolve` forces the STATIC session for `:batchGenerateContent` and `/cachedContents` paths (batch stance), and per-cache GET/PATCH/DELETE + list pass through; no note injection (would corrupt a batch body / change a cache). Gemini API Files are tracked (every create form, `files:register`, and a finished batch's output file), `/download/v1beta/` reaches the Gemini upstream. BATCH READS + FILES (recognized so a proxy-held credential may reach them; tests/test_gemini_batches.py, test_gemini_files.py, test_anthropic_files.py over `tests/lent_routes.py`): GET `/v1beta/batches[/{id}]` CHAT (display name + a finished batch's INLINED responses restored; the list's `{"operations"}` items attributed by `name` via the optional `listing_item_id` hook), `:cancel`/DELETE REDACT_ONLY, `:asyncBatchEmbedContent` REDACT_ONLY + tracked; Gemini `wants_system_note` needs a generate verb or countTokens. Files: GET `/v1beta/files[/{id}]` CHAT (displayName restored, `{"files"}` list attributed), DELETE REDACT_ONLY, `…:download` + `/download/v1beta/…:download` CHAT via `documents.rehydrate_download` (= `openai.rehydrate_text_file`, the ONE file-download restoration, decided per FILE by `classify_file`: a JSONL file line by line as JSON (`transform_all_strings`, no skip set), a remembered text upload or any other non-JSON text file as ONE text — the value lands exactly as the upload redacted it, never JSON-escaped because a line parses as JSON once it holds a placeholder — one JSON document escape-aware, a binary file untouched), POST `/v1beta/files` CHAT, POST `/upload/v1beta/files` CHAT for the single-request `multipart/related` form (`multipart_boundary` hook; `documents.redact_related_upload` reads the FIRST part as the file's metadata — the one JSON value the stored-object check reads (`upload_view.read_metadata_part` → `openai._Reading("metadata")`, redacted by `_redact_metadata`: every string via `transform_all_strings`, only the JSON span spliced, only on a change or a repeated key; floors from `json_floors`; what that reading refuses is `UnredactableRequest` under `require_scanned`, the Free core included — a pretty-printed part was once redacted as raw text, escapes unresolved) — and EVERY other part as a file by `upload_content.classify_file` through the OpenAI upload's own `_redact_part`, `forward_binary` Callable contract, counted in `unscanned_uploads`). RESUMABLE: `matches_request` leaves a data chunk (`upload_id`, a non-`start` `X-Goog-Upload-Command`) and the raw protocol unrecognized (pass-through, own key only); under a proxy-held credential `proxy_credential_refusal` refuses anything but the single-request protocol (recorded 403 `_credential_protocol_refused`, after the plan/origin recheck, before the scanned-body rule) and `_deliver` drops `capability_response_headers` (`x-goog-upload-url`) — never relay a capability minted for the proxy's credential; `/upload/v1beta/` + `/download/v1beta/` are exempt from the extra-prefix misaddressed rule (`_MEDIA_FAMILIES`). Anthropic Files (`anthropic-version` sole marker): POST `/v1/files` CHAT through the OpenAI Files upload unchanged (`documents.redact_files_upload`), GET list/metadata/content CHAT (content via `rehydrate_download`, `{"data"}` list attributed), DELETE REDACT_ONLY. `_restore_buffered` falls back to `rehydrate_raw_body` when a JSON-typed body is not ONE JSON value (`application/jsonl` contains `application/json`). Recognized upload routes are capped by `max_body_bytes`. And the Gemini API's OpenAI-compatible `/v1beta/openai/` surface is served on the gemini provider by `GeminiOpenAIAdapter` + `GeminiOpenAIResponsesAdapter` (create only: a stored response read back on that prefix is left alone) through the custom providers' tail rule. Vertex uses a different batch API (BatchPredictionJob) and is out of scope. Azure (`azure_openai.py`): OpenAIAdapter subclass, only `matches()` differs; needs `[providers.azure]` set (502 until then). Vertex AI (`vertex.py`): GeminiAdapter subclass, only `matches()` differs (v1/v1beta1, publishers/models + endpoints, with/without projects/locations prefix); needs `[providers.vertex]` set (502 — the host embeds the region); registered after Gemini, matchers proven disjoint. Claude-on-Vertex (`claude_vertex.py`, `ClaudeVertexAdapter`): AnthropicAdapter subclass reusing ALL Messages redaction/rehydration/note-injection — only `matches()` differs (`publishers/anthropic/models/{m}:rawPredict|:streamRawPredict`, anchored to the anthropic publisher because Llama/others use rawPredict with different bodies; body carries `anthropic_version: vertex-2023-10-16`, no `model`); `name = "vertex"` so it shares the same upstream as the Gemini VertexAdapter (disjoint matchers — rawPredict vs generateContent — proven by test); registered after VertexAdapter. CLOUD NON-CHAT ROUTES (identity auth forwards ONLY recognized routes, and unrecognized key-authorized routes were forwarded unredacted, so these are matched — docs/api-coverage.md's Vertex/Azure/Bedrock tables, tests/test_cloud_routes.py): Vertex `projects/{p}/locations/{l}/cachedContents` create REDACT_ONLY (static session via the `"/cachedContents" in path` stance; `tracks_object_ids`, the name reported as `cachedContents/<id>` through the shared `gemini.cache_object_ids` so pro's `_cache_reference` matches) + get/list/PATCH/DELETE; `:computeTokens`/`:embedContent`/`:fetchPredictOperation`; publisher-model + Model Registry metadata GETs (`_MODEL_METADATA`). Azure (both `/openai/deployments/{d}/` and `/openai/v1/`): legacy completions CHAT (no note), images/generations|edits + audio/speech REDACT_ONLY, files (v1 too) incl. list/metadata/delete, batches CHAT (user `metadata` echo), v1 conversations CHAT (DELETE REDACT_ONLY), Responses cancel CHAT / DELETE REDACT_ONLY, models/deployments listings; `wants_system_note` = chat/completions + per-line file uploads only. Bedrock: `/model/{m}/count-tokens` + `POST /async-invoke` REDACT_ONLY, ApplyGuardrail `/guardrail/{id}/version/{v}/apply` CHAT (outputs/matches carry the sent placeholders; ids greedy for decoded ARNs), `GET /async-invoke[/{arn}]`; `wants_system_note` only on the four `/model/` inference routes. A body-less REDACT_ONLY is the "recognize a metadata GET" mechanism (a no-op redaction: bytes/credentials unchanged for passthrough auth) — also for model listings, OpenAI deletes of responses/conversations(+items)/files/videos, `GET /v1/videos/{id}/content`, Gemini `GET /v1beta/models[/{m}]` and Ollama `/api/tags|ps|version|show`, so a credential the proxy holds may reach them. Still pass-through (and refused under identity): Azure audio transcriptions/translations (media), Vertex batchPredictionJobs/endpoint GETs, Azure v1 threads/vector_stores/evals/containers. EMBEDDINGS are REDACT_ONLY on OpenAI `/v1/embeddings`, Azure embeddings paths, and Gemini `embedContent|batchEmbedContents` (input redacted, vector response verbatim); `wants_system_note(kind, path)` gates note injection — CHAT-only by default, Anthropic keeps it on count_tokens and Gemini/Vertex on countTokens, embed* bodies are never touched (injecting would corrupt them). AWS Bedrock (`bedrock.py`, bearer-key auth or the proxy's own identity (`auth = "identity"`, below); a CLIENT-computed SigV4 signature stays a permanent non-goal — it covers the unredacted body the proxy rewrites): four `/model/{id}/…` runtime routes, all CHAT (count-tokens decodes `input.invokeModel.body` base64, redacts it like an invoke body and re-encodes — byte-identical when clean, the envelope walked with the blob held out); model ids can be percent-encoded ARNs, so `matches` runs greedy on the decoded path while the proxy forwards `scope["raw_path"]` (raw-path forwarding applies to ALL providers); needs `[providers.bedrock]` set (502 — the host embeds the region). Note injection is positively-recognized-shapes-only: `anthropic_version` ⇒ Messages-style (shared `inject_anthropic_system_note`), Converse-signature messages (type-less keyed-union content blocks) ⇒ append `{"text": NOTE}` to `system`; anything else untouched (a wrong-schema field corrupts a native invoke body; a missing note only weakens token preservation). Streams are binary vnd.amazon.eventstream (`eventstream.py`: CRC32 = zlib.crc32, both CRCs validated, headers keep type codes so untouched frames re-serialize byte-identical, `feed()` never loses bytes on error — `residual` = everything unreturned); the proxy's third response branch (adapter flag `handles_eventstream`) degrades to verbatim pass-through on any framing violation (unrestored placeholders are safe; guessing at corrupt frames is not). converse-stream channels `("text"|"reasoning"|"tool" json_source, contentBlockIndex)` flush on contentBlockStop with synthetic delta frames; invoke-with-response-stream `chunk` frames base64-wrap Claude Messages events → shared `rehydrate_messages_payload` (extracted from anthropic.py); unrecognized inner shapes forwarded verbatim. Cohere (`cohere.py`, `[providers.cohere]` default `https://api.cohere.com`): `/v2/chat` CHAT (messages redacted; non-streaming response rehydrated by the generic jsonwalk with the `arguments` JSON-source override; streaming SSE rehydrated per channel — content-delta `delta.message.content.text` keyed `(text,index)`, tool-plan-delta `delta.message.tool_plan` keyed `(tool_plan,)`, tool-call-delta `delta.message.tool_calls.function.arguments` keyed `(args,index)` json_source; flush on `content-end`/`tool-call-end` per index and `message-end` for all, leftovers → synthetic deltas before the end event), `/v2/embed` + `/v2/rerank` REDACT_ONLY, legacy `/v1/chat` + `/v1/generate` CHAT (v1 `event_type:text-generation` stream on a single `("v1text",)` channel, flush on `stream-end`); note injection = a system message (v2 messages[]) or `preamble` (v1 chat), never for v1 generate's bare prompt. Drift set `KNOWN_COHERE_EVENT_TYPES` (live test); unrecognized events forward verbatim (worst case an unrestored placeholder, never a wrong/leaked value). Ollama NATIVE API (`ollama.py`, default upstream `http://127.0.0.1:11434`; its OpenAI-compat `/v1` routes still go through the openai adapter): `/api/chat` + `/api/generate` CHAT, `/api/embed` + deprecated `/api/embeddings` REDACT_ONLY; streams are `application/x-ndjson` (verified in ollama's server/routes.go — stream:false returns plain application/json, so the response-content-type branch stays unambiguous) → `ndjson.py` line codec + the proxy's fourth response branch (adapter flag `handles_ndjson`, `_stream_rehydrated_ndjson`); channels: chat `message.content`, generate `response`; the `done:true` line is the flush point (leftovers fold into its content); `tool_calls` arguments are PARSED OBJECTS (plain walk, not json_source); a line that fails json.loads is forwarded byte-identically. Ollama note injection appends to an EXISTING system message (chat, last one) or `system` string (generate) and NEVER creates one — a fresh `system` would override the Modelfile SYSTEM template and change model behavior. CUSTOM PROVIDERS (`custom.py`, `[providers.custom.NAME]`, served under `/custom/NAME/`): `_CustomPrefixMixin` wraps the full OpenAI (`CustomOpenAIAdapter`) and Responses (`CustomResponsesAdapter`) surface per named upstream — each independently enable/disable-able (fail-closed 502), several side by side (vLLM + LM Studio + OpenRouter). Every path-sensitive hook (`matches`/`wants_system_note`/`rehydrate_raw_body`) routes through `_PrefixedOpenAIMixin._canonical`: the endpoint is the path's TAIL — the longest tail, at a segment boundary, that is a known OpenAI endpoint (as is when it starts `/v1/`, else under `/v1`) for the request's METHOD (or, from a hook that knows only the matched kind, of that kind), never nested below an OpenAI resource (`_OPENAI_RESOURCES`: `/containers/…/files/…/content` is not the Files download) — so Groq `/openai/v1`, OpenRouter `/api/v1`, Fireworks `/inference/v1`, GitHub Models `/inference`, Azure AI `/models`, Zhipu `/api/paas/v4`, a Cloudflare gateway and a `/v1`-less inner path all match; without a match the old rule (re-anchor at the last `/v1/`, else prepend `/v1`) applies — without normalization a non-`/v1` base path matched NOTHING and the request was silently forwarded UNREDACTED; a genuinely unknown tail still falls through to NONE (pass-through) via the wrapped EXACT matcher, so this only ever promotes real OpenAI endpoints. Unmatched subpaths under a configured prefix pass through to THAT upstream (the client addressed it explicitly). PER-PROVIDER DETECTION OFF: `[providers.NAME] detection = false` forwards that provider's requests UNREDACTED (no detection/deny/block/note — like warn mode, docs must never imply protection) while REHYDRATION stays active; applies to realtime WS frames too; surfaced via `providers_detection_off` in /status, a per-request INFO log line, a dashboard ⚠ marker, and an editor checkbox. MCP EXEMPT SERVERS: `[detection.mcp] exempt_servers = [names]` — MCP content blocks addressed to those servers (Anthropic server_name, OpenAI server_label) bypass detection via a stash-around-redaction in base `prepare_request` (stash_exempt_mcp_blocks / restore_exempt_mcp_blocks, position-based restore decided on the ORIGINAL so sentinel-shaped user data can't confuse it, restored BEFORE note injection which may restructure lists); Anthropic mcp_tool_result names no server — exempt only when tool_use_id correlates to an exempt mcp_tool_use in the same body, else redacted (fail-closed); HTTP bodies only. PROVIDER DISABLE: `[providers.NAME] enabled = false` fails CLOSED — checked in `handle()` BEFORE the body is read, answers a proxy-generated 502 (`adapter.error_body` shape); it must never fall through to pass-through, which would forward traffic unredacted (covers inferred pass-through paths too, pinned by test). `?key=` query auth passes through but httpx/uvicorn loggers print full URLs — cli.py silences both; never log URLs with queries. UPSTREAM IDENTITY (`[providers.NAME] auth = "passthrough"|"identity"`, identity only for bedrock/vertex/azure (`IDENTITY_AUTH_PROVIDERS`); `region` only for bedrock with identity): `plugin_api.UpstreamAuth` (`async authorize(method, url, headers, body) -> headers`, `close()`) + `UpstreamAuthError` (source-only message), built per provider by `Registry.build_upstream_auth(name, provider)` (Free default: None for passthrough, ConfigError naming llm-redact-pro for identity); held in `ProxyState.upstream_auth`, HOT (rebuilt only when `(auth, region, upstream_base_url)` changes, built before the router reconfigures, displaced ones closed at swap — an open realtime relay opened under a displaced authorizer is revoked (1012) first — `validate_config` builds+closes as a dry run; `handle()` reads its provider's authorizer ONCE, before the body, so a request keeps the one it was admitted with). For such a provider `handle()` never consults the router and forwards ONLY matched adapter routes (an unmatched/pass-through path is a recorded local 403 — the proxy's identity is never lent to the rest of the cloud API), `strip_client_credentials` drops every header containing "authorization" or ending in "api-key", `x-amz-*`, cookie, ocp-apim-subscription-key, x-goog-user-project and the x-goog-iam-* selectors plus password/passwd/x-goog-quota-user headers, and the `key`/`$key`/`api-key`/`api_key`/`access_token`/`oauth_token`/`subscription-key`/`userProject`/`quotaUser`/`password`/`passwd`/`X-Amz-*`/any `*authorization*` query params (`identity_upstream_problem`: an `http://` base off loopback is a ConfigError, at parse and again in `_build_upstream_auths`), then `authorize()` sees the httpx wire-form URL and the FINAL (redacted, note-injected) bytes and exactly those are sent; any exception → a recorded provider-shaped 502 (`_upstream_auth_failure`, error text or type only, counted in `upstream_errors`); realtime WS upgrades are authorized the same way on the exact documented paths (below); a plugin returning None for an identity provider is a startup ConfigError (`_build_upstream_auths`). Surfaces: /status `providers_auth`, the status posture line, doctor `_check_upstream_auth` (WARN on a non-loopback bind without `[auth] require`/`broker`), and `identity_exposure_warning` (serve + `serve --check`: identity providers on a non-loopback bind with no access gate). REQUEST TARGET: `handle()` and `ws_handle` refuse a non-origin-form target (`origin_form_target` reads `raw_path`; 400 / 1011) and `_same_upstream` checks the built URL keeps the base's scheme/userinfo/host/port AND starts with the base path (with `exact_path`, identity: raw path == base path + request raw path) — a `%2F…x@host` target once joined onto the base as userinfo@host (SSRF with the proxy's identity); `has_dot_segment` refuses `.`/`..` segments (incl. %2E and backslash forms) 400 / 1011 before admission. `has_empty_segment` refuses an empty segment (`//`, raw or decoded, backslash counted as `/`) the same way (400, pre-admission, never recorded or logged with its path). `refused_response` answers a recorded 400 for `redactor.UnredactableRequest` (an undecodable Bedrock count-tokens blob, an unscannable upload piece, `PlaceholderLimitReached`). PROVIDER ATTRIBUTION (`providers/attribution.py`): `provider_for` returns `str | None` via `attribute(path, headers, query)` — an explicit path family first (`PATH_FAMILIES`, segment-aware: `/v1beta`, `/upload|download/v1beta` gemini; `/v1/projects`, `/v1/publishers`, `/v1beta1` vertex — express mode's Google key notwithstanding; `/openai` azure; `/model`, `/guardrail`, `/async-invoke` bedrock; `/api` ollama; `/v2` cohere; `/v1/messages`, `/v1/complete`, `/v1/organizations` anthropic; `/custom/NAME`), else headers only one provider's clients send (`anthropic-version`, a Google key/any `x-goog-*`/`?key=`/`$key=`, `openai-*`, Cohere's `x-fern-sdk-name`; two providers' markers attribute nothing), else openai for a non-`sk-ant-` `Bearer sk-` key or an `OPENAI_PREFIXES` path; None is a recorded 404 (`_unattributed_refused`, naming the path and marker KINDS, never the query). THERE IS NO ANTHROPIC DEFAULT (it sent an OpenAI key and prompt to api.anthropic.com). MISADDRESSED (`_misaddressed`, unmatched paths only): another spelling of a recognized route (trailing `/`, case) is a recorded 400; the route without its `/v1` (the message names `OPENAI_BASE_URL=…/v1`) or under an extra prefix (`/v1/v1/…`) a recorded 404; Azure `/openai/…` and `/custom/…` paths are exempt from the extra-prefix rule and an `/openai/` tail never counts as one. ROUTING COST: path routing is linear — `_canonical` tries tails of ≤ `custom.MAX_ENDPOINT_SEGMENTS` (10; the deepest route has 5 segments after /v1) segments plus the one at the first OpenAI resource; `_misaddressed` looks ≤ 8 segments deep for an extra prefix and runs only for a request the request-origin rule and the gate admitted; `_spellings` = as sent, `_normalized_path` (NFKC, `\`/`%5C`→`/`, `;params` dropped, trailing whitespace/dots trimmed, empty segments merged) and `_decoded_again` (%uXXXX, `+`, a second unquote), each in its own/lower/.NET-folded case; only a spelling that matches a route while the raw path does not refuses (400) — Gemini `:method` paths and Bedrock %-encoded ARNs are pinned unaffected. tests/test_routing_cost.py times the worst shapes at 16 KiB and pins match counts.
- **Realtime WS** (`realtime.py`, `realtime` extra = websockets>=15.0 — older clients lack `process_redirect` or append a second User-Agent after the forwarded one — which serves BOTH uvicorn's ws protocol (auto-enabled when importable — without it uvicorn refuses upgrades entirely) and the upstream client): a catch-all `WebSocketRoute` relays OpenAI Realtime (`/v1/realtime`, beta AND GA event names in the channel tables) and Gemini Live (`BidiGenerateContent`, JSON over text OR binary frames — re-serialize in the SAME frame type) to the provider's wss endpoint derived from `[providers.*]` by scheme swap. Outbound: jsonwalk over EVERY client event with `_REALTIME_STRUCTURAL_KEYS` (base64 `audio` + enums skipped; a missed redaction is a leak, so walking everything is the default); `BlockedRequest` closes the connection 1008 (type only); policy closes go through `close_on_policy` — the client FIRST, then the upstream (closing the upstream first let its mirrored 1000 reach the client ahead of the 1008) — and on an identity connection `redact_message(require_json=True)` makes a non-JSON frame `UnredactableRequest` (1008, unsent, row 400; key-auth connections relay non-JSON frames verbatim), a frame over its own `max_body_strings` budget closes 1009 (row 413) and a vault fault 1011 (row 503). Inbound: `rehydrate_message` returns a frame LIST (flush leftovers become synthetic delta frames); channels keyed (item_id, kind, output_index, content_index) for OpenAI, (modelTurn, text|thought) + outputTranscription for Gemini; flush on `*.done`/`response.done`/turnComplete; item/session echo events rehydrate whole (the client owns the originals). Close codes mirror BOTH directions — websockets' iterator raises ConnectionClosed for non-OK codes (handled, else the client would see 1000). The relay dials base path + raw path (APIM/gateway bases keep their path) and WS refusals are recorded (non-authorizable 403, failed `_same_upstream` 400, `_record_ws_refusal`); the `websockets` logger is pinned at WARNING. Headers/query/subprotocols pass through and are NEVER logged (wss URLs carry `?key=`; browser clients carry keys in subprotocols). Refusals (unknown path — there is no default WS upstream, disabled provider, missing extra, reserved path, a sealed session) are accept-then-close 1011 so the reason reaches the client (close reasons cut to 123 bytes, `_close_reason`); the request-origin check (Proxy bullet) runs FIRST, before the gate refusal and route checks (1008, row 403), and every pre-dial refusal — gate 403, disabled/unconfigured provider 502, `[audit] required` START 503 — is recorded (`_record_ws_refusal(adapter=, session=)`). The dial never follows a handshake redirect (`_connect_without_redirects`: a 3xx is a failed dial, 1011, counted, recorded) — the proxy's credential, or the client's key, never follows a Location. Azure Realtime matches `/openai/realtime` and GA `/openai/v1/realtime`; `VertexLiveWs` (GeminiLiveWs subclass, `[providers.vertex]`) matches exactly `/ws/google.cloud.aiplatform.{v1,v1beta1}.LlmBidiService/BidiGenerateContent` (Gemini Live structural keys include the snake_case forms). IDENTITY: `WsAdapter.identity_paths`/`authorizable` = the exact documented paths; for a provider in `state.upstream_auth`, `_authorize_upgrade` strips header/query credentials (`strip_client_credentials`) and every subprotocol outside the `identity_subprotocols` allowlist (`realtime`, `openai-beta.*`; a bare marker entry also drops its successor), applies `_same_upstream`, calls `authorize("GET", http-form URL, headers, b"")` and dials with exactly the returned headers; any other path to such a provider is refused 1011; authorizer failure = 1011 naming the source only, counted in upstream_errors and recorded (502, before begin_audit); a failed upstream dial is also counted and recorded (it used to leave an `[audit] required` START row without an END). Note injection appends ONLY to existing instruction fields (session updates replace instructions wholesale — creating one clobbers the server default), idempotently. Static vault session ONLY (no first-message anchor at upgrade time) — resolved through `state.context_for`, so a user-scoping router (llm-redact-pro named users) yields that user's own copy. Every client frame is put to the session router's optional `realtime_frame_refusal` first (Sessions bullet): a refusal closes 1008, row 403; every JSON upstream frame is handed to its optional `realtime_server_frame` before it is restored (read-only, faults contained). One `record_request` per connection at close (method "WS"); detections use a per-connection tee counter (the HTTP diff trick corrupts across awaits). RELOADS REACH OPEN RELAYS (`RealtimeRelay`): `ws_handle` snapshots a connection's admission in the one synchronous stretch after `state.admit` — its `ProviderConfig`, its `UpstreamAuth` (None for passthrough) and `state.detectors`/`allowlist`/`modes` — registers it in `ProxyState.realtime_relays` for the connection's life (try/finally; the dial and relay body is `_relay`), and `apply_config` calls `_revoke_stale_relays()` in the SAME synchronous step as the swap (after every assignment, before displaced routers and authorizers close). `stale()` names `[providers.NAME] settings` (any ProviderConfig field), `upstream authorizer` (the object was replaced — all are rebuilt when any provider's `_auth_settings` change) or `[detection] policy` (any of the three objects replaced); `revoke()` is synchronous, first-wins, never raises and wakes the relay via `call_soon_threadsafe`. `client_to_upstream` checks `relay.revoked` between `receive()` and redaction with NO await between, so a frame read after the swap is never forwarded under the old admission (only one already read and queued on the upstream socket can leave — `websockets`' `send()` writes before it first yields); `relay.wait_revoked()` wakes the relay loop, which closes the client 1012 (`RELOAD_CLOSE_CODE`, Service Restart: reconnect; `_reload_reason` names what changed, never a value) and the upstream 1000; the row stays 101. A revocation during `_authorize_upgrade` is never dialled (1012, recorded 503); an ACCESS revocation (`RealtimeRelay.close_for_access`, the gate's `ConnectionControl.close` or a failed recheck) reuses the same machinery: client 1008 with the gate's reason, upstream 1000, row 101 (before the dial: refused, row 403); a dial in flight completes and closes: `_relay` re-checks `relay.revoked` after the dial and BEFORE `accept()`, so the client is never accepted onto that upstream and no frame (its greeting included) crosses either way — upstream closed 1000, client rejected with the revocation's code and reason, row 503 (reload) or 403 (access) ending the audit START row; on an open relay `upstream_to_client` checks `relay.revoked` after each upstream frame and before each send, so nothing is restored or sent to the client once it is revoked (from any thread). Hot for open relays: `inject_system_note` and `max_body_strings` (read per frame); `rehydration.fuzzy` is per connection; the vault session never switches, so a reconnect restores tokens issued before the reload. uvicorn's sans-I/O WS closes its socket right after its close frame, so a client still writing may see a reset instead of 1012 (true of every proxy close). Tests: tests/test_realtime_reload.py runs the reload on the server's own loop through a loop-capturing ASGI wrapper. Drift: `KNOWN_REALTIME_EVENT_TYPES` / `KNOWN_LIVE_SERVER_KEYS` asserted by live tests. Testing: WS cannot ride ASGITransport — test_realtime_relay.py runs uvicorn on port 0 + a fake `websockets.serve` upstream (the test_tls pattern); adapter sweeps split at every offset.
- **Proxy** (`proxy.py`): catch-all route. Unrecognized traffic forwards verbatim — but only to the provider it is positively attributed to (PROVIDER ATTRIBUTION, Providers bullet; unattributable → a recorded 404) and only with the client's own credential (a credential the proxy holds is lent to recognized routes only); a recognized route forwards only a body it scanned (SCANNED-BODY RULE below). ORDER in `handle()`: target, dot- and empty-segment 400s → reserved paths → admission (the gate scrubs its credentials) → route + `_misaddressed` → `provider_for` → `upstream_auth` snapshot (read ONCE with `provider_conf`, before the body await: a reload landing mid-body never changes what a request was admitted as, and the upstream comes from the same snapshot — `ProxyState.upstream_for` is gone) → REQUEST ORIGIN → `GET`/`HEAD /` answered locally → misaddressed / unattributed / unknown-custom / disabled-provider / gate refusals → the METHOD-OVERRIDE 400 (a matched route carrying `X-HTTP-Method-Override`/`X-HTTP-Method`/`X-Method-Override` or a `_method`/`$method`/`$httpMethod`/header-named query parameter, `method_override`: recorded, provider-shaped, kind only; the headers are also dropped from a matched route's forwarded headers — `_request_headers(matched=)` — and from every realtime relay's `_filtered_headers`; unmatched pass-through forwards them as sent) → the identity unrecognized-route 403 → `local_answer` → an unrecognized route's plan (lending → 403) → the capped body read and `loads_request` → a recognized route's `plan()` (lending → the request-origin host rule again, markers or not) → the SCANNED-BODY RULE (parts cap first) → `_ownership_body` + `object_access_refusal` → `context_for` → redaction in `run_batched` (an upload with a binary part to inspect: the block check, then `before_inspection` — missing upstream, target, routed `local_refusal`, `[audit] required` START — then the inspector, then redaction) → routed or legacy delivery. REQUEST ORIGIN (`request_origin_refusal`, threat-model 'Requests from web pages'): the vault is lent on every rehydrating route (a page with its own key read it back through a relayed CORS answer, a WebSocket, or after DNS rebinding) and identity/operator keys on some, so every forwarded HTTP request is checked right after admission — before the `GET /` answer, the misaddressed/unattributed/disabled-provider/gate refusals, the body read and any credential — and every WS upgrade first of all: browser markers (Origin, any Sec-Fetch-*; page script can neither set nor remove them) require `_host_allowed` (loopback names, the bind host, `allowed_hosts`, the gate's public origin) plus either the request's own origin (port-exact `_own_origin`) with Sec-Fetch-Site same-origin/none, or every foreign Origin exactly in the operator's opt-in `allowed_origins` (`config.normalize_origin`: http(s) only, plain http only on loopback, never `null`; Sec-Fetch-Site then skipped; a request with no Origin is never listed); a request spending a proxy-held credential (the identity snapshot; a routed plan's `_lends_credential`, re-checked once planned) needs `_host_allowed` even without markers unless its scheme is https/wss (a browser checks the certificate against the name it resolved). Refusals: a recorded provider-shaped 403 (`_request_origin_refused`) / WS accept-then-close 1008; `/status` `request_origin_refusals_total{host,origin,fetch_site}`; kind-only logs. `allowed_hosts`/`allowed_origins` are restart-only (in RESTART_ONLY_KEYS, so the pro editor preserves them); reserved paths never consult `allowed_origins`; CORS for listed origins is transparent (preflight forwarded, the provider's answer relayed; proxy-generated errors carry no CORS headers); `/status` `allowed_origins` is the count; doctor `_check_allowed_hosts` (WARN: a plain-HTTP non-loopback bind lending a credential with none listed) and `_check_allowed_origins` (WARN listing them); a status posture line; the Helm standalone mode emits its Service names as `allowed_hosts`. SCANNED-BODY RULE (`_unscanned_body`): on a matched route with a non-empty body, when the request spends a proxy-held credential OR `detection` is on (the client's own key included), forwardable is only a JSON object (read from the bytes whatever the content-type; BOM/UTF-16/32 fine) or canonical multipart on a `redacts_multipart` route, every part then scanned (`require_scanned=True`, always passed); anything else — non-JSON, invalid UTF-8, bytes after the value, a top-level array or scalar (`null` included), whitespace, other or non-canonical multipart, a repeated Content-Type — is a recorded 400 naming the KIND (`_unscanned_body_refused`, clause `_scanned_body_clause`: identity / routed proxy credential / key auth), and any coding other than identity in any Content-Encoding header (comma lists included) a 415 with `Accept-Encoding: identity`; the route is checked before the multipart parse, the parts cap before both. `detection = false` with the client's own key and unmatched pass-through forward verbatim; with `detection = false` under a proxy-held credential the whole-body rule holds but the per-part scan does not (an upload's non-JSONL part goes out as sent once `_ownership_body` read it). REDIRECTS (`_is_redirect`: a 3xx with Location, not 304): `_deliver` answers `_redirect_refused` (a recorded provider-shaped 502 naming the status only, counted in `upstream_errors`; the Location is never relayed or logged — it can carry a presigned credential) before all four branches unless `relay_redirects` (unrouted, not identity-signed, no credential presented for the proxy itself — a `/u/` prefix, `x-llm-redact-*`, an admitted subject — not a custom upstream, and pass-through or body-less); `_issue_hop` makes a routed hop's redirect the hop fault `"UpstreamRedirect"`. POST-UPSTREAM FAULTS are contained: `_contained` wraps `record_response_id`/`record_object_ids` (buffered and streamed) and `_restore_listing` each item, counted in `ProxyState.bookkeeping_errors[stage]` (`llm_redact_bookkeeping_errors_total{stage}`, /status `bookkeeping_errors_total`; stages response_id/object_ids/listing/delivery/vault, and realtime_frame for a failed per-frame check), logged by type, the answer still delivered; the buffered post-read section is `_restore_buffered`, whose fault is `_delivery_fault_response` (a recorded provider-shaped 502 with its audit END); a stream the proxy itself cuts (`_stream_delivery_fault`) is booked 502 (routed: `stream_error`). Streaming-vs-JSON handling branches on the upstream *response* content-type, not the request's `stream` flag. A FILE DOWNLOAD (`adapter.restores_file_download(method, path)`: OpenAI/Azure/custom file and container-file content, Anthropic Files content, Gemini `:download`) never takes a streaming or whole-body JSON branch: it is buffered and restored per file by `rehydrate_raw_body` whatever its Content-Type (a pretty-printed JSON file served as application/json once kept its keys' tokens and was re-serialized). UPSTREAM FAULTS fail closed: transport errors on `send`/buffered `aread` return a recorded, provider-shaped **502** via the `upstream_fault_response` closure (never a bare 500 or a leaked upstream connection), and the three streaming `finally` blocks suppress an `aclose()` that itself raises on a broken stream so a mid-stream drop ALWAYS finalizes (record_request + `llm_redact_upstream_errors_total`); the full fault catalogue is docs/resilience.md, and stream-end-mid-token flushes the partial placeholder verbatim (test_stream_truncation.py). Auth headers pass through untouched and are never logged; log lines contain only path, status, and detection counts. Reserved `/__llm-redact/*` paths (status, metrics, healthz/readyz, recent, events, sessions, audit, guide — plus the DASHBOARD_PATHS and ACCESS_PATHS below) are answered by the first statement of `handle()` and provably never forwarded (`/recent` and `/audit` are `_host_allowed`-gated like `/events`/`/sessions` — pre-debut DNS-rebinding fix; `/metrics` + health probes stay open for monitoring); every reserved reply is stamped in that one place with browser-hardening headers (`_SECURITY_HEADERS`: strict CSP `default-src 'none'` + inline script/style + `connect-src 'self'` — exactly what the self-contained pro dashboard needs — plus `X-Frame-Options: DENY`, nosniff, `Referrer-Policy: no-referrer`; `setdefault` so a handler's own header wins; forwarded upstream traffic is never touched). BROWSER DASHBOARD SEAM (the page, config editor and redaction preview are llm-redact-pro, Pro tier): `DASHBOARD_PATHS` (the bare prefix, `/`, `/config`, `/preview`) are the ONLY paths `_handle_local` dispatches to `state.dashboard` (`plugin_api.Dashboard.handle(request, host)`, built by `Registry.build_dashboard(tier)` — Free default None; rebuilt by `apply_config` only when the resolved tier changes), so a plugin can never shadow a core endpoint; without one they answer a local 404 whose `_dashboard_unavailable` reason names the actual gap (package absent / plugin not registered / no Pro key) and points at /status, /metrics, `llm-redact status|preview`. `ProxyState` structurally satisfies `plugin_api.DashboardHost` (pinned by test_plugin_api_surface.py): `config`, `csrf_token`, `config_file_path()`, `host_allowed`/`origin_allowed`/`guarded_post_json` (the guard chain), `validate_config(candidate)` (the editor's dry run of everything apply_config builds — detectors/allowlist/modes, license, routing credentials, `Router.validate` or a build-and-close probe; raises, never swaps), `apply_config`, and `preview(text)` (the LIVE detectors/allowlist/modes on a THROWAWAY InMemoryVault → `{redacted, detections, warnings, blocked}`; no upstream/vault/metrics/audit write; warn-mode values stay in `redacted` — honest). `llm-redact preview` is the free CLI twin (config from disk, entirely local, `--json`). The restart-only set is `config.RESTART_ONLY_KEYS` (apply_config pins it; the pro editor's readonly set; README/deployment.md enumerate it — test_restart_only_docs.py). GET-only except the guarded POSTs sharing one guard chain (`_guarded_post_json`: layered Host-validation (DNS rebinding) → Origin → per-process CSRF token in a custom header (forces preflight; OPTIONS→405 no CORS) → content-type → 1 MiB cap): `POST /__llm-redact/sessions/prune` (whole idle sessions only, sqlite only, evicts manager views, never the active static session — the CLI prunes it with the proxy stopped), the pro access gate's `POST /__llm-redact/users/invite|revoke` (ACCESS_PATHS: host/origin-checked by the core, then dispatched ONLY to a registered gate, else a local 404 naming the package), and the pro dashboard's `POST /config` + `/preview`. The CSRF token is handed to pages only by the pro dashboard's GET /config (core tests read `app.state.proxy.csrf_token`). SIGHUP and the pro editor share `apply_config` (returns restart-required names). SIGHUP triggers `ProxyState.reload()`: hot-swappables are built fully then swapped in one assignment block, and open realtime relays whose admission changed are revoked in that same synchronous step, before displaced routers and authorizers close (1012; Realtime WS bullet); vault/audit/host/port changes warn "require restart"; a bad config file never crashes the proxy (registered via `loop.add_signal_handler`, guarded for Windows).
- **Overrides** (`overrides.py`, `override_cli.py`, docs/overrides.md, `[overrides]` restart-only, default OFF — opt-in with `enabled = true`): OFF means `ProxyState.overrides` is None, so `override_scope()` is None everywhere — no code, no hint (HTTP and realtime close reasons), no store opened or written, a store's existing rules/grants INERT (never read); `/__llm-redact/overrides*` 404 naming the setting (`overrides.DISABLED_REASON`/`TO_ENABLE`); `override CODE` exits 1 touching nothing, `override list` prints the store read-only + an inert line on stderr and exits 1, `override revoke ID` still deletes (it only narrows; `_revoke`, a missing store never created) and exits 1 with the same line, an unparseable config exits 2 (the CLI reads ITS config: `--config`/LLM_REDACT_CONFIG/search, and the off message `_off_note` names that file or that it found none and points at `--config PATH` — never a claim about the running proxy, which `serve --config` may have started with overrides on); `/status` `{"enabled": false}`; doctor PASS line, or WARN while the store holds approvals (`_check_overrides_off`, never an opt-out); pinned by tests/test_overrides_opt_in.py + test_override_cli.py. ON: a DETECTION refusal carries a single-use code (12 Crockford base32, stored as a SHA-256 hash, TTL `ttl_minutes`, ≤256 pending PER SUBJECT + `MAX_PENDING_TOTAL` 4096 in the file, drop-oldest) + `to allow: llm-redact override CODE --once | --always`. Overridable kinds ONLY: VALUE kinds `block` (BlockedRequest, HTTP + realtime 1008 — `realtime.blocked_reason` fits the code into 123 bytes), `binary_values` (BinaryValuesDetected), `verbatim_field` (VerbatimFieldRedacted; the verbatim scan is now `Redactor.scan_text`, detection only) — matched per (type, HMAC-SHA256(value) under a per-install key in the store), wherever that value would refuse; ROUTE kinds `unscanned_body` (the scanned-body rule's `_NOT_JSON_OBJECT` only, client's own key, only when no stored-object check is registered, and only a body `_plain_text_body` proves no JSON reader takes for a request — strict UTF-8, first char past spaces/BOMs/controls not `{`/`[`, no multipart/form media type: an approved route rule once let a chat JSON with one trailing or invalid byte through unredacted — `_body_overridable`) and `binary_upload` (`UnscannedBinaryFile` under `binary_uploads = "refuse"`, client's own key) — matched per (kind, provider, method, exact path). Everything else (access control, origin, target, identity/lent credentials, framing, 413/415, vault faults) never gets a code — default non-overridable. Under a credential the proxy holds there is NO scope at all (`handle()`: `scope = None if proxy_credential`, identity or a lending plan; realtime: no `frame_scope` when `require_json`, an identity connection): no code, no pending record, no approved rule (a value rule covers every route) consulted — tests/test_overrides_proxy_credential.py. Overrides never turn detection off: `Redactor.with_overrides(OverrideScope)` asks `allows(type, value)` only for a refusing winner (block in `redact_text`/`blocked_type`, every refusing winner in `scan`/`scan_text` EXCEPT a tier-0 deny string — the operator's always-redact list is never overridable, and one in text the proxy cannot rewrite calls `OverrideCheck.unoverridable()`: the request's value refusal then mints no code; `Redactor.scan(text, redactable=True)` (judge, a convertible reading whose `convert_text` fits) puts only BLOCK winners to the overrides — a convertible file's values are redacted, never refused, recorded for a code or consuming a grant; `TextScan.overridden` makes a reading clean only by an approval the `overridden`/`overridden_refused` outcome, never `clean`); an allowed value stays in place (FORWARDED, like warn, uncounted). The scope (one per request / realtime frame, `ProxyState.override_scope()`, subject = `_REQUEST_USER` or "" = the local operator) reads its subject's live rules once (lazy sqlite read, refusal path only — CLI approvals apply to the next request, no reload), records refused (type, value) in memory to mint the code (`refusal_code`), and `commit()` consumes one-time grants with a conditional UPDATE right after the redaction's `run_batched` (no await between: exactly one of two parallel requests wins; a lost race is a 400 `OVERRIDE_RACED` / 1008); the use is SETTLED with the request's fate through `_UploadFate.hold(scope.settle)` (now a list of holders): sent → counted, refused before the upstream (audit START 503, no upstream 502, authorizer, routed 402) → `OverrideStore.release` hands the one-time grant back (a consumed grant stays inert until it expires so it can be released). A store fault reads as no override (fail closed). Event-loop cost: the store is WAL + `synchronous=NORMAL` (no fsync per commit), the proxy's busy timeout is `PROXY_BUSY_TIMEOUT_MS` (200; CLI 5000), every-time rule uses are counted in memory (`count_uses`, flushed every `USE_FLUSH_SECONDS` and at close; `entries()` adds the unflushed ones) so an always-only pass never writes, and a failed one-time commit says whether it lost a race (`OVERRIDE_RACED`) or hit a store fault (`OVERRIDE_FAULT`, `scope.fault`). Who gets a code (`OverrideScope.approvable`, `ProxyState._approves_overrides`): the local operator (subject "") always, with the CLI hint `allow_hint(code)`; a named subject only when the gate's OPTIONAL `approves_overrides(subject) -> bool` is True (pro: a user `[auth.dashboard]` admits as an administrator), with `DASHBOARD_HINT` (realtime: `blocked_reason(named=True)`); absent/False/exception → no code minted, no hint. Both gate questions are LAZY (`OverrideScope(approvable=callable, owner=callable)`, memoized, asked only when a refusal is decided — `allows`/`route_rule`/`refusal_code`; never per forwarded request or realtime frame: pro answers from users.db on the event loop; the `binary_upload` rule only once the read upload holds a binary part it would refuse, via `proxy._forward_on_rule` as `forward_binary`, which raises `UnscannedBinaryFile` without a rule; the override endpoints ask on each use). Records are keyed by `OverrideScope.owner` = `ProxyState.override_owner(subject)`: "" for the operator, `id:` + the gate's OPTIONAL `override_subject(subject) -> str` (a STABLE id: pro's namespace — a rename keeps, a reused name inherits nothing), else the subject; a failing/empty answer → None = no override, no code, and the override endpoints answer 503 (never the plain name instead). Listed/prompted `OverrideEntry.route`/`subject` are `overrides.shown()` (every non-printable char escaped: a request-chosen path once carried terminal escapes into the confirmation prompt; cut to `SHOWN_CHARS` 120 with the length named, and the CLI's confirm line repeats kind and types: a long printable route once pushed them off screen); matching uses the stored raw route. Approval: the CLI reads `allow` from `/dev/tty` only (`override_cli._open_tty`, refuses without one) and approves only subject-"" records; the dashboard POSTs (`OVERRIDE_PATHS`, guarded chain) approve/revoke only the dashboard-admitted subject's, and ONLY when `state.guards_dashboard`, that subject is non-empty AND `ProxyState.browser_signed_in(request, subject)` — the gate's OPTIONAL `browser_signed_in(conn, subject) -> bool` (may be awaitable; only True counts; pro: its web-session cookie, never a chain credential) says a PERSON signed in in a browser (`can_approve` in the GET) — else 403 `_OVERRIDE_SIGN_IN` (any local client can read the CSRF token: an agent with curl once approved its own refusal; and a gate admits an agent's API/user key to the dashboard too — it once approved its own refusal with `x-llm-redact-user`). The /dev/tty confirmation guards against ACCIDENTAL approval only: an agent with a shell can pty.fork and type `allow` (docs say so). Rows carry `override: once|always` (`_REQUEST_OVERRIDE` contextvar, set only when `_UploadFate` settles SENT via `_mark_override` — a request refused before the upstream hands its grant back and its row says none; the write-ahead START row reads `_COMMITTED_OVERRIDE`, set at commit / realtime `override=`; `AuditRecord.override`), `/status` `overrides`, `llm_redact_overrides_used_total{kind}`, a status posture line, a doctor WARN (`_check_overrides`, offline, never creates the store). Doctor and `override list` open the store `read_only=True` (a `mode=ro` URI; no schema, key, journal-mode change or chmod; a file without the store's tables reads as none). Pinned by tests/test_overrides*.py, test_override_cli.py, test_redactor_overrides.py.
- **Routing (pro)**: rule-based upstream routing, fallback chains, cooldowns, plan-limit detection, model rewrite/restore, `/v1/models` discovery and monthly budgets are the llm-redact-pro routing layer; the core holds the SEAM only. Config SHAPES live here (`config.py`: `UpstreamConfig`/`RouteRule`/`RoutingConfig`/`PricesConfig` + the `[upstreams]`/`[routing]`/`[prices]` parser, invariants I-1..I-6, `resolve_credentials`, the emitter in `config_write.py`) — parsed LAST, file-only in the pro editor (`FILE_PRESERVED_KEYS` there, 400 on a POST naming them, preserved from file truth in the merge, re-validated in `validate_config` via `resolve_credentials` + `Router.validate` or a build-and-close probe), hot on SIGHUP (`apply_config` builds/reconfigures/drops the router AFTER the detector+adapter builds and closes the displaced router at swap time; parser warnings are logged by the core at startup and on every reload). The CONTRACT is `plugin_api` (`Router` → `RoutePlan` → `HopRequest`/`HopResult`/`HopDecision` → `RouteDelivery`, plus `RouteInbound`/`RouteRefusal`/`LocalAnswer`; pinned by tests/test_plugin_api_surface.py — change it deliberately and bump the pro floor); the registry factory `build_router(config, tier)` returns None unless `[routing] enabled = true`, and the Free default then raises ConfigError naming the package (never a silent one-upstream downgrade; `[upstreams]` alone stays warn-inert). The DRIVER in `proxy.py` is policy-free: `handle()` consults `state.router` only when it is not None (two `is None` tests per request — the unrouted path is byte-identical, pinned by tests/test_routing_seam.py); `local_answer` sits after the disabled-provider 502 / access-gate 403 / identity unrecognized-route 403 and before the body read; an UNRECOGNIZED route is planned right after it, before its body is read (`_plan_request`, adapter None), and a plan that `_lends_credential` (optional `RoutePlan.proxy_credential`; a plan without it counts as the proxy's — fail closed) is a recorded 403 (`_unrecognized_route_refused`) before the audit row, `begin()` and any hop; a recognized route is planned once its body is parsed, BEFORE the scanned-body rule, the stored-object check and redaction (the first upstream's `inject_system_note` governs the prepared body; a lending plan re-runs the request-origin host rule); `plan()` returns None for providers outside the router's protocols (legacy path), a `RouteRefusal` (502 no_route, recorded with the configured static session), or a plan (side-effect free until `begin()`: one a later refusal abandons is dropped); `_handle_routed` = `local_refusal` (404 count_tokens, BEFORE the audit START row) → `_begin_audit_guarded` → `begin()` (402 budget, WITH the token, or hop 1) → issue/`decide` loop (`_issue_hop` sends, pre-reads bodies `_deliver` would buffer so a mid-body drop is still re-issuable, counts faults by upstream NAME and reports the exception TYPE, and turns an upstream redirect into the hop fault `"UpstreamRedirect"`; `_discard` closes every undelivered response; `plan.wait` sleeps retry-same; `metrics.reissues[(from,to)]` bumps when a hop's `reissued_from` is set) → `delivery()` hooks (`observe_event`/`observe_line` after the adapter's rehydration and before serialization, `observe_payload` on buffered JSON, `mark_failed("transport"|"stream_error")`, `x-llm-redact-*` headers merged) → `finish_route` (`metrics.routed[(upstream, rule or "-")]` on EVERY routed outcome incl. 502s, then the router's `finish` = spend) → `record_request(route=row)` (the recent/events row's `route` key, None on the legacy path; audit rows unchanged). The Bedrock eventstream branch has no hook (never routed). Log lines carry the fixed-key `_route_log_suffix` (`rule upstream hops auth class reissue`) — names/ids/modes/classes only. Surfaces are core: `/status` `routing` block (the router's, `{"enabled": false}` without one), the two Prometheus counters, `routes list|test` / `spend` / `doctor --offline` parsers (lazy-import dispatch to `llm_redact_pro.routes_cli`, the requires-package line on ImportError), `_print_routing`/`_routing_posture`, the doctor `_check_routing` shell (PASS not-configured/disabled, FAIL without the package, else `llm_redact_pro.routing_doctor.routing_checks` rows) and the `_check_access` shell (lazy `llm_redact_pro.access_doctor.access_checks` rows, area "access"; silent without the package), the `routes`/`spend` plugin commands, the (pro) dashboard pill/card, the Anthropic 402/404 error types, the user guide. `config.example.toml` carries a 6-line pointer, never the enabled block; core docs never link a `docs/routing.md` (it lives in the pro package). Tests stay keyless via `tests/fake_router.py` (scripted `FakeRouter`/`FakePlan`/`FakeDelivery` registered on a bare `Registry`).
- **Licensing & users (FOSS core)**: THE CORE ENFORCES NOTHING — there is no tier-gating module: no tier gates, no seat caps, no cloud entitlements, no k8s gate, no non-loopback licensing gate (binding beyond loopback is purely the `validate_bind_security` mTLS policy). `proxy._resolve_license_info` resolves the key for INFORMATIONAL surfacing only (/status `license` block, the pro dashboard's pill — keyless shows "nothing gated", or "pro features need a key" when llm-redact-pro is installed (its factories refuse paid subsystems on the Free tier), doctor PASS line, `llm-redact status`) and hands it to the pro plugin; warnings (key set without pro, expiry grace, 14-day grace window) still log loudly and `refresh_license_warnings` still ticks daily. What fails closed is PACKAGE PRESENCE: a config that requests a subsystem only llm-redact-pro implements (vault encryption/cipher, RDBMS vaults, the audit log + sinks, OTel, per-conversation sessions, named users, `[audit] required`) raises ConfigError from the registry/factory seams naming the feature AND the package — never a silent downgrade (pinned both directions by tests/test_no_gates.py; free-repo capabilities a tier matrix might suggest are paid — non-loopback, k8s, Bedrock/Azure/Vertex adapters — are pinned to build keyless). Free `licensing.py` keeps the DATA (License/ResolvedLicense, TIER_ORDER/TIER_USER_CAPS, grace windows, FREE; the retired `clouds` entitlement survives only as a defaulted, ignored `License.clouds` + the `CLOUDS` constant for llm-redact-pro ≤0.4 compat — never surfaced) + the thin registry-dispatched `resolve_license` DELEGATOR (free_defaults returns Free with a loud "llm-redact-pro not installed" notice when a key is set; the pro plugin registers the real Ed25519 resolver, and PRO'S OWN FACTORIES honor the key's tier/seats/expiry — tiers are commercial packaging of the pro package, enforced there, never here). Resolution env → `[license] key` → `key_file`; `[license]` stays in config and `license show|verify` stays in the CLI (informational; the verify core lives in pro). ACCESS CONTROL IS PRO-ONLY — THE CORE HOLDS NO AUTHENTICATION CODE: named users, per-user keys, seats, the `/u/<key>/` + `x-llm-redact-user` credential channels, the 2+-verified-users enforcement rule, the `/__llm-redact/users*` admin endpoints, the `llm-redact users` command, the verification-email sender and every future authentication method live in llm-redact-pro. The core's SEAM is `plugin_api.AccessGate` (`admit(conn, surface) -> Admission(subject, refusal, redirect)` — may return an AWAITABLE, `ProxyState.admit` is async and awaits it, so directory/IdP checks never block the loop / `status()` → the /status `users` block / `handle(request, host)` for the fixed gate paths: ACCESS_PATHS + everything under AUTH_PREFIX (`/__llm-redact/auth` and below, `_is_auth_path`: the `AUTH_PATHS` login/callback/logout plus any page or JSON endpoint a sign-in method adds, e.g. pro's passkeys; Host+Origin checked, POST included; never dashboard-admitted, never forwarded; a look-alike such as `/authx` is an ordinary reserved path) + everything under SCIM_PREFIX (`/__llm-redact/scim/v2`, Host-checked only — IdPs are not browsers) / `close()` at shutdown; OPTIONAL members read via getattr so older gates keep working: `guards_dashboard` → `_admit_reserved` calls `admit(conn, "dashboard")` for every reserved path except PROBE_PATHS (healthz/readyz/metrics) and the gate paths, refusing 403 or — browser GET only — 303 to `Admission.redirect` iff it is a same-proxy `/__llm-redact/…` path (no `//`, no backslash: never an open redirect); `public_origin()` (read once at startup, honored ONLY with guards_dashboard, malformed = ConfigError) adds its host to `_allowed_hostnames` and exactly that origin to `_origin_allowed`; `bind_sessions(store)` (called once at startup with `plugin_api.SessionStore` = `proxy._LiveSessions`: `session_ids()` + `forget(ids)` over the LIVE vault manager's `forget_sessions` — whole sessions only, the configured static session never forgotten, forgotten ids leave `_known_sessions`; pro drops a purged user's namespace this way); `bind_connections(control)` + `recheck_interval` (5..3600, default 30, malformed = ConfigError): `Admission.grant`/`recheck` (both compare=False, repr=False; the grant is opaque, never logged or reported) are recorded for realtime relays and events streams in `connections.LiveConnections` (= `plugin_api.ConnectionControl`: thread-safe `close(subject=, grant=, reason=)`; the lifespan backstop asks every open connection's recheck each interval, concurrently, one pass at a time, awaitables capped at min(interval, 10 s); an exception, timeout or non-bool/None/str verdict CLOSES, bookkeeping stage `recheck` — a `CancelledError` too unless the backstop's own task is being cancelled (`_being_cancelled`: `current_task().cancelling()`), in `_recheck_one` and around a pass in `run()`, so only `stop()` ends the backstop, and `_ensure_running` restarts a task that is `done()`; a check left running (timed out, or its pass cancelled — `_bounded` never waits for it) is ABANDONED: cancelled, its outcome discarded whenever it ends (never an unretrieved exception), held in `_abandoned` with its connection until then — a connection with one still running is not asked again (a failed check) and past `MAX_ABANDONED_CHECKS` (64) a new awaitable check is never started (closed coroutine, a failed check); a connection already `closing` (`TrackedConnection.closing`: an events stream closed, a relay revoked — it stays tracked until its handler ends, indefinitely for a client that stopped reading) is never re-checked again nor counted open; /status `connections`, `llm_redact_connections_closed_total{cause}`); streaming HTTP responses are not cut), built by `Registry.build_access_gate(config, license)`: `handle()` calls `ProxyState.admit` FIRST (the gate MUST scrub its own credentials from the scope before returning), applies `refusal` as a provider-shaped 403 AFTER the disabled-provider 502 (recorded; WS: accept-then-close 1011), and attributes `subject` via `_REQUEST_USER` into recent/events/audit rows (WS rows too). The Free default returns None on the Free tier and FAILS CLOSED (ConfigError) on a paid tier (only reachable with an llm-redact-pro that predates the seam), an explicit `[users]` section, or a configured license key while llm-redact-pro is INSTALLED but its plugin did not register (a pre-0.8 pro no longer imports on this core; its key would otherwise fall to Free and serve with no access control) — never an open proxy. llm-redact-pro's register() in turn installs a refusing `build_users_store` when it lands on a core without the seam, so both version-skew directions refuse to start. Without a gate the core still guards two LEAK PATHS: every `x-llm-redact-*` request header is dropped before forwarding (HTTP `_request_headers` + WS `_filtered_headers`), and a path still starting `/u/` after admission is answered with a local 404 that is neither forwarded nor recorded nor logged with its path (the next segment is a key); a reserved path reached through a stripped prefix is a local 404. The `[users]`/`[email]` config SHAPES stay here (like routing's) — `[users] unrecorded_objects = "refuse"|"allow"` is the shape of pro's unknown-owner policy (restart-only with `[users]`; any non-default `[users]` without the package fails closed); `[email]` also carries the SMTP OAuth shape (`auth` password|oauth, `oauth_provider` azure|google|refresh_token, `oauth_subject`, `oauth_token_url` (https)/`oauth_client_id`/`oauth_client_secret_env`/`oauth_refresh_token_env`/`oauth_scope`, `implicit_tls` ⊥ `starttls`, `EmailConfig.tls`): oauth without TLS is a ConfigError, `*_env` values are env-var names never echoed, provider-foreign keys refused, doctor `_check_email` is value-free and offline; token fetching and XOAUTH2 live in llm-redact-pro. Transport mutual TLS (`[tls] client_ca` + `validate_bind_security`) also stays: it is the bind-safety rule and identifies no user. CONFIG SEAM: `plugin_api.ConfigSection` (`name`, `parse(raw, where)`, `emit(value) -> mapping`) registered on `Registry.config_sections`: `parse_config` accepts claimed top-level tables (CORE_SECTION_KEYS are never claimable) into `Config.extensions[name]` (present sections only), the emitter writes them back via `_emit_table` (nested tables + arrays of tables; before routing, which must stay last), `apply_config` pins them restart-only reporting each changed section NAME; an unclaimed section stays a ConfigError whose message names llm-redact-pro (llm-redact-pro's `[auth]` lives here — the core has no auth config shape). TLS PEER CERT: under mutual TLS `serve` passes `http=`/`ws=` protocol classes from `tls_scope.uvicorn_protocol_kwargs()` (uvicorn's auto choices, subclassed to wrap `self.app` per connection) so every scope carries the ASGI TLS extension (`client_cert_chain` PEM leaf-first, `client_cert_name` RFC 4514, `tls_version`); transport info only, read by pro's certificate authenticator, never by the core. Related seams: `Registry.cli_commands` (`plugin_api.CliCommand`: name/help/completion + add_arguments/run; cli.py adds each subparser, a name colliding with a core command is ignored, `completions.all_commands()` merges their words) and `Registry.tool_base_url` (`llm-redact run` passes the exported base URL through it; pro attaches `/u/<key>` from LLM_REDACT_USER_KEY; the log line prints scheme://host:port only). The suite runs keyless and pins the no-gates contract; `tests/test_open_core_free_coverage.py` still drives every free-code function whose only e2e path rides a pro feature (complexity gate, empty allowlist). LICENSED-FEATURES SIGNAL (`registry.pro_package_installed`, a pure find_spec probe): doctor prints an informational `license` line ("licensed-features package not installed (FOSS core is complete; pro-only config fails closed)" / "installed (<plugins> active)", NEVER a FAIL), /status carries `license.package_installed` + `license.plugins`, the ONE loud case — package present but its plugin did NOT register — stays a doctor WARN (and the dashboard paths' 404 says the plugin did not register). LICENSES: this repo is GNU AGPL-3.0-only (LICENSE = verbatim text; pyproject `AGPL-3.0-only`; pre-relicense private-lineage artifacts remain MIT); contributions require the CLA (docs/CLA.md, sign-off = agreement — keeps the dual-license model legal); llm-redact-pro is proprietary (per-seat commercial subscription license, no redistribution). Threat model: distribution control is the primary protection; verification never phones home.
- **Ops surface**: `metrics.py` hand-rolls Prometheus text (stdlib only). `DurationHistogram.observe()` increments every covering bucket, so stored counts are ALREADY cumulative — `render()` must not re-sum. `record_request()` always updates metrics AND the in-memory recent ring buffer (`deque(maxlen=200)`, served newest-first at `GET /__llm-redact/recent`, same row shape as audit — the dashboard's recent table reads it so the tail works without the audit DB), and writes an audit row only when enabled; 413s, pass-through, upstream-fault 502s, and stream-close finalization included. Upstream transport faults are counted per provider (`llm_redact_upstream_errors_total`, /status `upstream_errors_total`, `LlmRedactUpstreamErrors` alert). `record_request` also fans the row out to `GET /__llm-redact/events` SSE subscribers (per-subscriber bounded `asyncio.Queue(100)`; QueueFull silently drops — the dashboard's EventSource prepends rows while the 3 s poll stays authoritative and self-heals; host-check gated like /sessions; 15 s comment keepalives; NEVER drive this endpoint through ASGITransport to completion in tests — the infinite stream hangs; test_events_endpoint.py speaks raw ASGI and cancels) and, when `[otel]` is enabled, to `otel.py` (`otel` extra; `build_telemetry` → scoped SDK providers — never the process globals; spans get explicit start/end computed from the measured duration so streaming needs no context threading; metadata-only, same contract as audit; restart-only + editor-readonly; enabled-without-extra is a startup ConfigError with the install hint; lifespan shutdown flushes the batched exporters). `GET /__llm-redact/sessions` lists vault sessions (metadata only) via `VaultManager.sessions_summary()`. `[log] format = "json"` (`log.py` JsonFormatter, restart-only, `serve --log-format` overrides) emits one object per line — a FIXED value-free key set ts/level/logger/service/version/message (+exception), never record extras (the never-widen-content invariant, pinned by test); json mode passes `log_config=None` to uvicorn so its records propagate to the root JSON handler. OPS ASSETS (`deploy/`): prometheus-scrape.yml + prometheus-alerts.yml (warn-mode-forwarding is the honesty alert) + grafana-dashboard.json + k8s-sidecar.yaml (same-pod sidecar BINDING 127.0.0.1 with exec healthz/readyz probes — a pre-debut fix corrected the 0.0.0.0 bind that contradicted its never-exposed claim; runAsNonRoot/readOnlyRootFilesystem/drop-ALL) + `helm/llm-redact/` (the Helm chart, `mode: sidecar|standalone` from one chart — SIDECAR BINDS 127.0.0.1 (pod-shared netns makes "never exposed" structural; kubelet httpGet dials the POD IP so sidecar probes are EXEC probes, the Dockerfile-HEALTHCHECK one-liner; no INSECURE_BIND needed) while standalone binds 0.0.0.0 + httpGet + INSECURE_BIND-or-mTLS (extraVolumes/extraVolumeMounts exist to mount the [tls] Secret — without them the mTLS advice was unwireable); standalone renders Deployment+Service+optional HPA(autoscaling/v2, CPU Resource metrics + optional memory)+optional ServiceMonitor+optional PDB; optional NetworkPolicy (sidecar default-deny / standalone allowFrom) + ServiceAccount (IRSA/WI annotations) + imagePullSecrets; tool.enabled defaults FALSE (placeholder image would ImagePullBackOff — NOTES warns if enabled unedited); NOTES also warns on sqlite-without-persistence and on an RDBMS backend with the STOCK image — the `-rdbms` GHCR variant (Dockerfile ARG EXTRAS; release.yml builds+cosigns both) adds vault-postgres/vault-mysql/crypto; the hardened container spec is shared via `_helpers.tpl` `llm-redact.proxyContainer`; the LOAD-BEARING `llm-redact.validate` guardrail `{{ fail }}`s a standalone autoscaled/multi-replica render on a per-pod memory/sqlite vault — replicas would issue divergent tokens (never-wrong-value), so a shared RDBMS backend is forced; Chart appVersion == `__version__`, pinned; k8s needs NO license — the FOSS core is ungated, the chart adds no gate); `test_deploy_assets.py` pins that the dashboard/alerts reference only emitted metrics, the k8s manifest keeps its hardening, and (stdlib-always + helm-gated test tiers) the chart's hardening/guardrail/appVersion hold — the CI `helm` job installs helm PINNED+checksum-verified and renders both presets for real. The systemd unit (`service_cli.py`) ships a sandbox (NoNewPrivileges/ProtectSystem=strict/empty CapabilityBoundingSet/@system-service filter/ReadWritePaths scoped to XDG data). `user_guide.md`/`user_guide.html` are package data loaded once via importlib.resources; packaging is verified by a unit test *and* a CI wheel/sdist listing step — twine can't catch missing package data (the same CI step asserts the LICENSE file ships in both distributions, and that `dashboard.html` — llm-redact-pro package data — does NOT ship in the AGPL wheel).
- **Audit** (`audit.py`, `[audit]`, default off): separate metadata-only SQLite DB (types+counts/path/duration — never values, never placeholder ids); `synchronous=NORMAL` vs the vault's FULL (losing an audit row is acceptable; losing a vault row is not). TAMPER-EVIDENT CHAIN (`[audit] tamper_evident`, default off): each row stores `chain_hash = HMAC-SHA256(key, prev_hash \x00 canonical_row)` (`_chain_serialize` = compact JSON of the stored fields incl. the pre-serialized detections/rehydrations, so record and verify hash identical bytes); the `chain_hash` column is always in `_SCHEMA` (NULL when off) and `_ensure_chain_column` migrates pre-column DBs; ZERO-LOSS MODE (`[audit] required`, Pro, requires `enabled`): the proxy's `begin_audit` durably commits a write-ahead START row BEFORE any upstream contact (HTTP + realtime WS) and refuses the request with a provider-shaped 503 on `AuditWriteError` (the audit-storage twin of the upstream-fault 502); `record_request(audit_token=...)` finalizes the END row (buffered, all three streaming finalizers, WS close), an END-side fault after the response is committed logs CRITICAL (refusal impossible), startup fails closed when the built AuditLog lacks the `begin`/`finalize` pair (the `WriteAheadAudit` sub-Protocol, resolved once in ProxyState — old pro or a required config with no log both refuse), and proxy-LOCAL replies (400/403/404/413/415/502, the vault-fault 503) stay best-effort rows — the guarantee is scoped to upstream contact. Default stays fail-open; the in-memory tip advances ONLY after a successful insert (a swallowed write can't orphan the next link). Key is env-only (`LLM_REDACT_AUDIT_HMAC_KEY`, SHA-256 → 32 bytes, `audit_hmac_key_from_env`); `tamper_evident` without it is a startup ConfigError in ProxyState (fail closed — a keyless chain is attacker-recomputable). `verify()` walks ascending: the oldest surviving chained row is an UNTRUSTED ANCHOR (its predecessor may have been legitimately pruned, so a first-row mismatch is adopted, not flagged) and every row after it is cryptographically checked → `ChainVerification(ok, checked, broken_at, reason)`; `llm-redact audit verify [--json]` (exit 0/1/2) runs it offline. Surfaced in /status (`audit.tamper_evident`), the editor readonly card, dashboard, and a doctor FAIL when the key is absent. S3 SINK (`audit_s3.py`, `[audit.s3]`, default off, restart-only): batches the SAME metadata-only rows as NDJSON objects to aws (virtual-hosted host from bucket+region) / minio / ceph (endpoint_url, path-style) / gcs (Google Cloud Storage via its S3-compatible XML API at storage.googleapis.com, path-style, HMAC interop keys — reuses the AWS SigV4 signer unchanged since GCS interop accepts it); credentials from env vars ONLY (never the config file), provider-keyed via `credential_env_names` (aws/minio/ceph → AWS_*, gcs → GCS_HMAC_ACCESS_ID/GCS_HMAC_SECRET); hand-rolled SigV4 on stdlib hmac/hashlib pinned by the official AWS docs test vector (query strings unsupported by design; key prefixes restricted to URI-unreserved chars so the canonical URI never needs encoding); every failure WARNs-and-drops (missing creds warn ONCE, naming the provider's own env vars), buffer bounded at 10k rows drop-oldest, flush loop starts in the lifespan and aclose() flushes at shutdown; doctor checks credential PRESENCE only (provider-aware); surfaced in /status (batches_uploaded/rows_dropped) and the editor's readonly card. GCS-native GOOG4/OAuth is deliberately NOT used (the S3-interop path needs no new signer). AZURE BLOB SINK (`[audit.azure]`, `AzureAuditSink`, separate section — SharedKey is not an S3 API): hand-rolled SharedKey HMAC-SHA256 over the documented StringToSign (12 fixed lines + canonicalized x-ms-* headers + `/account/container/blob` resource; Date blank because x-ms-date is signed; locale-proof `rfc1123`), account key from `AZURE_STORAGE_KEY` (env only), `endpoint_url` overrides the host for Azurite; the signer is pinned by an INDEPENDENT StringToSign transcription in tests (checksum-table discipline), mis-canonicalization degrades to a 403 the sink WARNs-and-drops on. The proxy holds parallel `audit_s3`/`audit_azure` optionals; the lifespan runs a flush task per active sink and aclose()s each; /status and the editor readonly card carry both `audit.s3` and `audit.azure` blocks. Multiple sinks can run at once. SINK AUTH (`[audit.s3] auth = "keys"|"identity"` — identity refused for minio/ceph at parse; `[audit.azure] auth = "key"|"sas"|"identity"`; `sink_endpoint_problem`: endpoint_url with no userinfo/query/fragment, no path for S3 (signed over /bucket/key), https off loopback for azure sas/identity (bearer secrets)): the core holds only the SHAPE (emitted when non-default, restart-only with `[audit]`), `required_credential_env(sink, provider, auth)` for doctor presence (`()` for identity → an informational "resolves at runtime, not checked offline" row, never a network call; sas → AZURE_STORAGE_SAS_TOKEN) and `auth` in the /status sink blocks; credential fetching is llm-redact-pro's. VERSION SKEW for shapes with no factory seam: `config.plugin_capabilities_required` names non-default sink auth (enabled sinks), `email.auth=oauth`, `email.implicit_tls`; with a plugin loaded, one missing from `Registry.config_capabilities` (pro advertises its set) is a ProxyState ConfigError and a doctor FAIL (`_check_licensed_features`) — never an older pro silently using static secrets. BATCH ENCRYPTION (`[audit.s3]`/`[audit.azure]` `encryption = "fernet"`, Pro with the sink): `encode_batch` uploads each NDJSON batch as ONE Fernet token (`.ndjson.fernet`, application/octet-stream) ahead of BOTH signers; key env-only (`LLM_REDACT_AUDIT_ENC_KEY`, SHA-256 → Fernet key via `audit_enc_key_from_env` — the HMAC-key recipe); enabled-without-key/extra = startup ConfigError in ProxyState, and a key that vanishes at runtime WARNs once and DROPS batches (NEVER a plaintext fallback — pinned: nothing uploads at all); `llm-redact audit decrypt FILE [--out]` reads objects back (exit 1 on wrong key, no garbage output); doctor checks per-sink posture, /status carries `audit.s3/azure.encryption`. Rehydration counting threads a `Counter` through `substitute_tokens`.
- **Env & containers**: `LLM_REDACT_HOST/PORT/CONFIG` overrides (CLI > env > file); config search adds `/etc/llm-redact/config.toml`. An empty `XDG_DATA_HOME`/`XDG_CONFIG_HOME` counts as unset (`default_vault_path`, `default_audit_path`, `default_config_path`; it once resolved to a relative path). The Dockerfile sets `LLM_REDACT_HOST=0.0.0.0` (container netns only — the documented publish spec is `-p 127.0.0.1:8787:8787`) plus `LLM_REDACT_INSECURE_BIND=1` (the documented hatch for exactly that confined case) and `XDG_DATA_HOME=/data` for vault/audit persistence; the native default stays 127.0.0.1. Released GHCR images are MULTI-ARCH (amd64+arm64 via QEMU+buildx in release.yml; CI smoke jobs stay native amd64). `perf` extra = uvloop; uvicorn `loop="auto"` auto-selects it — serve must NEVER pass an explicit `loop=` (that would break the auto-selection the extra relies on). serve passes `proxy_headers=False`: the ASGI client address is the SOCKET PEER, never rewritten from X-Forwarded-For (an access gate's loopback/trusted-proxy checks read it); WS upgrades still under `/u/` after admission are refused without logging the path.
- **Client CLIs** (`run_cli.py`, `doctor_cli.py`): `llm-redact run -- cmd` injects the tool→env-var map (shared `TOOL_EXPORTS` in init_cli.py, incl. ollama→OLLAMA_HOST; `OPENAI_BASE_URL` carries `/v1` via `ENV_BASE_PATHS`/`export_value`, appended after `tool_base_url` so pro's `/u/<key>` stays first — `init`, the plugin guards and the deploy manifests say the same); CLIENT DIALING: every client probe of the configured bind uses `config.dial_url(host, port, scheme=)` (status, doctor `_check_proxy`, run and the URL it exports, `plugin_cli._default_base_url`, `vault_cli._proxy_reachable`, the serve banner, pro's `routes test`) — a wildcard bind (0.0.0.0, `::`, empty) is dialed at 127.0.0.1 / `[::1]`, an IPv6 literal bracketed; never build `f"{scheme}://{config.host}:{port}"`; the probes also catch `httpx.InvalidURL` (not an `httpx.HTTPError`), and doctor's port probe uses an AF_INET6 socket for an IPv6 host; an already-running proxy is reused and NEVER killed; otherwise an ephemeral `serve` subprocess runs for the child's lifetime (torn down on every exit path). POINTED-AT PROXIES: `LLM_REDACT_PROXY_URL` (or `run --proxy-url`) names an EXISTING proxy — `run` uses it verbatim (must answer /status; never auto-starts beside it), `status` queries it (same --ca/--cert/--key mTLS flags), `validate_proxy_url` allows plain http on LOOPBACK ONLY (remote = https, or prompts cross the network in cleartext), and log lines print scheme://host:port only (an /u/<key> mistakenly embedded never echoes). SIGINT is deliberately ignored in the wrapper (the child gets it from the terminal process group; forwarding would double-signal); SIGTERM is forwarded; the child's exit code propagates. Plain-http loopback only ([tls] set ⇒ refuse). `doctor` is read-only PASS/WARN/FAIL (config parse, bind policy, proxy reachability + version skew, port occupancy via a bind probe, vault/audit 0600/0700 perms, fernet-without-key, missing extras with install hints, and a COVERAGE-POSTURE check `_check_posture` that WARNs for every configured opt-out — warn-mode rules, per-provider detection off, MCP exempt servers, language-scoped-out national-ids — or one PASS when none), exits non-zero on FAIL, never prints values. `status` mirrors this at runtime: `_print_posture` reads the live /status honesty fields (warn counts, `providers_detection_off`, `mcp_exempt_servers`, `language_inactive_rules`, `compaction_forks`, S3/Azure `rows_dropped`, disabled providers, `allowed_origins`, `vault.owner_bound_shared`) into a loud posture block, silent only when nothing is opted out. Both are enumerated in completions.py's COMMANDS (a coverage test enforces parser↔completions sync). AGENT PLUGINS (`plugin_assets.py` single source, `plugin_cli.py` installer, docs/plugins.md): eleven slash commands mirroring the dashboard + config editor (status/recent/sessions/config-show/config-edit/preview/doctor/audit/routes/spend/guide — there is deliberately no `users` command in the core plugin: named users are llm-redact-pro; `guide` displays the packaged user guide, also served at `/__llm-redact/guide`) rendered per tool — Claude Code (checked-in `plugins/llm-redact/` + repo-root `.claude-plugin/marketplace.json`, regenerated by scripts/render_plugins.py and pinned BOTH directions by test_plugins.py; copy-install goes to ~/.claude/commands as flat llm-redact-*.md), Codex (~/.codex/prompts, top-level only), OpenCode (~/.config/opencode/commands), Cursor (~/.cursor/commands — PLAIN markdown: no frontmatter, $ARGUMENTS rewritten to prose; render_cursor). EVERY body opens with PROXY_GUARD (missing `llm-redact` CLI ⇒ stop and ask approval before installing — never install uninvited; pinned by test), and `plugin install` ends with a proxy posture hint line (probe injectable for tests). config-edit mirrors the editor guardrails (effective config → file edit → `serve --check` gate → SIGHUP → posture read-back) and is user-invocation-only; `lookup` is deliberately NEVER a plugin command (an agent reading a secret value would send it upstream — pinned by test); body `llm-redact` invocations are sync-tested against completions COMMANDS; install never overwrites modified files without --force, uninstall removes only managed files. PROXY SETUP STEP (`proxy_setup` in plugin_cli, probe/runner/ask injectable): `plugin install --proxy-url URL` points at an existing proxy (probed, validated, prints the LLM_REDACT_PROXY_URL export; the posture hint names the env var WITHOUT echoing its value); `--install-proxy` = `init --yes` (only when no config exists) + `service install`, explicit ask only; interactive single prompt (install/point/skip) ONLY on a tty with no flags and nothing answering; scripted default stays skip-with-hint. The agent-side PROXY_GUARD in command bodies is unchanged.
- **TLS & FIPS**: `[tls]` (restart-only, config-editor readonly): certfile+keyfile always together; `client_ca` = MUTUAL TLS (uvicorn `ssl_cert_reqs=CERT_REQUIRED`). `validate_bind_security` in config.py is the fail-closed bind policy `serve` runs pre-socket: non-loopback ⇒ full mTLS trio required unless `LLM_REDACT_INSECURE_BIND=1`; unresolvable hostnames count as non-loopback. `_origin_allowed` accepts https origins only when TLS is on; `status --ca/--cert/--key` builds an `ssl.SSLContext` (httpx 0.28 dropped per-request `cert=`). The real-socket mTLS e2e (test_tls.py) generates a throwaway PKI via cryptography (importorskip) and runs uvicorn on port 0 in a thread. FIPS (`fips.py`, `llm-redact fips-check`, docs/fips.md): approved-algorithm-selection posture, validation belongs to the HOST; wording rule "never certified" is machine-enforced by tests/test_fips_posture.py alongside a banned-primitive scan (md5/sha1/rc4/3des) of src/.
- **Bench** (`bench/`, `python -m llm_redact.bench`): deterministic runtime-generated corpus (never committed — positives are secret-shaped); exact span+type scoring; `--check` gates recall==1.0 per rule (functional regression gate) AND the false-positive corpus. `bench/fp_corpus/` (repo root, vendored, ruff-excluded, never shipped) holds real-world NEGATIVES; `fp_scan.py` gates exact per-file counts against `MANIFEST.toml` in both directions. When adding a detection rule: add its generator to `bench/corpus.py`, run the fp gate, and update the manifest (with justification) only for hits you judge legitimate.

## Testing conventions

The load-bearing suites are the chunk-split sweeps: `test_rehydrate.py` and `test_sse.py` split inputs at every offset and assert streaming output equals non-streaming output; they are parametrized over `fuzzy` and json-source-escape modes. When touching `rehydrate.py`, `sse.py`, or an adapter's event handling, extend those sweeps rather than adding single-case tests; `test_eventstream.py` mirrors the convention for the binary codec (golden fixtures assembled longhand, never via the codec's own serializer), `test_ndjson.py` for the NDJSON line codec, and `test_properties.py` (hypothesis, deadline=None) probes the same invariants with random inputs — it extends, never replaces, the sweeps. Integration tests (`test_proxy_integration.py`) run the real app in-process against fake upstreams via `httpx.ASGITransport` — NOTE: ASGITransport joins response body parts into one chunk, so chunk-boundary behavior can't be pinned through it (test_provider_bedrock.py's `_ChunkedUpstream` custom transport exists for exactly that). The mTLS suite (`test_tls.py`) is the one real-socket exception: throwaway PKI via cryptography (importorskip), uvicorn on port 0 in a thread. NER unit tests inject fake models (spaCy nlp / GLiNER / a fake Presidio analyzer) so the suite runs without any extra installed. The RESILIENCE suites prove fault behavior: `test_upstream_faults.py` (connect/timeout/mid-body drops fail closed with a recorded 502; the streaming generators finalize on a mid-stream drop), `test_stream_truncation.py` (a stream ending mid-token flushes the partial verbatim — `feed(prefix)+flush()` == `rehydrate_text(prefix)` at every truncation), `test_vault_faults.py` (write-fault rollback, dense counter, corrupted-ciphertext fail-closed), and `test_soak_concurrency.py` (a `soak` marker, deselected by addopts, run as its own CI step — cross-session isolation + counter density + bounded LRU eviction under many connections); faults are injected with fake transports/conns, never real sockets or disks. Live-API tests (`-m live`) are deselected by default via addopts and double-gated on `LLM_REDACT_LIVE=1` + API keys; the `/v1/responses` drift detector asserts observed event types ⊆ `KNOWN_EVENT_TYPES`, and the Bedrock one (AWS_BEARER_TOKEN_BEDROCK) asserts ConverseStream `:event-type`s ⊆ `KNOWN_CONVERSE_EVENT_TYPES`. The ner CI job installs presidio alongside spaCy and runs test_presidio.py's real-AnalyzerEngine smoke test (skips wherever the extra or model is absent); OTel tests inject fake tracer/meter the same way. ISOLATION (`tests/isolation.py` + `conftest.pytest_configure`, before collection): `HOME` and `XDG_CONFIG/DATA/STATE_HOME` point into one throwaway directory for the whole session (subprocesses inherit it; `XDG_CACHE_HOME` stays real for third-party libraries), deployment `LLM_REDACT_*` variables plus `CLAUDE_CONFIG_DIR`/`CODEX_HOME` are dropped (`LLM_REDACT_TEST_*`, `PRO_*`, `LICENSE_*`, `LIVE*` and the live-model variables kept), and a session guard fails the run on any change under the real llm-redact dirs, agent command dirs or the user service unit (`LLM_REDACT_TEST_ALLOW_REAL_DIR_CHANGES=1` opts out); use the `isolation_root` fixture, never `import conftest` (mutmut re-imports it). Request-path suites of the hardening round prove each refusal end to end and that nothing reached the upstream or the authorizer: test_request_origin.py/test_allowed_origins.py (+ real-socket test_realtime_request_origin.py), test_scanned_body.py, test_request_attribution.py (a table of documented tool request shapes asserting upstream host, credential and redaction), test_upstream_redirects.py, test_lent_credentials.py, test_realtime_reload.py; the vault invariants ride test_vault_floors.py (every backend), test_token_floors*.py, test_vault_batch*.py, test_vault_deletion.py and the test_vault_properties.py state machine (a token restores to its own value or nothing), whose never-wrong-value rules the hypothesis machine in test_properties.py shares (floored writes: each number is max(previous, retired, floor) + 1).

Placeholders use guillemets (`«»`) precisely because they don't collide with code/markdown; don't change the token format without updating `viable_prefix_start` holdback logic, the fuzzy grammar (`FUZZY_PLACEHOLDER_RE`/`canonicalize`), and the split sweeps together.

## Known limitations (deliberate scope)

Bracket-swap/ASCII-guillemet mangles (`[EMAIL_001]`, `<<EMAIL_001>>`) are never restored — too collision-prone with real code. Base64 media is not decoded and scanned (images can't leak through text regexes; PDFs would need parser deps) — on realtime streams that includes voice audio: only the TEXT modality is redacted, and what a user says aloud goes to the provider as-is. Realtime WS connections use the static vault session (per-conversation anchors don't exist at upgrade time) — with llm-redact-pro's named users, each user's own copy of it (`context_for` goes through the session router, which scopes by user; the same holds for batch/conversations/context-caching flows). Synthetic flush events on the Responses stream omit `sequence_number`. Originals that arrive *pre-escaped inside request JSON-source strings* are captured in escaped form. Per-conversation mode: history compaction rewrites the first-message anchor and forks a fresh session — deliberately fails pass-through-verbatim (never wrong-value); forks are counted and surfaced (`compaction_forks`), and relink is a rejected design (docs/compaction-relink.md). Bare-digit phones/SSNs never fire (collision-prone); street addresses and passport/DL numbers are out of scope (no reliable grammar); `+`-prefixed integer literals (INT_MAX in code) match the solid E.164 phone form — pinned in the fp-corpus manifest. An approved refusal override (docs/overrides.md) FORWARDS the refused value (or body / binary part) as sent, exactly like warn mode; its approval guards against an agent approving by accident, not against local software running as the operator (which can edit the store, like the config). Warn mode is observation only: the matched value (and anything a longer warn-mode match overlaps) IS forwarded upstream — docs must never imply protection. The same honesty applies to the other deliberate opt-outs: `[providers.NAME] detection = false` (whole provider unredacted, rehydration active), `[detection.mcp] exempt_servers` (named MCP servers' blocks), and `[detection] languages` (scoped-out national-id rules are not built) — all surfaced in /status, never silent. A body the proxy cannot read is refused where redaction applies rather than forwarded unscanned — except a BINARY file upload (PDF, image, archive, non-UTF-8 text) under the client's own key, which is forwarded UNSCANNED by default (counted: `unscanned_uploads_total`; `[detection] binary_uploads = "refuse"` refuses it) and refused under a proxy-held credential — unless an upload inspector (the core's `[extraction]`, or a plugin's) read it completely as clean text, which sends it byte-identical (under a proxy-held credential only when the `Inspection` allows it: `[extraction] proxy_credential = true`, off by default); a clean scan covers the extracted text only; convert mode (`[extraction] convert`, opt-in) sends a document holding values as its REDACTED display text (`text/plain`, `.txt`) on routes whose provider takes a text file — the model then sees text, not the original file; a cloud OCR reading clears a file only when it covered every page of a file whose page count is known; text uploads (CSV, logs, UTF-16 with a BOM) are redacted. Values a client deliberately encodes inside a string it sends (base64 in a JSON string) and structural names (JSON keys, multipart header names and the part `name`) are never scanned. Token floors are per request (per connection on realtime): a foreign token that arrives after its number was issued, or one another request of a shared session carries, is the documented residual. TTL "idle" means no new value in N days, so on a shared vault one instance can prune another's static session (its tokens then pass through verbatim, never wrong); retired-number rows grow by one per deleted session; an instance older than this release sharing a vault deletes without retiring numbers. Realtime connections under key auth relay non-JSON frames verbatim (the session router's frame check never sees them), and with `detection = false` under identity relay frames untouched unless the router checks frames. Vault encryption is at-rest only (memory holds plaintext) and the v2→v3 migration is one-way. RDBMS vaults fix the encryption mode at schema creation (no encrypt-in-place, no key rotation — server MVCC would keep scrubbing-proof old row versions; stand up a fresh schema instead), and `vault verify/rotate-key/backup` are sqlite-only. Multi-instance sharing of one vault database is safe but pays a round trip per first-sight allocation. The (llm-redact-pro) config editor rewrites the TOML file without preserving comments (one .bak kept). Supported platforms are Linux and macOS (both CI-tested); Windows is unsupported (SIGHUP reload alone would not save it). Non-loopback binding is fail-closed behind mutual TLS (`[tls]` certfile+keyfile+client_ca, enforced by `serve`); keep the proxy on 127.0.0.1 unless you operate a client-cert PKI — the written threat model lives in `docs/threat-model.md`.
