SIGN IN SIGN UP

Let the boundary ask a person, not only say yes or no (#15)

* Let the boundary ask a person, instead of only saying yes or no

The action policy had two answers, so every action a deployment was unsure about
had to be permanently forbidden or permanently permitted. The cases people
actually have are in between: it may do this, but I want to see the first one; it
may spend money, but ask me over fifty pounds. The only approximation was to deny
the action and have somebody take the wheel, which throws away the Bot's turn and
everything it had worked out to reach it.

Adds a third list. `ask` is evaluated after `deny` and before `allow`, and both
halves of that position are load-bearing. It must not soften a deny, because a
thing a deployment has forbidden is not up for renegotiation at a prompt. It must
beat allow, because the shipped policy is `allow: ["true"]` and an ask checked
afterwards would be unreachable, so the first rule anybody wrote would silently
do nothing. A broken ask expression asks, the same way a broken deny denies.

An answer is bound to a fingerprint of the exact action it was given for: the
Bot, the tool, the ref, the key, the file path and the page. That binding is what
makes this more than a dialog box, because without it an id granted for "click
Place order" is spendable on "click Delete account". Approvals are single use and
expire after ten minutes, and they live in memory: a pending question is about a
live browser session and a live turn, and an approval that outlived the process
would be a grant nobody remembers giving.

Answering is audited as its own act by its own actor. The person who approves is
usually not the one whose turn raised the question, the two happen minutes apart,
and an approval that is given and never spent leaves no action row at all, so the
request, the answer and the action are three rows joined by an approval id rather
than one row with a flag on it.

The surface holds the tool call open, polls for the answer, and re-issues the
identical request with the approval attached, so an approved action costs the Bot
some seconds rather than its turn. A declined one comes back as a refusal and
Stop still works out of the wait.

In dry-run an ask interrupts nobody and is only recorded. The whole promise of
dry-run is that switching a policy on changes nothing, and a mode that started
stopping people to ask questions would be a mode nobody dares switch on.

* Ask about tool calls too, and put each question on the line that raised it

The ask list was only half wired. `evaluateActionPolicy` judges a Bot's calls to
other people's MCP servers as well as what it does in a browser, and that call
site only knew about yes and no: an ask verdict does not forward, so it was
thrown as a permanent refusal, with a sentence that read "The Bot wants to call
." because the neutral blanks a tool call is judged against were mistaken for a
file path. Nobody was asked, no id existed to grant, and the third list quietly
became a second deny list for every tool a deployment has added. That is the
outcome the list exists to prevent, and it lands on the first rule most people
write, which is about somebody else's system rather than about a button.

The MCP path now asks the same way the computer does: the question is opened
against the same registry, the route reports it as 409 with the same body the
acting routes use, and the surface holds the call open and re-issues it with the
answer attached. Its binding covers the arguments as well as the tool, because a
call to somebody else's server is identified by what it says rather than by what
it lands on, and "post the release note in the team channel" is not permission to
post something else somewhere else.

Answering moved to `/api/approvals`, its own surface rather than a pair of
handlers under the computer. A deployment can run plugins without a browser, and
a question raised where nobody can answer it is worse than a rule that never
fired: the Bot waits out the full ten minutes and then reports that nobody
answered, having never asked anybody. The one projection lives beside the
registry now, so the fingerprint cannot leave the process through one handler
while its sibling four lines away is careful to keep it in, and an answer names
the Bot the question was actually about rather than the address it arrived at:
otherwise the trail holds a grant filed under one Bot and the action it paid for
filed under another. The three rows are `approval.requested`, `approval.granted`
and `approval.denied`, no longer named for the computer, each filed against the
thing the question was about.

The card in the transcript is handed its question by the tool call that raised
it. It used to poll the Bot's list and show the oldest unanswered entry, and
nothing withdraws a question when a wait ends, so pressing Stop or reloading a
tab left one open for the rest of its ten minutes: the next turn's card offered a
person a stale question, recorded their Allow against it, and left the action
they were actually looking at waiting for an answer that never came.

`submit` is a policy attribute now. The type tool takes a flag meaning "and press
Enter", the computer presses it itself, and no keypress ever arrives as an action
of its own, so a boundary written about clicking and about `key` watched the one
call that submits a single-field form go straight past it. Both form presets say
it, and it is in the binding, because "fill the postcode in" is not "fill it in
and send the form".

---------

Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
J
Jerel Velarde committed
fcebb4a93f83c89f567e5da2529726c46120c07e
Parent: 6b9e4cc
Committed by GitHub <noreply@github.com> on 8/20/2026, 1:40:59 AM