@n50/safety-gates
v0.1.0
Published
Three decoupling patterns for autonomous-system safety gates that don't decay into paralysis. Implements the fix archetypes from PAT-039 (safety-mechanism-without-unlock-criteria) in the ALEF Pattern Catalog.
Maintainers
Readme
@alef/safety-gates
Three decoupling patterns for autonomous-system safety gates that don't decay into paralysis.
Implements the fix archetypes from PAT-039: safety-mechanism-without-unlock-criteria in the ALEF Pattern Catalog.
The problem this solves
A safety mechanism gets installed in response to a real threat (cease-and-desist, prompt-injection, chaos-test finding) but ships without a retirement condition. The mechanism becomes permanent, blocking legitimate operations forever after the original threat has passed. Defense decays into paralysis.
Symptoms in production:
- Hardcoded
if (CHAOS_MODE) return falseflags that nobody remembers installing. - Circuit breakers stuck open for hours despite the upstream having recovered.
- Observer-only modes that silently drop legitimate work.
- Gates whose only documentation is the original incident PR, retrieved by archaeology.
This package gives three composable primitives that bake retirement, decoupling, and verification into the gate itself.
Install
npm install @alef/safety-gatesNode ≥ 18, ESM-only.
Quick reference
import {
withTTLGate,
withProcessBoundary,
adversarialGateTester,
} from "@alef/safety-gates";1. withTTLGate — retirement clock
Wraps a safety-check function with an explicit retireBy. After that date, the function refuses to execute (or invokes an explicit renewal handler that returns true to grant one more pass).
const safeCheck = withTTLGate(myGateFunction, {
installedAt: new Date("2026-05-19T11:00:00Z"),
retireBy: new Date("2026-06-02T11:00:00Z"),
onExpired: async () => {
return await promptOperatorForRenewal(); // true → granted, false → leave retired
},
});
await safeCheck(input); // works
// ... 14 days later ...
await safeCheck(input); // throws TTLGateExpiredError unless operator renewsInspect operational state via _status:
console.log(safeCheck._status);
// {
// installedAt: Date,
// retireBy: Date,
// lastTrigger: Date | null,
// renewalCount: number,
// isExpired: boolean
// }Why this matters: Hardcoded if (CHAOS_MODE) lasts as long as the file does. withTTLGate makes the retirement decision an active, dated act — visible to the operator, requiring confirmation, with a paper trail.
2. withProcessBoundary — fate-separated check
Wraps a safety-check with an explicit failMode ("fail-open" or "fail-closed"). When the check times out or throws, the wrapper returns the configured fail-mode value — never a silent "skip because the check didn't return."
const guarded = withProcessBoundary(safetyCheck, {
timeoutMs: 500,
failMode: "fail-closed",
logRejection: (err, input) => metrics.recordBoundaryFailure(err, input),
});
const verdict = await guarded(request);
// Always a verdict. Never a silent miss.For full process-isolation (separate OS process, separate trust domain), supply an adapter that owns the inter-process channel:
const httpAdapter = {
send: async (input) => {
const res = await fetch("http://localhost:9876/check", {
method: "POST",
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`adapter HTTP ${res.status}`);
return (await res.json()).allowed;
},
};
const guarded = withProcessBoundary(/* unused */ () => null, {
timeoutMs: 500,
failMode: "fail-closed",
adapter: httpAdapter,
});Why this matters: In-process safety checks share fate with the orchestrator. A worker pool exhaustion or a hung promise stalls the check and the orchestrator continues believing the check succeeded. The process boundary makes the failure mode explicit and the verdict deterministic even under partial failure.
3. adversarialGateTester — verifier-of-the-verifier
Periodically synthesizes legitimate inputs and confirms the gate ALLOWS them. If the gate starts blocking synthetic-legit inputs, the tester reports a dysfunction. This is the recursive application of fate-separation: a verifier whose only job is to verify that the verifier still works.
const tester = adversarialGateTester({
gate: safeCheck,
synthesize: () => [
{ user: "synthetic-1", action: "read", resource: "public-doc" },
{ user: "synthetic-2", action: "list", resource: "public-feed" },
],
onDysfunction: (err) => {
alertOperator(err); // err.input, err.verdict, err.runIndex
},
intervalMs: 60_000,
});
tester.start();
// ... later ...
console.log(tester.stats());
// { totalRuns, dysfunctionCount, lastRunAt, lastDysfunctionAt, isRunning }
tester.stop();Why this matters: Gates can silently drift to "always-block" — the PAT-039 paralysis decay — and the orchestrator never knows. With adversarialGateTester, drift becomes a periodic, surfaced signal. The first synthetic-legit input that gets blocked is the moment the operator finds out, not the day the production user complains.
Composition
The three patterns compose:
import {
withTTLGate,
withProcessBoundary,
adversarialGateTester,
} from "@alef/safety-gates";
// Layered: process boundary on top of TTL on top of the raw check.
const rawCheck = (input) => { /* original safety logic */ };
const ttlChecked = withTTLGate(rawCheck, {
installedAt: new Date(),
retireBy: new Date(Date.now() + 30 * 86400_000), // 30 days
onExpired: async () => false, // require manual renewal
});
const guarded = withProcessBoundary(ttlChecked, {
timeoutMs: 200,
failMode: "fail-closed",
});
// Self-verifying: adversarial tester runs against the composed stack.
const tester = adversarialGateTester({
gate: guarded,
synthesize: () => SYNTHETIC_LEGIT_INPUTS,
onDysfunction: alertOperator,
intervalMs: 5 * 60_000, // every 5 minutes
});
tester.start();This gives you:
- A check that retires after 30 days unless explicitly renewed.
- A check that always returns a verdict (never silently stalls).
- A check whose silent decay-into-paralysis is detected within 5 minutes.
Pattern catalog reference
This library is the implementation arm of PAT-039 in the ALEF Pattern Catalog — a public corpus of named failure modes in autonomous and agentic systems.
Related patterns:
- PAT-038 prompt-injection-via-issue-comment
- PAT-040 bounded-iteration-without-progressive-state-preservation
- PAT-041 self-metric-calibration-lag-blinds-to-success
Browse all patterns: n50.io/patterns
License
MIT
