How it works Human gates

Docs How it works

Human gates

A gated tools/call pauses at the MCP gate until a human decides. Two channels can answer (the terminal prompt and a signed webhook decision) and the first decision wins.

On this page
Explainer · 0:59 · Human gates · English captions
Transcript

0:00 Some tool calls are too consequential to leave to an agent. Constle can make them wait for a human. List the exact tool name in human_gates. When the agent calls it, the MCP gate holds the call — outside the sandbox. At the terminal you see the agent, the tool, the subject digest and every argument. Approve, or deny. Or decide remotely. Constle posts the request to your webhook, then polls for a decision signed with your Ed25519 key. The signature must verify against approver_pubkey, and the request id and subject digest must match. Only then does the call go through. A bad signature: denied. A replayed decision for another request: denied. No answer at all: on_timeout decides — and it defaults to abort.

0:52 A person stays in the loop — where the agent can't reach.

Declaring a gateLink to this section

mcp:
  servers:
    - id: accounting
      url: https://mcp.accounting.internal   # host-side only
      tools: [list_invoices, pay_invoice]

human_gates:
  enabled: true                   # the master switch; the default is false
  require_approval_for:
    - pay_invoice                 # an exact MCP tool name
  approver_pubkey: did:key:z6Mk…  # from `constle webhook-keygen`; required when gating
  approval_timeout_seconds: 300   # the default
  on_timeout: abort               # the default: refuse the call and stop the run
  notify:
    - channel: webhook
      url_secret_ref: HUMAN_GATE_WEBHOOK_URL   # the env var holding the URL
  • enabled defaults to false, and then nothing is gated even if require_approval_for lists entries. validate and run warn about disarmed entries.
  • require_approval_for entries are exact, case-sensitive MCP tool names: the params.name of a tools/call on a server declared under mcp.servers. No patterns, no semantic matching. An entry that can match no declared tool is reported at validate and run time. With no mcp.servers declared, nothing is gated. This is limitation 1.
  • approver_pubkey is required whenever require_approval_for is non-empty. It is a did:key for an approver keypair made with constle webhook-keygen, which is not an agent identity: it authenticates the human deciding, not the agent asking.
  • notify names the environment variable that holds the webhook URL, so the URL never sits in the Agentfile. An unsupported channel is a validation error.

Gates cover MCP tool calls only. Plain HTTPS through allowed_hosts never passes the gate, and there is no gate on filesystem writes (there is no chokepoint outside the sandbox for them yet; spec §14.3).

What happens at run timeLink to this section

When a gated call arrives, the gate pauses it, writes a gate_triggered audit event, notifies every resolved webhook URL, and waits for a decision, recorded as gate_approved, gate_denied or gate_timeout. An approved call is then forwarded like any other, bracketed by tool_call_start / tool_call_end events.

The terminal promptLink to this section

⏸  human gate: agent "invoice-processor" wants to call MCP tool "pay_invoice" on server "accounting"
   subject: sha256:4b3f…e91a
   arguments (25 bytes, 1 lines) — shown in full:
   {"invoice_id":"INV-0042"}
   approve? [a]pprove / [d]eny (timeout 300s → abort):

A terminal approval is an unsigned local operator action, recorded with decided_by: terminal. Arguments too large to show in full can be denied at the terminal but never approved there. When stdin is not a terminal (CI, a pipe, a backgrounded run) the gate says so once and waits for the deadline, so on_timeout decides; it never blocks forever on a read that cannot resolve, and never treats "nobody is watching" as approval.

The signed webhook decisionLink to this section

With approver_pubkey set and a notify URL resolving, Constle also POSTs the request to your endpoint and polls GET <url>/<request_id>/decision for an answer. The decision must be Ed25519-signed by the approver key over request_id + "." + decision + "." + subject_digest, and Constle checks, in order:

  1. Decode approver_pubkey from the Agentfile.
  2. Rebuild the signed payload from the response fields.
  3. Verify the signature with that key.
  4. Confirm request_id is the one minted for this gate (so one "yes" can't answer an identical later call).
  5. Confirm subject_digest is the one sent (the hash of the exact tool call).
  6. Only then, and only if decision == "approved", forward the call.

The full wire protocol is below, under Webhook specification.

Fail closed, and where it doesn'tLink to this section

A decision that arrives is verified and can only deny. No setting relaxes this:

A decision that arrives Result
approver_pubkey missing, or not a valid did:key constle validate fails
Signature doesn't verify denied · gate_signature_invalid
request_id mismatch denied · gate_request_id_mismatch
subject_digest mismatch denied · gate_digest_mismatch
Anything but "approved" denied

No decision at all by the deadline is resolved by policy, not by a guarantee. on_timeout decides, and both values are legal:

on_timeout Result
abort (the default) The call is refused and the run is terminated · gate_timeout
proceed The call is forwarded without approval · gate_timeout

An unreachable endpoint, an endpoint that answers 200 with a body that doesn't parse as a decision, and an approver_pubkey with no resolvable notify URL all count as no decision. A broken webhook therefore fails toward whatever on_timeout names. Under proceed, a gate delays a call rather than blocking it.

Limits of the signed channel

  • No rotation or revocation: a compromised approver key stays valid until the Agentfile changes.
  • One approver per agent. No M-of-N.
  • Ed25519 removes host-side forgery, not host-side coercion: the host could lie about what it is asking.
  • A racing terminal prompt can approve without any signature (decided_by: terminal).
  • subject_digest is unsalted SHA-256: guessable arguments can be recovered from the log.

In the audit logLink to this section

Every terminal event of a gate decided over the webhook carries the decision that produced it, the identifying fields of the request (never its arguments) and the approver key actually used. constle audit verify --agentfile=<path> re-verifies each recorded decision against the Agentfile's approver_pubkey, so an approval the approver never signed cannot be recorded as though they had. See Audit log and verification.

Webhook specificationLink to this section

The specification below is spec/human-gates-webhook.md from the Constle repository, reproduced verbatim.

Status
Draft
Spec version
0.4.0 (§9 now describes a decision record the runtime actually writes and can re-verify)
Last updated
2026-09-21

ChangelogLink to this section

  • 0.4.0 (2026-09-21): §9 rewritten. Every version up to 0.3.0 promised that the signed decision was persisted and re-verifiable offline, and the runtime persisted none of it — gate_approved recorded that something had been approved and nothing that showed the approver had approved it. The decision is now written as its signed fields rather than as the raw response body (equivalent for a §6 signature, which covers a derived string, and bounded where the body is not); the recorded request deliberately omits tool_call.arguments; the approver_pubkey actually verified against is recorded alongside; and constle audit verify --agentfile=<path> (or --approver-pubkey=<did:key:…>) re-verifies the result. §9 also states which fields the 256-byte bound actually covers and that only approvals are required to carry proof, and §10 records the relabelling limitation that excluding the arguments leaves open.
  • 0.3.1 (2026-09-21): Documentation accuracy only — no change to the wire format, the verification steps, or any runtime behaviour. §8 previously read as an unconditional "fail closed, always". It is now split into what holds unconditionally (§8.1: a decision that arrives is verified and can only deny), what is operator policy rather than a guarantee (§8.2: a gate that receives no decision is resolved by human_gates.on_timeout, which may legally be proceed), and what this channel does not cover at all (§8.3: a racing terminal prompt approves without signing). §4's timeout bullet and §4.1's poll table are corrected to match: a 200 whose body does not parse as a decision object continues polling rather than denying.
  • 0.3.0 (2026-09-04): Resolved the two questions 0.2.0 left open. §4.1 (new) specifies the delivery mechanism previously deferred as "out of scope for this revision": POST-once-then-poll against the same URL human_gates.notify already uses, with a derivable per-request decision endpoint. §5 canonicalization is now implemented (internal/humangate.SubjectDigest) rather than merely specified, with its two documented, deliberate deviations from strict RFC 8785 noted inline.
  • 0.2.0 (2026-09-04): Replaced HMAC-SHA256 symmetric signing with Ed25519 asymmetric signatures. Introduced a dedicated webhook signing keypair, decoupled from internal/identity's per-agent DIDs. Public key now declared in the Agentfile as a did:key string. Fail-closed behavior for a missing or malformed key made explicit.
  • 0.1.0 (2026-08-28): Initial draft. HMAC-SHA256 signing (Stripe-style), subject-digest binding, signed-decision verbatim logging.

1. PurposeLink to this section

When human_gates.require_approval_for names an MCP tool call that Constle intercepts, the runtime needs a way to ask an external decision-maker — a human, not another agent — whether the call should proceed. This spec defines that channel: the wire format of the request Constle sends out, the wire format of the decision that comes back, and how Constle verifies the decision actually came from the party the Agentfile designates as the approver.

It does not define how the approver's UI collects the decision. That's implementation-specific (Slack button, web form, CLI prompt) and out of scope.

2. Trust modelLink to this section

Constle's identity system (internal/identity, pkg/did) authenticates agents, not people. identity.Create() issues one Ed25519 keypair per agent, keyed by agent name. There is no existing concept of a human-held key, and the Owner field elsewhere in the Agentfile is a free-text label — it is checked against what's written, not cryptographically bound to anything.

This spec introduces a separate keypair, scoped only to the human-gates webhook flow. It is not an agent identity, is not created by identity.Create(), and is not tracked anywhere internal/identity looks. Reusing an agent's DID for a human approver would conflate "the agent that's boxed" with "the person approving what it does" — a fusion this spec deliberately avoids.

The webhook's public key is encoded as a did:key string, using the same multicodec/Ed25519 wire format pkg/did already decodes. This is a formatting-convenience decision, not a model decision: pkg/did's decoder is generic to any Ed25519 public key regardless of who holds it, so reusing it here costs zero new code — but it does not imply the webhook keypair is, or becomes, an agent identity.

3. Agentfile fieldLink to this section

human_gates:
  require_approval_for:
    - "fs.write"
    - "network.request"
  approver_pubkey: "did:key:z6Mkf5rGMoatrSj1f4CyvuHBeXJELe9RPdzo2PKGNCKVtZxP"  # example value
  • approver_pubkey is required whenever require_approval_for is non-empty. An Agentfile that declares gated tools without an approver_pubkey fails constle validate.
  • Format: a did:key multibase string encoding a single Ed25519 public key (32 bytes), decoded via pkg/did.Decode().
  • Rotation is manual: changing the approver means editing this field and redeploying. There is no registry or discovery mechanism (see §9).

4. Request: Constle → decision endpointLink to this section

{
  "request_id": "hg_7f3a9c2e",
  "agent_name": "invoice-processor",
  "tool_call": {
    "name": "fs.write",
    "arguments": { "path": "/data/out/report.csv", "content": "..." }
  },
  "subject_digest": "sha256:4b3f...e91a",
  "timestamp": "2026-09-04T14:22:03Z"
}
  • subject_digest is SHA-256 over the exact, canonical byte representation of tool_call (§5) — this is what the approver is actually signing off on, byte for byte.
  • No response within the configured timeout is not a denial in itself. The gate has nothing to verify, so human_gates.on_timeout decides: abort (the default) refuses the call and stops the run, proceed forwards it unapproved. See §8.2.
  • Constle MAY include additional fields beyond the five above — run_id, approval_timeout_seconds, timeout_at, on_timeout — for a receiver's own bookkeeping (a countdown display, knowing when to give up holding a gate open). These carry no cryptographic weight: the signed statement in §6 is exactly request_id + "." + decision + "." + subject_digest, nothing else. A receiver MUST NOT require them; one that only implements the five fields above still works correctly end to end.

4.1 Delivery mechanismLink to this section

Constle POSTs the §4 request to the URL configured via human_gates.notify (channel: webhook, url_secret_ref) — the same URL that already receives gate-triggered notifications; there is no separate URL to configure for decisions. Where several notify entries resolve to several URLs, trigger notifications fan out to all of them but the decision channel is the first resolved URL only: one gate has one decision endpoint, and it is not the case that any declared receiver may answer. The receiver acknowledges with any 2xx status. request_id is the idempotency key: a receiver MUST treat a repeated POST carrying the same request_id as a retry of the same gate, never as a new one.

The decision is fetched by polling GET <configured URL>/<request_id>/decision — derivable from the configured URL and request_id alone, so a receiver that never saw the POST (or whose 2xx response was lost in transit) still exposes a discoverable decision endpoint once it learns about the gate by whatever means. Poll responses:

Response Meaning
200 with a body that parses as a §6 decision object Decided. Constle verifies it per §7 and stops polling either way — an invalid decision denies the call (§8.1); it does not fall back to continued polling.
200 with a body that does not parse as a decision object Not yet decided. Parsing is what makes a response a decision at all, so a body Constle cannot decode is indistinguishable from "no answer yet" and polling continues. An endpoint that answers 200 with a permanently malformed body therefore resolves as a timeout (§8.2), not as a denial. A body that parses but is empty or unsigned — {}, null, a decision object with no signature — is a decision, and denies.
anything else (202, 404, 5xx, connection failure, timeout, …) Not yet decided. Constle retries the POST (if not yet acknowledged) and re-polls, on a fixed interval, until a decision arrives or the gate's timeout elapses.

A receiver MAY hold the GET open before answering, as a latency optimization — Constle neither requests nor requires this; it simply polls again on its own schedule regardless.

This endpoint is one input to a gate, not necessarily the only one. When Constle is also running an interactive terminal prompt for the same gate, both channels are live simultaneously and the first to produce a decision wins; the loser's context is then canceled. Nothing in this spec makes the decision endpoint authoritative over a local operator, and §8.3 states what that costs.

Every outbound request is bound by the gate's own approval_timeout_seconds deadline, computed once when the gate opens. No single request, retry, or poll extends a decision's validity past that deadline, and no response arriving after it is honored — unchanged from the existing timeout behavior.

5. Canonical subject encodingLink to this section

subject_digest must be independently reproducible on both sides, or the binding in §6 is meaningless:

  1. tool_call serialized as JSON with sorted object keys, no whitespace (RFC 8785 JCS-style canonicalization).
  2. UTF-8 encoded.
  3. SHA-256 of the resulting bytes, hex-encoded, prefixed sha256:.

Constle computes this once when building the request. The decision endpoint doesn't have to recompute it to respond — but should, to confirm it's approving what it thinks it's approving (§6).

Note — this is a second canonicalization convention, not a reuse of the existing one. The codebase's two existing signed-payload flows (audit.Entry in internal/audit/logger.go, and a2a.Envelope in internal/a2a/envelope.go) both deliberately avoid re-canonicalization: they sign/verify over the exact wire bytes produced by a single encoding/json.Marshal call, with the signature field declared last and stripped by byte-offset rather than by re-serializing. envelope.go states this explicitly as a design choice ("no re-canonicalization, so verification is over the very bytes that traveled"). RFC 8785 JCS is the opposite strategy: both sides independently re-derive canonical bytes from a parsed structure, which only holds if both implementations produce identical output (sorted keys, number formatting, escaping) for every value in tool_call.arguments — a guarantee encoding/json does not provide out of the box and Go's stdlib has no built-in JCS encoder for. This isn't a hard conflict — tool_call is a fresh object, not a shared struct with the other two flows — but it does mean the codebase would carry two different canonicalization philosophies for adjacent problems. Worth a deliberate call before implementation, not an accretion by default.

Implemented as internal/humangate.SubjectDigest, "-style" rather than a strict RFC 8785 encoder, on the strength of two guarantees encoding/json already provides: Marshal always emits map keys in sorted order, and decoding with UseNumber() carries each number's original literal text through untouched rather than the lossy float64 default. This is deliberately not a full RFC 8785 implementation; the one documented gap is that object keys are ordered by Go's byte-wise UTF-8 comparison rather than UTF-16 code-unit order — the two agree for every key made of Basic-Multilingual-Plane characters (in practice, every real MCP tool argument name) and diverge only outside it. A future revision that needs strict cross-language byte-for-byte reproducibility should adopt a dedicated JCS library instead.

6. Response: decision endpoint → ConstleLink to this section

{
  "request_id": "hg_7f3a9c2e",
  "decision": "approved",
  "subject_digest": "sha256:4b3f...e91a",
  "signature": "z3xQb...c7f1",
  "decided_at": "2026-09-04T14:22:41Z"
}
  • request_id: must echo the request's request_id verbatim. It is what binds the decision to one specific gate. Two gates for the same tool call with the same arguments are indistinguishable by subject_digest alone, so a decision that does not echo the id is answering a different gate and is denied (§7 step 4), however genuine its signature.
  • decision: "approved" or "denied". Any other value, or a missing field, is treated as denied.
  • subject_digest: must echo the digest from the request verbatim. The approver isn't signing "I approve request hg_7f3a9c2e" (a label Constle could relabel later); they're signing the literal hash of the tool-call bytes. What-you-see-is-what-you-sign.
  • signature: Ed25519 signature over the exact bytes request_id + "." + decision + "." + subject_digest (UTF-8, ASCII period separators), signed with the private key matching the Agentfile's approver_pubkey.

7. Verification (Constle side)Link to this section

  1. Decode approver_pubkey from the Agentfile via pkg/did.Decode().
  2. Reconstruct the signed payload (request_id + "." + decision + "." + subject_digest) from the response fields.
  3. Verify signature against that payload with the decoded public key.
  4. Confirm the response's request_id matches the one Constle minted for this gate.
  5. Confirm the response's subject_digest matches the one Constle sent in the request.
  6. Only if steps 3, 4 and 5 succeed, and decision == "approved", does the tool call proceed.

Steps 4 and 5 are separate checks, and neither is implied by step 3. The signed payload covers request_id and subject_digest both, so a valid signature proves only that the approver said this about that request_id and that digest — not that either is the one this call is waiting on. Checking the digest alone leaves the gate open to replay: two invocations of the same tool with the same arguments share a subject_digest by construction (§5 is a pure function of name and arguments), and request_id is the only thing distinguishing them. Without step 4, a decision genuinely signed for the first invocation verifies cleanly against the second, and one human "yes" to a repeatable call silently answers every identical gate that follows it. Step 4 is checked against the locally-held id rather than inferred from the decision having been fetched from a request_id-derived URL (§4.1) — a verification step that fails closed cannot depend on how its input was obtained.

8. Fail-closed behaviorLink to this section

The boundary is narrower than "fail closed, always," and stating it precisely matters more than stating it strongly: a decision that arrives is fail-closed; the absence of a decision is resolved by operator policy. Those are two different kinds of claim — one is a property of this protocol, the other is a configuration default — so they are separated below rather than summed into a single sentence that is only true under the default.

8.1 A decision that arrives — unconditionalLink to this section

Condition Result
approver_pubkey missing from Agentfile constle validate fails — agent cannot run at all
approver_pubkey present but not a valid did:key Ed25519 string constle validate fails
Signature verification fails denied, logged as EventGateSignatureInvalid
request_id mismatch between request and response denied, logged as EventGateRequestIDMismatch
subject_digest mismatch between request and response denied, logged as EventGateDigestMismatch
decision missing, malformed, or anything other than "approved" denied

No configuration relaxes any row above: at an armed gate, nothing turns one of these into an approval. (The one thing that removes them is human_gates.enabled: false, which disarms every gate so that no call is ever held and no decision is ever solicited — the rows do not apply because there is no gate, not because they were softened. constle validate still requires a valid approver_pubkey in that state, and Constle warns at both validate and run time that the declared entries will run without approval.) Verification returns approved on exactly one path — signature, request_id and subject_digest all check out and decision == "approved" — and no code path treats an unverifiable or malformed decision as approval. A decision that fails any check denies the call at once and stops polling: it is never retried into a later poll that might answer differently.

8.2 No decision at all — policy, not guaranteeLink to this section

When the deadline passes and no channel has produced a decision, there is nothing to verify and §8.1 does not apply. human_gates.on_timeout decides, and both of its values are legal:

on_timeout Result
abort (the default) The call is refused and the run is terminated. Logged as gate_timeout.
proceed The call is forwarded without approval. Logged as gate_timeout.

Everything that yields no decision lands here, including cases that look like failures rather than like silence:

  • The decision endpoint is unreachable, or is reachable and never answers.
  • The endpoint answers 200 with a body that does not parse as a decision object (§4.1) — indistinguishable from "not yet".
  • approver_pubkey is declared but no human_gates.notify URL resolves, so no decision endpoint exists to poll. Constle warns at run time and continues with the terminal prompt as the only decision channel; it does not refuse to start.

Under on_timeout: abort each of these blocks the call. Under on_timeout: proceed each of them forwards it. A broken or misconfigured webhook therefore fails toward whatever on_timeout names — toward blocking the agent on the default, toward letting the call through where an operator has chosen proceed. An agent running proceed has a gate that delays a consequential call rather than one that can block it, and that is a property of the deployment, not a defect in this channel.

8.3 What this channel does not coverLink to this section

A terminal prompt racing this endpoint (§4.1) can approve a gated call on its own. That approval is an unsigned local operator action: it is not signed, not bound to subject_digest, and leaves no artifact anyone can re-verify offline. §8.1 constrains what this channel can be talked into approving. It does not constrain every route by which a gated call can be approved, and an audit log showing an approved gate does not by itself imply a signed decision was involved — decided_by on the gate_approved event is what distinguishes them.

Naming note: internal/audit/logger.go names its existing gate events EventGateTriggered, EventGateApproved, EventGateDenied, EventGateTimeout (string values "gate_triggered", "gate_approved", "gate_denied", "gate_timeout") — a Gate prefix, not HumanGate. The three new constants above follow that existing convention (EventGateSignatureInvalid / "gate_signature_invalid", EventGateRequestIDMismatch / "gate_request_id_mismatch", EventGateDigestMismatch / "gate_digest_mismatch") rather than introducing a new HumanGate prefix alongside it.

9. Audit logLink to this section

Every terminal event of a gate decided through this channel — gate_approved, gate_denied, and the three fail-closed events of §8.1 — carries the decision that produced it, alongside the identifying fields of the request it answers. A denial is the exception in both directions: two of them are reachable before a request exists at all — when the subject digest or the request body cannot be built — so a gate_denied may carry nothing, and verification does not require it to. It requires evidence only of gate_approved, because a denial needs no approver signature to justify having blocked a call. What has to be provable is a call that ran. A gate that timed out unanswered records the request alone, so it can still be correlated with the receiver's own records by request_id. Anyone holding the Agentfile's approver_pubkey can re-verify, offline, that a given decision was genuinely signed by the approver's key over that subject_digest — which is a weaker statement than "over that exact tool call", and deliberately so: the fourth limitation in §10 is that nothing in the log ties the digest back to the tool name the entry displays; constle audit verify --approver-pubkey=<did:key:…> <logfile> does exactly that.

"The full response object" means its fields, not its bytes. The four signed fields and the one unsigned one are recorded as fields, not as the raw HTTP body they arrived in. The two are equivalent here, and only here: §6's signature covers the derived string request_id + "." + decision + "." + subject_digest, which a verifier reconstructs from the parsed fields and never from the body, so a record of the fields reproduces the signed payload exactly. The codebase's other two signed payloads sign their own wire bytes and genuinely do require them (see the note in §5); this one does not. Keeping the body instead would add nothing verifiable while copying an unbounded, endpoint-controlled blob into a signed, hash-chained log that is designed to travel.

Each of the four endpoint-supplied fields is bounded at 256 bytes — far above any real value, since a request_id, a subject_digest and an Ed25519 signature all have fixed short lengths. The bound covers exactly what an untrusted endpoint chooses, because the records that matter most are the rejected decisions, whose contents it chose entirely. It does not cover the recorded request's agent_name and tool name, or approver_pubkey: those are operator configuration, validated at parse time, and are recorded as the manifest declares them. Across a run the evidence grows linearly with the number of gates — one record per gate, no per-run budget — as every other audit event does. A record that hit the bound is marked as truncated, and the verifier reports it as unverifiable rather than as a bad signature: those bytes were dropped by the runtime, not forged by the endpoint.

decided_at is recorded but not attested. It falls outside the signed payload, so it is the endpoint's own claim about when it decided, and nothing verifies it.

The recorded request omits tool_call.arguments. It carries request_id, agent_name, the tool name, subject_digest and the timestamp. Arguments routinely carry secrets and payloads, and the signature attests to the digest rather than to them, so excluding them costs nothing that the signature itself establishes. It is not free: it is exactly what leaves the digest unrelatable to the displayed tool name, which is the fourth limitation in §10. The trade is deliberate — a travelling log that cannot be made to leak an argument, against a verifier that cannot by itself say which call a signed digest was for. The digest is not a substitute for the secrecy of the arguments, and the record should not be read as though it were: subject_digest is an unsalted SHA-256 over the tool name and arguments, so over an enumerable argument space — a PIN, a boolean confirmation, an address from a known set — it is recoverable by brute force. §5 requires exactly that construction, so it cannot be salted without breaking the cross-side reproducibility it exists for. Treat it as a forensic handle rather than a confidentiality boundary, and assume anyone who can read the log can learn guessable arguments. (internal/mcpgate's runGate states the same caveat at the point the digest is computed.)

The approver key in use is recorded too. The paragraph above assumes a verifier who brings approver_pubkey from the Agentfile; recording it as well pins which key the runtime actually verified against, so a runtime that ran with a swapped approver key leaves a log that visibly disagrees with the Agentfile rather than one that verifies cleanly against the swap. Pinning the key at verification time is what acts on that difference — an unpinned check accepts whatever key the log names, which establishes the log's internal consistency and nothing about its trust anchor.

What this closes. Verification compares the cryptography against the event the log claims, in both directions: a gate_approved that does not re-verify as approved is an approval the declared approver never gave, and a gate_signature_invalid that now verifies cleanly is a denial the log misattributes to the approver. An approval that was never signed can therefore no longer be recorded as though it had been, and an approval with no decision recorded at all fails however many other entries share that shape — an absence of proof is not excused by the absence being thorough.

What it does not close is three things. A host that lies about what it is asking approval for lies before any of this is written (§10). A genuine decision can still be relabelled afterwards as answering a different call, because excluding the arguments leaves nothing to recompute the digest from (§10). And decided_by is narrower than it may look: every entry carrying a decision is verified whatever that field says, and the field decides only one question — whether an approval carrying NO decision is exempt. The single exempt value is terminal, because §8.3 states that a terminal approval signs nothing and leaves no artifact to re-verify. An approval with no decision and any other value, or none, fails: a provenance claim a forger can decline to make is not one worth reading.

10. Known limitationsLink to this section

  • No rotation or revocation mechanism. A compromised approver key stays valid until someone notices and edits the file.
  • The webhook keypair is not part of internal/identity. A future version may unify it with the agent DID system if a real need for cross-referencing emerges.
  • Ed25519 removes host-side forgery, not host-side coercion. The runtime host can still lie about what it's asking approval for before the digest is computed. This spec closes the "declared approval that was never real" gap; it doesn't make the host itself trustworthy by assumption.
  • A recorded decision does not bind to the tool call the log names it for. The signature covers subject_digest, and the digest commits to the tool name and the arguments together (§5) — but §9 excludes the arguments, so nothing reading the log offline can recompute it. Whoever writes the log can therefore present a genuine approval for one call as an approval for another, by rewriting the recorded tool name and the entry's own together. A verifier catches the half-done version of this — it requires the entry's own tool name and digest and the recorded request's to be present and equal, so deleting either side is itself a failure — and it cannot catch the consistent one. Recording the arguments is not the only way out of this, and §9's exclusion of them is not what makes it permanent: a future revision could have the approver sign the tool name as a field of its own, or sign a commitment to the name and a separate argument digest, either of which would bind the label without publishing the arguments. What is fixed is that the CURRENT signed fields cannot do it. The request_id recorded alongside is what settles such a case out of band: the approver's endpoint holds its own record of what it was shown for that id, and §4.1 makes request_id the idempotency key it files that under.
  • Single approver per agent. Multi-approver / M-of-N gating is not supported by this version.
  • The signed-decision guarantee is not the gate's whole guarantee. §8.1 holds unconditionally, but it covers only decisions that arrive through this channel. A gate can still be resolved without one: by on_timeout when nothing answers (§8.2), or by an unsigned terminal approval that wins the race (§8.3). Read §8 in full before treating "the approver's key" as the only thing standing in front of a gated call.

Something wrong or unclear? Open an issue. The specifications on these pages are copies of spec/ in constle/constle.