DEV Community

Human approval as a 2PC vote: a draft protocol for safe multi-tool agent action

Murthy G on October 26, 2026

Update (Nov 11): HATP 0.2.1 is on npm, completing the v0.2 milestone. A human can now take hours to decide. An agent can get a signed approval for...
Collapse
 
_firelinks profile image
Mike Dabydeen •

I'd choose the mode per transaction from the clock rather than make either one the default. A hold tier guarantee lasts only as long as its lease, and the human's decision time comes out of that lease. In your example Bob's card has decideSeconds 900, and if Carol's SLA is the same, escalation after a miss puts the worst case near 30 minutes before anyone commits. The 20 minute seat lease is gone by then. Renewal before commit helps only if the airline grants it, and because card capture runs before the holds commit, a seat that can't be confirmed leaves you refunding a step the plan declared final.

So the coordinator could add up the routing chain's worst case, each candidate's SLA in escalation order, and compare it with the shortest lease minus the time commit needs. If it fits, run 2PC. If it doesn't, switch to approval-first or don't place the holds yet. I'd also put each lease's expiry inside the signed plan digest, so the approval records the window it was given, and a commit after that window fails verification instead of running.

Collapse
 
murthyg profile image
Murthy G •

Agreed: pick the mode per transaction based on the clock. Adding up the escalation chain's worst-case SLAs is a sharper test than what I had in mind, so I'll put that in v0.2. If it fits inside the shortest lease minus the commit time, run 2PC; if not, use approval-first.

One correction: the card capture can't run against an expired seat hold. Before any commit step, the coordinator checks every lease and renews any that are running short. If the airline refuses, the transaction aborts before the capture, so there's nothing to refund.

On signing the lease expiry: in parallel mode the human signs before the holds come back, so their expiry times aren't known yet. In approval-first, the quote's validUntil is already signed. Your idea points to a third mode, though: place the holds first, then have the human sign a digest that includes their expiry times. I'll add that too.

Collapse
 
_firelinks profile image
Mike Dabydeen •

You're right on the expiry case. If every lease is checked and renewed before capture, an expired seat aborts with nothing to refund, so I'll drop that point. What the check doesn't cover is a confirm that fails inside a live lease, say the airline rejects it on fare or inventory. In 2PC the prepare vote covers that, because a participant that voted yes has promised it can commit. So it's worth asking each provider which kind of hold it gives. If confirm is guaranteed while the lease runs, the hold is a real vote and capture can go first. If it's only a reservation, I'd authorise the card, confirm the seat, then capture, so the step that can still fail runs while the payment is a void and not a refund.

For the third mode, the human's decision time now runs inside the lease, so I'd give the approval request its own deadline (the shortest expiry minus commit time) and let it lapse there, rather than accept a late signature and fail at commit.

Thread Thread
 
murthyg profile image
Murthy G •

Agreed, and that's what the tiers are for. A participant may only declare hold if confirm is guaranteed while the lease runs (§5.2). A reservation that can still fail on fare or inventory has to declare final. Your ordering then falls out of the fixed commit order: the card authorization is a hold, the seat confirm runs as the final step, and capture comes last. So a failed confirm voids the authorization instead of refunding a capture.

Where you've found a real limit: a plan allows only one final step. Two providers whose confirms can both fail means one of them has to support cancellation, or the plan can't be built. I'll make the spec say that explicitly, with the airline case as the example.

On the third mode: yes. Parallel mode already shrinks the approval deadline to the shortest hold expiry minus commit time once the holds report back. In holds-first the expiries are known before the ask, so that deadline can go straight into the signed plan.

Thread Thread
 
_firelinks profile image
Mike Dabydeen •

Transaction managers have a name for that rule: last resource commit optimisation. Narayana in JBoss lets one one-phase participant commit at the end of 2PC and refuses to enlist a second by default. Its docs call the override "transactionally unsafe", so your one-final-step limit matches theirs and is worth citing in the spec.

The case I'd add is a final step that times out. The airline may have confirmed the seat and the response got lost, so the coordinator can't choose between void and capture. I'd require a final participant to accept a key the coordinator generates and to answer a status query on that key, and keep the plan in an in-doubt state until it does. What should v0.2 do with the card authorisation while the plan is in doubt?

Thread Thread
 
murthyg profile image
Murthy G •

Thanks, both are good. The Narayana precedent matches exactly, down to its advice to use compensation instead, and I'll cite it in §5.3.

The timeout case is a real bug: today a lost response from the final step is treated as a failure, so the card gets voided while the seat may already be ticketed. Every request already carries an idempotency key (txnId and branchId), so retrying commit is safe and works as a status query. What's missing is an in-doubt state.

My answer for v0.2: while in doubt, neither capture nor void. Keep renewing the authorization and retry until the answer is definite. If it nears its horizon with no answer, escalate to a human rather than guess.

Collapse
 
max_quimby profile image
Max Quimby •

Modeling the human approval as a signed vote over a digest of the exact plan is the part most "human-in-the-loop" designs skip, and it's the part that actually matters. The common failure mode isn't that nobody approved — it's that someone approved a thing and the agent then executed a slightly different thing, because the approval was bound to a Slack message, not to the arguments. Binding the signature to the plan hash closes that gap cleanly.

Where I'd expect the hard part to live is the "prepare" phase holding real resources. 2PC's classic weakness is the coordinator dying after prepare, leaving participants holding locks — and a held flight seat or a card authorization has a TTL you don't control. So the approver deadline has to be shorter than the shortest participant hold, and you need a defined behavior when a hold expires mid-vote. Does HATP let a participant signal its own prepare-TTL back to the coordinator so the human deadline can be computed from the tightest one, rather than set as a flat timeout?

Collapse
 
murthyg profile image
Murthy G •

Good question. The answer is partly. A participant's prepare vote returns its real leaseExpiresAt, which it can cap below what was asked for. Holds can be renewed up to a limit the participant sets. Before committing, the coordinator checks that every lease still has enough time left, and renews or aborts if not. If a hold expires while the human is still deciding, the participant releases it and the transaction aborts before anything runs. There's also an approval-first mode that holds nothing while the human decides: the human approves quotes, and the holds are placed afterwards.

The gap you found is real, though. In v0.1 the human deadline (decideBy) is a flat value. It isn't worked out from the shortest hold. For v0.2 I plan to:

add a holdHorizon to the prepare vote: the latest time the participant will hold, renewals included
set the human deadline from the shortest horizon, minus time for the commit phase and a safety margin (it can only get shorter than what was signed)
tell the approver early when a hold dies, instead of only noticing at commit time

Collapse
 
murthyg profile image
Murthy G •
Collapse
 
alexshev profile image
Alex Shev •

The 2PC analogy is strongest when approval binds to an immutable intent: exact tool, parameters, scope, expiration, and expected effect. Otherwise approval becomes a reusable permission token that can outlive the context the human actually saw. I’d also make abort the default after a timeout and leave an auditable record of the prepared-but-never-committed action.

Collapse
 
murthyg profile image
Murthy G •

Agreed on all three, and that's how it works now. The approval is signed over a digest of the whole plan: the tool, its arguments, the amounts, the participants and the deadlines. So it can't be reused for anything the human didn't see, and it ends with the plan's decideBy.

A timeout aborts by default: a missed deadline counts as a no, every hold is released, and the record keeps the prepared-but-never-committed transaction with each participant's signed abort receipt.

Expected effect is the one gap. The approver sees the operations and amounts, not a preview of what will change. Effect previews (dry-run diffs) are planned for v0.3.

Collapse
 
murthyg profile image
Murthy G •

Collapse
 
murthyg profile image
Murthy G •
Thread Thread
 
alexshev profile image
Alex Shev •

The digest + default abort gives the protocol a clear authority boundary. For v0.3, I’d make the effect preview carry an explicit uncertainty flag: a dry-run shows predicted effects, not a guarantee. Then the audit record can distinguish “approved predicted diff” from observed post-commit state.

Thread Thread
 
murthyg profile image
Murthy G • • Edited

Agreed: a dry run is a prediction, not a guarantee. For v0.3, each effect preview will carry certainty: exact | predicted | none. That label goes into the signed digest, so the approver sees it and the approval covers it. Commit returns the observed effect, so the record keeps both the approved preview and what actually happened, and flags any difference. Nothing gets fixed automatically: a difference is offered for undo, with fresh consent. One open question: should an irreversible step be allowed to show only a predicted preview? I'll open an issue and credit you.

Collapse
 
murthyg profile image
Murthy G •

Collapse
 
anp2network profile image
ANP2 Network •

A Transaction Record can establish agreement while leaving evaluation unproven. Signatures cover what the participants asserted. Nothing in a hash chain is evidence that a predicate was computed, because a comparison that never ran and a comparison that ran and passed leave the same bytes.

We develop an open agent protocol and operate a public relay, and our spec carries a MUST close to your authority.maxAmount: a quote must satisfy the requester's spending cap, and a currency mismatch makes the comparison undefined and must be rejected.

In the running relay's source the cap field occurs exactly once, inside an example payload string in a help response. The quote field occurs zero times. Neither field is read anywhere.

Across the full ledger history of 120,229 rows, 1,826 acceptance rows carried a quote in a different currency from the requester's reward. That is the condition the spec says to reject. All 1,826 are stored.

Rows exceeding the cap: zero.

Before leaning on that, the strongest objection to my own reading: the two amounts are different economic quantities (one is the provider's execution budget, the other is what the requester pays), so differing currencies may be entirely legitimate and the stored rows may not be violations at all. I can't settle that from the log.

The zero is weaker for a duller reason. Quote amounts took two distinct values in the whole history, 0 and 0.01, with a maximum of 0.01. The inputs never approached the cap. On inputs like those, an enforced rule and an absent rule yield the same compliance rate, so "100% compliant" carries no information about whether the check exists. Source inspection is what established the gap. The ledger could not.

One more thing I'd flag for a draft spec specifically. The same document's open-questions list asked which reference rate to use for cross-currency comparison. The MUST was written on top of a quantity the document itself admitted was unresolved, and neither CI nor type checking said a word.

So for HATP I'd publish a planted violation per rule instead of a conformance figure. One transaction built to exceed the cap, one built with mismatched currencies, and the Transaction Record showing which clause fired in each. The record also wants the ID of the object that was actually checked, with an explicit absent result when there isn't one, since an outcome with no reference can't be re-derived later by anyone.

This is what bites "never routed to Bob." From the published record, routing that excluded Bob on an authority check and a $25,000 request that never arrived look identical. The routing chain tells an auditor who was asked, and the testable property needs the set of people who were eligible and not asked.

Would you add an above-cap transaction to the draft's worked examples, with a Transaction Record that names both the request it checked and the authority clause that rejected Bob?

Collapse
 
murthyg profile image
Murthy G •

This is the right test, so I ran it on the reference code before replying.

The authority comparison does run. The gateway resolves candidates through a check that drops anyone whose maxAmount is below the requirement, and a currency mismatch counts as not covered, so it's rejected rather than undefined.

But your point lands one level up. The requirement amount is written by the agent into the plan, and nothing checks it against the plan's own total. A plan could claim $100 for a $25,000 charge and be routed to someone with a $500 limit. I tried it: it committed. That comparison doesn't exist yet, and no conformance number would have shown it.

The record has the gap you describe too: it keeps who was asked, not who was eligible and passed over, or which rule excluded them. I've opened three issues for v0.2:

Thanks for publishing your relay's numbers alongside the argument. Quotes that only ever took the values 0 and 0.01 are a sharp example of why a 100% compliance rate can tell you nothing.

Collapse
 
murthyg profile image
Murthy G •
Collapse
 
alexshev profile image
Alex Shev •

The 2PC analogy is useful because it forces us to define what happens when approval and execution are separated by time. The prepare phase should freeze the intended effects, and commit should fail closed if any target, credential scope, or precondition has changed since the human reviewed it.

Collapse
 
murthyg profile image
Murthy G •

Agreed, and you've found a gap: today only held items are protected by their lease.

I've opened github.com/probita-ai/hatp/issues/76.

Each prepare vote will return its preconditions (target versions and credential scope), and these go into the signed digest. Commit carries them back, and a participant must refuse with PRECONDITION_CHANGED if anything differs. The coordinator treats that as a no: it aborts and never retries with fresh values without a new approval.

Your earlier certainty idea is in #62.

Collapse
 
murthyg profile image
Murthy G •

Update: this is in 0.1.4. One correction to what I said above. The preconditions are signed into each participant's prepare receipt, not into the digest the human signs, because in parallel mode the dry runs come back while the human is already being asked. Commit carries them back and fails closed with PRECONDITION_CHANGED. Putting the exact versions in front of the approver is the effect preview planned for v0.3.