Approval Store & Channels
Store each held call's approval request and signed decision on disk, and spend every approval token exactly once.
On this page
When a person approves or denies a held call, the decision survives a restart, and an approval can be spent only once. The approval store keeps the pending request, the decision bound to it, and a record of the token that decision consumed.
ApprovalStore is a synchronous trait. Durable nodes use its SQLite backend and ephemeral ones its in-memory backend, and the same SQLite file also backs the threshold collector for m-of-n approvals.
One transaction resolves a request and spends its token
The host holds a call that needs approval and stores its request. A channel presents the request, or the approver finds it with chio approvals pending, and signs a decision with chio approve <id>.
One signature runs one call
The host holds the call and stores pending request r. The approver signs τ. The host checks that r is still pending and τ unspent, runs verify_against, and resolves r in the same store call that records τ as consumed. It resubmits the stored call with τ. The kernel verifies τ again, runs every other check, and reserves τ for this subject, request, and intent hash with reserve_for_dispatch. Only then does the tool run, and the kernel signs an Allow receipt. Resume flow.
In Rust · approval.rs:74415 lines
pub fn resume_with_decision(
store: &dyn ApprovalStore,
decision: &ApprovalDecision,
now: u64,
) -> Result<ApprovalOutcome, KernelError> {
let pending = match store
.get_pending(&decision.approval_id)
// … an unknown or already resolved id is rejected
let already = store
.is_consumed(&decision.token.id, &pending.parameter_hash)
// … a consumed token is rejected as a replay
let token_decision = approval_token.verify_against(&pending, now)?;
// … the HTTP outcome must match the signed decision
store
.resolve(&decision.approval_id, decision)
Before anything is written, the host checks that the request is still pending and the token unspent, then runs verify_against. A decision signed over other parameters is refused with 403 and the request stays pending. A denial resolves the request and ends at the host. An approval resolves it in the same store call that records the token as consumed, and the kernel verifies the token again and reserves it for one dispatch.
Inside resolve, one SQLite transaction does the work:
- Read the pending request's policy and parameter hash.
- Reject a token already consumed for that parameter hash.
- Reject an existing resolution for this approval id.
- Insert the resolved record with the pending request's identity and the supplied decision.
- Insert the consumed-token key.
- Delete the pending request and commit.
The resolved row, the consumed-token row, and the delete commit together or not at all. A second presentation of the same token finds it consumed and is refused as a replay.
Read more in Approval & HITL, for the binding checks a token must pass.
Five tables in one file serve two stores
SqliteApprovalStore implements ApprovalStore over the first three tables and ThresholdApprovalCollectorStore over the last two, creating all five on open.
| Table | Holds | Keyed on |
|---|---|---|
chio_hitl_pending | outstanding requests | approval_id |
chio_hitl_resolved | the audit row each resolution leaves | approval_id |
chio_hitl_consumed_tokens | one row per spent token | (token_id, parameter_hash) |
chio_threshold_approval_collectors | one signed threshold proposal per row | proposal_id |
chio_threshold_approval_collector_votes | individual approver tokens | (proposal_id, token_id) |
The approval store is an own-file store in the sense Node State on Disk defines: it takes a path, opens it directly, and passes the shared schema gate under the key approval at revision 2. A path aimed at a receipt, revocation, budget, or authority database is refused before any approval table is written.
Single use is per store. Two kernels with separate stores keep separate replay registries, and one approval database takes one writing process.
Requests arrive from the approval guard and from the sidecar
ApprovalGuard runs ahead of the guard pipeline and reads the matched grant's constraints. When they call for approval and no token is attached, it stores an ApprovalRequest, dispatches it to every configured channel, and holds the call. An ordinary pipeline guard that returns PendingApproval is refused fail-closed.
The chio api protect sidecar writes requests over HTTP. POST /approvals/submit takes operator-supplied context, and resolving the request records the decision; the operator resubmits the held call. When a grant's cumulative-approval requirement trips with no token attached, budget admission instead parks a signed ThresholdApprovalProposal on the durable admission operation, writes no pending row, and returns a deny receipt reading cumulative approval required.
A channel that fails to deliver leaves the request pending, still listed by GET /approvals/pending. A webhook outage delays a call; it neither drops nor allows one.
Read more in Human-in-the-loop.
A request's deadline ends it, and a token lives at most an hour
Each request carries a deadline. When it passes with no decision, the request's timeout action applies: deny by default, or escalate to the next tier of approvers with a new deadline. A timeout that denies signs a deny receipt naming the approval-timeout guard.
The token is bounded separately. MAX_APPROVAL_TTL_SECS = 3600 caps expires_at - issued_at, because the single-use registry's retention horizon is pinned to that value and a longer token could outlive its own replay entry.
Threshold and batch approvals stretch one decision across signers or calls
The threshold collector gathers m-of-n approvals for one signed proposal. Each token must match the proposal, come from an eligible approver, and count once per approver, and the vote table enforces uniqueness in SQL. Delivery hands over only tokens that are still valid, so a quorum that has lapsed is refused at delivery.
A batch approval lets one person pre-approve a class of calls for a bounded window: a subject, a server and tool pattern, optional amount and call caps, and used counters on the row. Matching a batch and recording its usage are separate operations, so the host coordinates admission with usage accounting.
Read more in Threshold Approvals.
Approval store reference
Try store_pending, AlreadyResolved, or webhook. Each entry has its own link.
Table details
chio_hitl_pending stores the serialized ApprovalRequest in payload with eight fields lifted into columns, indexed on subject_id and expires_at. chio_hitl_resolved records outcome, timestamp, approver hex, and token id, indexed on (subject_id, policy_id, outcome) for count_approved. chio_threshold_approval_collectors holds each proposal's requirement, state, an optimistic version, and the whole record as canonical JSON in record_json, which reads come from. The vote table exists so SQL enforces uniqueness; reads come from the collector record.
Every table uses CREATE TABLE IF NOT EXISTS beside the shared chio_store_schema_versions row the store stamps. A standalone open accepts one anchor, chio_hitl_pending. open_colocated_with_receipt_store adds three receipt anchors, because chio api protect keeps both stores in one file and opens the receipt store first. standalone_open_refuses_a_receipt_sidecar_that_colocated_open_adopts pins the pair.
Writing the request
store_pending is idempotent on approval_id for a byte-identical payload:
INSERT INTO chio_hitl_pending (...) VALUES (?1, ..., ?9)
ON CONFLICT(approval_id) DO UPDATE SET payload = excluded.payload
WHERE chio_hitl_pending.payload = excluded.payload
RETURNING payloadA retry with the same request updates the row to itself and returns it. A retry with a different request fails the WHERE, RETURNING yields nothing, and the call reports approval_id ... already exists with different payload instead of overwriting in-flight state; the in-memory store compares before insert. The refusal is an ApprovalStoreError::Backend, so a duplicate /approvals/submit answers 500 internal_error. The comparison is on the serialized string, so a payload whose shape changes across a binary upgrade reads as a conflicting retry.
Sidecar submission
POST /approvals/submit materializes an ApprovalRequest with governed_intent: None and a default policy_id of policy-hermes-hitl, and names the sidecar's own signing key as the sole entry in trusted_approvers. /approvals/{id}/operator-respond signs the decision with that key, falling back to it for the token subject when the request carries none, so authority comes from reaching the route. The sidecar clamps ttl_seconds.min(3600), uses 3600 when the field is zero or absent, and mints 600-second tokens on operator-respond.
Every approval route sits behind the sidecar-control gate, which admits any loopback caller when no control token is set. Set CHIO_SIDECAR_CONTROL_TOKEN before exposing approvals. Start the sidecar with --authority-seed-file: without it the sidecar generates a keypair per boot and warns, and after a restart operator-respond refuses rows written before it with sidecar signer is not a trusted approver for this request.
Durable and ephemeral mode
chio api protect selects SqliteApprovalStore when it has a durable --receipt-store path and refuses to boot without one unless --allow-ephemeral-receipts is set. In that mode the approval store and threshold collector are in memory and the durable admission stores are absent, so pending rows and collected votes end with the process and the cumulative-approval path has nowhere to park a proposal. An in-memory SQLite path counts as no path.
Channels
A channel is a delivery sink with two methods, name and dispatch, and ApprovalGuard::evaluate dispatches to each configured channel in turn. dispatch returns a ChannelHandle of channel, channel_ref, and an optional action_url. A Transport, Remote, or Config failure logs a redacted warning and leaves the pending row in place. GET /approvals/pending returns every matching row unless the caller passes a limit.
WebhookChannel is a blocking ureq POST with a 5-second default timeout and one optional static header for the single auth secret a receiver uses; it sends each request once. Its body is { event, approval, callback_url }, with event fixed at approval_requested, approval the whole serialized request including its trusted approver keys, and callback_url a host-less relative path. RecordingChannel appends every dispatch to an in-memory vector.
Resolve and resume
A second direct resolve normally returns NotFound, because the first transaction removed the pending row. AlreadyResolved is reachable when a pending row was reinserted after resolution. Token verification belongs to the caller’s approval flow; the store itself persists the supplied decision.
The caller above it is resume_with_decision. It loads the pending record, reports already resolved: {id} ({outcome}) when only a resolution exists, checks the replay registry, verifies the token against the stored request, and then compares the HTTP envelope's outcome with the signed decision. That last check runs before the store is touched, so a mismatched pair never flips the request to resolved or moves the approved counter.
| Condition | What resume returns | Status on respond |
|---|---|---|
URL id disagrees with the signed request_id | Rejected before the store is read | 400 |
No pending row and no resolution | Approval | 403 |
A resolution row already exists | Approval | 403 |
The token is already in chio_ | Approval | 409 |
The store raises Replay inside the transaction | Approval | 409 |
Binding, approver-trust, time, lifetime, or signature check fails | Approval | 403 |
A concurrent respond deleted the pending row between load and transaction | Internal("approval store: approval request not found") | 500 |
The resume path carries every rejection in one string-carrying variant, and the HTTP mapping returns 409 when the message contains replay; anything resume leaves unclassified becomes 500. Handlers that call the store directly, such as GET /approvals/{id}, map NotFound to 404 and AlreadyResolved to 409. Match on the code, and read a 403 on respond as any rejection, signature or otherwise.
Batch respond
POST /approvals/batch/respond runs the resume path per entry in independent transactions and never aborts the set. Each decision lands as resolved or rejected with its own message, under one summary of total, approved, denied, and rejected. The list is uncapped and the response is 200 even when every entry was rejected, so inspect the summary and each entry before treating the batch as resolved.
Request deadlines and escalation
ApprovalRequest::expires_at is written and indexed. When it passes, timeout_action applies: deny, the default, or escalate along an EscalationChain whose tiers each name approvers, channels, and timeout_seconds, ending in a terminal_action. A deny records approval request {id} timed out after {elapsed}s (escalation tier {tier}) from the approval-timeout guard. verify_against separately rejects a token outside its own issued_at to expires_at window or longer than an hour. ApprovalFilter::not_expired_at on list_pending filters listings.
Threshold collector
chio api protect routes four endpoints under /approvals/threshold/proposals, for create, get, respond, and deliver, and opens SqliteApprovalStore a second time against the same file to back them. Those routes are the only writers of the collector tables. A proposal must be signed by a trusted policy authority (in the sidecar, the sidecar's own signer, which also signs its embedded kernel's proposals) and agree with its requirement on threshold and eligible-set digest before it is stored.
Each token must match the proposal on request id, intent hash, subject, and the proposal's artifact digest; have issued_at at or after creation and before the deadline and expires_at no later than the deadline; be valid now; come from an eligible approver; and pass a submitter-separation check when one is required. Its decision must be Approved. The collector records approvals only, and cancel ends a proposal.
Every mutation reads the record inside a transaction, refuses if the version moved or the state is terminal, and writes with the expected version and previous state in the WHERE clause; a row count other than one is a conflict. The vote table enforces the rest:
canonical_token_digest TEXT NOT NULL UNIQUE,
...
PRIMARY KEY (proposal_id, token_id),
UNIQUE (proposal_id, approver_fingerprint),
UNIQUE (proposal_id, canonical_token_digest),
FOREIGN KEY (proposal_id)
REFERENCES chio_threshold_approval_collectors(proposal_id)A canonical token digest is unique across the whole file, so one signed token counts toward one proposal. Revision 2 scopes token_id to the proposal: opening a v1 database rebuilds the vote table and copies every row in one transaction, as open_migrates_v1_threshold_vote_ids_to_proposal_scope asserts. deliver filters to tokens valid at delivery and fails with threshold approval quorum is no longer satisfied when lapsed tokens break the quorum; the proposal stays Ready so the signer can replace an expired token. threshold_collector_recovers_votes_and_persists_delivery_before_return recovers votes across three store opens and records delivery before returning the token set.
Batch approvals
SqliteBatchApprovalStore takes its own path and holds one table, chio_hitl_batches, indexed on (subject_id, revoked), under the schema key batch_approval at revision 0. A BatchApproval names a subject, a server and tool pattern, an optional per-call and total amount, an optional call count, a time window, and used counters, so consumption reconciles without the receipt log.
find_matching narrows in SQL to unrevoked rows for the subject inside the window, applies pattern, count, and amount filters in Rust, and returns the first hit; with no ORDER BY, the winner among several matches is unspecified. Patterns match exactly, by full wildcard, or by one trailing-star prefix. A call with no monetary intent matches only a batch that caps neither per-call nor total amount, so a capped batch covers priced calls only.
record_usage is a separate unconditional UPDATE, so concurrent callers can both pass a max_calls check, and store is INSERT OR REPLACE, so re-storing a batch id resets its counters. The host that consults batch approvals makes matching and consumption atomic for its own admission.
Two replay registries
chio_hitl_consumed_tokens records approval tokens spent on a resolution; resolve_rejects_replay and record_consumed_is_idempotent_on_first_write_only pin that a second record_consumed for the same pair is a Replay error, even after the pending row is reinserted. GovernedApprovalReplayStore is a separate mechanism: it reserves a (subject_id, request_id, intent_hash) tuple at the dispatch boundary before any payment or tool side effect and keeps the marker blocking when a commit result is unknown. A deployment that needs replay safety across restart installs a durable implementation of both.
persistence_survives_restart (chio-store-sqlite/tests/approval_store.rs) opens a second handle on the same file, lists the pending request, and resumes it to Approved.