DEV Community

Murthy G
Murthy G

Posted on Edited on

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

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 a single MCP tool call, and the tool server refuses the call without one. Agents can also ask people questions: pick an option, fill in a form, or answer in their own words. See What changed after your feedback and try npm run demo:gated.

Update (Nov 6): HATP 0.2.0 is on npm. It closes the biggest gap your comments found: the amount an approver's authority was checked against was a number the agent wrote. The gateway now works it out from the plan, and a record shows who was passed over and why. See What changed after your feedback.

Update (Nov 2): Two more fixes from the comments, now on npm as 0.1.4. A step's commit is now refused if its target changed after the human approved. And approvers can sign with a real passkey. See What changed after your feedback.

Update (Oct 31): Undo what an agent did. https://dev.to/murthyg/undo-for-ai-agents-a-draft-spec-for-taking-back-what-an-agent-already-did-41e0

Update (Oct 29): Readers' comments on this post found real gaps, and they're now fixed in the spec and on npm. The protocol plans the human's decision time around how long holds last, a payment step that times out is no longer mistaken for a failure, and any MCP server can join a transaction. See What changed after your feedback. Try it with npm install hatp-mcp.

Picture an agent booking a business trip. It holds a flight seat, reserves a hotel, adds a calendar event, and charges the corporate card. Finance has to approve anything over $500.

Today that is four separate tool calls and a Slack message. If the hotel is sold out after the card is charged, or the approver says no after the seat is booked, you clean it up by hand. And when the approver clicks "Approve", nothing records which amount, recipient, or card they agreed to.

I've been writing a draft protocol for this gap. It's called HATP (Human-Agent Transaction Protocol). This post covers the core idea, the parts of the spec, and what works today.

The gap

Protocol What it connects
MCP agent → tools
A2A agent → agent
AP2 agent → payments
IETF HEM / CHEQ drafts, Twilio A2H agent → a known human, with no role-based routing or transaction binding

None of these define:

  1. how to find the right human by role, skill, spending authority, workload and availability;
  2. how to run several tool calls on different servers as one all-or-nothing action; or
  3. how to make a human's approval part of that action, with a signature that covers the exact arguments.

The core idea: a human approval is a vote in a two-phase commit

Databases solved "all or nothing across several systems" decades ago with two-phase commit (2PC). Phase 1: each participant prepares and votes yes or no. Phase 2: if all voted yes, everyone commits; otherwise everyone aborts.

HATP treats the human approver as one more participant. Their "Approve" is a yes vote, signed over a SHA-256 digest of the whole plan. If they say no, or don't answer by the deadline, the transaction aborts and the holds are released. No effect is left behind.

Initiator   Coordinator     Airline   Hotel   Calendar   Payments   HumanGW   Bob
   │ begin ─▶│                │        │        │          │          │        │
   │         │─prepare───────▶│ hold seat (lease 20m)      │          │        │
   │         │─prepare────────────────▶│ hold room          │          │        │
   │         │─prepare─────────────────────────▶│ validate  │          │        │
   │         │─prepare────────────────────────────────────▶│ validate │        │
   │         │─prepare(finance)───────────────────────────────────────▶│─ask──▶│
   │         │◀── yes ×4 ─────────────────────────────────────────────│◀approve (passkey)
   │         │◀── yes + signed approval────────────────────────────────│
   │         │ log COMMIT
   │         │─commit calendar → charge card → commit holds
   │◀ record │ COMMITTED
Enter fullscreen mode Exit fullscreen mode

Honest guarantees: participant tiers

Real tools can't all do proper 2PC. So each operation declares what it can actually guarantee:

Tier Prepare Commit Example
hold Places a hold with a lease Converts the hold; must succeed while the lease is valid Card authorization, a seat the airline guarantees to ticket
compensate Dry run only Does the effect and returns an undo token Calendar event, CRM update
final Dry run only Irreversible Card capture, sending an email, a booking whose confirm can still be refused

A plan may contain at most one final step. The coordinator commits in a fixed order: compensate steps first, then the single final step, then the holds. Running the one irreversible step while every hold is still guaranteed is the old XA "last resource commit" trick. If anything fails before the holds commit, the only effects left are ones that can be undone.

Finding the right human

HATP adds addresses for people and agents:

human://acme.com/bob                                  a person
human://acme.com/role/finance-approver?quorum=2-of-3  any 2 people holding a role
human://acme.com/pool/security-leads?skill=sox        a group, filtered by skill
agent://acme.com/travel-bot                           an agent
Enter fullscreen mode Exit fullscreen mode

The Human Card

A2A gives every agent an Agent Card that says what it can do. HATP gives every person a Human Card. Here's a shortened example (the full schema is in the spec):

{
  "id": "human://acme.com/bob",
  "displayName": "Bob Chen",
  "roles": ["finance-approver"],
  "skills": ["travel-policy", "sox-controls"],
  "authority": [
    { "action": "approve.spend", "maxAmount": { "value": 10000, "currency": "USD" } }
  ],
  "channels": ["slack", "sms", "voice"],
  "availability": { "timezone": "America/New_York", "status": "available" },
  "sla": { "decideSeconds": 900 },
  "capacity": { "maxOpen": 10, "open": 2 },
  "delegates": ["human://acme.com/carol"],
  "keys": [{ "kid": "human://acme.com/bob#passkey-1", "use": "webauthn", "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "credentialId": "…", "rpId": "acme.com" }],
  "issuer": "https://directory.acme.com",
  "signature": { "alg": "Ed25519", "kid": "acme-directory-1", "sig": "…" }
}
Enter fullscreen mode Exit fullscreen mode

What each part is for:

  • roles and skills let an agent ask for "a finance approver who knows SOX" instead of hard-coding a name.
  • authority says what this person may approve, for example spend up to $10,000. A $25,000 request is never routed to Bob.
  • availability, sla and capacity let routing prefer someone who is online, not overloaded, and likely to answer before the deadline.
  • delegates says who takes over if Bob doesn't decide within the SLA.
  • channels lists channel types only. Bob's phone number and Slack ID stay inside the Human Gateway; agents never see them.
  • keys holds Bob's passkey. Approvals that need Bob's own signature are checked against it.
  • signature: the company's directory signs every card, so an agent can't invent an approver or give someone more authority.

The card guides routing, but it isn't the security boundary. The Human Gateway checks authority again against company policy when the request arrives.

Routing

A Directory resolves an address to ranked candidates using these cards. The Human Gateway then delivers the request and enforces separation of duties: the person the agent acts for can't approve their own spend. It escalates to a delegate when someone misses their SLA, and records the whole routing chain in the approval, so auditors see who was asked as well as who decided.

Evidence anyone can verify

  • Every vote, receipt and approval is signed with Ed25519 over its canonical JSON (RFC 8785), and each one includes the plan digest.
  • An approval records who decided, how they authenticated, and who was asked before them. The gateway's signed receipt also lists everyone the address matched, and which rule excluded those it passed over.
  • Above a threshold, the approver signs with their own passkey, not just the gateway's key. The passkey signs a digest of the plan, the decision and a nonce. A signature made to reject can't be replayed as an approval, and a compromised gateway can't produce one on its own.
  • The coordinator emits a hash-chained Transaction Record. Anyone with the public keys can check offline that every party agreed to the same plan and that no event was removed or reordered.

Gateways must render the approval screen from the structured plan, not from text the agent wrote, and label agent-written text as such. That shuts off the most obvious prompt-injection path: an agent describing a $10,000 wire as "a small refund".

What changed after your feedback

The comments on this post went straight at the weak spots. Here's what they found and what changed.

"The human's decision time comes out of the hold's lease." A held seat or card authorization only lasts so long. If the approver takes 25 minutes and the seat is held for 20, the approval arrives for a plan that can no longer happen. Now:

  • Every hold reports its horizon: how long it can last, renewals included.
  • The coordinator works out the worst case along the approver's escalation chain. If that doesn't fit inside the shortest hold, it switches to approval-first (the human approves quoted prices, and holds are placed afterwards). This answers my first question below: neither mode is the default, and the clock decides.
  • When a hold comes back shorter than expected, the approver's deadline shrinks to match. A late signature is never collected only to fail at commit.
  • Holds are renewed while the human decides. A hold that's lost is placed again without asking the human twice, because the approval is bound to the plan, not to a specific hold.

"A hold isn't a vote if confirm can still fail." Many airline "holds" are only reservations: confirm can still be refused on fare or inventory. The spec now asks every provider one question: while the lease runs, is confirm guaranteed? If not, the step is final, not hold. The commit order then authorizes the card, confirms the seat, and captures last, so a refused seat voids an authorization instead of refunding a charge. The one-final rule turns out to match Narayana, the JBoss transaction manager. It allows one last resource by default and calls more than one "transactionally unsafe".

"What if the final step times out?" The airline may have ticketed the seat and only the response got lost. Before this fix, the coordinator treated that as a failure and voided the card. Now a missing answer puts the transaction in doubt: it neither captures nor voids, keeps the holds alive, and retries the commit, which is idempotent, so the retry doubles as a status query. If the answer still hasn't come back before the holds run out, it escalates to a human with the holds still live.

"A card authorization can't be renewed, and a person needs hours, not a minute." With Stripe, for example, the hold window is fixed when the card is authorized. So a plan now sets an escalation lead time: an in-doubt step escalates that far before the first hold ends. After escalating, the coordinator keeps asking. A late answer is recorded along with which holds are still live, and recovery is offered to a human, never performed automatically. A plan can also say in advance whether the payer consented to being re-charged off-session, so the coordinator knows when the plan is signed whether it will need the payer back.

Holds-first, which I asked about in the first version of this post, is in. It's a third mode: holds are placed first, and their end times and an approval deadline that fits inside them go into the plan the human signs.

"Commit should fail closed if anything the human reviewed has changed." A hold is protected by its lease, but a calendar update or a card capture only does a dry run at prepare, and its target can change while the human decides. Now each dry run returns its preconditions: the version of each target it saw, and the credential scope it will act under. These are signed into the participant's prepare receipt. Commit carries them back, and the participant must refuse with PRECONDITION_CHANGED, doing nothing, if any of them differ. The coordinator treats that like any other failed commit: it undoes what already ran, releases the holds, and the transaction ends ABORTED. It never retries with the new values without a new approval.

One correction to my reply in the comments: the preconditions are not in the digest the human signs. With parallel prepare, the dry runs return while the human is already being asked, so the versions aren't known in time. To get them in front of the approver, the dry run has to come before the ask. That's the effect preview planned for v0.3, which will be computed from the same versions.

Also new: passkeys. Approvals can now be human-signed with a passkey (WebAuthn). An approval group can require it. The gateway then only asks people with a passkey on their card, an approve without a valid passkey signature doesn't count, and the coordinator and offline verifier check the signature against the card. The demo does this in the browser: node demo/trip.ts --passkey.

"A comparison that never ran and a comparison that ran and passed leave the same bytes." This was the hardest comment on the post, so I ran it against the code. The authority check did run, but on a number nobody had verified: the agent writes the requirement into the plan. A plan could state $100 for a $25,000 charge and be routed to someone with a $500 limit, and the record would look fine.

0.2.0 changes that in four ways:

  • The gateway works out the amount itself. It adds up the plan's branches per currency, plus tolerance, and checks authority against that. A $1,000 plan with 2% tolerance needs an approver who covers $1,020. A requirement below the total is rejected, and currencies are never converted.
  • A record shows who was passed over. "Bob was excluded" and "it never reached Bob" no longer look the same, even when nobody was eligible. This is from a real $25,000 request, run against the npm package:
  {
    "planTotals": [{ "currency": "USD", "value": 25000 }],
    "checked": [{ "currency": "USD", "value": 25000 }],
    "humans": [
      { "id": "human://acme.com/alice", "result": "excluded", "rule": "authority", "detail": "limit 10000 USD < 25000 USD" },
      { "id": "human://acme.com/dana", "result": "excluded", "rule": "separation-of-duties" },
      { "id": "human://acme.com/erin", "result": "excluded", "rule": "authority", "detail": "limit 500 USD < 25000 USD" }
    ]
  }
Enter fullscreen mode Exit fullscreen mode

The gateway signs this, and offline verification recomputes checked from the plan, so a gateway can't record a check it didn't make. If no amount check ran, it says "absent", which never counts as a pass.

  • Tool servers refuse a plan that understates their cost. The gateway can only see amounts the plan declares, so a charge with no amount would total zero. The tool server is the one party that knows the real figure, so it now votes no. A charge that pays for other branches names them in settles, which stops the money being counted twice and can't hide a new cost.
  • The human sees the most they're approving. An ask now shows "$1,000, up to $1,020". Before, the person saw the planned total but signed for that plus tolerance.

You also asked how anyone can tell an enforced rule from one that was never tested. So the spec now has an appendix of planted violations: eleven transactions, each built to break one rule, each with the evidence it must leave. To check they prove something, I switched off each of the eight checks behind them in turn. Each time, exactly the cases that depend on that check failed.

My first fix had a hole. Before releasing, I attacked my own code. Branches of $25,000 and -$24,900 totalled $100, an approver with a $500 limit approved it, and offline verification passed, because it repeated the same arithmetic. A tolerance of -99% did the same thing. Both are now rejected, by the coordinator and separately by the gateway, and both are planted violations.

The limit is worth stating: the tool server check only works if the server reports its cost. The spec requires that, but nothing can detect a server that doesn't.

0.2.1: what was still missing for real use.

  • Humans can take hours. A Human Gateway answers with a signed pending vote, and the coordinator polls for the decision. An approval that arrives before the group's signed deadline lands in the same transaction, signed over the same plan. At the deadline the gateway withdraws the asks and votes no, and its signed routing shows who was asked.
  • A coordinator can crash. A replacement reads the decision log and finishes the job: presumed abort if nothing was decided, otherwise phase 2 with the logged decision, continuing the same hash chain. Locks with fencing tokens stop a stale writer.
  • Not everything is a transaction. Most agent actions are one tool call. Now an agent can ask the gateway to approve exactly that call, and the tool server checks the approval itself before it runs:
  1. Calling charge_card without an approval
     ✗ APPROVAL_REQUIRED: charge_card needs an approval from human://acme.com/role/finance-approver
  3. The agent asks the Human Gateway
     ask → Bob Chen: charge_card({"amount":{"value":2140,"currency":"USD"},"payee":"Lufthansa"})
     ✓ human://acme.com/bob approved; signed by https://gateway.acme.com, for https://payments.example
  4. Calling it with the approval
     ✓ payments ran the tool: 2140 USD to Lufthansa
  5. Replaying the same approval
     ✗ APPROVAL_INVALID: approval by human://acme.com/bob was already used
  6. Using the approval with changed arguments
     ✗ APPROVAL_INVALID: approval by human://acme.com/bob is for another call
Enter fullscreen mode Exit fullscreen mode

The approval is signed over a digest of the exact tool, arguments and server. It expires, and it works once. If the server sets the price, as with x402, the approval also fixes the most that can be paid and to whom, and the payer's wallet checks the server's terms before signing. Optionally it binds the tool's definition too, so a tool whose description changes after approval is refused.

  • Not every question is "approve?". hatp/ask/choose, form and text reach the right person the same way and return a signed Answer. Someone can decline, which passes the question to the next eligible person, and an answer that doesn't fit the question is never signed.

The earlier lesson applied here too, so the gated-tool rules come with eighteen planted violations: replays, changed arguments, expired or forged approvals, too few approvers, a raised price, a tool changed after approval, and the requester approving their own request. Each must be refused, and the tool must not run.

Designed as an MCP extension, and now usable with real MCP servers

An existing MCP server becomes a participant by advertising one capability and implementing a few extra methods:

{ "capabilities": { "extensions": { "ai.probita/human-gateway": { "revision": "1", "hatp": {
    "version": "0.1",
    "roles": ["participant"],
    "operations": { "book_seat": { "tier": "hold", "maxLeaseSeconds": 1800, "maxHoldSeconds": 1800 } },
    "keys": [{ "kid": "mcp://airline.example/hatp#key-1", "kty": "OKP", "crv": "Ed25519", "x": "…" }]
} } } } }
Enter fullscreen mode Exit fullscreen mode

(Servers from 0.2.0 advertised this under experimental.hatp. 0.2.1 advertises both, so older clients still work.)

Methods: hatp/directory/resolve, hatp/txn/{begin,prepare,quote,commit,abort,compensate,renew,estimate,tighten,status,voted}, and hatp/lock/{acquire,renew,release} with fencing tokens.

The hatp-mcp package does this for you. One call makes any MCP server a participant, and its normal tools keep working:

import { serveHatp, mcpParticipant } from "hatp-mcp";

// In the tool server:
serveHatp(mcpServer, airline, { version: "0.1", roles: ["participant"],
  operations: { book_seat: { tier: "hold", maxHoldSeconds: 1800 } }, keys: [signer.publicJwk] });

// In the coordinator, over an MCP client session:
const remoteAirline = mcpParticipant("mcp://airline.example/hatp", client, { keyring });
Enter fullscreen mode Exit fullscreen mode

The approval side is written up as an MCP extension proposal, the Human Gateway SEP. A tool server makes tools require approval with one call, after registering them as usual:

import { gateTools } from "hatp-mcp";
import { createApprovalGate } from "hatp-protocol";

const gate = createApprovalGate({ serverId, trustedGateways: { [gatewayId]: gatewayKeys } });
gateTools(paymentsServer, { gate, extension: { serverId, trustedGateways: [gatewayId] },
  tools: { charge_card: { requires: { address: "human://acme.com/role/finance-approver",
                                      requirement: { action: "approve.spend", amountFrom: "/amount" } } } } });
Enter fullscreen mode Exit fullscreen mode

Try it

npm install hatp-mcp hatp-protocol
Enter fullscreen mode Exit fullscreen mode

Or clone the repo and watch a whole trip commit across separate MCP server processes:

git clone https://github.com/probita-ai/hatp && cd hatp
npm install && npm run demo:mcp
Enter fullscreen mode Exit fullscreen mode

Or watch a tool call get blocked, approved, and refused on replay:

npm run demo:gated
Enter fullscreen mode Exit fullscreen mode

Or approve as the finance approver in your browser, with a real passkey:

node demo/trip.ts --passkey    # then open http://localhost:4321
Enter fullscreen mode Exit fullscreen mode

Status: what works today

  • Spec v0.1 (draft): Discovery, Transactions, Evidence, and the MCP binding. Organisations publish their Directory, Human Gateway and card-signing keys at /.well-known/human-card.json.
  • JSON Schema for all the core types.
  • hatp-protocol on npm: coordinator with a durable decision log, participant wrapper, Directory, Human Gateway, signing and offline record verification. Holds are planned around their horizons, renewed while humans decide, and protected against an in-doubt final step. Passkey approvals, preconditions checked at commit, and the holds-first mode. No runtime dependencies.
  • hatp-mcp on npm: participants, Human Gateways and directory resolution as MCP servers. Approval-gated tools, Human Cards as MCP resources, and asks that wait through MCP Tasks.
  • Tests and CI on every change, including a real MCP request timeout during the final step.
  • New in 0.2.1: deferred (pending) votes, coordinator recovery from the decision log, locks with fencing tokens, approvals for single tool calls, and questions (choose, form, free text) with signed answers.
  • Known gap: a pending vote, or an ask waiting through the in-memory task store, is lost if the gateway restarts.
  • Upgrading from 0.1? Upgrade Human Gateways first, and note that 0.1 records fail verification on 0.2. See the upgrade notes.

After that: A2H and CHEQ as delivery channels behind a gateway. Then the Journal in v0.3 (undo for agents), and in v0.4 SIP-style live calls where any human or agent can dial any other (voice with humans, a structured data channel between agents), including turning a spoken "yes" into a passkey-confirmed approval.

I'd like feedback on

  1. Preconditions and the digest: they're bound by the participant's signed receipt, not by what the human signed. Is that enough for your systems, or should high-risk plans always run the dry run before the ask, so the approver sees the exact versions?
  2. Would you implement hold / compensate / final in your MCP server? Which tier do your real "holds" actually deserve?
  3. How should a Human Gateway in one company trust a coordinator in another?
  4. Reported costs: would your tool server return what it will really charge at prepare? If it can't, what could stand in for that?
  5. Approval-gated tools: if you maintain an MCP server, would you gate your risky tools this way? Is a JSON-RPC error (-32050, "approval required", with what to ask for) the right way for a server to refuse?

Spec and SDK: https://github.com/probita-ai/hatp. Comments and issues welcome. Thanks to everyone whose comments shaped this update.

Top comments (28)

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.

Thread Thread
 
_firelinks profile image
Mike Dabydeen •

I'd plan v0.2 on the assumption that the card authorisation can't be renewed. With Stripe, for example, the hold window is fixed when you authorise: 7 days for most online card payments, and about 4 days 18 hours for a Visa merchant-initiated one. The charge carries a capture_before timestamp, and once it passes the funds are released and the payment status becomes canceled. A longer hold has to be requested up front as an extended authorisation, and only some payments qualify. So holding at neither capture nor void becomes a void on that date whether the protocol picks it or not. I'd set the escalation deadline at capture_before minus the time a person needs to act, and put it in the signed plan with the other deadlines.

What should the coordinator do if the airline answers after the hold has lapsed and the seat was ticketed?

Thread Thread
 
murthyg profile image
Murthy G •

Agreed. A fixed window is what our holdHorizon is for: a card participant reports capture_before as the horizon, and renewals stop there. The real gap is the lead time. Today we escalate a minute before the horizon, when a person needs hours. I'll make the escalation deadline capture_before minus the time a person needs to act, and put it in the signed plan with the other deadlines.

On a late answer: today the coordinator stops listening once it escalates, which is wrong. It should keep polling, record "ticketed, authorization lapsed" in the transaction record, and offer recovery rather than act on its own: either a new linked transaction that re-authorizes and captures, with its own approval, or the airline's void while the ticket is still voidable. Either one is a new action that needs new consent.

Thread Thread
 
_firelinks profile image
Mike Dabydeen •

The first recovery option only works if the original approval saved the card for off-session use. A new authorisation after the hold lapses is merchant initiated, because the customer isn't in a checkout flow to answer an authentication request. With Stripe that means the first PaymentIntent set setup_future_usage to off_session, and you kept a record of the customer's explicit consent to later charges. Without that, the coordinator can't re-authorise on its own and has to bring the person back on session. Even with a saved card, the bank can decline with authentication_required, which also needs the person present.

So I'd settle it when the plan is signed. If the plan has a deadline that can lapse, it should say whether off-session re-authorisation is consented and up to what amount. Then the recovery you described has a known shape, and the coordinator knows before the horizon whether it can act or has to find the person.

Thread Thread
 
murthyg profile image
Murthy G •

Agreed. That settles what I left open. In v0.2, a plan with a hold that can lapse will carry a signed recovery clause. It says whether off-session re-authorisation is consented, up to what amount, and which saved mandate it refers to. If there's no consent, the coordinator knows when the plan is signed that it has to bring the payer back on session, so it escalates to them early. If there is consent, it can re-authorise up to the cap. An authentication_required decline is handled like no consent.

Thread Thread
 
murthyg profile image
Murthy G •

A plan can set escalation.leadSeconds. An in-doubt final step escalates that far before the first hold ends, so a card window like capture_before leaves a human time to act.

After escalating, the coordinator keeps asking. A late answer is recorded, with whether each hold is still live or has lapsed. Recovery is offered, never performed. A linked transaction says whether the payer must be present, based on recovery.offSessionReauth (consented, up to what amount).

holds-first: the holds are placed before anyone is asked, so the plan the approver signs includes each hold's end and an approval deadline that fits inside it.

Thanks, both of you. This thread shaped most of the release. npmjs.com/package/hatp-protocol

Thread Thread
 
murthyg profile image
Murthy G •
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.