# Safety policies (/docs/safety)



A safety policy is **advisory by default**: it annotates a call —
contributing refusal / guardrail prose into the system prompt and
recording the operator's stated intent on the audit row — never
generates new content. The hard, seam-level effects the contract
*describes* (refusing or rewriting a call before it fires) are a
planned, forthcoming capability; v1.1 routes every enforcement
level through `log_only`, so the policy prose surfaces in the
prompt but the seam does not yet block or rewrite the call. The
contract is capability-subtracting:
`defineSafetyPolicy({ name, appliesTo: "pre-dispatch" | "post-tool",
async check(state, ctx) → { allow, reason, forceText } })`, or with
`rewrite(prompt) → { allow, rewrittenPrompt, auditNote }`. Both
forms are async; both land their result on the audit row.

A `SafetyContribution` is the registration shape behind those
policies. It looks similar to a `PromptContribution` on the surface
but the structural model is different in five places, which is why
`@pleach/core` ships them as separate contracts.

Plugin authors **register** policies (declare what they ship).
Operators **enable** policies at runtime construction (declare
what's active). Different defaults, different composition,
different audit surface.

```typescript
import {
  SafetyPolicyRegistry,
  composeSafetyContent,
  SAFETY_SECTION_HEADER,
  SAFETY_ENFORCEMENT_EFFECTS,
  KNOWN_REGULATORY_FRAMEWORKS,
  safetyPolicyId,
} from "@pleach/core/safety";
import type {
  SafetyContribution,
  SafetyPolicyId,
  SafetyPolicySummary,
  SafetyEnforcement,
  SafetyPolicyScope,
  SafetyGuard,
  RegulatoryFramework,
  KnownRegulatoryFramework,
} from "@pleach/core/safety";
```

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

> **Safety lives outside the cluster pattern.** Five distinct
> concepts (`safety`, `scrubbers`, `fabrication-detection`,
> `determinism`, `fingerprint`), each with its own mechanism — no
> natural three-concept triplet. See [What lives outside the
> cluster pattern](/docs/concept-clusters#what-lives-outside-the-cluster-pattern).

## Why a separate contract [#why-a-separate-contract]

Five structural differences earn the split:

| Difference         | `PromptContribution`            | `SafetyContribution`                                                                       |
| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------ |
| Default state      | Active when plugin is loaded    | Inert until enabled by id at runtime construction                                          |
| Composition role   | Adds prose that shapes behavior | Adds gates + refusal prose, composed LAST                                                  |
| Auditability       | Internal to the registry        | Public — `runtime.safety.listActivePolicies()` returns a versioned `SafetyPolicySummary[]` |
| Override semantics | `mode: "replace"` overrides     | `replace` is structurally absent — the field doesn't exist on `SafetyContribution`         |
| Versioning         | Implicit                        | Explicit semver per policy; bumps participate in the fingerprint key                       |

The split is what lets a non-regulated deployment of a host
silently skip sector-specific disclaimer policies that a regulated
deployment of the same host enables — same plugin set, different
active policies.

## The `SafetyContribution` shape [#the-safetycontribution-shape]

```typescript
interface SafetyContribution {
  readonly id: SafetyPolicyId;          // "<plugin>.<policy-name>"
  readonly version: string;              // semver
  readonly framework?: RegulatoryFramework;
  readonly enforcement: SafetyEnforcement;
  readonly scope?: SafetyPolicyScope;    // optional callClass / family / runtimeRole filter
  readonly content: string | ((ctx: PromptContext) => string);
  readonly guards?: readonly SafetyGuard[];
}
```

| Field         | Purpose                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | Branded `SafetyPolicyId`. `core.*` is reserved (throws `ReservedSafetyNamespaceError`); unnamespaced ids throw `UnnamespacedSafetyIdError`                                                                                                                                                                                                                                                          |
| `version`     | Semver string. Bumps are cache-invalidating via `safetyPoliciesHash`                                                                                                                                                                                                                                                                                                                                |
| `framework`   | Open `string` field. `KNOWN_REGULATORY_FRAMEWORKS` enumerates conventional values (`HIPAA`, `GDPR`, `SOC2`, `ITAR`, `EAR`, `CWC`, `BWC`, `FDA-21CFR11`, `clinical-research-disclaimer`) for autocomplete; plugin authors are free to pass any id (e.g. `"MDR-EU"`, `"NIST-800-53"`, `"my-company.policy-v3"`). The pre-1.1 `{ custom: string }` form is deprecated — pass a plain string id instead |
| `enforcement` | `advisory` / `guardrail` / `refusal` — v1.1 is prose-only across all three; the field is audit-surface even when runtime behavior is identical                                                                                                                                                                                                                                                      |
| `scope`       | When the policy participates — `callClass`, `family`, `runtimeRole`                                                                                                                                                                                                                                                                                                                                 |
| `content`     | The refusal / gate / disclaimer prose; string or `(PromptContext) => string`                                                                                                                                                                                                                                                                                                                        |
| `guards`      | Future-facing `SafetyGuard` slot — contract surface is live; no `kind` is implemented yet. `tool-call-precondition` is the first concrete kind on the roadmap                                                                                                                                                                                                                                       |

## Registering policies (plugin author) [#registering-policies-plugin-author]

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

const compliancePlugin: HarnessPlugin = {
  name: "compliance",

  contributeSafetyPolicies: () => [
    {
      id:          safetyPolicyId("compliance.pii-redaction"),
      version:     "1.2.0",
      framework:   "GDPR",
      enforcement: "refusal",
      scope:       { callClass: "synthesize" },
      content:     "Never include raw PII in the final response. Redact email addresses, phone numbers, and identifiers.",
    },
    {
      id:          safetyPolicyId("compliance.export-control"),
      version:     "1.0.0",
      framework:   "EAR",
      enforcement: "guardrail",
      content:     "Refuse export-controlled technical data unless the requester has presented a current authorization.",
    },
  ],
};
```

Registered ≠ enabled. Plugin authors ship policies; operators
opt in.

## Enabling policies (operator) [#enabling-policies-operator]

The registry separates **register** (a policy is known) from
**enable** (the operator opted in). Enablement is config-driven —
the operator passes the id list at runtime construction via
`SessionRuntimeConfig.enabledSafetyPolicies`. The constructor
calls `register()` for every plugin contribution and `enable()`
for every id in the config list before the runtime serves traffic.

```typescript
import { safetyPolicyId } from "@pleach/core/safety";
import { SessionRuntime } from "@pleach/core";

const runtime = new SessionRuntime({
  plugins: [compliancePlugin],
  enabledSafetyPolicies: [
    safetyPolicyId("compliance.pii-redaction"),
    safetyPolicyId("compliance.export-control"),
  ],
});
```

Construction throws `UnknownSafetyPolicyError` if the config
references an id no plugin registered — operator typos surface
at boot, not on the first turn. A deployment that doesn't need
a particular policy simply omits its id from the list. The
policy stays registered (visible to
`runtime.safety.listAvailablePolicies()`) but inert (zero effect
on prompt composition).

For interactive surfaces that need to toggle policies after
construction, call `runtime.safety.getRegistry().enable(id)` /
`.disable(id)`. `enable()` throws `UnknownSafetyPolicyError` for
unknown ids; `disable()` is idempotent.

Two further error shapes guard the registration surface:

* `DuplicateSafetyPolicyError` — two policies registered with the
  same id. Use a versioned suffix if both must coexist.
* `ReservedSafetyNamespaceError` — a plugin tried to register
  `core.*`. The core namespace is reserved; safety policies always
  originate from a plugin or compliance package.

## The fingerprint contract [#the-fingerprint-contract]

The active policy list participates in the per-call fingerprint
cache key as `safetyPoliciesHash` — sha256 of the canonicalized
`"<id>@<version>"` list, sorted by id. Flipping a policy on or
off invalidates the cache; bumping a policy's version invalidates
the cache. Two runs with different active sets resolve to different
cache buckets, so replay can't accidentally re-use a result that
was captured under different safety constraints.

The policy list in the audit ledger lets a regulator query "what
was active when this call fired" without re-deriving from the
plugin set.

## Listing active policies [#listing-active-policies]

```typescript
const active: readonly SafetyPolicySummary[] = runtime.safety.listActivePolicies();

for (const policy of active) {
  console.log(policy.id, policy.version, policy.framework, policy.enforcement);
}
```

`SafetyPolicySummary` is intentionally a projection — `id`,
`version`, `framework`, `enforcement`, `enabled`. The `content`
prose is OMITTED. Audit consumers receive the metadata, not the
prose; surfacing prose through the discovery surface would couple
audit tools to v1.x content shape changes.

`runtime.safety.listAvailablePolicies()` returns the full shelf
(enabled flag on each entry); `runtime.safety.listActivePolicies()`
is the enabled subset and is the surface replay and SOC2 exports
proxy. `runtime.safety.getRegistry()` returns the underlying
`SafetyPolicyRegistry` when consumers need `collectActiveContributions()`
for the raw prose.

## Composition order [#composition-order]

Safety contributions compose **last** — after all prompt
contributions. The composed system prompt ends with the safety
section. The header literal is fixed:

```typescript
import { SAFETY_SECTION_HEADER } from "@pleach/core/safety";

SAFETY_SECTION_HEADER; // "## Safety Policies"
```

Changing the literal value is a fingerprint-cache-invalidating
event. Operators and replay tools grep for this delimiter to
extract the safety portion of a composed prompt.

`composeSafetyContent` is wired inside the prompt resolver
(`resolvePromptForSeam`) — every seam invocation passes its
registry through `host.safetyRegistry?.collectActiveContributions()`
and appends the composer's output LAST. Consumers don't call
the composer directly in normal flows; the example below is the
shape the resolver uses when an audit or replay tool needs to
re-derive the safety section out-of-band.

```typescript
import { composeSafetyContent } from "@pleach/core/safety";

const { text, activePolicies } = composeSafetyContent(
  runtime.safety.getRegistry().collectActiveContributions(),
  { callClass: "synthesize", family: "anthropic", runtimeRole: "primary" },
);
```

The composer enforces three invariants:

1. **Safety last.** Output is appended after every prompt contribution.
   The order is set by the resolver call site, not by the composer.
2. **Scope filtering.** Policies with a `scope` participate only when
   `callClass`, `family`, and `runtimeRole` match every named field.
3. **Empty-output drop.** A policy whose context-aware `content`
   function returns `""` is dropped from the output without an empty
   separator — and is dropped from `activePolicies`. Enablement is
   independent of per-context content.

Same active set + same context produces byte-identical output.
That's what keeps the safety section fingerprint-safe.

## `SafetyEnforcement` semantics [#safetyenforcement-semantics]

| Level       | v1.1 behavior                                                            | Intent                                                                                                        |
| ----------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `advisory`  | Prose-only — surfaces guidance, no gate                                  | Non-binding norms; LLM is free to comply or not                                                               |
| `guardrail` | Refusal-shaped prose plus the `guards` slot for future structural checks | Operational guardrails (rate-limit refusals, tool-call preconditions)                                         |
| `refusal`   | Explicit refusal prose                                                   | Hard policy — "no PII in the synthesis"; hard runtime blocks land once `kind: "tool-call-precondition"` ships |

All three levels are prose-only today — LLM compliance is
best-effort and `SAFETY_ENFORCEMENT_EFFECTS` routes every level
through `log_only` until the `guards` slot ships a concrete
`kind`. The level still appears on every audit row because
downstream replay and compliance tools need the operator's stated
intent, not just the runtime effect.

### Branch on the EFFECT, not the name [#branch-on-the-effect-not-the-name]

`enforcement` describes operator intent — what the audit trail
records. `SAFETY_ENFORCEMENT_EFFECTS` is the constant that maps each
level to the runtime effect the seam commits to. v1.1 routes all
three through `log_only`; v1.2+ diverges as structural guards land.

```typescript
import { SAFETY_ENFORCEMENT_EFFECTS } from "@pleach/core/safety";

const effect = SAFETY_ENFORCEMENT_EFFECTS[policy.enforcement];
if (effect === "block_synthesis") {
  /* ... */
}
```

Plugin code that wants to react to runtime behavior should switch on
the constant, not the severity name — that way a future effect-map
change (when `tool-call-precondition` lands and `guardrail` /
`refusal` diverge from `log_only`) is a no-op for consumers. Audit
consumers continue to filter on the severity name for operator-intent
queries.

### `KNOWN_REGULATORY_FRAMEWORKS` [#known_regulatory_frameworks]

`framework` accepts any `string`. The exported constant lists nine
conventional ids (`HIPAA`, `GDPR`, `SOC2`, `ITAR`, `EAR`, `CWC`,
`BWC`, `FDA-21CFR11`, `clinical-research-disclaimer`) — render them
in a picker, or use the `KnownRegulatoryFramework` literal union as
an autocomplete hint. Plugin authors are free to pass any id
(`"MDR-EU"`, `"NIST-800-53"`, `"my-company.policy-v3"`); the
substrate doesn't hard-code one host's regulatory vocabulary.

```typescript
import {
  KNOWN_REGULATORY_FRAMEWORKS,
  type RegulatoryFramework,
} from "@pleach/core/safety";

for (const id of KNOWN_REGULATORY_FRAMEWORKS) {
  // id: KnownRegulatoryFramework
}

const myFramework: RegulatoryFramework = "my-company.policy-v3";
```

## Structural guards [#structural-guards]

`SafetyContribution.guards` is the contract slot for non-prose
enforcement. The slot ships today; no concrete `kind` is
implemented yet — the `SafetyGuard` shape is reserved so
plugin authors can declare guards now and have them activate
when the runtime ships matching handlers.

```typescript
interface SafetyGuard {
  readonly kind: "regex-refusal" | "tool-call-precondition";
  readonly definition: unknown; // shape varies by kind
}
```

`tool-call-precondition` is the first concrete kind on the
roadmap. It lands once the prose-only shape has field experience
proving the gap. Until then the slot is inert at the runtime
layer — `guards` arrays parse and persist to the registry but
the seam never reads them.

## Scrubber contract (redaction at write time) [#scrubber-contract-redaction-at-write-time]

Safety policies on this page gate prompt-input behavior. Fabrication
detection (`/docs/fabrication-detection`) inspects model output for
unsupported claims. Scrubbers are a third surface: they run
*before* event log rows persist, redacting payload fields at write
time so the ledger never holds the unredacted shape.

The substrate ships a `Scrubber` contract. `@pleach/compliance`
bundles four concrete implementations — SSN-US, Luhn (card-number
heuristic), US-DL, and a generic KeyedRegex — covering the common
PII shapes most deployments need on day one.

Host plugins register additional scrubbers through
`contributeScrubbers`, the same registration pattern used elsewhere
in the plugin contract. Registration is auditable; bypassing a
registered scrubber requires an explicit opt-out that lands on the
audit row.

For the contract shape, a custom-scrubber example, and the audit
gate that proves redaction happened, see `/docs/scrubbers`.

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

<Cards>
  <Card title="Plugin contract" href="/docs/plugin-contract" description="`contributeSafetyPolicies` is the registration hook." />

  <Card title="Prompts" href="/docs/prompts" description="The distinct contract for capability-adding contributions." />

  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The active safety set is recorded in the per-call audit row." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="Event-log redaction that runs before rows persist." />

  <Card title="Attestation" href="/docs/attestation" description="The cryptographic surface that signs the audit row safety policies appear on." />
</Cards>
