Human approval gates for autonomous systems, where a timeout never reads as consent.
A file-backed approval store, an append-only decision ledger, and one enforced rule:
Expiry is not approval. Silence is not consent. An unanswered request fails closed.
Stdlib Python, no dependencies, transport-agnostic. grep is the query language.
Most ad-hoc approval flows have the same quiet bug. An approval message goes out. A human is busy. A timeout fires. And somewhere, a code path treats "no answer" as "no objection" — the action runs, and nobody decided it.
That default is exactly backwards for agents. An autonomous system that can publish, spend, or delete needs the opposite default: when the gate is unanswered, the action does not happen, and the record shows that nobody said yes.
Here, an overdue request is closed by a sweep as expired, and the expired record deliberately
carries no decider — so a downstream check that requires a named approver refuses it
mechanically, not by convention.
approvals/
pending/ one JSON file per open request
resolved/ one JSON file per closed request (archive, never deleted)
decisions/ approval-YYYY-MM-DD.jsonl — the append-only ledger
Your pipeline raises a gate; your transport (chat bot, email, a human with a terminal) shows it and records the decision; your pipeline asks one question — may I proceed? — and gets a fail-closed answer.
# 1. the pipeline raises a gate (idempotent: same gate → same id)
./failgate.py request --pipeline content --subject publish \
--title "Publish: weekly digest" --subject-ref post-42 \
--expires-in-hours 72 --route channel=#approvals
# 2. a human decides (via whatever transport you wired — or directly)
./failgate.py decide 1a2b3c4d5e6f approved --by "Reviewer" --actor-id r1
# 3. a cron closes whatever nobody answered — silence is not consent
./failgate.py sweep
# 4. the pipeline gates on the outcome: exit 0 proceed, 1 pending, 2 blocked
./failgate.py gate 1a2b3c4d5e6f && publish.sh post-42gate is fail-closed on every edge: unknown id → blocked; request overdue but the sweep hasn't run
yet → blocked; every outcome except approved → blocked.
Each of these was earned in production, and each is pinned by a test:
Deterministic request ids. The id is a hash of (pipeline, subject, subject_ref, title). A
pipeline re-run re-raises the same gate instead of stacking a second card nobody remembers — and
dedup costs nothing because it is the id.
One decision per request. A double-click, a race between two approvers, or a replayed webhook
raises AlreadyResolved carrying the record that won. The losing decision appends nothing.
Superseding is explicit, renaming, and auditable. Re-raising an already-decided gate requires
retiring the old archive first — it is renamed with a timestamp, never deleted. The bug that taught
us: a revived card silently bounced every new decision against the old archive, and three
approvals were lost before anyone noticed. request --supersede exists so that can never be silent
again.
Expired records name no decider. decided_by is forbidden on expired (the schema enforces
it). Nobody decided; the record must not be able to impersonate consent.
Files, not memory. A gate routinely outlives the process — often the machine — that raised it. Everything is plain JSON on disk; the daily ledger is JSONL you can grep, ship, and diff.
The transport is not the protocol. The core knows nothing about chat platforms. route carries
opaque hints; any transport that can read a JSON file and run decide is complete. A Telegram bot,
a Slack app, an email loop, and a human with a terminal are all equivalent citizens.
A complete transport is three responsibilities:
- render pending requests to humans (
list→ your channel of choice), - translate their responses into
decidecalls, - run
sweepon a schedule, and reflectexpiredoutcomes back to the channel.
That's it. The reference implementation this was extracted from is a Telegram bot with inline buttons; nothing about the protocol requires one.
schema/approval-request.schema.json and
schema/approval-decision.schema.json — the decision
schema mechanically enforces "expired names no decider" via if/then.
uvx --from pytest pytest tests/18 tests, leaning on the edges where approval flows quietly go permissive: expiry, double decisions, revived gates, missing archives, the unswept-but-overdue window.
- schedule-sentinel — the other half of running agents unattended: proving the scheduled machinery (including the sweep this tool depends on) is still alive.
- awesome-governed-agents — curated tools and prior art for governed agents.
Extracted by Ruiqi Tan from the approval layer of Silicon Awakening's agent operating system, where every outward-facing action — publishing, config changes, upgrades — passes a gate with these semantics. Generalised for release: the Telegram transport stayed home; the protocol is what travels.
MIT licensed.