Safety policies
SafetyContribution is structurally distinct from PromptContribution — registered policies stay inert until an operator opts in.
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.
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";@pleach/core/safetySourcesrc/safety/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.
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
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)
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)
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.
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 registercore.*. The core namespace is reserved; safety policies always originate from a plugin or compliance package.
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
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
Safety contributions compose last — after all prompt contributions. The composed system prompt ends with the safety section. The header literal is fixed:
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.
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:
- Safety last. Output is appended after every prompt contribution. The order is set by the resolver call site, not by the composer.
- Scope filtering. Policies with a
scopeparticipate only whencallClass,family, andruntimeRolematch every named field. - Empty-output drop. A policy whose context-aware
contentfunction returns""is dropped from the output without an empty separator — and is dropped fromactivePolicies. 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
| 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
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.
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
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.
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
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.
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)
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
Plugin contract
`contributeSafetyPolicies` is the registration hook.
Prompts
The distinct contract for capability-adding contributions.
AuditableCall row
The active safety set is recorded in the per-call audit row.
Scrubbers
Event-log redaction that runs before rows persist.
Attestation
The cryptographic surface that signs the audit row safety policies appear on.
Security
Auth, RLS, secret handling, tenant isolation, and the surfaces that need attention before going to production.
Scrubbers — redaction at write time
The Scrubber contract that gates event log writes through allowlisted redactors — SSN-US, Luhn, US-DL, KeyedRegex out of the box, plus the contributeScrubbers plugin hook.