# Student safety (/docs/use-cases/education/student-safety)



`createStudentSafetyScrubber` is the conservative default for
redacting student-identifying fragments before they cross the
LLM boundary. It composes
[`KeyedRegexScrubber`](/docs/scrubbers) — same shape as the
shipped SSN / credit-card / driver's-license scrubbers — so the
result drops into any `ComplianceRuntime` scrubber chain without
new types.

The scrubber is wired automatically when you use
[`educationAgent`](/docs/use-cases/education). This page is for
when you need to extend it, tune it, or wire it into a different
runtime (e.g. a teacher-facing transcription pipeline that runs
outside `@pleach/recipes`).

## What it redacts today [#what-it-redacts-today]

Four pattern categories ship as defaults. The match is tuned to
favor false positives over false negatives — a missed
student-ID leakage is a worse failure than over-redaction in a
classroom transcript.

| Pattern name             | Regex (illustrative)                                                                       | Example match                         |
| ------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------- |
| `student-id-number`      | `\b(?:student\s*id\|id#\|id\s*number)\s*:?\s*\d{4,10}\b`                                   | `Student ID 9876543`, `ID# 1234567`   |
| `grade-band`             | `\b(?:K\|[1-9]\|1[0-2])(?:st\|nd\|rd\|th)?[\s-]+grad(?:e\|er)\b`                           | `5th grade`, `11th-grader`, `grade 9` |
| `school-issued-email`    | `\b[a-z0-9._%+-]+@[a-z0-9.-]*\.(?:k12\|edu\|sch)\.[a-z.]{2,}\b`                            | `jane.doe12@district.k12.ca.us`       |
| `labeled-parent-contact` | `\b(?:parent\|guardian)[\s\w]{0,20}(?:phone\|contact\|number)\s*:?\s*\+?[\d\s().\-]{7,}\b` | `Parent phone: (555) 123-4567`        |

Default replacement is `[STUDENT]`. Override per scrubber, or
per-entry on `extra` patterns.

A district that wants to NARROW the default match — for example,
to disable `grade-band` redaction in a research-only context where
grade is part of the analysis dimension — should compose its own
`KeyedRegexScrubber` directly rather than try to subtract from
the default set.

## Minimal usage [#minimal-usage]

```ts
import { createStudentSafetyScrubber } from "@pleach/compliance/education";

const scrubber = createStudentSafetyScrubber();

const result = scrubber.scrub(
  "Hi — Student ID 9876543 needs help with 5th grade fractions",
  {
    eventType: "user.message",
    fieldPath: ["content"],
    tenantId: "district-1234",
  },
);

// result.redacted   : "Hi — [STUDENT] needs help with [STUDENT] fractions"
// result.matchCount : 2
// result.matchedScrubberIds : ["student-safety:student-id-number",
//                              "student-safety:grade-band"]
```

The scrubber result shape mirrors every other
[`Scrubber`](/docs/scrubbers) in `@pleach/compliance` —
`{ redacted, matchCount, matchedScrubberIds }`. Audit ledgers
can read `matchedScrubberIds` to attribute which pattern fired,
without needing to re-run the scrubber.

## Extending with district-specific patterns [#extending-with-district-specific-patterns]

Districts and platforms accumulate their own identifier shapes:
internal student-information-system IDs, locker numbers, bus-route
codes, IEP-document filename prefixes. Pass them through `extra`:

```ts
import { createStudentSafetyScrubber } from "@pleach/compliance/education";

const scrubber = createStudentSafetyScrubber({
  id: "district-1234-student-safety",
  replacement: "[STUDENT]",
  extra: [
    // District-issued 8-digit student ID with leading `D`.
    { name: "district-id", pattern: /\bD\d{8}\b/g, replacement: "[DID]" },
    // Bus route on a transportation transcript.
    { name: "bus-route", pattern: /\bRoute\s+\d{1,3}[A-Z]?\b/gi },
    // IEP filename pattern. Replacement defaults to the scrubber's.
    { name: "iep-doc", pattern: /\bIEP-\d{6}\.pdf\b/g },
  ],
});
```

`extra` runs AFTER the defaults in insertion order. Use a
per-entry `replacement` to override the scrubber-level default
(useful when downstream code distinguishes redaction markers by
shape — `[STUDENT]` vs `[DID]`).

## Composing with `KeyedRegexScrubber` directly [#composing-with-keyedregexscrubber-directly]

`createStudentSafetyScrubber()` is a thin factory over
[`KeyedRegexScrubber`](/docs/scrubbers). When you need full
control over pattern ordering, attribution, or want to stack
multiple keyed-regex scrubbers in a deterministic order,
construct `KeyedRegexScrubber` directly:

```ts
import { KeyedRegexScrubber } from "@pleach/compliance/scrubbers";

const districtScrubber = new KeyedRegexScrubber({
  id: "district-1234-overrides",
  entries: [
    { name: "internal-ticket", pattern: /TKT-\d{6}/g, replacement: "[TKT]" },
    { name: "lunch-account", pattern: /LUNCH\d{8}/g, replacement: "[LUNCH]" },
  ],
});

// Chain in your ComplianceRuntime:
// scrubbers: [createStudentSafetyScrubber(), districtScrubber, ...]
```

Per the `KeyedRegexScrubber` contract, the consumer owns the
false-positive rate on its patterns. Idempotency is a consumer
responsibility — chained pathological patterns
(e.g. `{ pattern: /\d+/, replacement: "0" }`) are a config
error, not a scrubber bug.

## Wiring into a custom `ComplianceRuntime` [#wiring-into-a-custom-complianceruntime]

When you're not using `educationAgent` — for example, a
teacher-facing transcript-correction pipeline that runs
separately from the chatbot — compose the scrubber into a
`ComplianceRuntime` directly:

```ts
import {
  createComplianceRuntime,
  SsnUsScrubber,
} from "@pleach/compliance";
import { createStudentSafetyScrubber } from "@pleach/compliance/education";

const runtime = createComplianceRuntime({
  scrubbers: [
    createStudentSafetyScrubber(),
    new SsnUsScrubber(), // belt-and-suspenders for parent SSNs on emergency forms
  ],
});

// In your per-event boundary:
const { redacted, matchCount } = runtime.scrub(transcript, {
  eventType: "transcript.captured",
  fieldPath: ["content"],
  tenantId: districtId,
});
```

## Honest gap — content moderation [#honest-gap--content-moderation]

The shipped scrubber redacts **identifiers**. Per-category
content safety (e.g. "self-harm", "violence", "age-inappropriate
language") is a separate concern that is NOT covered today.

`educationAgent` accepts a `contentSafety` array but the values are surfaced on
the runtime tag for telemetry only. The shipped scrubber does
NOT have per-category branches. Two paths if you need category
moderation today:

1. **External moderation API.** Wire the Anthropic moderation
   endpoint, the OpenAI moderation endpoint, or a vendor like
   Hive / Perspective in front of `ask()`. Reject or escalate
   based on the verdict before the message reaches the model.
2. **Custom `Scrubber` implementation.** Implement the
   [`Scrubber`](/docs/scrubbers) contract directly with your own
   classifier inside `scrub()`. The contract is structurally
   minimal — `id` field plus a `scrub(input, ctx)` method —
   so a classifier-backed scrubber drops into the same chain.

The category-level enforcement gap is tracked on the roadmap.

## Source [#source]

* `packages/compliance/src/education/studentSafety.ts` — the
  `createStudentSafetyScrubber` + `createCostCapPolicy`
  implementations.
* `packages/compliance/src/scrubbers/keyedRegex.ts` — the
  underlying composable scrubber shape.

## See also [#see-also]

* [Education](/docs/use-cases/education) — the landing page.
* [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) —
  what this scrubber does NOT cover.
* [Scrubbers](/docs/scrubbers) — the redaction contract,
  per-event-type allowlist, and authoring guide.
* [`@pleach/compliance`](/docs/compliance) — the underlying
  scrubber library and `ComplianceRuntime`.
