Skip to content

Layered PII/PHI protection

Status: Draft | Last reviewed: 2026-08-30

The design for keeping PII/PHI out of external LLM providers, external systems, and our own stores unless a tenant's policy explicitly allows it — while keeping the latency benefits of speech-to-speech (S2S) models. Read this before building any redaction, detection, or compliance-profile feature, and before adding a new place where conversation content leaves the platform. This is a design doc: none of the layers below are built yet except where a section says otherwise.

Implementation status

Phase 1 is built (2026-08-30); the phase-2 store/recordings enforcement below is built (2026-08-30); the rest of phases 2–4 remains design:

  • Shared detector lib libs/pii — the async-agent app/pii abstraction lifted (that module is now a re-export shim) plus the tier-1 rules detector (rules.py: Luhn-validated cards, mod-97 IBANs, checksummed ABA routing, context-anchored SSN/MRN/insurance/DOB). Both runtimes bootstrap it (pii_bootstrap.py in each).
  • ComplianceRedactor v2 (compliance_redactor.py) — tier-1 detectors replace the naive patterns; a second instance is inserted pre-LLM in cascaded chains (before the user aggregator) so the external LLM never receives the raw entity; frame clones preserve the concrete type.
  • Tool-argument gate (tool_arg_redaction.py) — inline API executor hook + wrap of MCP/Knowledge registered handlers; deliberately narrow entity set (cards/IBANs under PCI, SSNs under HIPAA — phone/email/MRN are legitimate lookup keys).
  • Telemetry scrubbing (libs/telemetry/scrub.py) — a delegating exporter wrapped around every system sink and every tenant OTLP destination; content attributes only; on by default, TELEMETRY_PII_SCRUB=off disables; a per-span detector fault withholds the content rather than exporting it raw.

Phase 2, L3 store/recordings enforcement (2026-08-30):

  • Store-boundary enforcement at both persist seams, driven by the tenant data_protection policy's store action (off/flag/redact/block, honoring entity_types; resolver = tenant_policy.py, ~60 s TTL): the orchestrator's conversation_store.py (add_message/update_message/add_event — message block skips the INSERT and returns a dp_blocked placeholder object; event block keeps the row with a {"dp_blocked": true, "event_type_preserved": …} stub so timelines still replay) and conversation-api's gRPC ingest (data_protection.py applied in grpc_api._process_incoming_message before both the Redis SSE broadcast and the persist, so stored copy == streamed copy; block skips both, returns success, logs once per conversation). Every fault fails open (raw text flows) — a broken policy plane must not lose conversation history; the fail-closed queue in the table below remains design. Regressions: test_store_data_protection.py, test_data_protection.py.
  • disable_transcript_storage wired: every builder ConversationAPIPublisher( construction site passes suppress_all_transcripts= (source-pinned per site), which makes _publish_transcript return immediately for both speakers — transcripts neither persist nor stream; events/tool calls still publish.
  • disable_audio_recording wired: recorder creation in universal_builder.py forces store_audio=False; the tenant policy's recordings: block does the same via the sync resolver data_protection_policy.py (psycopg2, build-time; fail-open).
  • S2S shadow detection + react (L2 item 4) built (2026-08-30): the builder snapshots the provider boundary into runtime_context["dp_provider_policy"] (fail-open), pii_shadow_flagger.py sits right after the S2S provider (observe-only: type-count logs + a pii_shadow_detection event, frames pass through untouched), and on redact/block the OpenAI Realtime wrapper rotates the transcribed item out of the live context (conversation.item.delete + redacted re-create). Reaction post-first-exposure by design. Regressions: test_pii_shadow.py.

Phase 2 remainder (2026-08-30): the tenant Settings UI for the data_protection key is built (ai-manager Settings → Tenant Settings → Data Protection, admin-only sub-tab, /api/settings/data-protection); notification gating is built (async-agent apply_notification_policy + the ChannelRegistry.send gate, test_notification_data_protection.py); the T2 self-hosted detector tier is built as an optional dependency (local_model.py: GLiNER behind local_model_available(), merged by detect_tiered for the self_hosted/cloud tiers — installing gliner in a service image is what turns it on).

Phase 3, L1 secure field capture (2026-08-30) — built for the digit-typeable vocabulary:

  • collect_secure_field is a real built-in (builtin:secure_data_capture + builtin_tool_configs.secure_data_capture.fields[]): secure_field_controller.py SUBCLASSES PaymentModeController so the proven mechanics (equal-length silence, playout-tail timers, digit barge-in, #/* conventions, private handoff TTS — the lane is shared with payments via handoff_tts_stage_for_owner) are inherited, and only the SINK differs: local validation against the typed field specs (ssn/dob/mrn/policy_number/account_number/custom), then a masked SensitiveValue session variable. The wrapper is a str SUBCLASS whose visible content IS the mask, so every existing reader (prompts, expressions, JSON, logs, telemetry) is safe structurally; the raw value's ONE exit is an explicit tool_logic bind (tool_gates.py _resolve_bound_value reveals). Regressions: test_secure_field_capture.py.
  • Deliberately NOT encrypted in process memory (an AES envelope whose key lives in the same process is indirection, not protection); nothing that persists or publishes a session variable ever sees more than the mask, which supersedes the earlier enc:{}-at-rest sketch for this path.
  • Speech capture for non-digit fields (the local-STT diversion) remains design — digit fields work today with DTMF alone, as phased.
  • The authoring card shipped (2026-08-30): a "Secure Data Capture" entry in the node Capabilities section / Add-capability library (SecureDataCaptureCapability + form; helpers mirroring the runtime's normalize_secure_fields in lib/secure-data-capture.ts). Voice-only by manifest constraint (builtinCapabilityManifests() declares stage_types: s2s/s2s_llm/realtime/llm; seed row in 054 — 045 is frozen); publish validation only warns on a policy with no usable field, mirroring the runtime's skip-with-a-warning gates. builtin_tool_configs.secure_data_capture is declared in the generated config schema (data dictionary). User-facing walkthrough: userguide/agents/secure-data-capture.md.
  • Compliance-profile enforcement now runs at BOTH points the L0 section calls for: publish (ai-manager publishAgentVersion blocks; denied stack-derived profiles drop with a warning) and orchestrator preflight (preflight.py _apply_compliance_gate — exactly as strict as the runtime ProfileSelector: an individually denied profile the selector ladders past is a warning, a channel whose every candidate including the implicit default is denied is an error, and every fault fails open so a broken policy read never disables the editor's test buttons). Regression: test_preflight_compliance_mirrors_the_selector in test_profile_selector.py.

Still design: the async post-write redaction pass for late T2/T3 findings, and phase 4's browser-form secure field surface.

Why

Every S2S provider receives the caller's raw audio in near-real-time, and today several downstream paths carry raw conversation text with no filtering at all. Clients in regulated industries expect defense in depth: even when they accept that an S2S provider hears the audio, they do not accept that the same PII then lands in transcripts, telemetry, tool calls, and recordings forever.

The verified leak inventory as designed (phase 1 closed the telemetry and tool-argument rows — telemetry scrubbing for everyone, the rest for agents with compliance flags on):

Path What leaks today Where
Telemetry spans Raw user/assistant text on input.value, output.value, transcript.*.full (4,000 chars), per-turn copies (2,000 chars); forwarded to tenant-configured OTLP destinations with zero scrubbing conversation_tracer.py#L349-L360, tenant_routing.py
Conversation store Raw text into conversation_messages / conversation_events; a recorder comment defers redaction as "a retention question" conversation_store.py
Tool-call egress LLM-generated arguments (which echo caller speech) template into arbitrary external HTTP requests — "LLM args win" inline_api_tools.py#L604, MCP and knowledge tools
Recordings Raw caller + agent audio to S3/MinIO hot bucket and archive bundles conversation_recorder.py
Workflow sends Notification-node merge templates ({{trigger.body.message}}) can carry conversation text into SMS/email notification.json

What exists is real but fragmented:

  • A proven partial cascade: secure payment capture. PaymentSpeechInputGate replaces every InputAudioRawFrame with equal-length zeros while capture is active and runs local Silero VAD (activity signal only); PaymentS2SOutputGate drops provider output; scripted prompts play through a private handoff TTS; handback is a masked LLMMessagesAppendFrame summary. The provider model never hears a digit.
  • A compliance redactor: ComplianceRedactor, off by default. Phase 1 rebuilt it on the shared tier-1 detectors (its original hand-rolled patterns redacted any 5-digit number as a "zip") and added the cascaded pre-LLM placement; its disable_transcript_storage / disable_audio_recording flags are now wired (see Implementation status): the first suppresses transcript publishing entirely (persist + stream), the second forces store_audio=False at recorder creation.
  • A pluggable detector abstraction in async-agent (app/pii/): 13 canonical PiiEntity types, a shared apply_redaction, and AWS Comprehend / GCP DLP / Azure AI Language providers — exposed only as the pii_redaction workflow node, never on the voice path.
  • The PCI gold standard in payment-vault: the Sensitive typed wrapper (masks on str/repr, single greppable .reveal() exit) and the log_guard denylist sink.
  • Prior art: an archived Guardrails-AI integration (docs/_archive/GUARDRAILS_VOICE_PIPELINE_INTEGRATION.md) validated LLM output pre-TTS before it was removed.

Governance context: this design is the data-path slice of three open findings in the control gap register — CR-12 (no compliance-profile enforcement point for providers/models, Critical), CR-09 (PCI capture/recording controls incomplete), and CR-03 (retention/deletion does not cover every store).

Design overview

Five layers, each answering one question. A layer never depends on a lower-numbered layer being enabled — that is what makes them layers rather than a pipeline.

Layer Question it answers Latency cost
L0 policy plane What is this tenant/agent allowed to send where? None (publish/preflight + cached resolve)
L1 secure field capture How does a known-sensitive value avoid the provider entirely? None (tool-triggered diversion)
L2 in-session text plane What PII is in the text flowing through the session, and what do we do about it right now? ~0–50 ms on text frames; zero on the audio path
L3 egress/at-rest scrubbing What is guaranteed never to persist or leave the platform, even when L1/L2 missed it? None (off the latency path)
L4 provider-side controls What does the provider contractually and technically do with what it receives? None
flowchart LR
    POL["L0 policy plane<br/>tenant_settings 'data_protection'<br/>+ compliance profile"]

    CALLER(("caller<br/>audio")) --> GATE["L1 secure field<br/>capture gate"]
    GATE -->|"zeroed audio<br/>during capture"| PROV["S2S / STT<br/>provider"]
    GATE -->|"local VAD + capture"| SV["Sensitive session<br/>variable (enc)"]
    PROV -->|"transcription<br/>events"| TXT["L2 text plane<br/>tiered detectors"]
    TXT --> TOOLS["tool-call egress"]
    TXT --> TTSOUT["TTS / output text"]
    TXT --> STORE["L3 conversation store<br/>redaction-before-write"]
    TXT --> TEL["L3 telemetry<br/>scrubbing processor"]
    TXT --> REC["L3 recordings<br/>policy"]

    POL -.->|configures| GATE
    POL -.->|configures| TXT
    POL -.->|configures| STORE
    POL -.->|"provider/model/region<br/>allowlist (L4 matrix)"| PROV

The honest statement this doc is built around: for an S2S model, the caller's audio reaches the provider in real time. There are exactly three ways to prevent PII in that audio from reaching the provider, and each pays a different price:

  1. Divert it (L1): a tool-triggered secure capture cuts the provider out of the loop for the duration of the sensitive exchange. Zero leak, zero latency cost, but only works for anticipated fields the agent asks for.
  2. Buffer and gate (L2 strict mode): hold audio in a lookahead buffer, run local streaming STT + detection, and suppress before forwarding. Catches volunteered PII, but adds hundreds of milliseconds — which defeats the reason the tenant chose S2S. Opt-in only.
  3. Degrade to cascade (L0 profile decision): a cascaded STT→LLM→TTS pipeline exposes text between every stage, so redaction before the LLM is structural. Trades S2S voice quality/latency for interceptability.

Everything else — shadow detection, storage redaction, telemetry scrubbing — is reaction and containment, not prevention, and the per-tenant policy is what decides which combination a given deployment runs. That matches the requirement: prefer S2S, redact after the fact where possible/supported.

L0 — policy plane

A per-tenant data_protection key in tenant_settings, following the established pattern (knowledge, telemetry, customer_auth, ai_generation): JSONB row, admin Settings page, and a ~60 s-TTL resolver in a shared lib with injected pool getter + decryptor (the tenant_store.py / libs/knowledge shape). Agents and stages can tighten the tenant policy via stage config, never loosen it.

The policy declares:

Field Meaning
entity_types Which of the canonical PiiEntity types (plus voice-specific additions) are in scope
detector_tier regex / self_hosted / cloud — which detection tiers run (see L2); cloud names an integration so the detector itself stays in the tenant's account
boundaries Per-boundary action map: provider, tools, store, telemetry, recordings, notifications → off / flag / redact / block
failure_policy Per-boundary fail-open/fail-closed overrides (see the failure-policy table)
compliance_profile none / hipaa / pci / gdpr — constrains provider, model, and region choices via the L4 matrix

The compliance profile is enforced at two points, mirroring the two validation tiers agents already have: publish (ai-manager validation alongside the config-schema check) and orchestrator /preflight (so the editor's test buttons disable for a profile-violating draft, same UX as an unbuildable one). This is the enforcement point CR-12 calls for, scoped to the data path; contractual evidence (provider agreements, assurance reports) stays in the register's remit. The variant-selection acts this profile implies — forcing a cascade over S2S, denying a provider per the L4 matrix — execute through the runtime profiles substrate (its ProfileSelector applies this profile as a hard filter).

Note: the capability registry cannot be the runtime carrier for any of this — it expands only into inline tools, prompt text, and logic at publish. Runtime frame processors are driven by stage/settings config the orchestrator reads directly (the auto_injections/lifecycle model). A companion capability supplies the authoring-facing half: tools and prompt guidance (see L1).

L1 — secure field capture (deterministic pre-provider diversion)

Generalize the payment partial cascade into a platform primitive: a collect_secure_field built-in tool (offered via a "Secure data capture" capability) with a typed field vocabulary — ssn, dob, mrn, policy_number, account_number, custom per-tenant fields.

The mechanics are the proven payment ones, reused rather than re-derived:

  • The model calls collect_secure_field(field_type=…); the controller takes over exactly as PaymentModeController does — the input gate at the canonical pre-provider slot (universal_builder.py#L2340) zeros audio to the provider, the output gate silences the provider, scripted prompts play through the private handoff TTS, DTMF and local capture collect the value.
  • Speech capture for non-digit fields needs a local STT (self-hosted, in-process or on-cluster) — the value must not detour through a cloud STT, or the diversion is theater. Digit-only fields work today with DTMF alone; speech capture is the incremental build.
  • The captured value lands in a Sensitive-wrapped session variable (payment-vault's sensitive.py typed-wrapper pattern: masks on str/repr, greppable .reveal()), AES-256-GCM enc:{} envelope at rest per secret storage.
  • Handback appends a masked summary (SSN captured, ending 6789) — the model, the transcript, and every downstream system only ever see the mask. Tools that legitimately need the raw value (an eligibility-check API) receive it through explicit variable binding at the tool boundary, the one auditable exit.

This is the only mechanism that fully prevents S2S provider exposure without abandoning S2S, and the doc-level claim is deliberately narrow: it covers values the agent asks for. A caller who volunteers their SSN mid-sentence is L2/L3's problem.

L2 — in-session text plane

One shared detector library, libs/pii, lifted from async-agent's app/pii/ (same lift the knowledge and telemetry libs went through: host service injects a pool getter + decryptor; sync and async resolvers for the two Python runtimes). Three tiers, selected by policy:

Tier Detector Latency Catches
T1 Rules: Luhn-validated card numbers, SSN, IBAN, phone, email, MRN-with-context — the good half of today's ComplianceRedactor patterns, checksum-hardened <1 ms, in-process Structured PII, high precision
T2 Self-hosted small model (Presidio NER or GLiNER via ONNX — precedent: Silero, Krisp, and LocalSmartTurnV3 already run ONNX in-process) ~10–50 ms Names, addresses, free-text PHI; data never leaves our infra
T3 Cloud PII service (Comprehend / DLP / Azure via the existing provider classes) ~100–500 ms, network Broadest entity coverage; acceptable where the detector runs in the tenant's own cloud account

All three return PiiEntity spans into the one shared apply_redaction. Four integration points:

  1. Cascaded pipelines — redact before the LLM. For STT→LLM→TTS agents, a detector processor on TranscriptionFrames upstream of the LLM service means the external LLM never sees the raw entity. This is the structural win S2S cannot have, and the cheapest strong guarantee in the whole design. ComplianceRedactor v2 replaces the legacy processor; the settings.compliance config migrates into the L0 policy.
  2. Tool-argument gating. A detector pass over LLM-produced arguments before inline_api_tools.py templating, MCP dispatch, and knowledge-tool queries. Policy decides redact vs block per tool; a blocked call returns a readable tool result ("this tool cannot receive a social security number"), never a protocol error — the established tool-gate discipline.
  3. Output text. TTSTextFrame/bot transcript redaction per policy — prevents the model repeating PII into stores even when it legitimately heard it.
  4. S2S shadow detection + react. Every S2S provider emits transcription events (Nova Sonic's wrapper already re-pushes them downstream). Detectors run on those text frames off the audio path, and on a hit execute the policy's react actions: redact the stored/telemetry copies (guaranteed via L3), mark the recording segment, flag the conversation, and — where the provider supports it — rotate the entity out of the live context (e.g. OpenAI Realtime conversation.item.delete; per-provider support belongs in the L4 matrix and needs verification). Shadow detection is reaction, not prevention: the provider heard the utterance once. The doc says so plainly so nobody sells it as a leak-prevention control.

Strict mode (opt-in, L0 profile decision): lookahead audio buffering + local streaming STT + gating before the provider. Rejected as a default — a few hundred milliseconds of added mouth-to-ear latency defeats the reason the tenant chose S2S — but specified as the option between "shadow react" and "degrade to cascade" for tenants whose profile demands provider-side prevention of volunteered PII. The latency budget is an open question below.

L3 — egress and at-rest scrubbing (the guarantee layer)

Whatever survives the session must not persist or leave the platform unredacted. These run off the latency path and are the layer a compliance reviewer can actually verify.

  • Telemetry. A scrubbing span processor in libs/telemetry — attribute allowlist plus T1/T2 detection over input.value, output.value, transcript.*, llm.input_messages.* — mandatory before tenant_routing.py forwards AI spans anywhere, including the tenant's own OTLP destination. Fits the existing rule that all routing happens in span processors on the one global TracerProvider.
  • Conversation store. Redaction-before-write in conversation_store.py for T1 (cheap enough inline), plus a fast async redaction pass for T2/T3 findings that arrive after the write. Whether the pre-redaction original is kept as an enc:{} envelope (DSAR/audit tension, CR-03) or destroyed is an open question below.
  • Recordings. Three policy levels: record nothing (built — disable_audio_recording and the tenant policy's recordings: block both force store_audio=False at recorder creation); suppress segments (secure-capture windows already produce zeroed audio by construction — L1 gives this for free); or mark segments with redaction metadata, Contact-Lens-style, for player-side muting (design). disable_transcript_storage is likewise a real reader now (transcripts neither persist nor stream).
  • Notification nodes. async-agent's sms/email merge-template rendering routes through libs/pii before send, per the tenant's notifications boundary action.

L4 — provider-side controls

A machine-readable per-provider matrix consumed by L0 compliance profiles at publish and preflight — the evidence artifact CR-12's closure requires. Columns: zero-data-retention / no-train availability, BAA eligibility, region pinning, native redaction/guardrail features. Illustrative rows (every cell must be verified against current contracts before a profile relies on it — these change):

Provider (S2S/LLM) ZDR / no-train BAA Region pinning Native controls
AWS Nova Sonic / Bedrock No-train by default Yes Yes Bedrock Guardrails (PII filters) — applicability to Nova Sonic S2S needs verification
OpenAI Realtime API no-train default; ZDR by approval Yes (approval) Limited —
Google Gemini Live / ADK / CES Differs sharply between Vertex and the public Generative Language API Vertex only Vertex only Vertex DLP adjacency
Deepgram / ElevenLabs Opt-outs available Deepgram yes; ElevenLabs verify Verify Deepgram redaction (STT)
AWS Transcribe (cascaded STT) No-train by default Yes Yes Built-in PII redaction — free win for cascaded/copilot agents

Warning: GoogleS2STService deliberately uses the public Generative Language API with an API key, not Vertex — a hipaa/gdpr profile almost certainly has to deny it. This is exactly the class of fact the matrix exists to catch mechanically instead of tribally.

Configuration

Tenant policy: tenant_settings key data_protection, admin-only Settings sub-tab (same adminOnly gating as the Knowledge group). Stage-level tightening: a data_protection stage-config key. Implementation gotchas the builder must not rediscover: a new envelope key goes through generate-config-schemas.ts (re-run, every field described) and must be added to the collab Y.Doc allow-list in both pipeline-shape.ts copies or autosave silently drops it.

Failure policy

Call-site-owned, following the logic-expressions precedent — the policy belongs to the boundary, not the detector:

Boundary Default Rationale
L0 publish/preflight validation Fail closed A profile violation must block, like any other publish gate
L1 secure capture Fail closed Zeros keep flowing to the provider on any internal failure — the proven payment behavior
L2 latency-path detectors (cascaded pre-LLM, output) Fail open + flag T1 effectively cannot fail; a T2/T3 timeout passes text through and marks the conversation, tenant-overridable to fail closed per profile
L2 tool-argument gate Fail closed where the profile requires it Matches available_when semantics; a blocked call is a readable tool result
L3 stores and exports Fail closed An unscrubbable record queues for retry rather than being written/exported raw

Phasing

Phase Scope Why this order
1 libs/pii lift; T1 tier; ComplianceRedactor v2 (cascaded pre-LLM + tool-argument gate); telemetry scrubbing processor Zero latency cost, no new models to operate, closes the two largest live leaks (tenant OTLP export, external tool HTTP)
2 data_protection tenant settings + admin UI + resolver; store redaction; recordings policy; wire or delete the dead disable_* flags; notification gating; T2 self-hosted model The policy plane plus the at-rest guarantees a reviewer can verify
3 Generalized secure field capture (capability + controller/gates + local capture STT); S2S shadow detection + react; T3 cloud detectors; L4 matrix + compliance-profile enforcement at publish/preflight The S2S-specific value, on top of a policy plane that can carry it
4 Strict mode (lookahead buffering / profile-forced cascade degradation); per-provider context rotation Only where a tenant accepts the latency trade; needs the phase-3 machinery

Open questions

  • Strict-mode latency budget. What added mouth-to-ear latency is acceptable before a tenant would rather run cascaded? Needs a measured prototype (target: is ~300 ms viable?), not an assumption.
  • Is post-first-exposure "react" acceptable for HIPAA tenants given provider ZDR/BAA, or must the hipaa profile force secure-capture + strict/cascade for in-scope agents?
  • Per-provider context rotation support — which S2S providers can actually delete/rewrite a context item mid-session? Needs per-provider verification before the L4 matrix claims it.
  • Should profiles hard-deny S2S providers without ZDR, or is flag-and-attest enough for gdpr?
  • T2 model choice — Presidio+spaCy vs GLiNER vs a distilled custom model; language coverage must match the platform's translation story.
  • Store redaction: keep an encrypted original or destroy? Keeping supports DSAR/audit/legal-hold; destroying is the stronger privacy claim. Interacts directly with CR-03's retention design.

See also