pleach
Operate

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";
Subpath@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:

DifferencePromptContributionSafetyContribution
Default stateActive when plugin is loadedInert until enabled by id at runtime construction
Composition roleAdds prose that shapes behaviorAdds gates + refusal prose, composed LAST
AuditabilityInternal to the registryPublic — runtime.safety.listActivePolicies() returns a versioned SafetyPolicySummary[]
Override semanticsmode: "replace" overridesreplace is structurally absent — the field doesn't exist on SafetyContribution
VersioningImplicitExplicit 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[];
}
FieldPurpose
idBranded SafetyPolicyId. core.* is reserved (throws ReservedSafetyNamespaceError); unnamespaced ids throw UnnamespacedSafetyIdError
versionSemver string. Bumps are cache-invalidating via safetyPoliciesHash
frameworkOpen 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
enforcementadvisory / guardrail / refusal — v1.1 is prose-only across all three; the field is audit-surface even when runtime behavior is identical
scopeWhen the policy participates — callClass, family, runtimeRole
contentThe refusal / gate / disclaimer prose; string or (PromptContext) => string
guardsFuture-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 register core.*. 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:

  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

Levelv1.1 behaviorIntent
advisoryProse-only — surfaces guidance, no gateNon-binding norms; LLM is free to comply or not
guardrailRefusal-shaped prose plus the guards slot for future structural checksOperational guardrails (rate-limit refusals, tool-call preconditions)
refusalExplicit refusal proseHard 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

On this page