# Regulated-domain agent (/docs/regulated-domain-agent)



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](/docs/multi-tenant-saas-agent) if one
runtime serves many regulated customers.
[Internal knowledge agent](/docs/internal-knowledge-agent) if
grounding comes from a private corpus you must cite from.
[Customer support agent](/docs/customer-support-agent) if the
regulated turn is part of a longer-lived support session.

## What you're building [#what-youre-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 [#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.

```typescript
// 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](/docs/providers) for the cascade rules and
[Multi-tenant](/docs/multi-tenant) for the three-place isolation
pattern.

## Gate PII before the prompt leaves the process [#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.

```typescript
// 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](/docs/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](/docs/scrubbers));
   `defineSafetyPolicy` carries the operator-stated guidance
   alongside it. See [Safety](/docs/safety).

## What the audit row carries [#what-the-audit-row-carries]

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

| Column           | Carries               | Audit question it answers                   |
| ---------------- | --------------------- | ------------------------------------------- |
| `record_id`      | ULID, monotonic       | "is this row out of order or duplicated?"   |
| `tenant_id`      | runtime context       | "which covered entity is this for?"         |
| `turn_id`        | one per user message  | "what was the unit of work?"                |
| `tool_name`      | per tool call         | "what did the agent invoke?"                |
| `subagent_depth` | per spawn             | "did a child agent do this?"                |
| `model_id`       | resolved at call time | "which model produced this?"                |
| `family`         | resolved family       | "did routing stay inside the BAA list?"     |
| `created_at`     | server clock          | "when did this happen, to the millisecond?" |

See [Auditable call row](/docs/auditable-call-row) for the full
column list and [Audit ledger](/docs/audit-ledger) for the
schema.

## The "chain of custody for turn T" query [#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.

```sql
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 [#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.

```typescript
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](/docs/eval-and-replay) for the recording
modes and [Determinism](/docs/determinism) for the fingerprint
contract.

## Bundled scrubbers from `@pleach/compliance` [#bundled-scrubbers-from-pleachcompliance]

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

| Scrubber     | Matches                     | Notes                                       |
| ------------ | --------------------------- | ------------------------------------------- |
| `SSN-US`     | US Social Security Numbers  | `NNN-NN-NNNN` and run-on `NNNNNNNNN`        |
| `Luhn`       | Credit card numbers         | Luhn-validated, not pattern-only            |
| `US-DL`      | US driver's license numbers | per-state format set                        |
| `KeyedRegex` | host-supplied identifiers   | parameterized; 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:

```typescript
// 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:

* `hipaa`   — `SsnUsScrubber` + `UsDriverLicenseScrubber` + `CreditCardScrubber`
* `gdpr`    — `SsnUsScrubber` + `CreditCardScrubber`
* `pci-dss` — `CreditCardScrubber` (Luhn + IIN range)
* `soc2`    — `SsnUsScrubber` + `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:

```typescript
// 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](/docs/scrubbers) for the
contract and the bundled-set guarantees.

## Tamper-evident hash chain on the event log [#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](/docs/hash-chain) for the verification walk and the
current soak status.

## Typed audit records [#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](/docs/typed-records)
for the full set and the schemas.

## CI gates from `@pleach/compliance` [#ci-gates-from-pleachcompliance]

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](/docs/tenant-facet), the gates
turn "we stamp tenant\_id everywhere" from a code-review promise
into a CI invariant. See [Recipes](/docs/recipes) (recipes 11
and 13) for the end-to-end PHI-gate and hash-chain verification
walks, and [Packages](/docs/packages) for the SKU status table.

## Project layout [#project-layout]

The heaviest delta of the six shapes. On top of the
[baseline](/docs/project-layout#a-layout-that-works), 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`](/docs/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](/docs/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](#the-chain-of-custody-for-turn-t-query)
  and the
  [hash-chain verification walk](#tamper-evident-hash-chain-on-the-event-log)
  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`](#ci-gates-from-pleachcompliance)
  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 [#where-to-go-next]

<Cards>
  <Card title="Compliance" href="/docs/compliance" description="The @pleach/compliance plugin — scrubbers, hash chain, typed records, and the CI gates that come with it." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="The Scrubber contract, the four bundled scrubbers, and the KeyedRegex extension shape." />

  <Card title="Hash chain" href="/docs/hash-chain" description="The prev_hash + row_hash columns on harness_event_log and the verification walk." />

  <Card title="Typed records" href="/docs/typed-records" description="SynthesisQualityRecord, InterruptDecisionRecord, and the rest of the typed audit set." />

  <Card title="Tenant facet" href="/docs/tenant-facet" description="The runtime.tenant config the CI gates and the chain both lean on." />

  <Card title="Safety" href="/docs/safety" description="The capability-subtracting policy contract — refuse, rewrite, or annotate, but never generate." />

  <Card title="Providers" href="/docs/providers" description="The family-strict cascade and the permittedFamilies constraint that locks it." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema, indexes, and retention." />

  <Card title="Eval and replay" href="/docs/eval-and-replay" description="runtimeMode, recording, and the diff engine that powers replay defense." />
</Cards>
