Skip to content

About

Human approval gates for autonomous systems, where a timeout never reads as consent — file-backed requests, an append-only decision ledger, and fail-closed expiry: an unanswered gate blocks, and the expired record names no decider.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fail-closed-gate

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.


The failure this prevents

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.

How it works

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-42

gate is fail-closed on every edge: unknown id → blocked; request overdue but the sweep hasn't run yet → blocked; every outcome except approved → blocked.

Semantics worth stealing

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.

Wiring a transport

A complete transport is three responsibilities:

  1. render pending requests to humans (list → your channel of choice),
  2. translate their responses into decide calls,
  3. run sweep on a schedule, and reflect expired outcomes 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.

Schemas

schema/approval-request.schema.json and schema/approval-decision.schema.json — the decision schema mechanically enforces "expired names no decider" via if/then.

Tests

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.

Related

  • 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.

Provenance

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.

About

Human approval gates for autonomous systems, where a timeout never reads as consent — file-backed requests, an append-only decision ledger, and fail-closed expiry: an unanswered gate blocks, and the expired record names no decider.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages