# Scrubbers — redaction at write time (/docs/scrubbers)



Scrubbers are one concept in the **safety & determinism**
[thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) —
siblings of [safety](/docs/safety),
[fabrication detection](/docs/fabrication-detection),
[determinism](/docs/determinism), and [fingerprint](/docs/fingerprint).

A `Scrubber` gates writes into `harness_event_log`. When a row would
persist, every registered scrubber runs against the payload first;
matched spans get rewritten before the row reaches storage. The
redacted form is what hits disk — the original payload never lands.

Not prompt-input scrubbing — that's [`/docs/safety`](/docs/safety).
Not fabrication detection — that's
[`/docs/fabrication-detection`](/docs/fabrication-detection). Scrubbers
sit downstream of synthesis, on the persistence boundary; safety and
fabrication detectors sit upstream, on the model boundary.

<SourceMeta subpath="@pleach/core/scrubbers" source="{ label: &#x22;src/scrubbers/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/scrubbers&#x22; }" />

## The `Scrubber` contract [#the-scrubber-contract]

A scrubber is small: an `id` and a `scrub(input, ctx)` method. It takes
a single string plus structural context and returns the redacted string
plus attribution metadata. Scrubbers do **not** declare which event
types they apply to — scoping is global (see below).

```typescript
import type { Scrubber } from "@pleach/core/scrubbers";
```

Scoping is global, not per-scrubber. A single allowlist —
`SCRUBBABLE_FIELDS`, keyed by event type — names the dotted field paths
each event type exposes to redaction. The chain applies *every*
registered scrubber to *every* allowlisted field; an individual
scrubber never names an event type. The runtime still requires that
every event type the substrate writes has an entry in that allowlist
(see the coverage rule below).

`scrub` returns a `ScrubResult`: `{ redacted, matchCount,
matchedScrubberIds }`. `redacted` is the post-scrub string (equal to
the input when nothing matched); `matchCount` is the number of redacted
matches; `matchedScrubberIds` is the required list of scrubber ids that
fired (`[]` when none did). These feed the `event.redacted` audit
meta-event — downstream consumers (a security dashboard, a SOC2
evidence export) count redactions per type without re-running the regex
against persisted rows. No original matched text, offsets, or spans are
returned.

## The four bundled scrubbers [#the-four-bundled-scrubbers]

These four ship in `@pleach/compliance` (see
[`/docs/compliance`](/docs/compliance) for the package surface). Each
independently implements the `Scrubber` contract.

| Scrubber                  | Pattern                                                | Notes                                                                                                         |
| ------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `SsnUsScrubber`           | US Social Security numbers                             | Redacts the matched span; the redaction key identifies the pattern family for audit                           |
| `CreditCardScrubber`      | Credit card numbers validated via the Luhn check digit | Validation cuts the false-positive rate of naive 16-digit regex matches against arbitrary numeric content     |
| `UsDriverLicenseScrubber` | US driver's license numbers                            | Per-state heuristics — the format varies; the scrubber composes a state-keyed pattern table                   |
| `KeyedRegexScrubber`      | Generic pattern + key adapter                          | The shape hosts use for custom regulated identifiers (medical record numbers, tax IDs, internal employee ids) |

The first three are independent implementations, not configurations of
a shared base — `SsnUsScrubber` carries SSA invalid-range guards,
`CreditCardScrubber` gates on a Luhn checksum plus an IIN range
allowlist, and `UsDriverLicenseScrubber` anchors on per-state formats.
`KeyedRegexScrubber` is the separate consumer-extension shape: hosts
that need a new regulated identifier reach for it directly rather than
waiting for an upstream addition.

## The `contributeScrubbers` plugin hook [#the-contributescrubbers-plugin-hook]

Hosts register additional scrubbers through the `HarnessPlugin`
contract. The hook returns an array; the runtime composes
contributions across every loaded plugin into the registry the
event log writer consults.

```typescript
// lib/plugins/tenantCompliancePlugin.ts
import type { HarnessPlugin } from "@pleach/core";
import type { Scrubber } from "@pleach/core/scrubbers";

const tenantIdentifierScrubber: Scrubber = {
  /* shape defined below in the custom example */
};

export const tenantCompliancePlugin: HarnessPlugin = {
  name: "tenant-compliance",
  contributeScrubbers: () => [tenantIdentifierScrubber],
};
```

Registration order matches the plugin registration order at runtime
construction. Scrubbers compose by chaining — the redacted output of
one scrubber is the input to the next. Two scrubbers that match
overlapping spans should be sequenced explicitly at the registration
site; the runtime won't reorder them.

## Event-type allowlist coverage [#event-type-allowlist-coverage]

Every event type the substrate writes must have an entry in
`SCRUBBABLE_FIELDS`. An empty entry counts — the requirement is
*coverage*, not *redaction*. The point is to force an allowlisting
decision for every event type, so a new type can't sneak past the
redaction surface.

The CI gate `audit:c8-event-type-allowlist-coverage` enforces this on
the core repo: it scans the `SCRUBBABLE_FIELDS` keys against the event
union, and a new event type that lands without an entry fails the
audit. The same rule applies to consumer code: plugins that declare new
event types through `contributeEventTypes` should ship a corresponding
allowlist entry.

If you genuinely want a type to write unredacted, give it an empty
field list (`[]`) — the exported `SCRUB_NONE_AUDITED` sentinel is the
grep-able way to spell it. The entry is the load-bearing artifact: `[]`
means "audited, no scrubbable fields," distinguished from a missing
entry (which fires `[C8:unscrubbed-event-type]`).

## `EventLogWriter` integration [#eventlogwriter-integration]

The writer applies scrubbers automatically when persisting. Consumer
code that calls into the event log doesn't reach for the scrubber
registry directly — the redaction step is structural, not opt-in.

```typescript
import { EventLogWriter } from "@pleach/core/eventLog";

// Consumer code is unchanged by scrubber registration.
// The writer consults the registry at persist time.
await writer.append({
  type: "tool.completed",
  sessionId,
  turnId,
  payload: { /* ... */ },
});
```

The single write surface is what keeps the gate honest. There's no
"raw" write path that skips scrubbers — bypassing redaction would
mean bypassing the writer, which would also bypass the hash chain,
the tenant scoping, and the append-only invariant. The scrubber pass
is part of the same atomic step that lays down the row.

## Production-path adoption [#production-path-adoption]

The scrubber hook runs at the `EventLogWriter` boundary, but a hook
that nothing calls isn't a gate — it's a contract. Production
adoption is what makes it load-bearing.

Earlier in the substrate, the canonical browser-event POST surface
ran an inline row-builder and a direct insert into storage. The
writer was in the codebase, but the route bypassed it. Any new
event-type the writer's scrubber chain would have gated could land
in the table unredacted simply because the route never asked the
chain to run.

That bypass is now closed. The production route calls the writer's
`write` and `flush` directly; the inline insert is retired. The
scrubber chain runs against every row that lands through the
canonical surface — there's no "stale code path" alternative for a
host to accidentally pick.

The practical consequence: registering a scrubber against an event
type the route writes is now sufficient. Previously, adoption took
a second step (route refactor); now it doesn't. Hosts that wired
scrubbers expecting production gating finally get it.

## What scrubbers do NOT do [#what-scrubbers-do-not-do]

* **They don't redact prompt inputs sent to the model.** That's a
  synthesis-time concern handled by safety policies — see
  [`/docs/safety`](/docs/safety). A scrubber runs after the model has
  already seen the data; redacting at write time protects the
  *persisted* surface, not the *model* surface.
* **They don't detect fabricated output.** Fabrication is a content
  truthfulness signal, not a pattern match — see
  [`/docs/fabrication-detection`](/docs/fabrication-detection).
* **They don't replace tamper-evidence.** The hash chain (see
  [`/docs/hash-chain`](/docs/hash-chain)) proves a row hasn't changed
  since write; scrubbers shape what the row contains *at* write.
  Different jobs, different contracts.
* **They don't dedupe.** A value redacted on one write site stays
  redacted on every other write site that matches the same pattern.
  Scrubbers are stateless across rows; the same input produces the
  same output every time.

## Custom scrubber example [#custom-scrubber-example]

A tenant with internal employee identifiers shaped `EMP-` followed by
six digits can register a `KeyedRegexScrubber` for them. The scrubber
takes an `id` plus one or more `entries` — each an
`(name, pattern, replacement)` tuple. The consumer supplies the
replacement string; `KeyedRegexScrubber` does not auto-format one. Note
there is no `eventTypes` field — scoping comes from `SCRUBBABLE_FIELDS`,
not the scrubber:

```typescript
// lib/plugins/employeeIdScrubber.ts
import { KeyedRegexScrubber } from "@pleach/compliance/scrubbers";
import type { HarnessPlugin } from "@pleach/core";

const employeeIdScrubber = new KeyedRegexScrubber({
  id: "internal-pii",
  entries: [
    {
      name:        "employee-id",
      pattern:     /\bEMP-\d{6}\b/g,
      replacement: "[REDACTED:EMPLOYEE_ID]",
    },
  ],
});

export const tenantCompliancePlugin: HarnessPlugin = {
  name: "tenant-compliance",
  contributeScrubbers: () => [employeeIdScrubber],
};
```

Register the plugin once at runtime construction. Every subsequent
write through `EventLogWriter` runs the scrubber against the fields
`SCRUBBABLE_FIELDS` allowlists, and rows persist with the supplied
`replacement` — here `[REDACTED:EMPLOYEE_ID]` — in place of the matched
text.

The entry `name` is what shows up in the `matchedScrubberIds`
attribution (as `<id>:<name>`) and in any downstream audit consumer.
Pick a name that reads in a compliance review — `employee-id`, not
`EMP_REGEX_V2`.

## What CI checks when you add a scrubber [#what-ci-checks-when-you-add-a-scrubber]

A scrubber registered through `contributeScrubbers` adds redaction
coverage; the gate that fails is the one on the *event type* it gates:

* `audit:c8-event-type-allowlist-coverage` — every event type the
  substrate writes has an entry in `SCRUBBABLE_FIELDS`, empty entries
  included. A plugin that declares a new type through
  `contributeEventTypes` ships its allowlist entry in the same plugin.

Run `npm run audit:c8-event-type-allowlist-coverage`. The scrubber —
the "clearer" — is one row in the [extension map](/docs/extending),
which pairs every extension point with the gate that guards it.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Safety policies" href="/docs/safety" description="Prompt-input scrubbing and refusal contracts — the synthesis-time counterpart to write-time scrubbers." />

  <Card title="Compliance" href="/docs/compliance" description="Where the four bundled scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) ship from." />

  <Card title="Plugin contract" href="/docs/plugin-contract" description="The HarnessPlugin surface that exposes the contributeScrubbers hook." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The audit row every redacted event is joinable against by turnId." />
</Cards>
