Confirms & receipts
A high-effect ask carries receipts — what will happen, why it's fireable, where the human is — and by default the agent's own `confirm: true` crosses it; `requireHumanApproval` makes the crossing require an approval a person recorded.
High-effect actions (confirm: true in the graph) stop at a needs-confirm gate that carries
receipts, and every answer leaves an auditable row.
Where the gate lives
Read this before anything else on the page, because it is a trust boundary and an undocumented trust boundary is how audits fail.
The gate keys off the principal, not the door. confirm is a Mode B tool argument, not a
session concept — session.fire() has no confirm field and never will, because a boolean the
caller controls is not evidence. So a fire that reaches the session directly is not gated by
confirm at any layer: the app's own code owns its session, and source: 'user' /
source: 'system' / invoke: false are the app reporting motion a person really performed.
What requireHumanApproval changes is that an
agent-sourced fire is gated wherever it comes from — the Mode B port, the MCP server, the
testing harness, or your own code calling session.fire(id, { source: 'agent' }). One chokepoint,
every door.
The corollary, in the same voice the never-trap gate
already uses: hand a model a port built with source: 'user' and you have disarmed this gate.
What `confirm: true` is, and what it is not
By default confirm: true is the agent asserting that a human approved — nothing ties it to a
recorded human decision, so a model that skipped the ask is indistinguishable from one that got a
yes; sent on the very first call it crosses the gate and the journal stays empty. Until you
turn enforcement on, Approve is a recorded decision plus a convenience message — honest for a demo,
but we would not describe it as enforced human-in-the-loop. With
requireHumanApproval, a confirmed fire must
carry the askId of a decision a person recorded, and anything the library cannot prove is
refused.
The ask carries receipts
An empty "are you sure?" makes a human rubber-stamp. So the needs-confirm result carries a
receipts object, assembled from what the session already knows — no new work, nothing
for you to wire:
{ "judgment": "needs-confirm", "step": "checkout.place-order", "askId": "ask#1",
"performed": false, // nothing happened — this is a pause, not a failure
"why": "Nothing has been done. This is a question for the human, not a failure — do not report it as an error, and confirm: true is not the human’s answer.",
"receipts": {
"willDo": { "does": "Place the order", "writes": ["orders"] }, // what happens (a claim, honesty-tagged)
"because": [{ "key": "cartCount", "op": "gt", "actual": 2, "result": true }], // the guard evidence
"youAreOn": "checkout", "version": 7, // where the human is
"recentSteps": [{ "what": "catalog.add-to-cart", "principal": "agent", "outcome": "committed" }]
},
"howToAct": "Show the human what this will do (see receipts)…" }because is structural guard evidence, not a guessed rationale — the session knows why
the edge is fireable, because the guard just evaluated. willDo is the
authored claim (does + declared writes), honesty-tagged like every claim in the library.
performed: false and why are the other half, and they are there because of a reported
failure: an agent read ok: false as the app broke, told the person so, and went hunting for
another route. Nothing had happened and nothing was wrong — a person had the question. Both
fields are fixed authored text plus a boolean, so a machine can branch on the first and a model
reads the second.
While the human is deciding
session.asks() is the ask book: one row per card — { askId, affordanceId, instance?, answer?, spent? } — with answer absent while the person still has it. It is a read; the receipts stay
on the ask.
An agent holding the askId asks the same question through did_it_work,
which answers 'awaiting-human', 'approved-not-yet-done' or 'declined' — and, once the yes
has been spent, the settlement of the fire it authorized. A paused action has no transition, so
it never appears in pending() or awaitingSettlement(); before the ask book it was answered
UNKNOWN_TRANSITION beside two lists that could not contain it.
The whole surface, with its honest limits, is A pause is not a failure.
Decisions leave a record
- Approve (default) — the agent calls again with
confirm: true; the fire lands and its transition carriesaskIdback to the receipts the human saw. This is the agent's assertion, recorded and not enforced. - Approve (enforced) — the app calls
session.approveAsk(askId, { by })when the person clicks Approve; the agent's nextconfirm: truefinds that yes and crosses. One yes, one action. - Decline — the app calls
session.declineAsk(askId, { by }). Under enforcement, a decline relayed by the agent (Mode Bdecline: true, orsession.declineConfirm(id)) is recorded as its report, closes nothing, and leaves the ask open — so an agent cannot bury a pending ask or manufacture a human no, and a real no is terminal for thataskIdforever.
session.confirms() returns the ask → decision → fire chain; session.onConfirm(fn)
streams rows to your audit sink live. This journal is deliberately SEPARATE from the
gap ledger: a gated action is consented capability, not unmet
demand.
The seven kinds, side by side
ConfirmRecord.kind grows: 'ask' | 'approved' | 'declined' shipped in 0.6.0, and enforcement
adds four facts the library previously could not record. Laid out together because the difference
between an ALLOW and an ALWAYS ALLOW has to be visible on the page as well as in the data:
kind | who writes it | what it means | authorizes |
|---|---|---|---|
'ask' | the gate, when a high-effect step stops | the card exists; it carries the receipts | nothing — an 'ask' row's principal proves only who asked |
'approved' | approveAsk (principal: 'user') — or, in default mode, the fire itself | a human's ALLOW | one fire, then it is spent |
'always-approved' | alwaysApprove (principal: 'user') | a human's ALWAYS ALLOW — a standing policy, scoped to the action (+ optional scopeInstance), never consumed | every matching fire until it expires or is revoked |
'declined' | declineAsk (principal: 'user') — terminal | a human's no | nothing, and it outranks a standing grant |
'declined' + relayed: true | declineConfirm / Mode B decline: true under enforcement | the agent's report of a refusal | nothing, and it closes nothing — the card stays open |
'used' | the fire that spent an approval (carries transitionId) | an approval was exercised | nothing — it is the receipt for a spend |
'refused' | the gate (carries rejectionReason) | a crossing attempt with no valid yes | nothing — it is the forgery, recorded |
'revoked' | revokeAlwaysApprove (principal: 'user') | a standing grant was withdrawn | nothing |
A durable grant is a new kind rather than a scope field on 'approved' precisely so a
0.6-era filter cannot silently miscount it as a one-time yes — being missed here is a security
misreading, not a cosmetic one. Read the kinds you know and let the rest fall through.
'declined' is listed twice because the relayed flag is the whole difference between a human's
no and an agent's report of one — and an auditor must never have to infer that from principal,
which on a relayed row is the caller's claim rather than a fact. Rows the enforcement path wrote
also carry enforced: true, so a journal export separates what the gate will honour from the
pre-enforcement rows without inferring it from a kind.
requireHumanApproval — make Approve enforceable
Opt in at the session, and a high-effect agent fire is refused unless it carries the
askId of a journal row a person's own control recorded. The proof stops being a boolean the
model sets and becomes a pointer to a decision the app wrote down.
const session = map.createSession({ node: 'checkout', requireHumanApproval: true });
// Your Approve button — a channel the model does not write.
onApproveClick(() => session.approveAsk(askId, { by: 'alice@ops' }));
onDeclineClick(() => session.declineAsk(askId, { by: 'alice@ops' }));by is required: an approval whose decider is unknown is the claim-as-fact this closes.
There is deliberately no principal argument — an approval must be one thing only, so the
door stamps 'user' and there is nothing to lie with. The doors return a typed
ApprovalResult rather than throwing, because they run inside click handlers, and they answer
NOT_ENFORCED on a session without the option: a row nothing reads would authorize nothing.
One yes, one fire. An ALLOW is single-use. The fire that spends it appends a 'used' row,
so an auditor can count approvals against executions, and a second fire under the same askId
is refused APPROVAL_SPENT.
The approval binds to the receipts. A human approved the action and the input on the card
(receipts.willUse), so a fire under that askId carrying anything else is refused
APPROVAL_MISMATCH. Identity is exact structural equality over a canonical, key-order-independent
rendering; anything the receipts cannot hold faithfully — a Map, a Date, a BigInt, a cycle,
a value past the snapshot caps — is refused rather than guessed. This is the one place the library
declines toward REFUSE: elsewhere an unjudgeable thing is passed, because a wrong rejection has no
appeal; here an unprovable match is not a match.
It binds to a copy, and that is not a detail. confirmAsk detaches the input the moment it
arrives, so keeping your own reference and changing it after the yes is refused
APPROVAL_MISMATCH rather than compared against itself. Without the copy, an app holding its form
state — or a relay reusing one arguments object for the ask and then the fire — could send 999999
against a card that said 10, and the journal would read ask → approved → used with nothing
wrong in it. A value the library cannot copy faithfully binds to a stand-in that can never match,
so it is refused too.
And the payload the gate proved is the payload that executes. The copy above closes the ask
side; the fire side is the same rule. Under enforcement the gate reads your payload once and
your handler is then called with that reading — so a value that changes between the two cannot
exist. It could before: fire() returns synchronously and the handler runs on the next microtask,
so a plain payload.total = 999999 on the following line beat it to the object, and a getter or a
Proxy did the same inside one statement. A payload the library cannot copy faithfully is refused
APPROVAL_MISMATCH / cannot-judge here too, because it cannot prove what such a value will be
when the handler reads it. Two consequences worth knowing: under enforcement a high-effect handler
receives a structural copy of your payload, not your object (so a Map, a class instance or a
function in a high-effect payload will not survive — send plain data); and none of this applies
without requireHumanApproval, where your handler still receives your own object exactly as
before.
ALWAYS ALLOW is a policy row, not an approval. session.alwaysApprove(id, { by, instance?, expiresInMs? }) records a standing grant, scoped to the action (and optionally one instance) and
deliberately not to the input — a grant bound to one input would be indistinguishable from a
single ALLOW. Tell the human the truth in those words: "always allow Add to cart — any item, for
the next hour." Every fire it authorizes still lands a 'used' row, so the exercise count is
visible, and session.revokeAlwaysApprove(id, { by }) withdraws it immediately.
Staleness is recorded always, enforced only when asked. Every enforced row carries its
timestamp and the stateVersion the human decided at. Pass a policy to act on them:
requireHumanApproval: { expiresAfterMs: 120_000, refuseWhenWorldMoved: true }Both default off, because the threshold is a product decision the library cannot make for you — approving a refund may legitimately take four minutes, and in a live-tapped app the state version moves on almost every report.
Turning it on
Nothing changes unless you ask. requireHumanApproval defaults off and 0.6 behaviour is
byte-identical without it — same rows, same principals, same supersede semantics, pinned by its own
test file (test/human-approval-default-unchanged.test.ts).
And the library says something when you have not. On the first high-effect fire from principal
'agent' with no askId on its record, on a session that never mentioned this option, onWarn
carries one line naming the action, the option, and the fact that this fire executed with no
approval on record. It is said once per session and it refuses nothing.
It exists because of a real configuration: a gate declared on one serving port
(confirmHighEffect), or as a boolean inside one chatbot, is a property of a door — any other
caller holding the same session performs the same action unheld, and the journal shows an agent fire
with nobody's approval attached. The gate here travels with the session, which is why the warning
points at it. An app that means the default can say so — requireHumanApproval: false is a policy
stated rather than a policy never considered, and it is never warned about.
Note for anyone switching exhaustively. Three public unions widen — FireResult,
GapRecord.rejectionReason, and ConfirmRecord.kind — so an exhaustive switch gains cases and
stops compiling until you add them. Every existing value keeps exactly the meaning it had: a new
kind is a new fact, never an old one relabelled. Read the kinds you know and let the rest fall
through.
Turning it on is a two-part change, and the second part is yours: the option makes the gate
real, and your app has to give a person a way to answer. Wire your Approve/Decline controls to
approveAsk / declineAsk. Until you do, every high-effect agent fire is refused
APPROVAL_REQUIRED — fail-closed on purpose.
A decline is as unforgeable as an approval
A caller must not be able to manufacture a no, and — worse — must not be able to bury a
pending ask by declining it so the human's card disappears. So under enforcement declineConfirm
records a report and closes nothing, whatever principal it is handed: the ask stays open, the
row is marked relayed, groundTruth() keeps saying "Awaiting the human's decision", and the
served result says so. principal is an argument, and an argument is a claim — passing 'user'
would otherwise have made the burial a one-word request.
A human's no arrives through declineAsk(askId, { by }) — keyed to the card they answered,
with no principal argument to lie with, exactly like approveAsk. It is terminal for that askId
for the session's life, and it outranks a standing grant for the thing the person was shown, with
or without the pointer: dropping the askId does not walk around it. A different input is
still authorized by a live grant, because a grant is deliberately not input-bound — and a re-ask
after a no mints a new askId, so an agent grinding a person toward yes leaves a countable
trail.
Every refusal, and what it teaches
| Refusal | What happened | The next move it names |
|---|---|---|
APPROVAL_REQUIRED | No recorded approval authorizes this | Show the receipts and wait for the person |
APPROVAL_SPENT | That yes was already used | Ask again — and say it is the second time |
APPROVAL_MISMATCH | A different action, input or instance (differs) | Ask again for THIS input |
APPROVAL_STALE | Too old, or the state moved after the yes | Show the current receipts and re-ask |
APPROVAL_DECLINED | The human said no | Tell them it was not done; do not re-ask the same thing |
Every refused crossing lands in both ledgers — a 'fire-rejected' gap row, so
groundTruth() says "did NOT happen … was refused: APPROVAL_REQUIRED", and a
'refused' confirm row, so the journal an auditor points at tells the whole story. The rows are
never deduplicated (a repeated forgery is new information); only the dev warning is.
At the served boundary APPROVAL_REQUIRED comes back as judgment: 'needs-confirm' with fresh
receipts — enforcement is not a wall the agent bounces off, it is the ask, again, honestly.
What this does NOT prove
It proves exactly this much: a row of the right kind, from the right principal, for this action and this input, exists and has not been spent. Everything below is outside that sentence, and is listed rather than implied.
- WHO the human is.
byis a string your host supplies. The library records it and never checks it — authentication is your job. - The integrity of your own approval channel — the loudest item. The option moves approval onto
a channel the model does not write by a convention you uphold, not by a proof we can offer. If
you wire
approveAsksomewhere a model can reach, the gate is only as strong as that wiring. - What your handler does with what it is handed. The gate binds the payload — the value it proved is the value your handler receives, and a value it cannot copy is refused. What it cannot bind is what happens next: a handler that re-reads your app's live state instead of its argument is acting on something no gate saw.
- A port that stamps a human principal. Covered at the top of this page: building one warns
through your
onWarnand serves the unenforcedconfirm/declinedescriptions, because telling a model "this app refuses that" through the one port whose fires are exempt would be the same class of lie.session.requiresHumanApprovalFrom(principal)is the honest question for a port;session.requiresHumanApprovalis the question about the session. - Cross-session
askIds. Ask ids are per-session counters, so two sessions both mintask#1and an id from one never resolves in the other. An audit sink must key on(session, askId). - Tier-2 effect-signature inference. A transition the session inferred from a state delta
never went through
fire(), so no gate saw it. It is recordedprincipal: 'unknown'and marked attributed by inference, not observed — see Ground truth. - The app calling its own handler function. The gate is on
fire(). Code that calls the underlying function directly bypasses the session entirely, and the session cannot know it happened. - Non-high-effect actions. The gate only holds fires of steps declared
confirm: truein the graph. An action nobody marked high-effect is not held, and marking it is a graph decision.
One thing it does cover, because it is the natural next question: a
tour cannot walk through a high-effect door.
allowUnmaterializedFires lets an unbound fire through as an honest no-op, but the approval gate
sits before that arm — so an unapproved high-effect fire is refused rather than answered
ok: true, executed: false, and an agent cannot enumerate the high-effect doors by firing them.
`willUse` puts the input in the receipts pack
Binding the approval to what was shown means the input rides the receipts — to the model, to the
human, and into the journal export. That is the point of it — a receipt that hides the amount is
worse than useless — and it means an input carrying a secret is in the pack by default.
redactedKeys governs state keys and never governed a payload, here or on
TransitionRecord.payload.
It rides only where it binds something: the Mode B port passes the model's input to
confirmAsk under requireHumanApproval and not otherwise, so a session without the option keeps
the 0.6 receipts and the 0.6 journal rows exactly. Call session.confirmAsk(id, { input })
yourself if you want the card to show it either way.
To hide a field inside it, name the path: redactedFields.
redactedFields — hiding a field inside the data
redactedKeys governs state keys. redactedFields governs the values a transition carries —
the payload a fire sends and the data a handler returns — which is where a real integration's secrets
actually live. It is off unless you ask for it, and you aim it per channel:
const session = graph.createSession({
node: 'checkout',
requireHumanApproval: true,
redactedFields: {
// every rendering of what the fire CARRIES: TransitionRecord.payload, willUse.input,
// AND what the control HOLDS — the same value, one turn before it is sent
payload: ['payment.token'],
// what a handler RETURNS: TransitionRecord.produced, the settlement, producedFor()
produced: ['apiToken', 'items.secret'],
},
});Dot paths, the same grammar footprintjs's RedactionPolicy.fields teaches. A path segment applied to
an array applies to every element ('items.secret' hides it in each item). A named field that is
present arrives as the literal '[REDACTED]' — exported as REDACTED, and the same marker a
redacted state key already shows in guard evidence.
payload governs three points now, not two — four in all. The payload list covers the
record's payload, the receipts' willUse.input, and — since a control's contents are the next
fire's payload one turn early —
what the served row says that control holds.
A field hidden from the log and the card that still rides the action row a model reads before it
fires is not hidden. The fourth point is produced, governed by its own list, which covers only
what a handler returns.
A marker, never a drop. A dropped field reads as a field that was never sent; the marker says a
value was here and you are not being shown it. A field that is absent or undefined stays absent —
marking it would announce a secret that was never sent. null is a value the app chose to send, so it
is marked. A value the library cannot read faithfully (a Map, a class instance) is hidden whole
rather than reached into, because own-property enumeration cannot prove the secret is gone.
What redaction costs, and what it deliberately does not touch
The approval gate is untouched, and that is tested. The ask binds to a faithful detached copy of
the input (bound-input.ts) and the gate compares the fire against that, never against the
rendered receipts. So the comparison keeps running on the real values: an approved fire still crosses,
a laundered one is still APPROVAL_MISMATCH, and a marker can never turn a mismatch into a match. A
caller that echoes the redacted card back as its payload is refused — a rendering is not an input.
The human and the model are not separable here. confirmAsk() returns one receipts pack to one
caller, and over Mode B that caller is the model, which this library then instructs to show the human.
There is no second channel down which an unredacted card could be sent, so hiding a field hides it
from the model, from the person reading the model's rendering, and from the journal alike. Aim
payload at fields a person does not need in order to judge the action; an app that draws its own
approval card still holds the raw input it passed in, which is the honest place to show a value the
model must not see.
What stays exposed by design. A payload that is itself a primitive (there is no field to name); a
handler's failure reason, which crosses as capped text through settlement.error and not through
produced; the key names in a schema-mismatch message (names are the designed disclosure, exactly
as with redactedKeys); and everything your own handler does with the value it is handed — the
library redacts what it records, never what your app is given. And an auditor recomputing the gate's
comparison from an exported journal can judge every field they can see; for a hidden one the marker
tells them precisely which field they cannot.
Two ways to run the approval
- Over MCP — the gate is just data in the result, and the host collects the yes. Portable, framework-free. See The MCP server.
- In-process — pause the agent's ReAct loop on a checkpoint, hand control to the human, resume exactly where it stopped. The dress-shop demo implements this with agentfootprint's pause/resume checkpointing.
When a control is busy
The third state — the app's own words for "this one is working right now", on the action row a model reads. A label, never a flag; it gates nothing, and no clock in this library will ever expire it.
A pause is not a failure
A needs-confirm result carries `performed: false` and one authored sentence saying nothing has been done — and `did_it_work` takes the askId, so an agent can ask whether the human has decided instead of guessing.