pleach
Cookbook

Regulated-domain agent

An agent built for HIPAA, SOC 2, PCI, FedRAMP, or 21 CFR Part 11. Constrained routing, PII gate, append-only ledger keyed for the regulator's query.

A regulated-domain agent is the use case where "we have audit logs" stops being the answer and "show me the chain of custody for turn T" becomes the question. Pleach answers it by construction: provider routing is constrained at session start, a PII gate runs before the prompt leaves the process, and every call writes one append-only row keyed by (turnId, toolName, subagentDepth).

This page walks the four pieces auditors ask about: provider routing constraints, the PII gate, the audit row, and the replay path that lets an engineer defend the answer six months later.

Related shapes. Multi-tenant SaaS agent if one runtime serves many regulated customers. Internal knowledge agent if grounding comes from a private corpus you must cite from. Customer support agent if the regulated turn is part of a longer-lived support session.

What you're building

A turn loop for a regulated domain — a clinical decision-support helper, a claims-review assistant, a know-your-customer reviewer, an FERPA-bound tutor. The shape is the same regardless of framework:

  • Provider routing is locked to a vetted family list at session start.
  • A PII redaction policy gates the call before it dispatches to the provider.
  • The ledger row carries enough identity to answer "who, what, when" without joining four tables.
  • The turn is replayable for as long as the storage adapter keeps the inputs.

What @pleach/core ships today gets you the constrained routing, the PII gate contract, the auditable row, and the replay. @pleach/compliance layers on the bundled scrubbers, the tamper-evident hash chain, the typed audit records a regulator reads first, and the CI gates that keep tenant scoping honest.

Lock provider routing at session start

permittedFamilies narrows the family-strict cascade to a vetted set. A model family that isn't on the list can't be picked, even as a fallback.

// lib/runtime.ts
import { SessionRuntime, definePleachPlugin } from "@pleach/core";

export function buildClinicalRuntime(req: AuthedRequest) {
  return new SessionRuntime({
    provider:     anthropicProvider,
    storage:      new SupabaseAdapter({ client: supabase }),
    checkpointer: new SupabaseSaver({ client: supabase }),
    plugins:      [definePleachPlugin("clinical-tools", {
      tools:          clinicalTools,
      safetyPolicies: [phiRedaction, refuseOnUncertainty],
    })],
    permittedFamilies: new Set(["anthropic-bedrock", "azure-openai"]),
    tenantId:       req.tenantId,          // e.g. hospital_id
    organizationId: req.organizationId,
    userId:         req.userId,
  });
}

Why this matters under a BAA or FedRAMP boundary: the family-lock isn't a runtime hint, it's structural. The cascade walks in- family rungs only. If openai-direct isn't on the list, the cascade can't fall through to it under any condition. The constraint is auditable: the family list at session start is recorded in the session row.

See Providers for the cascade rules and Multi-tenant for the three-place isolation pattern.

Gate PII before the prompt leaves the process

A pre-dispatch safety policy runs before the provider call. If it rewrites the message, the provider sees the rewrite. If it refuses, the call doesn't happen at all.

// lib/safety/phiRedaction.ts
import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety";

export const phiRedaction = defineSafetyPolicy({
  id:          safetyPolicyId("regulated-domain.phi-redaction"),
  version:     "1.0.0",
  framework:   "HIPAA",
  enforcement: "guardrail",
  content: ({ runtimeRole }) => `
[PHI policy ${runtimeRole ?? "default"}]
Before responding, scrub identifiers that match
common PHI patterns (MRN, SSN, DOB, account
numbers, full names paired with conditions).
Replace each occurrence with the literal token
"[REDACTED]". Do not paraphrase around the
redaction — the original token never leaves.
  `.trim(),
});

Three load-bearing details:

  1. defineSafetyPolicy is identity-at-runtime — the SafetyContribution shape it returns composes into the system prompt LAST, after every other plugin contribution. PHI guidance reaches the model before any tool call fires.
  2. enforcement: "guardrail" records the operator's stated INTENT on every ledger row that consults this policy — a later audit can ask "which turns ran under the PHI guardrail" without re-running the policy. See Audit ledger.
  3. The contract is capability-subtracting: a safety policy can refuse or constrain, but never generates new content. Per-pattern scrubbing for the redaction itself lives in @pleach/compliance (see Scrubbers); defineSafetyPolicy carries the operator-stated guidance alongside it. See Safety.

What the audit row carries

Every call writes one row to harness_auditable_calls. The identity columns are what an auditor reads first.

ColumnCarriesAudit question it answers
record_idULID, monotonic"is this row out of order or duplicated?"
tenant_idruntime context"which covered entity is this for?"
turn_idone per user message"what was the unit of work?"
tool_nameper tool call"what did the agent invoke?"
subagent_depthper spawn"did a child agent do this?"
model_idresolved at call time"which model produced this?"
familyresolved family"did routing stay inside the BAA list?"
created_atserver clock"when did this happen, to the millisecond?"

See Auditable call row for the full column list and Audit ledger for the schema.

The "chain of custody for turn T" query

A regulator hands the engineer a turn_id and a question. One query, ordered by record_id, returns the chain.

select
  record_id,
  call_kind,
  tool_name,
  subagent_depth,
  model_id,
  family,
  payload -> 'identity' as identity,
  payload -> 'output'   ->> 'auditNote' as audit_notes,
  created_at
from harness_auditable_calls
where turn_id = $1
order by record_id;

What this reads out: every model call, every tool call, every spawned subagent, every PII redaction notation, every routing decision — in the order they happened. No joins, no log correlation, no grep.

For a regulator asking about a whole session instead of one turn, swap the where clause for session_id = $1.

Replay for audit defense

A turn that's been recorded can be replayed against the same inputs months later. The replay walks deterministically: same provider seed, same tool returns, same final text.

import { createReplayRuntime } from "@pleach/replay";

const replayRuntime = createReplayRuntime({
  sessionRuntime: runtime,
  tenantId,
});

const replay = await replayRuntime.replayTurn({
  chatId:    sessionId,
  tenantId,
  messageId: turnId,
});

// `replay.state` is the reconstructed HydratedHarnessState (typed `unknown`):
// the tools that fired, the model's answer, the redaction notes, and the
// family choice all hang off the rehydrated turn state.
console.log(replay.state);

Why this matters in a regulated context: an auditor asking "how did the system reach this conclusion?" gets a byte-identical walkthrough of the original decision. The engineer doesn't reconstruct from logs — they replay the turn.

See Eval and replay for the recording modes and Determinism for the fingerprint contract.

Bundled scrubbers from @pleach/compliance

@pleach/compliance ships four scrubbers implementing the Scrubber contract from @pleach/core. They cover the identifiers a US-regulated host hits first:

ScrubberMatchesNotes
SSN-USUS Social Security NumbersNNN-NN-NNNN and run-on NNNNNNNNN
LuhnCredit card numbersLuhn-validated, not pattern-only
US-DLUS driver's license numbersper-state format set
KeyedRegexhost-supplied identifiersparameterized; one registration per pattern

Registration runs at runtime construction through the plugin's contributeScrubbers hook — the runtime composes the host's set into the same pre-dispatch gate the phiRedaction policy above plugs into.

The fastest wiring is a profile-keyed convenience factory that returns a HarnessPlugin wired with the canonical bundle for the regulatory regime:

// lib/runtime.ts
import { SessionRuntime } from "@pleach/core";
import {
  complianceProfilePlugin,
  KeyedRegexScrubber,
} from "@pleach/compliance";

const compliance = complianceProfilePlugin("hipaa", {
  // Host-specific identifiers layer on top of the profile bundle.
  // KeyedRegexScrubber takes an `entries` array; each entry carries
  // the pattern + replacement + a stable name for audit-trail attribution.
  extraScrubbers: [
    new KeyedRegexScrubber({
      id: "mrn",
      entries: [
        { name: "MRN", pattern: /\bMRN-\d{7}\b/g, replacement: "[MRN-REDACTED]" },
      ],
    }),
    new KeyedRegexScrubber({
      id: "ein",
      entries: [
        { name: "EIN", pattern: /\b\d{2}-\d{7}\b/g, replacement: "[EIN-REDACTED]" },
      ],
    }),
  ],
});

export function buildRegulatedRuntime(req: AuthedRequest) {
  return new SessionRuntime({
    plugins: [compliance],
    // ...routing, storage, tools, tenant as above
  });
}

The supported profile names are "hipaa" | "gdpr" | "pci-dss" | "soc2". Each maps to a curated subset of the four ship-set scrubbers:

  • hipaaSsnUsScrubber + UsDriverLicenseScrubber + CreditCardScrubber
  • gdprSsnUsScrubber + CreditCardScrubber
  • pci-dssCreditCardScrubber (Luhn + IIN range)
  • soc2SsnUsScrubber + CreditCardScrubber

If you'd rather wire scrubbers by hand — for instance, to compose them with other plugin contributions (safety policies, prompts, tools) in a single plugin literal — definePleachPlugin from @pleach/core is the canonical surface:

// lib/plugins/myCompliancePlugin.ts
import { definePleachPlugin } from "@pleach/core";
import {
  SsnUsScrubber,
  CreditCardScrubber,
  UsDriverLicenseScrubber,
} from "@pleach/compliance/scrubbers";

export const myCompliancePlugin = definePleachPlugin("my-compliance", {
  scrubbers: [
    new SsnUsScrubber(),
    new CreditCardScrubber(),
    new UsDriverLicenseScrubber(),
  ],
  // ...other contributions (safety policies, prompts, tools, etc.)
});

The KeyedRegexScrubber is the host's extension point. A regulated SaaS adds its domain identifiers without forking the core scrubber set. See Scrubbers for the contract and the bundled-set guarantees.

Tamper-evident hash chain on the event log

harness_event_log carries two columns the chain depends on: prev_hash (the previous row's hash, by record_id order) and row_hash (this row's content hash, including prev_hash). A malicious DB write to any row breaks the chain at that row and every row after it; a verifier walks the table and surfaces the break.

Writer-side stamping ships in @pleach/core/eventLog (chainStep, computeRowHash, computeGenesisSeed) behind the c9PhaseBEnabled flag. End-to-end verification ships in @pleach/replay@0.1.0 as verifyChainForChat and generateProof. The combination is in soak — operators running the chain in production should pin to the @pleach/core + @pleach/replay versions that match. See Hash chain for the verification walk and the current soak status.

Typed audit records

Two record shapes earn their keep in regulated domains:

  • SynthesisQualityRecord — written on every synthesize row. Carries the synthesis-quality probes as typed boolean fields: imperativeFabricationDetected (the fabrication-detector arm fired) and degenerationFired (an observer halt fired mid-stream), alongside finalContentLength, synthesisRetries, forceSynthesis, latencyMs, and the terminalSynthesis marker. A regulator asking "did this answer trip a fabrication or degeneration guard" reads one row.
  • InterruptDecisionRecord — written when a human-in-the- loop interrupt resolves. Carries the interrupt reason, the resolver's identity, the chosen branch, and the timestamp. The chain of custody for a clinician override is one row.

Both records share the audit row's identity columns (tenant_id, turn_id, subagent_depth) and chain into the hash-linked event log. See Typed records for the full set and the schemas.

CI gates from @pleach/compliance

Two CI gates ship with the package; both fail the build on violation:

  • audit:tenant-scoping — every storage read and write carries a tenant predicate. A query that omits one stops the merge.
  • audit:harness-event-log-tenant-id-required — every emit to harness_event_log stamps tenant_id. An untagged write stops the merge.

Combined with the tenant facet, the gates turn "we stamp tenant_id everywhere" from a code-review promise into a CI invariant. See Recipes (recipes 11 and 13) for the end-to-end PHI-gate and hash-chain verification walks, and Packages for the SKU status table.

Project layout

The heaviest delta of the six shapes. On top of the baseline, regulated deployments add a compliance/ module wiring @pleach/compliance into all three audit-ledger plug-points, lock provider selection at the runtime construction site, and check in the SQL the auditor will read.

my-app/
  src/
    pleach/
      runtime.ts                # SessionRuntime with permittedFamilies + @pleach/compliance plug-ins
      compliance/
        scrubbers.ts            # the bundled scrubber set from @pleach/compliance
        hash-chain.ts           # tamper-evident chain wiring on the event log
        typed-records.ts        # SynthesisQualityRecord, InterruptDecisionRecord, etc.
      safety/
        family-lock.ts          # → /docs/family-lock — provider routing pinned at session start
      ledger/
        gdpr-soft-delete.ts     # the GDPR entry point (callable, audited)
    app/
      api/agents/[id]/route.ts
  audit/
    chain-of-custody.sql        # the "turn T" query auditors run
    hash-chain-verify.sql       # the manual prev_hash walk
  .github/
    workflows/
      compliance.yml            # runs audit:tenant-scoping + audit:harness-event-log-tenant-id-required

What changes from the baseline:

  • compliance/ wires the three plug-points. The audit ledger exposes TamperEvidence, PiiRedactor, and a typed-record emitter; this directory is where each one gets its real implementation from @pleach/compliance. Keeping them adjacent makes the "audit-grade" claim a single-directory read, not a treasure hunt.
  • safety/family-lock.ts enforces provider routing at session start. permittedFamilies pins which model families a session can ever reach — the family lock fails closed if the wrong family is requested mid-turn. This is the file an auditor asks to see first.
  • ledger/gdpr-soft-delete.ts is callable code, not a SQL migration. Soft-delete runs through the ledger's typed entry point so the deletion itself is an audited event. A direct UPDATE would erase the trail GDPR requires.
  • audit/*.sql lives in the repo. The chain-of-custody query and the hash-chain verification walk are what an external auditor runs. Checking them in keeps the query shape locked to the column shape — and gives the auditor one file path to ask for instead of a screenshot.
  • .github/workflows/compliance.yml runs the CI gates. The two gates listed above (audit:tenant-scoping and audit:harness-event-log-tenant-id-required) only enforce the invariant if CI runs them. The workflow file is what makes "stamped on every write" a structural promise instead of an aspirational one.

Where to go next

On this page