# Redact PII before it reaches the model

Take an input-phase PII guard from monitor to enforce, with the false-positive check in between.

Source: https://tokentriage.com/docs/recipes/redact-pii-before-it-reaches-the-model

---
import Callout from '../../../components/Callout.astro';
import CodeTabs from '../../../components/CodeTabs.astro';
import NextSteps from '../../../components/NextSteps.astro';

**Goal:** replace detected structured PII in supported prompt fields before provider dispatch,
then measure false positives before enforcement. Successful inspection and transformation are
required; this example inherits `on_error: open`, which may forward original bytes when protection
fails. Use acknowledged closed enforcement when that continuation is unacceptable.

## 1. Pick the detectors deliberately

The nine embedded `pii.*` detectors are individually bindable, and arming all nine is not the safe
default it looks like.

```yaml
guardrails:
  enabled: true
  default_profile: standard
  local_budget_ms: 50
  guards:
    - id: pii_in
      category: pii
      phases: [input]
      detectors: [pii.card, pii.email, pii.iban, pii.ssn, pii.phone]
      mode: monitor
      action: redact
      rule_ref: POL-PII-1
  profiles:
    standard: [pii_in]
```

<Callout type="warning">
**Two to think twice about.** `pii.ip` cannot tell an IPv4 address from a four-part software version
number, because they are the same string — measured at **4 false positives on 101 hard negatives
(0.0396)**, all of them version quads. Its raw score is deliberately set to `0.60`, below every
other shape, so you can threshold above it. And `pii.mac` is scored by the product's own corpus as a
**hard negative**: binding it takes the family's hard-negative FP rate from 0.0396 to **0.0792
(8/101)**. Whether a MAC address is personal data is a jurisdictional question this build has not
settled, so it is your call and not a default.
</Callout>

Read [what each one misses](/docs/reference/guardrail-detectors-pii) before arming it. Three that
catch people out: `pii.phone` needs a leading `+` and misses every national format; `pii.ssn` is
US-only and misses a bare nine-digit run in prose; `pii.email` deliberately does **not** refuse
`example.com`.

## 2. Dry-run it before it sees traffic

```console
$ tokentriage guardrails test --config tokentriage.yaml \
    --content 'Please charge card 4111 1111 1111 1111 for docs-demo@example.com'
```

Read `operation_kind`, case inspection state and execution records. Literal text defaults to
`detector_diagnostics`; it is not a serving-path proof.

Point it at authorized stored traffic with `--req-id` or `--dataset` to run over stored request bodies. Use
`--corpus` if you have a labeled set — that is the only subject that carries ground truth.

<Callout type="note">
Use obviously synthetic values in anything you paste into a terminal. `4111 1111 1111 1111` is the
standard test PAN, issued to nobody, and `example.com` is reserved for documentation. A doc that
trains people to paste real PII into a shell is its own failure.
</Callout>

## 3. Run it in `monitor` and read the rows

`monitor` runs the detectors on real traffic and **changes no byte**. Every fire lands on the row
with `effect: annotated`.

<CodeTabs>
  <Fragment slot="python">
  ```python
  import json, time, urllib.request

  # The guard runs on every request; you read what it did from the decision row.
  req = urllib.request.Request(
      "http://127.0.0.1:8787/v1/messages",
      data=json.dumps({
          "model": "claude-haiku-4-5",
          "max_tokens": 64,
          "messages": [{"role": "user",
                        "content": "Charge card 4111 1111 1111 1111 for docs-demo@example.com"}],
      }).encode(),
      headers={"content-type": "application/json"},
  )
  with urllib.request.urlopen(req) as response:
      response.read()  # Complete the response before looking for its row.
      req_id = response.headers["x-tt-request-id"]
  if not req_id:
      raise RuntimeError("gateway request ID missing")

  # Primary rows omit kind; companions may be appended afterward. Match this request.
  deadline = time.monotonic() + 5
  row = None
  while time.monotonic() < deadline:
      try:
          with open("decisions.jsonl", encoding="utf-8") as fh:
              for line in fh:
                  if not line.endswith("\n"):
                      continue  # An append may still be in progress.
                  candidate = json.loads(line)
                  if candidate.get("kind") is None and candidate.get("req_id") == req_id:
                      row = candidate
      except FileNotFoundError:
          pass
      if row is not None:
          break
      time.sleep(0.05)
  if row is None:
      raise RuntimeError("primary decision row not available within five seconds")

  print(json.dumps(row["disclosures"]["guardrails"]["fired"], indent=2))
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```typescript
  import { readFileSync } from "node:fs";

  const response = await fetch("http://127.0.0.1:8787/v1/messages", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model: "claude-haiku-4-5",
      max_tokens: 64,
      messages: [{ role: "user",
                   content: "Charge card 4111 1111 1111 1111 for docs-demo@example.com" }],
    }),
  });

  await response.arrayBuffer(); // Complete delivery before looking for its row.
  if (!response.ok) throw new Error(`gateway returned HTTP ${response.status}`);
  const reqId = response.headers.get("x-tt-request-id");
  if (!reqId) throw new Error("gateway request ID missing");

  // Primary rows omit kind; match this request and leave all companion rows intact.
  const deadline = Date.now() + 5000;
  let row: any;
  while (Date.now() < deadline) {
    let ledger = "";
    try { ledger = readFileSync("decisions.jsonl", "utf8"); }
    catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
    const completeLines = ledger.split("\n").slice(0, -1);
    row = completeLines.filter(Boolean).map((line) => JSON.parse(line))
      .find((candidate) => candidate.kind == null && candidate.req_id === reqId);
    if (row) break;
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
  if (!row) throw new Error("primary decision row not available within five seconds");

  console.log(row.disclosures.guardrails.fired);
  ```
  </Fragment>
</CodeTabs>

```json
{
  "binding": "pii_in",
  "detector": "pii.card",
  "detector_version": "pii.card-v1",
  "action": "redact",
  "effect": "annotated",
  "entities": [{ "type": "CREDIT_CARD", "count": 1 }],
  "reversible": false,
  "confidence": { "basis": "exact", "point": 1 },
  "rule_ref": "POL-PII-1"
}
```

## 4. The false-positive check — do not skip this

<Callout type="key">
**A fire count is not a precision figure.** Your binding can produce half a million fires and still
have produced *no* evidence about whether any of them were correct. The only figure with a labeled
denominator is `eval:guardrail_finding_correct` — the metric a human fills in by marking findings
true or false positives.
</Callout>

```console
$ curl -s http://127.0.0.1:9090/tt/api/v1/guardrails/evidence |
    jq '{adjudicated: .quality_sources, silent: [.configured[] | select(.fired==false) | .id]}'
```

Three things to satisfy yourself about before climbing:

1. **Adjudicated outcomes above zero**, with a denominator you would defend.
2. **`suspended[]` is empty on this binding.** A guard that was silently not running is not a guard
   you have evidence about.
3. **The binding is not in the "fired on nothing" roster.** If it is, you have proved nothing yet.

## 5. Enforce

```yaml
      mode: enforce
```

When supported inspection and transformation complete, `redact` splices the detected span and
the model sees `[REDACTED:CREDIT_CARD]` instead of the PAN. Verify the execution, transformation
and delivery receipts as well as `effect: mutated`. The default open failure posture can still
forward original content when protection cannot complete.

<Callout type="warning">
**`redact` is irreversible — there is no way back to the original value.** If a downstream step
needs the real value later, use `tokenize` or `pseudonymize` instead: both are marked reversible in
the action table and are backed by a vault. That bit is derived from the table and never authored,
so it is trustworthy on the row.
</Callout>

**Rollback is one line back to `monitor`.** Nothing else changes: the same detectors keep running
and the same rows keep being written.

<NextSteps items={[
  { title: "Roll out a guard without breaking traffic", href: "/docs/recipes/roll-out-a-guard-without-breaking-traffic", description: "The same ladder as a runbook, with the watch-list and rollback at each rung." },
  { title: "PII detectors in detail", href: "/docs/reference/guardrail-detectors-pii", description: "All nine, and what each one will miss." },
  { title: "Stop secrets leaking in model output", href: "/docs/recipes/stop-secrets-leaking-in-model-output", description: "The other direction — an output-phase guard, and the streaming caveat." },
  { title: "PII detector reference", href: "/docs/reference/guardrail-detectors-pii", description: "Review the PII detectors and their supported inspection behavior." }
]} />
