Skip to content

Approvals

When an agent's action needs a person's word first, who is asked, and what their yes covers.

Designed, partly built. docs/action-queue.md has the queue that shows a person what is waiting on them, and docs/inhibitors.md has the hold that stops the work. This is the part between them: what declares that an operation needs approving, what an approval is worth once given, and how far it reaches.

Why the declaration lives in the skill

An approval rule is prose about an operation: charging a card needs a manager. The place that already holds prose about operations is the skill that documents them, one file per operation, and skill-packages.md made those files into something a rule can safely live in:

  • Versioned and immutable. A version is a body and its files published together, so what the rule said on a date is recorded rather than inferred. Before that split the files were overwritten in place and only the body had a history -- a governance rule stored that way is one nobody can prove the wording of.
  • Written through the skill's own authorities. SkillsWrite, not StorageWorkspaceWrite. Prose that governs behavior must not be editable through the path meant for reference spreadsheets.
  • Read-only to the agent, through the skill/<slug>/ scope, resolved against the version the turn bound. A guest cannot edit the rule that governs it, and a turn that started before an edit is governed by what it started with.

The alternative homes are worse in specific ways. An egress rule is per host, and a charge and a room lookup are the same host -- host granularity cannot express "charges need approval, reads do not". A setting in the cascade is a scalar per key, and this is a rule per operation. A column on the agent would say nothing about which of its calls.

The frontmatter

YAML at the top of an operation's file, fenced by ---, before the prose. A file with none is an operation that needs no approval, which is nearly all of them -- so the common case stays a markdown file with no preamble, and nobody documenting a read has to know this exists.

---
approval:
  requires: charge
  matches: POST /charges
  covers: booking
  identified_by: booking_id
---

# charge_payment_account

Charge the payment account attached to a booking...

Parsed by api::skill::frontmatter, by hand rather than with a YAML crate. The contract is three scalar keys under one heading and is deliberately that narrow: a full reader would accept anchors, aliases, multi-document streams and nested collections, none of which mean anything here and every one of which is a shape somebody eventually writes and expects to work.

What it refuses is worth knowing, because every refusal is a rule that would otherwise be half-applied -- and a rule half-applied is an operation the file says is gated and the platform does not gate. Review found four ways that happened. approval : with a space was unrecognized and its keys skipped, so the operation was ungated and nothing said so. A wholly indented block was the same. An unclosed quote made "charge the act, which compares unequal to every grant for charge, so the rule existed and matched nothing. And a key given twice took the last, so a reviewer who read the first had approved something else. All four are refused now, and there is a test for each named after what it must not do quietly.

The backstop is the general form of that: a file mentioning approval that produces no rule this understands is refused rather than read as declaring nothing. Which is also why an empty approval: block is an error -- it is a rule somebody started writing far more often than a deliberate statement of no rule, and a file needing no approval says so by having no block. A key we do not know beside approval is still left alone, so a file written for a later version of this platform reads as prose here.

k8s/components/hollowbrook/skill/charge_payment_account.md is the worked example, and the parser's tests read it off disk -- so an edit that breaks the declaration fails the suite rather than a cluster.

Driven once, against a cluster

Worth recording, because the suite proves the parts and not the path. Against a live deployment: raising put a suspended hold on the session -- the first thing in this codebase ever to take one -- and an approval.charge item in the asked role's queue with the figure and the account label in its payload. The badge went to one. Approving released the hold and emptied the badge; answering again was refused as already resolved. Declining a second request left its hold on, and the conversation with it.

And the two-person case, which is the one worth being sure of: a clerk holding a role that was asked but does not carry approvals:answer saw the request in their queue and was refused when they tried to answer it. Being asked and being entitled are separate, and they are separate in the running system rather than only in the tests.

Four keys, and each earns its place.

requires -- what is being asked, in a word

The name of the act, used in the queue row and in the record afterwards. A person deciding sees "charge", not the tool name and not the URL.

Deliberately not free prose. It is compared: two operations declaring the same requires are the same act for the purposes of one grant, and the queue groups by it. Prose would make that comparison a string match on a sentence.

matches -- which request it applies to

A method and a path, as the gateway will see them. POST /charges, or DELETE /bookings/* where a trailing star stands for anything below it -- an operation on /bookings/{id} is one rule and not one per booking. A star anywhere else is refused: a pattern that can match in the middle is one somebody writes * into and gates far more than they meant, and a gate that is too wide refuses work nobody intended to gate, which reads as the platform being broken rather than as a rule being wrong.

Declared rather than read out of the prose below it. The body says fetch_url with POST http://.../charges because that is what a model needs; deriving a security gate by parsing that sentence would make the gate depend on how somebody phrased a paragraph. The host is not named here either -- it comes from the skill's own declared hosts, so a gate on POST /charges does not gate the same path on somebody else's API.

Required. A rule with nothing to match is one the gateway cannot apply, and a file that declares an approval and gates nothing is worse than one that declares none: it says the operation is gated, and a reader believes it.

binds -- which fields a yes is about

The names of the request fields a person is really approving -- body fields, and the path's own segments where matches names them (below). Required wherever an approval is declared, and the reason is the whole of what a grant is for.

approval:
  requires: charge
  matches: POST /charges
  binds: [payment_account_id, booking_id, amount_pence]

An approver is shown a rendered request -- charge £120 to Visa 4471 for booking bk_8812 -- and says yes to that. So what the grant permits afterwards has to be the requests that are indistinguishable from what they saw, and nothing else. binds is how the platform knows which parts of the request made it that one.

The grant is keyed on a digest over requires, the method, the host, the normalized path, and each bound field's value in declared order. A retry carries the same values and matches by construction. A second charge for a different amount does not, because amount_pence is in the digest.

The alternative was tried and is the bug this replaces. Keying on method, host and path alone -- the "request shape" -- reads as obviously sufficient, because a retry of one call is the same shape by construction. The converse is what matters and is easy to miss: two different charges are also the same shape. Approving £40 for one booking then produced a grant that let £4,000 for another straight through, because nothing in the key mentioned the amount or the booking. A grant has to be keyed on what made the request the one somebody looked at.

Declared rather than hashed whole, for two reasons that pull the same way. A digest over the entire body breaks its own approval the moment anything incidental varies -- a re-serialization with different key order, a timestamp, a client-generated nonce -- and the failure is a person asked twice for one charge, who reasonably concludes the button does not work. And a whole-body digest says nothing about why a request is the one it is, so a reader of the skill cannot tell what their yes was bound to.

The cost is that a field nobody declared may vary freely. A skill that omits amount_pence has an approval that does not bind the amount, which is exactly the hole above. So binds is required, and an approval: block without it is refused at publish -- the same treatment matches gets, and for the same reason: a rule half-applied is an operation the file says is gated and the platform does not gate.

Only top-level fields, and only scalars. A path into a nested object is a small expression language, and the thing a security decision is keyed on is not where to grow one. An operation whose significant values are nested wants flattening in the API it documents rather than a query syntax here.

A field a request does not carry is part of the digest as absent, distinctly from carrying the empty string. Otherwise omitting a field would be a way to collide with a grant taken out when it was present.

Path parameters

Some operations carry what makes them distinctive in the path rather than the body. DELETE /invoices/{id} sends no body at all, and POST /invoices/{id}/void sends one that says nothing about which invoice. Bound by body fields alone, neither could be gated with anything identifying what was approved. So matches may name a path segment, and binds may list it beside the body fields:

approval:
  requires: void
  matches: POST /invoices/{id}/void
  binds: [id, reason]

A parameter is a whole segment, {name}, matching any one segment in that place. A pattern names its segments or ends in a star, not both -- the star already stands for anything below, and a pattern doing both is one whose bound values depend on where a reader thinks the star stops. A name is either a path parameter or a body field: the same name in both would be read from one and sent in the other, so it is refused at publish, as is a bound name the pattern does not have. In a file that also declares the operation, the pattern's segments must be the operation's url, name for name. A rule binding only path parameters is valid -- that is the DELETE case.

Everywhere a bound value is read for a request, a path parameter is read from the request's path by the gate's pattern, through one reading (egress::gate::path_values, and Gate::bound over it): the grant digest, the gateway's matching, a policy's tests, the approval prompt, finding a held request again, the idempotency identity -- which may name a path parameter too -- and the unit a covers spans when identified_by names one. A path value is a string -- in the digest it is tagged as one, so /invoices/1 and a body's number 1 are never the same approval -- unless the declaration lists it in numbers, where a policy may compare it after a strict parse: digits with an optional minus, no leading zero, no plus, no space. Anything else is absent to the test, which fails. An amount may not be a path parameter: money read out of a URL is money nobody can type back into one.

Decoding is the gate's path normalization (egress::gate::normalize_path), the same in every place: percent-escapes decoded, repeated slashes collapsed, one trailing slash dropped. Segments are split before they are decoded, so /invoices/a%2Fb is the one segment a/b -- and a value containing / once decoded is refused, not matched: the recipient may read it as two segments and act on something nobody was shown. So are . and .., which a recipient may resolve away. The request is still covered by the gate -- the path decoded whole is what decides that, so %2F cannot be used to step around one -- and the gateway answers that it cannot be approved as written.

The gate's pattern travels to the gateway as the text it was written in, so a gateway older than this reads {id} as a literal segment and gates nothing that matches it. Roll the gateway before the API, as every upgrade does.

covers -- the unit one yes may span

Absent, an approval covers this call and its retries, and nothing else. That is the default and it is the one that needs no judgment from the approver: they were shown a charge and they approved that charge.

Present, it offers a wider extent -- here, every declared charge against one booking. The offer is not the grant. The approver ticks it, and it is never inferred from what was asked, because the hazard is the classic permission-dialog failure that inhibitors.md states:

a person approves the instance they were shown, not the class it belongs to

The test for whether an offer is honest is the one that document gives: would a person seeing two different instances of this permission ever answer differently? Reaching one host twice is the same act both times, which is why the egress model can approve a host. Charging £40 and charging £4,000 are not, which is why covers bounds an act on a unit and never an entity.

So covers: booking means "the charging of this booking" -- one act, however many calls it takes to complete. It does not mean "anything touching this booking": a refund declares a different requires, so a charge grant does not cover it, however tempting the grouping looks on screen.

identified_by -- which argument names the unit

Required when covers is present, meaningless without it. The name of a field in the request the grant is keyed on, so a grant for booking b-8812 covers that booking and not the next one.

Read from the request the gateway is about to make, not from anything the guest asserts separately. A guest that could name its own unit could name the one that was already approved.

What a yes is worth

An approval mints a capability the retry carries, rather than flipping the request to approved and hoping the same path is taken. Built: approval_grants, written when an answer settles and read when the next turn is prepared. It is the shape inhibitors.md asks for, and the shape this codebase already uses twice -- an egress rule records the skill whose declaration opened it, and a turn carries a gateway token minted for that turn alone.

A row rather than a token, for two reasons. The wait crosses a park that may last days and a pod that may not survive it, so a signed capability would need an expiry nobody can pick honestly. And a grant has to be auditable after the fact: a standing grant nobody can list is an authority the roles UI cannot see.

Extent, and why the ceiling is the turn

Every grant states how far it reaches, because one that does not is standing.

  • This call -- the default, keyed on the binds digest above. Permits the requests that are indistinguishable from the one somebody approved, which is what makes a retry free and a different charge a fresh question. This is what stops a resumed turn asking again, and it is the whole reason the capability exists.

An earlier draft keyed this as (session, turn, call-ordinal), borrowed from docs/idempotency.md. No call ordinal exists, that document is unbuilt, and building half of it here to serve this would have been a second spelling of a design nobody has settled. The digest is better anyway: an ordinal identifies where a call sat in a turn, and what a person approved is what the call said. A guest may also send an idempotency_key -- Hollowbrook takes one -- but that is the guest asserting two requests are the same, which is a deduplication hint and not a thing to key a permission on. - This unit, this turn -- what a ticked covers grants. It dies with the turn, so nothing accumulates and nothing granted at nine reaches a call at five. That bound is deliberate: "this session" reads as convenient and is the version that goes wrong quietly, because the person who approved is no longer watching by the time it is used.

Wider extents -- this session, until revoked -- are left undesigned. They are authorities rather than approvals, and the place to add one is the roles model, where a list of who holds what already exists. The standing case a workspace actually asks for -- stop asking about this once we trust the agent with it -- is built in auto-approval.md as policies a workspace can list, each naming who set it, decided at the gateway and recorded there.

The approval carries the call

Built; As built below records where it differs from what was designed. It replaces the retry above: what a person approves is sent as it was shown to them, by the platform, once.

What went wrong before it

A refused request is not kept. The queue item holds the method, host, path and the bound fields -- what a person needs to decide -- and the turn parks. When the answer comes, the resumed turn is told "it has since been approved, so make this call now", and the model writes the whole request again from its transcript. Three things follow, all seen driving Hollowbrook on 2026-10-21:

  • The model has to reproduce the call exactly. A grant is keyed on the digest of what was refused, so a call that differs in a bound field is refused again and asks again. A small model that rounds an amount or reformats a date asks a person twice about one charge.
  • A grant dies with its turn. Grants are keyed by job, and a job is one turn. A call approved and then dropped by the network ended the turn; the person's "try again" started another, which had no grant, so it asked again. The Don't ask again box did not help, because its extent is the turn too, which nobody approving a payment can see.
  • A grant is never spent. spent_at is reserved and written by nothing, since the gateway has no database. Within its turn an approved call can be sent any number of times, and only the idempotency record stops a repeat.

The design

Hold the request. When the runtime relays a refusal to /work/gated, it sends the whole request -- method, URL with its query, the headers the guest set, the body, and the operation and tool call it came from -- not only its shape. The API keeps it in a held_requests row beside the item, not in the item's payload: the payload is what every target of the item may see, and a body may carry more than a person deciding needs. It is capped at the egress body limit, and refused larger, as the call itself would be. The guest has no credentials, and the gateway attaches the rule's, so nothing held is a secret the platform issued -- though it is the workspace's data, kept as long as the item and swept with it.

Approving sends it. The grant names the held request rather than a digest, and the resumed turn's runtime sends that request before the guest runs: through the gateway, under the turn's token, with the idempotency handling any call gets. The bytes are the ones refused, so the grant matches by construction. In the runtime rather than from the API, because an agent's requests are made by the gateway for a turn: the call's usage, its transcript entry and its idempotency record belong to the turn that asked for it, and a second path out of the API would be a second thing to keep honest.

The model is given the answer, not an instruction. worker::answered replaces the refused tool result with the response, under a line saying who approved it -- "Approved by Dana Reyes and sent:" and then what came back. Declined reads "Declined by Dana Reyes", as now. The model never writes the approved call again; it reads what happened and carries on. A different call is gated as usual.

Spent when answered, not when used. The API writes spent_at when the held request's outcome is reported, which it can, having the database the gateway lacks. A grant is keyed to its held request, not its job, so it outlives a turn that ended badly and is good for exactly one answer. What the outcome was decides what happens next:

  • Answered (any status): spent. The response is the tool result; a 4xx is the recipient refusing, and the model reads it like any other.
  • Never sent -- the connection was refused, or failed before a byte went: not spent. The next turn sends it again, up to three times, and then the item reopens as approved, but could not be sent. This needs the gateway to tell "not sent" from "sent, no answer", which its did-not-complete mark does not yet; the transport knows, since it fails at a different step.
  • Sent, no answer: not sent again automatically, because it may have landed. If the operation sends an idempotency key, the recipient dedupes and a resend is safe, so it is resent as for never sent. Otherwise the item reopens as outcome unknown, for a person to check and then either resend or mark done. That is idempotency.md's attempted, given to somebody who can find out.

The checkbox goes

Don't ask again for charge while the agent finishes what it's doing is removed from the prompt. Its extent is invisible -- a turn is a word from inside this platform -- and it widens a yes beyond the figure shown beside it, which is the one place an approval must not be ambiguous. What it was for is covered better elsewhere:

  • Several of one kind, waiting together: the queue offers Approve all, listing every item it covers, so the person sees each one they say yes to.
  • The same kind, asked again and again: after a few approvals of one requires in a workspace -- three -- the prompt links to an auto-approval policy with the fields filled in -- a standing yes made on purpose, with a ceiling and an expiry, rather than ticked in passing. Built: shown only to somebody holding approvals:delegate and approvals:answer, from the count GET /v1/approvals/answered returns.

The unit extent stays in the gateway, where a policy may yet use it; only the prompt stops offering it.

What it changes elsewhere

  • idempotency.md, What approvals already assume: still true that the gate refuses before dispatch, and now also that only the held request is sent after approval. The retraction it warns about goes away.
  • worker::answered stops rewriting "do not retry" into "make this call now"; it substitutes the response.
  • The guest needs nothing new. It sees a tool result, as for any call.

As built

The pieces are held_requests (migration 0055), api::held, the shared types in egress::held, and AgentHost::send_held in the runtime. What differs from the design above, and why:

  • The grant travels in a token of its own. A grant in the turn's own commitment would let the guest's own calls through it too, so a model reading "Approved and sent" and making the call again would charge twice. The API commits the turn's gates with a call grant per waiting request into a second token (held_token, beside held_gates on the assignment), and the runtime sends the held requests under that one and nothing else. The guest repeating the call is refused and asks again, as any different call would. A runtime that misused the second token could send the approved request more than once for that token's few minutes -- what a compromised runtime still reaches, bounded as everything else it holds is.
  • The refusal is found, not named. The guest needs nothing new, so no tool call id travels with a refusal. The turn being prepared finds it: by the call's arguments digested exactly as the item was, where they are a fetch's, and otherwise by the method and URL the result echoes -- a declared operation's arguments are its own fields. Failing the digest, the bound fields are compared for what they say rather than how they are spelled, so a model writing "29000" where the guest sent 29000 still finds its refusal; the digest keeps telling the two apart, since a grant is keyed on it, and finding a refusal grants nothing. Each held request answers one refusal. The runtime puts the answer in the conversation it was handed, and worker::answered puts the same sentence (egress::held::result_text) there on every later turn. Where no refusal is found -- trimmed away, or not recognized -- both say it in a note naming the request (egress::held::unmatched_note): the runtime before the turn's prompt, and the API, on later turns, before the first thing written after the send was settled (settled_at, migration 0058), so the note stays where it happened and no later turn is left a stale refusal with nothing beside it.
  • Reopening is a new item. A turn's attempt is chosen by whether its approval was answered (grant::was_answered), so turning the answered item back to pending would unanswer it. Approved, but could not be sent and outcome unknown are raised as fresh approval.<requires> items carrying followup and the held request's id, with no hold and no turn: the conversation carries on, told what happened. POST /v1/approvals/{id}/resend and /done answer one; /answer does too, approving as resend and declining as done, so a client that knows only approvals still works.
  • Refused on this side. A held send the gateway refuses -- the rules changed, or a second gate covers it -- is not retried, since it would be refused the same way; it goes straight to could not be sent.
  • A resend starts a turn. Sending one again -- /resend, or /answer with a yes -- writes a note in the user position that nobody typed ("Dana Reyes asked for the approved charge ... to be sent again"), marked resend and framed as the platform's on the way to the model, as a wake's is, and queues a turn to answer it (held::resend_now). That turn sends the request before its guest runs, as any turn handed an approved one does. Written together, after the settle: a turn that fails to start leaves the request approved, and the conversation's next turn sends it.
  • What came before still works. An item raised by a runtime that relayed only the shape holds no request, so answering it mints a grant for its turn and the refusal is retracted as before. The covers_unit field is still accepted and still grants the unit for the turn where an item offered one; only the prompt stops offering it.
  • Spent on the held request. spent_at is a column of held_requests, written when the answer is reported. A held request's grant is never an approval_grants row -- it is derived from the request each turn it waits -- so that table's spent_at stays as reserved as it was.
  • Marked as going before it goes. The runtime tells the API it is sending a held request (POST /v1/work/held/sending, under the lease, turning approved into sending once) and sends only if the API agreed. A pod lost before it reports leaves the request sending, and before a turn of that conversation is next prepared -- which no turn still sending it would allow -- held::recover settles it as sent with no answer: sent again only where the operation sends the recipient a key to dedupe on, and otherwise raised as outcome unknown. A plain fetch_url is never sent twice this way. Reports are accepted from approved as well, for a runtime that does not mark.

Not settled

  • Whether a request that never left should start a turn of its own to try again, as a resend does. That costs a model call to say "still could not send it", so for now the conversation's next turn tries, and after three a person is asked.
  • How many approvals of one kind make the policy link worth showing.

A ceiling, rather than a rule per operation

Everything above is opt-in by whoever documents an operation, and that is right for it: a skill declaring a rule about its own endpoint can only make the platform stricter. Some deployments want the other direction -- an agent that may not reach anywhere new without somebody's word -- and that cannot be a declaration in a skill, for two reasons. It has to hold for an agent with no skills at all, and a workspace must not be able to escape it by publishing a skill that omits a line.

So it is a setting, approve_new_hosts, cascading operator to workspace to agent like every other (settings.md). Where it is on, the API turns it into ordinary gates at turn preparation: one per allowed host, every method, /* for the path. Nothing downstream knows the setting exists -- the commitment, the token claim, the gateway's check, the refusal and the parked turn are the machinery above, reused whole.

Three things about what it gates are worth stating, because each is a decision rather than a detail.

A host, not a request. The unit a person can honestly approve is a host: reaching api.example.com twice is the same act both times, which is the test inhibitors.md sets and the reason the egress model can approve a host at all. Per request would ask three times for three pages of one site.

Every method. A read of an unreviewed host is as much a reach as a write. The setting is about who the agent talks to rather than what it says to them.

Not the hosts a skill brought. A host that arrived because somebody installed a skill declaring it was consented to already, in an act that named the skill and the host together -- POST /v1/skills/{id}/hosts/approve is that act, and egress_rules.from_skill_id is what records it. Asking again, per conversation, asks the same question in a worse place. A host added by hand through /v1/egress-rules carries no skill and is not exempt: it says agents may reach that host, not that any particular use of it was reviewed, and that gap is the one the setting exists to close.

A wildcard is never exempt, however it arrived. Approving a skill's hosts takes settings:update, which is also what turns the ceiling on -- so whoever sets it can exempt a host from it, and a skill declaring *.example.com would otherwise exempt everything under that domain. No authority boundary is crossed, but the exemption is meant to be for a host somebody named, and a wildcard names a class: consenting to a class is exactly the permission-dialog hazard the covers section above is about. A workspace that wants the class exempt can say so host by host.

What it does not gate is anything nobody allowed. A host with no egress rule is refused outright and always was; a gate there would be a second answer to a settled question, and a worse one, since it reads as though approving were possible.

Off by default. An agent's first call to each new host becomes a stop-and-wait, which is the point in a deployment that wants it and an obstruction in the rest.

What a no is worth

A decline mints nothing and lifts the hold. Those are two different jobs and it took a wedged conversation to separate them: the grant is what lets the work proceed, so without one the gate refuses the same call again; the hold only decides whether the conversation can carry on, and the hold is session-scoped. Leaving it up suspended every later turn, with the queue item cancelled so nobody could answer it again -- declining one charge ended the conversation, and only an operator releasing the hold by id could revive it.

What stops the agent asking again a second later is being told, in the transcript, that a person said no and to discuss it before re-attempting (chat::DECLINED_GUIDANCE). Prose rather than a mechanism, deliberately.

A declined operation can always be raised again. That is the intended behavior and not a gap. Somebody who declines a charge, talks it over and changes their mind must be able to have it go through, which means the agent has to be free to try again once that conversation has happened -- and the gate holds the line regardless, since the retry raises a fresh approval rather than slipping past. A model that re-asks without discussing it first is a model ignoring a plain instruction, and the cost of that is an approver seeing the question twice.

The alternatives were all worse, and each needed a number nobody could choose well: a cap per session, a cooling-off window, or not refunding the attempt a park gives back. Each can be tuned wrong in a way that refuses a legitimate second ask, which is the failure that actually hurts.

Who may answer

Two questions that look like one, per inhibitors.md.

May this person answer approvals at all is an ordinary authority, approvals:answer, held by whoever a workspace decides. It goes to admin in the templates and deliberately not to operator: an operator builds and runs the agents that raise these, and the default should not be the one where the person who wrote the agent signs off its charges. A workspace that wants that can add it, which is the point of roles being the workspace's own.

Is the approver entitled to the thing approved depends on who owns the concept. Where the authority is ours, requiring it is right and its absence is a real escalation. Where it belongs to a remote system -- charging a card in somebody's payment API -- it is not ours to check: the vocabulary in rbac.rs is fixed and code-defined, and a customer's idea of who may take money was never in it. So for a remote call the approval is what it says it is: a person holding approvals:answer looked at this and said yes, recorded with what they saw.

Which is why the queue targets a role rather than a person, and resolves membership when the queue is read -- see action-queue.md. Who may approve a charge is a question about the workspace's own organization, and it changes without the pending request changing.

How the gate fires

Two halves, and only the first is being built now.

An explicit request. Built. POST /v1/approvals raises one: a suspended hold scoped to the session, and a queue item naming it. POST /v1/approvals/{id}/answer answers it, and an approval releases the hold, which gives the parked turn back.

Three things in there are worth knowing before changing them. Raising takes agents:inhibit and answering takes approvals:answer, because asking and answering are opposite acts and sharing an authority would make anybody who may approve a payment able to park any conversation. An answer finds its item through the caller's own queue, so an approval somebody was not asked about is not theirs to answer however senior they are -- and an item that is no longer open is told apart from one that was never theirs, because "already decided" is what two people answering at once produces. A decline leaves the hold on: the turn stays parked, which is honest, since nothing has changed about whether the work may proceed.

The automatic gate. Built. The gateway is the only tier that sees every outbound request, holds the credential, and can refuse before anything is spent; it is also the tier a compromised runtime cannot influence. What it cannot do is know which operation a request is: it receives a method, a URL and a body, and the file that declared the rule lives in the API.

So the rule reaches it the way egress rules do. The API resolves a turn's bound skills, hashes the gates it finds into a root (egress::gate) and signs that into the turn token beside the egress commitment; the runtime relays the set unchanged; and the gateway checks the set against the root before consulting it. A runtime that dropped a gate from its copy gets nowhere, because the set no longer hashes to what the token says.

The asymmetry with egress is the whole design, and it decides which way a stripped claim fails. An egress rule is a permission: a request proves one and is allowed, so failing to prove means refused and absence is safe. A gate is an obligation: a request matching one is refused until somebody approves, and absence read as "nothing is gated" would let everything through -- which is the answer a forger would choose. So a gate commitment is a second claim rather than folded into the first, "nothing gates this turn" is Gates::none() with a root of its own, and a token carrying no gate claim is refused outright rather than defaulted.

It also means the whole set travels rather than one gate and a proof. A Merkle proof shows presence; what the gateway has to establish is that a request matches none of the gates, and absence is not something a proof provides.

Two things follow that are worth knowing before changing them. The gate is declared with a matches -- POST /charges -- rather than derived from the prose that tells the model what to call: the body says "fetch_url with POST http://.../charges" because that is what a model needs, and deriving a security gate by parsing that sentence would make the gate depend on how somebody phrased a paragraph. And the gates are recorded when a version is published rather than computed per turn, because a file's content lives in the object store by hash, so a turn deriving them would read every bound skill's every file before its first token. A version is immutable, so what it declares cannot change afterwards.

What the gateway does on a match is refuse the request, in words the guest can read, rather than parking the turn: parking is the API's, which owns the job and the queue, and this tier has a method, a URL and a token. So the money does not move and get approved afterwards -- which is the ordering that matters.

The refusal ends in a fixed suffix (egress::gate::gated_suffix): a sentence telling the model not to retry, then a mark. Three things find it by that suffix -- the API putting an approved call's answer in its place, the worker recording the result as gated so the browser draws a held call as waiting rather than failed, and the worker taking the suffix out of what a person reads. Only ever as the end of an error the gate raised: the mark is not secret, and a fetched page carrying it is a page.

Not the guest asking. A request_approval host call would be a gate that fires only when the guest chooses to ask, which is the same fault inhibitors.md names in delivering a denial as a tool result. A guest may additionally ask -- "this looks unusual, check with a human" -- as a judgment above the mandatory gate. What must not happen is the mandatory gate depending on it.

Not yet

  • ~~The capability, and with it the loop.~~ Built: approval_grants, and egress::grant for what a grant permits. A grant travels in the turn token beside the gates, so the tier deciding whether a request goes out reads it from a signature rather than from a database -- which is also the only tier that has the request body, and a unit grant is keyed on a field of it.

The gate is not removed from the commitment when a grant exists. That was tried and is the opposite of safe: a gate nobody commits to is a gate nobody enforces, so anything an over-broad removal covered became an ungated request. Keyed on the act alone, one approved GET uncommitted every reach gate for every unreviewed host. The gate stays; the grant is what lets one request through it. - ~~Raising one from a refusal.~~ Built: api::gated. The gateway still only refuses -- it owns no job and no queue, and a write path there would put policy in the tier kept free of it. The runtime relays the shape of what was refused, and the API decides whether it really was gated by re-deriving the turn's own gates. A runtime that invented a refusal gets nowhere; what it can still do is decline to ask, which it could do anyway by not making the call.

The turn then winds down at its next round boundary -- never mid-round, since a round cut partway has its tool calls refused wholesale -- and the job parks rather than completing, keeping the reply it had written. Completing was the bug: the hold was taken, the job succeeded, and answering the approval had nothing to give back. - What the queue row shows for a charge. The payload carries what every target may see and no more (see NewItem in api::actions), which for a charge is the figure and the unit rather than the conversation that led to it. - Whether a declined approval is distinguishable from an expired one in the transcript. Both leave the turn unable to proceed; only one of them was a decision. - Tagging operations by risk, which inhibitors.md already places: the right shape applied too early, worth revisiting once there are enough declared operations to see whether they sort into groups. The OpenAPI wizard now produces hundreds, and auto-approval.md designs risk as a selector for workspace policies -- and nothing else.