@pleach/compliance
Regulated-environment bundle — four shipping scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) plus an opt-in tenant-isolation RLS template and the `auditRlsPolicies()` checker you wire into your own CI.
Pruning shears at the garden wall — what crosses the boundary, in
what shape, with what scrub. @pleach/compliance is the
regulated-environment add-on to @pleach/core. The first release ships
four Scrubber implementations that gate event log writes, plus an
opt-in tenant-isolation RLS template and auditRlsPolicies(), a
checker you wire into your own CI. The package is aimed at deployments
where the audit ledger is evidence — healthcare, finance, govtech —
and where every harness_event_log row needs both a tenant scope and
a redaction guarantee before it lands on disk.
Beta. While
@pleach/coreships stable,@pleach/complianceships beta for the first release. The scrubbers, the RLS template, andauditRlsPolicies()below are production-solid and battery-proven; the attestation / audit-report surface is the beta part — envelopes are unsigned by default unless a real key-store is wired, and the version maps are Phase-A-empty (see Attestation). A stable@pleach/complianceattestation claim lands at the 1.0 cut.
The canonical use case is alongside @pleach/core's
EventLogWriter: scrubbers run on the persistence boundary, the RLS
template partitions the rows in the database, and auditRlsPolicies()
confirms from your CI that the partition is live.
Install
npm install @pleach/compliancepnpm add @pleach/complianceyarn add @pleach/compliancebun add @pleach/complianceimport {
SsnUsScrubber,
CreditCardScrubber,
UsDriverLicenseScrubber,
KeyedRegexScrubber,
} from "@pleach/compliance";The package exports the four scrubber classes (instantiate with new),
the complianceProfilePlugin(profile) factory that bundles them by
named profile ("hipaa" / "gdpr" / "pci-dss" / "soc2"), the
createComplianceRuntime wrapper for transparent interception, and
the lower-level scrubbersForProfile(profile) helper for hand-rolled
plugins. The published README is the source of truth for export names
and constructor options — check it before pinning a version.
The four bundled scrubbers
Each implements the Scrubber contract from @pleach/core/scrubbers. The
contract — scrub(input, ctx) => ScrubResult returning redacted and
matched spans for audit reporting — is documented in Scrubbers;
this section covers what ships in the bundle.
| Scrubber | Pattern | Redaction key |
|---|---|---|
SsnUsScrubber | US Social Security numbers (NNN-NN-NNNN; opt-in unseparated NNNNNNNNN) with SSA invalid-range guards | [REDACTED:SSN] |
CreditCardScrubber | Credit-card-shaped digit runs validated by Luhn check digit AND IIN range gate | [REDACTED:CARD] |
UsDriverLicenseScrubber | US driver's license numbers — per-state format table, anchored or per-state mode | [REDACTED:DL] |
KeyedRegexScrubber | Generic pattern + key adapter — the building block hosts extend | caller-supplied |
SsnUsScrubber matches the canonical hyphenated form and (when opted
in) the unhyphenated nine-digit form, with SSA invalid-range guards
(area ≠ 000/666/900–999, group ≠ 00, serial ≠ 0000). The redacted
form replaces the matched span with [REDACTED:SSN]; the original
digits never reach storage.
CreditCardScrubber validates against the Luhn check digit AND the
IIN range before redacting. The double gate cuts the false-positive
rate that a naive 16-digit regex would produce against arbitrary
numeric content — order numbers, session ids, and similar digit runs
pass through untouched. Opt out of the IIN gate via
new CreditCardScrubber({ validateIin: false }) if you process exotic
card-network ranges.
UsDriverLicenseScrubber composes a state-keyed pattern table because
the driver's license format varies per state. The bundle ships the
table; consumers that need a state the upstream table doesn't cover
yet should open an issue on the package repo rather than fork the
scrubber.
KeyedRegexScrubber is the parameterized scrubber — the other three
are specializations that bind a fixed pattern and redaction key. The
next section covers it as the extension hook.
KeyedRegexScrubber — the extension hook
KeyedRegexScrubber is the one scrubber consumers commonly instantiate
directly. It's not a custom scrubber — it's a parameterized one — so
it stays inside the contract and inherits the matched-spans audit
reporting that ships with the bundle.
// lib/compliance/tenantMrnPlugin.ts
import { KeyedRegexScrubber } from "@pleach/compliance";
import type { HarnessPlugin } from "@pleach/core";
const mrnScrubber = new KeyedRegexScrubber({
id: "tenant-mrn",
entries: [
{
name: "mrn",
pattern: /\b\d{7}\b/g,
replacement: "[REDACTED:MRN]",
},
],
});
export const tenantCompliancePlugin: HarnessPlugin = {
name: "tenant-mrn-compliance",
contributeScrubbers: () => [mrnScrubber],
};The name (here "mrn") is what shows up in the matched-spans report
and in any downstream audit consumer — pick a name that reads in a
compliance review, not the regex itself. The replacement may be a
string or a function (match, name) => string if the redacted form
needs to be derived from match groups (e.g. preserving the last 4
chars).
For a host with multiple regulated identifier families — MRN,
employee ID, internal account number — register them as separate
entries on a single KeyedRegexScrubber, or use one scrubber per
family if you want the audit report grouped per-scrubber-id. The
per-entry name field is the attribution unit either way.
Advanced redaction
The four bundled scrubbers are keyed regexes. For redaction that
regex can't express — clinical PHI, named-entity detection, or a
model-driven pass — @pleach/compliance/redaction ships a composable
pipeline and three heavier scrubbers, each on its own subpath.
createPipeline({ scrubbers }) runs scrubbers in order; each is a
PipelineScrubber that returns redacted text plus ScrubMatch
records. createRedactionGate({ pipeline }) wraps a pipeline into an
ingress/egress gate you place at a trust boundary.
| Scrubber | Subpath | Detects via |
|---|---|---|
createPHIScrubber | @pleach/compliance/redaction/scrubbers/phi | tiered PHI patterns; strictness of "standard" | stricter tiers |
createPresidioScrubber | @pleach/compliance/redaction/scrubbers/presidio | a Presidio endpoint; DEFAULT_PRESIDIO_ENTITY_TYPES unless overridden |
createLLMScrubber | @pleach/compliance/redaction/scrubbers/llm | a model call; DEFAULT_LLM_SCRUBBER_INSTRUCTION unless overridden |
import { createPipeline, createRedactionGate } from "@pleach/compliance/redaction"
import { createPHIScrubber } from "@pleach/compliance/redaction/scrubbers/phi"
import { createPresidioScrubber } from "@pleach/compliance/redaction/scrubbers/presidio"
const gate = createRedactionGate({
pipeline: createPipeline({
scrubbers: [
createPHIScrubber({ strictness: "standard" }),
createPresidioScrubber({ endpoint: process.env.PRESIDIO_URL! }),
],
}),
})
const clean = await gate.ingress(rawText, { tenantId })The Presidio and LLM scrubbers reach a network service, so they run in the redaction path, not the hot audit-write path. The PHI scrubber is pure regex and safe to run inline.
Redaction fails open — and you can now see it, or forbid it
A scrubber that throws (a Presidio blip, an LLM 429) fails open: the pipeline hands back the text it has and keeps going. That default is deliberate — the gate sits on a live request path, and throwing on a transient scrubber outage would turn every blip into a hard user-facing failure. What changed is that the failure is no longer invisible:
PipelineResultcarriesfailures(every scrubber that did not complete, in firing order) andcomplete(failures.length === 0).failures: []is a measurement; an absentfailuresmeans the pipeline implementation doesn't report — readcomplete === trueas the only affirmative pass.gate.ingressDetailed(...)/gate.egressDetailed(...)return aRedactionOutcome— the text alongside the per-scrubber failures — so a caller can decide what incomplete scrubbing means for it.createRedactionGate({ pipeline, failClosed: true })makes the plain string-returningingress/egressthrowRedactionIncompleteError(carrying the failures and direction) instead of handing back text that was not fully scrubbed.
Fail-closed is opt-in. The signal is unconditional; the policy is yours.
Wiring tenant isolation into your own CI
Installing @pleach/compliance does not add a gate to your build.
The package declares no bin, and its files allowlist ships dist,
the two SBOMs, src/tenant-rls/policies.sql, and the usual metadata —
no scripts. What it ships toward tenant isolation is a SQL template
and a checker you call yourself.
policies.sql — the RLS template
The template resolves as a package file, not a JS import:
const policiesPath = require.resolve(
"@pleach/compliance/tenant-rls/policies.sql",
);Each block is a CREATE POLICY tenant_isolation_${TABLE_NAME} ... USING (tenant_id = current_tenant()) over a ${TABLE_NAME} placeholder, with
ready-to-apply blocks for harness_event_log, audit_call, and
cost_event. Substitute your own table names and apply with sed,
envsubst, or your migration tool. The package does not apply it for
you, and it assumes your schema already defines current_tenant().
auditRlsPolicies() — the checker
import { auditRlsPolicies } from "@pleach/compliance/tenant-rls";
const report = await auditRlsPolicies({
pool,
expectedTables: ["harness_event_log", "audit_call", "cost_event"],
});
const gaps = report.filter(
(r) => !r.present || !r.references_current_tenant,
);
if (gaps.length > 0) throw new Error(`RLS gaps: ${JSON.stringify(gaps)}`);pool is duck-typed — pg, postgres.js, and a Supabase service-role
client all satisfy it. The function queries pg_policies and returns
one row per expected table; throwing on a gap is what makes it a gate.
See Multi-tenant
for the full option shape.
The gates that run in the source repo
Two gates of the same shape protect the substrate itself, in the
@pleach/core monorepo's own CI. They are not part of what
npm install grants you:
| Gate | Purpose |
|---|---|
audit:tenant-scoping | Every tenant_id-bearing table in supabase/migrations/*.sql has an RLS policy referencing current_tenant(). |
audit:harness-event-log-tenant-id-required | Every harness_event_log write site references tenant_id (or carries a documented opt-out marker). |
Both are source-text scans over that repo's own tree. If you author a
sibling SKU or a host that embeds @pleach/core, they are worth
mirroring in your own CI — see Audit gates.
Deployment pattern
Adopt the package once at the top of your SessionRuntime
construction via the standard plugin registration. The scrubbers wire
into EventLogWriter automatically; consumer code doesn't call them
directly.
import { SessionRuntime } from "@pleach/core";
import { complianceProfilePlugin } from "@pleach/compliance";
const runtime = new SessionRuntime({
storage,
checkpointer,
userId: req.user.id,
tenantId: req.user.orgId,
plugins: [complianceProfilePlugin("hipaa") /* , gatewayPlugin, ... */],
});complianceProfilePlugin(profile) accepts "hipaa" | "gdpr" | "pci-dss" | "soc2"
and returns a HarnessPlugin whose contributeScrubbers resolves to
the scrubber bundle for that profile. Use scrubbersForProfile(profile)
directly if you need to hand-roll the plugin shape (e.g. composing
your own name + id fields). Custom scrubbers (additional
KeyedRegexScrubber instances for tenant-specific identifiers)
register through their own plugin alongside this one — composition is
additive.
Tenant isolation is the one piece installation does not wire for you:
apply the RLS template to your schema and call auditRlsPolicies()
from your own CI step.
What's NOT in this package today
Honest about the scope of the 1.0 cut:
- No gateway-level auth or rate limiting. That lives in
@pleach/gateway@0.1.0. Today's compliance package does not see the outbound provider call. - This package ships no SBOM or attestation bundle.
@pleach/trust-packremains a reserved namespace with no shipping code, at0.0.1 · UNLICENSED. Supply-chain attestation is out of scope for this cut. (Cryptographic attestation of individual audit rows ships in@pleach/core/attestation— see Attestation.) - Deterministic replay over the event log ships in
@pleach/replay@0.1.0(replayTurn,fromSnapshot,fork, andaggregateMultiTenantbodies all land at the0.1.0cut) and@pleach/eval@0.1.0(scoring + diff). The compliance package writes the rows; the replay/eval packages read them. Compliance does ship a certifier over replay results —@pleach/compliance/determinism(createDeterminismCertifier,verifyDeterminism) — which attests that a re-run reproduced the original. See Determinism. - Tamper-evidence hash chain stamping ships in
@pleach/core/eventLog(chainStep,computeRowHash) behind thec9PhaseBEnabledflag; verification ships in the same@pleach/core/eventLogbarrel (verifyChainForChat,generateProof). TheTamperEvidenceplug-point in@pleach/core/auditkeeps its no-op default for hosts that route attestation elsewhere. - The bundled regex set is still four. International tax IDs, EU
formats, and sector-specific identifiers aren't in the regex
scrubbers yet; use
KeyedRegexScrubberfor those. Redaction beyond regex now ships separately —@pleach/compliance/redactioncarries a composable pipeline plus PHI, Presidio, and LLM scrubbers. See Advanced redaction.
Check the package README for the current roadmap before assuming a feature.
Adopting in stages
A host migrating an existing deployment toward mandatory tenant
scoping may want the scrubbers immediately but defer the RLS template
and its check until the codebase is ready. auditRlsPolicies() throws
on the first uncovered table — wiring it into CI before the tables are
covered means a red build for the length of the migration.
Register only the scrubbers via contributeScrubbers from a custom
plugin, and skip the package's full plugin export:
// lib/compliance/scrubbersOnlyPlugin.ts
import {
SsnUsScrubber,
CreditCardScrubber,
UsDriverLicenseScrubber,
} from "@pleach/compliance";
import type { HarnessPlugin } from "@pleach/core";
export const scrubbersOnlyPlugin: HarnessPlugin = {
name: "compliance-scrubbers-only",
contributeScrubbers: () => [
new SsnUsScrubber(),
new CreditCardScrubber(),
new UsDriverLicenseScrubber(),
],
};The scrubbers run; the RLS template stays unapplied. Track the
migration as a real ticket — running the scrubbers without the
tenant-scoping guarantee means redacted rows can still land
untenanted, which is a partial-isolation posture, not a finished one.
Flip to the full complianceProfilePlugin("hipaa") (or the profile
matching your posture) once auditRlsPolicies() comes back clean.
Related SKUs
@pleach/core— the substrate this package wraps. Scrubbers run atEventLogWriter's persistence boundary; the monorepo's ownaudit:harness-event-log-tenant-id-requiredgate scans write sites under@pleach/core/eventLog.@pleach/gateway— emitsdomain.gateway.cost.recordedevents with the fullRoutingDecisionshape. Compliance's attestation runtime reads those events without round-tripping the audit ledger.@pleach/replay—verifyChainIntegritywalks the hash-chained event log. Compliance writes the rows; replay attests them.@pleach/eval— scoring + diff over replayed, scrubbed event logs.
For the full SKU map see Which SKU do I need?.
Where to go next
Scrubbers
The `Scrubber` contract — event-type allowlists, matched-spans, and the `contributeScrubbers` hook.
Multi-tenant
Where `tenantId` lives in the substrate and the production tenant-isolation checklist.
Tenant facet
`runtime.tenant.id` — reading the substrate's tenant identity inside plugins and tool handlers.
Audit ledger
The `ProviderDecisionLedger` and the three compliance plug-points the package will extend.