Skip to content
pleach

@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/core ships stable, @pleach/compliance ships beta for the first release. The scrubbers, the RLS template, and auditRlsPolicies() 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/compliance attestation 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/compliance
pnpm add @pleach/compliance
yarn add @pleach/compliance
bun add @pleach/compliance
import {
  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.

ScrubberPatternRedaction key
SsnUsScrubberUS Social Security numbers (NNN-NN-NNNN; opt-in unseparated NNNNNNNNN) with SSA invalid-range guards[REDACTED:SSN]
CreditCardScrubberCredit-card-shaped digit runs validated by Luhn check digit AND IIN range gate[REDACTED:CARD]
UsDriverLicenseScrubberUS driver's license numbers — per-state format table, anchored or per-state mode[REDACTED:DL]
KeyedRegexScrubberGeneric pattern + key adapter — the building block hosts extendcaller-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.

ScrubberSubpathDetects via
createPHIScrubber@pleach/compliance/redaction/scrubbers/phitiered PHI patterns; strictness of "standard" | stricter tiers
createPresidioScrubber@pleach/compliance/redaction/scrubbers/presidioa Presidio endpoint; DEFAULT_PRESIDIO_ENTITY_TYPES unless overridden
createLLMScrubber@pleach/compliance/redaction/scrubbers/llma 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:

  • PipelineResult carries failures (every scrubber that did not complete, in firing order) and complete (failures.length === 0). failures: [] is a measurement; an absent failures means the pipeline implementation doesn't report — read complete === true as the only affirmative pass.
  • gate.ingressDetailed(...) / gate.egressDetailed(...) return a RedactionOutcome — 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-returning ingress/egress throw RedactionIncompleteError (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:

GatePurpose
audit:tenant-scopingEvery tenant_id-bearing table in supabase/migrations/*.sql has an RLS policy referencing current_tenant().
audit:harness-event-log-tenant-id-requiredEvery 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-pack remains a reserved namespace with no shipping code, at 0.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, and aggregateMultiTenant bodies all land at the 0.1.0 cut) 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 the c9PhaseBEnabled flag; verification ships in the same @pleach/core/eventLog barrel (verifyChainForChat, generateProof). The TamperEvidence plug-point in @pleach/core/audit keeps 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 KeyedRegexScrubber for those. Redaction beyond regex now ships separately — @pleach/compliance/redaction carries 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.

  • @pleach/core — the substrate this package wraps. Scrubbers run at EventLogWriter's persistence boundary; the monorepo's own audit:harness-event-log-tenant-id-required gate scans write sites under @pleach/core/eventLog.
  • @pleach/gateway — emits domain.gateway.cost.recorded events with the full RoutingDecision shape. Compliance's attestation runtime reads those events without round-tripping the audit ledger.
  • @pleach/replay — verifyChainIntegrity walks 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