Human oversight for AI agents: block a tool call on a verified human, with on-chain proof.
An AI agent stops mid-payment and pays a live, verified human for permission to continue.
An AI agent hits a policy threshold and is stopped by an HTTP 402. It pays $0.42 in USDC on Hedera to open a decision. A human witness who is not the operator proves liveness with World ID, bound to the hash of that exact decision, and approves or refuses inside 60 seconds. The default is refuse. An attestation binding the decision hash, the liveness proof, the witness nullifier and a consensus timestamp lands on a Hedera Consensus Service topic with no admin key.
The product is the evidence, not the approval.
Built for ETHOnline 2026. Partners: Hedera (AI & Agentic Payments, Improve the Harness), World (Selfie Check), Arc (Best Agentic Economy with Circle Agent Stack).
Every AI agent framework already ships a human-approval interrupt. LangGraph has interrupt, Temporal has signals, OpenAI has needsApproval. They are free, already integrated, and they all produce the same artefact:
approved_by: user_44, 14:22:07
That row is written by the system being audited. It is editable by the party being audited. And it contains no evidence that a human, rather than the agent's own service account, produced it.
Since 2 August 2026, the EU AI Act's obligations bind deployed high-risk systems. Article 12(3)(d) requires "the identification of the natural persons involved in the verification of the results", and Article 14(4) requires that such systems be "effectively overseen by natural persons" who can "interrupt the system". Both bind every high-risk deployer.
Article 14(5) goes further and requires "at least two natural persons" — but read its opening clause: "For high-risk AI systems referred to in point 1(a) of Annex III", which is remote biometric identification. A supplier-payment agent is not that, so 14(5) does not bind this example deployer. We build to it anyway, because it is the strictest oversight bar the Act names and the evidence is identical either way. The scope is set out in full here.
A self-written, self-editable log satisfies neither.
Proctor binds four things a self-hosted approve button cannot:
signal, not to "a human approved something at some point"Any third party verifies the whole record offline, against Hedera's mirror node and World's own verifier, without trusting Proctor.
No wallet, no API keys, no feature flags. This runs the entire oversight loop:
cd backend
cp .env.example .env # DATABASE_URL is the only value you must edit
bun install
bun run db:push
bun run seed && bun run demo
bun run doctor # what is live, and what each gap costs you
bun run acceptance # exercise every claim against the running service
Unconfigured, this runs the full loop and produces a record that is deliberately
NOT independently verifiable — it is signed with a published demo key and never
reaches a topic. bun run demo says so in its own closing line, and bun run doctor
lists exactly what is missing. The difference between "it ran" and "it produced
evidence" is the entire product, so the tooling refuses to blur it.
1. agent proposes: EUR 41200.00 -> Meridian Logistics
2. policy : ESCALATE (amount_threshold)
3. decision : cmtmkf18b0000lxw8lqurjc31
human line : Release EUR 41,200 to Meridian Logistics?
signal : 0x5b068ce449934ca1df15f5f4…
4. dispatched to the rota, 60s deadline running
5. proof : VERIFIED
signal matched this decision, and the witness is not the operator
6. APPROVED in 28 ms
7. attestation : 722 bytes, ind=crypto
signed by : 0xB73837E8C34E2897debeAca0898Da23B52a6Eb10
The agent is released. The evidence is independently verifiable.
And the outcome an auditor actually cares about:
bun run demo refuse
5. witness REFUSED. No proof required: refuse is the default outcome.
6. attestation : 628 bytes, wid=null, wa=token
The agent is NOT released.
bun verify/bin/verify.ts --topic 0.0.10390147
PASS 25 messages, chain intact from genesis.
No message was inserted, removed, reordered, or altered.
PASS completeness: issuance numbers dense across 1 issuer(s).
No decision was withheld from this log.
Two checks, two different claims. The first proves nothing was altered. The second proves nothing was withheld — an operator who never submits a record breaks no hash.
Zero dependencies. node:crypto only. It contacts Hedera's public mirror node and nothing of ours.
Every row is a link to a file range or a public explorer. Nothing here asks you to take our word.
| Claim | Verify here |
|---|---|
| Evidence topic exists with no admin key | HashScan 0.0.10390147 |
| The running hash chain verifies offline, from genesis | bun verify/bin/verify.ts --topic 0.0.10390147 |
| …and the implementation is dependency-free | verify/package.json — empty dependencies |
| The Java-framing footgun is real, not folklore | verify/src/runningHash.ts:56-62 + the failing-naive test in verify/test/runningHash.test.ts |
| Tampering is detected, naming the sequence number | docs/hashscan-links.md |
| The gate settles through Blocky402 | extra.feePayer: 0.0.7162784 in the live 402, docs/hashscan-links.md |
| The boot preflight stops a dead facilitator taking Hedera down | backend/src/lib/x402/server.ts:63 |
| A proof for another decision is rejected | backend/src/lib/world/verify.ts:104 |
| The operator cannot approve their own agent | backend/src/lib/world/verify.ts:123 |
| Second 61 is a hard refuse, arbitrated by Postgres | backend/src/lib/decision/lifecycle.ts:114 |
| Canonicalisation is RFC 8785, recursive at every depth | backend/src/lib/attestation/canonical.ts:37 |
| Independence is derived, never asserted | backend/src/lib/attestation/build.ts:179 |
| A decision withheld before submission is detected offline | verify/src/completeness.ts + bun verify/bin/verify.ts --topic <id> |
| Issuance numbers cannot be burned by a failed create | backend/src/lib/decision/issue.ts — counter and create share one transaction |
| Test fixtures cannot reach the immutable evidence topic | Org.attestable defaults to false; orgs opt in |
| Every 402 carries an EIP-712 offer we cannot later reprice | decode the payment-required header |
| HCS-14 UAID matches a fixed test vector | backend/src/lib/attestation/uaid.ts:77 |
| The export cites the Act provisions it speaks to | backend/src/lib/evidence/export.ts:110 |
Topic: 0.0.10390147
Memo: Proctor oversight evidence log
Admin key: NONE (immutable and undeletable, by anyone, including us)
Submit key: ECDSA, operator-held
Creation tx: 0.0.10349667@1788684617.529727573
Per Hedera's documentation: "if no adminKey is specified the topic is immutable."
Be honest about what the submit key does not buy. We hold it, so we can still write a false entry. What HCS prevents is us retroactively editing or deleting what we already wrote, and it binds every entry to a timestamp we did not choose. Combined with a liveness proof and a nullifier issued by parties that are not the deployer, the composite is what a Postgres table cannot give.
The log grows every time anyone runs bun run demo, so rather than a table that
goes stale, here is how to read it yourself:
curl -s "https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10390147/messages?limit=100&order=asc" \
| jq -r '.messages[] | .message | @base64d | fromjson
| "\(.sq) \(.out) wid=\(.wid // "null") ind=\(.ind) wa=\(.wa)"'
A record from an approval, and one from a refusal:
sq | out | wid | ind | wa |
|---|---|---|---|---|
| 30 | APPROVE | 8928af4e… | crypto | proof |
| 16 | REFUSE | null | policy | token |
wid: null on a refusal is correct, not missing: refuse is the default outcome and
requires no proof. Requiring a liveness proof to press a stop button would put a failure
mode between a person and a brake pedal. wa records which of the two actually
authenticated the witness, so the record never overstates what was proven.
sq starts at 15 rather than 1 because this issuer wrote to an earlier topic before this
one existed. The completeness check bounds its claim to the range a log can actually speak
to, so records filed on a previous topic are never reported as withheld.
Every record is under the 1024-byte single-chunk ceiling, so each is one sequence number and one consensus timestamp.
| Attack | Result |
|---|---|
| One bit flipped in sequence 4 | FAIL at seq 4 (running hash mismatch) |
| The refusal at sequence 2 deleted | FAIL at seq 3 (sequence gap: expected 2, got 3) |
The second row is the product in one line: an inconvenient refusal cannot be quietly removed.
Everything above proves integrity. None of it proves completeness, and those are
different claims. An operator suppressing an inconvenient refusal does not need to forge
anything. They simply never submit it. No hash breaks, no signature fails, the chain over
what was submitted stays pristine, and verify prints PASS.
That is the honest hole in every tamper-evident log, and it is the one a deployer would actually use.
Proctor closes it by numbering decisions densely per org at issue time and signing that
number into the attestation as sq. A withheld record becomes a hole in a sequence, and the
operator cannot renumber around it because the neighbouring numbers are already committed to
a topic with no admin key under a timestamp they did not choose.
PASS 25 messages, chain intact from genesis.
No message was inserted, removed, reordered, or altered.
FAIL completeness: 1 decision(s) never reached this log.
missing issuance number(s): 7
The chain is intact, so nothing was deleted. Something was never written.
The check runs offline against the mirror node, in the same zero-dependency verifier, so the party being audited is not the one telling you their evidence is complete.
| Question | Answered by | Catches |
|---|---|---|
| Was it altered? | running hash chain | insertion, removal, reordering, edits |
| Is it all there? | dense signed issuance numbers | a decision that was never written |
What it still does not prove, stated rather than hidden. An operator can stop writing entirely from some point on, and a trailing absence is indistinguishable from "nothing further happened". Suppression in the middle is caught; abandonment is merely loud. A test asserts that residual weakness explicitly, so nobody later "fixes" it into an overclaim.
Two details that took a live run to get right, both now tested:
sq is dense per issuer, not per topic. Checking density across a shared topic without
grouping invents gaps wherever two orgs interleave and reads a second org's #1 as a
duplicate. Both are false accusations of withholding evidence, which is worse than no check.
We group by the operator nullifier on, already in every record, so it costs no bytes.min..max, never 1..max. Records written before this field
existed carry no sq, and a mirror query window may not reach the first one. Starting at 1
would report records that are merely outside the view as suppressed.Four surfaces, one per role. None of them need a terminal.
| Who | Where | What they do |
|---|---|---|
| Operator | /operator | Describe an action, watch policy decide, hand it to a phone |
| Witness | /w/<token> | Approve or refuse on a phone, in under a minute |
| Auditor | /console | Read the evidence, re-verify it against Hedera without trusting us |
| Agent developer | POST /v1/gate/decisions | Integrate the gate. It is an HTTP 402, not an SDK |
| MCP agent | proctor-mcp on npm | npx -y proctor-mcp. One tool, request_human_approval. Claude Desktop, Cursor, anything MCP |
Full walkthrough: docs/USING_PROCTOR.md. Where this goes next, and what we deliberately will not build: docs/ROADMAP.md.
The operator console is the screen that makes this a product rather than a set of
scripts, and it is honest about its one shortcut: it opens decisions without settling a
payment, says so in its own API response (paid: false), and is off unless
DEMO_MODE=true. The paid path is unchanged and unbypassed — the witness token is a
bearer credential the gate never returns to the payer, which is why a2a.SendMessage
also refuses with -32601 rather than opening a decision for free.
Two paid calls, both x402 v2, both settled by a facilitator rather than by us.
1. The gate — a flat fee to interrupt a machine.
POST /v1/gate/decisions (unpaid)
→ 402 Payment Required
payment-required: <base64> the challenge is a HEADER, not the body
The agent decodes the header, verifies the offer we signed, picks a rail, signs a payment payload, and retries. The facilitator verifies and settles; we never touch the buyer's key.
POST /v1/gate/decisions (with payment)
→ 200 { decisionId, humanLine, decisionHash, expiresAt }
2. The release — priced from the seconds of human attention actually spent.
POST /v1/gate/decisions/:id/release
Flat-rate for the interrupt, metered for the attention, and linear in review seconds. A decision that expired meters to zero: nobody looked at it, so no human time is owed. An approval or refusal always bills something, because a human did look — a floor applies, since x402 cannot quote a zero amount and a free release would be free oversight.
Where each leg settles
| Leg | Payer → payee | Rail | Finality |
|---|---|---|---|
| Gate | agent → treasury | Hedera via Blocky402, or Arc via Circle Gateway | Hedera: on chain, final. Arc: committed to a batch |
| Release | agent → treasury | same | same |
| Witness fee | operator → witness | direct HBAR, no facilitator | on chain, final |
| Retainer | operator → witness | Scheduled Transaction, Hedera's clock | fires without us |
The machine pays through infrastructure. The human is paid directly.
The payment-required header, decoded from a live 402:
{
"x402Version": 2,
"accepts": [
{ "scheme": "exact", "network": "hedera:testnet",
"amount": "420000", "asset": "0.0.10394781",
"payTo": "0.0.10349677", "maxTimeoutSeconds": 180,
"extra": { "feePayer": "0.0.7162784" } },
{ "scheme": "exact", "network": "eip155:5042002",
"amount": "420000", "asset": "0x3600000000000000000000000000000000000000",
"payTo": "0xB73837E8…6Eb10", "maxTimeoutSeconds": 604900,
"extra": { "name": "GatewayWalletBatched", "verifyingContract": "0x0077777d…",
"minValiditySeconds": 604800 } }
],
"extensions": { "offer-receipt": { … }, "payment-identifier": { … } }
}
Reproduce it:
curl -s -i -X POST http://localhost:3700/v1/gate/decisions \
-H 'content-type: application/json' \
-d '{"action":{"kind":"transfer","amount":"41200.00"}}' \
| grep -i '^payment-required' | cut -d' ' -f2 | base64 -d | jq
Full artefact list, with every transaction id: docs/hashscan-links.md.
Hedera's qualification bullet names one facilitator. extra.feePayer is injected by the library from that facilitator's own /supported response, so it identifies the settling facilitator as a fact rather than a claim:
{
"x402Version": 2,
"accepts": [{
"scheme": "exact",
"network": "hedera:testnet",
"amount": "420000",
"asset": "0.0.429274",
"payTo": "0.0.10349677",
"maxTimeoutSeconds": 180,
"extra": { "feePayer": "0.0.7162784" }
}]
}
| Field | Origin |
|---|---|
extra.feePayer 0.0.7162784 | Blocky402's fee payer. x402.org would show 0.0.9185802 |
amount 420000 | library converted price: "$0.42"; no hand-rolled decimal maths |
asset 0.0.429274 | resolved by ExactHederaScheme. USDC on Hedera is an HTS token, so every settlement moves HTS |
| Role | Account | Type |
|---|---|---|
| Operator (writes attestations) | 0.0.10349667 | ECDSA secp256k1 |
Treasury (payTo) | 0.0.10349677 | ECDSA secp256k1 |
| Agent (pays the gate) | 0.0.10359475 | ECDSA secp256k1 |
| Agent ID | 891434 |
| Registry | 0x8004A818…BD9e, Circle's canonical deployment |
| Transaction | 0xab73e419…d30ed9 |
The agent carries the native identifier on each chain: HCS-14 on Hedera, ERC-8004 on
Arc. We previously declined ERC-8004 because a self-deployed registry is weaker than a
canonical one and Hedera has none. Arc ships canonical registries, so that objection does
not apply there. Both are published at /.well-known/proctor.json.
uaid:aid:9373hj8Dco351vuqrK3p4veDXreeN6848f5A2qQKBdQK4Azmi2BL7UB7rA3Fwiq4KU;uid=0;registry=proctor;proto=x402;nativeId=hedera:testnet:0.0.10349677
| Building block | What it is | How Proctor uses it | Product moment |
|---|---|---|---|
| Hedera Consensus Service | Ordered, tamper-evident message log with network-assigned timestamps | The evidence substrate. Every resolved decision writes one attestation under 1024 bytes, so it is one chunk with one sequence number and one consensus timestamp. backend/src/lib/hedera/hcs.ts | An auditor reads a record whose timestamp the deployer did not choose |
| Topic with no admin key | Immutable, undeletable topic | Created once with a submit key and deliberately no admin key. Nobody can update or delete it, including us. Irreversible: the memo can never change | "We cannot delete this even if we wanted to" is checkable on HashScan |
| Running hash chain (SHA-384) | Each message commits to the previous hash, payer, topic, timestamp, sequence number and message digest | Recomputed offline in verify/src/runningHash.ts to prove no message was inserted, removed, reordered or altered, with zero trust in the mirror node | bun verify --topic 0.0.10390147 prints PASS, and FAIL on one edited character |
x402 v2, exact scheme | Pay-per-request over HTTP 402 | The gate. An unpaid POST /v1/gate/decisions returns 402 with the requirements in a base64 payment-required header. backend/src/lib/x402/server.ts | The agent stops mid-run and pays, with no prior account |
| Blocky402 facilitator | Hedera-native x402 facilitator | The default, not a fallback, because Hedera's qualification bullet names it. Its fee payer appears in every 402 we issue | Provable from the wire, not from our prose |
| HTS | Hedera Token Service | The gate settles in an HTS token we minted, 0.0.10394781 (PGC, 6dp), carrying a custom fractional fee schedule. USDC on Hedera is also an HTS token, but Circle's faucet never delivered any, so that claim rested on an asset we had no balance of. Minting our own removed the dependency | A real HTS settlement with the custom fee visibly assessed on chain |
| Scheduled Transactions | Long-term schedules with waitForExpiry | On-call standby pay. Paying only per decision prices availability at zero, so a witness reachable all day who receives nothing earns nothing, and the rota decays until nobody is there. The next retainer is committed on chain and fires from Hedera's clock, not ours | A witness can verify their next payment exists before agreeing to be on call, without asking us |
| HCS-14 (draft) | Universal agent identifier | Generated per the draft spec: six canonical fields, SHA-384, Base58. No SDK dependency, because a draft standard on a pre-1.0 SDK must not break the evidence writer. backend/src/lib/attestation/uaid.ts | Fixed test vector so any reader reproduces the string |
| Mirror node | Public read API | Confirms each attestation and supplies the raw rows the offline verifier recomputes. Postgres is the index; HCS is the truth | Every console row carries the sequence number needed to re-verify independently |
The consensus protobuf documents twelve inputs to the SHA-384 running-hash digest. Hashing those twelve fields concatenated raw produces a hash that never matches, with no diagnostic.
The consensus node serialises them through a Java ObjectOutputStream, so the bytes actually hashed include the stream header, a class descriptor for byte[], block-data records around the primitives, and a back-reference for the second byte[]. Reproduced byte for byte in verify/src/runningHash.ts:56-62.
Verified against live testnet topic 0.0.4320226, 45 messages from genesis:
mirror node says : c91842fa5e802cc4ac04cf8dd3a5f6b2
naive (doc list) : 509d029eaf6b862d2f96cfb917943f82 <- WRONG
framed (ours) : c91842fa5e802cc4ac04cf8dd3a5f6b2 <- MATCH
The test suite asserts both directions: that the framed version reproduces consensus, and that the naive one does not. Nothing in the Hedera tooling ecosystem currently ships this.
wrapFetchWithPayment answers a 402 automatically, which is convenient and also means
an agent will pay whatever it is told to pay. A seller could quote one price in its docs
and another to a machine, and nothing in the base protocol leaves the buyer an artefact
to complain with.
So the agent fetches the 402 unpaid, verifies the seller signed those exact terms,
and checks the recovered signer against the attestor address published at
/.well-known/proctor.json. Only then does it hand over money.
offer verified: 100000000 0.0.0 on hedera:testnet
signed by 0xB73837E8C34E2897debeAca0898Da23B52a6Eb10, valid until 2026-09-06T09:02:56.000Z
offer verified: 420000 0x3600…0000 on eip155:5042002
signed by 0xB73837E8C34E2897debeAca0898Da23B52a6Eb10, valid until 2026-09-13T09:01:36.000Z
Both rails, one signer, two validity windows — Gateway requires at least seven days, Hedera does not. Recovering an address always succeeds; recovering the right one is the check, and treating "did not throw" as "signature valid" is how this gets built wrong. If the signer is not the published attestor the agent refuses to pay and says why.
A seller that has not adopted the extension is not refused: the agent says
paying on an unsigned quote out loud rather than pretending the check happened.
The agent pays on either rail, selected with PREFER_NETWORK:
=== ARC === === HEDERA ===
payer 0x361c196a…3aef payer 0.0.10359475
network eip155:5042002 network hedera:testnet
tx f039ac8b-1a82-4b55-… tx 0.0.7162784@1788752592.622448186
status committed to a Circle status on chain, final
Gateway batch
Gateway balance moved 2.000000 → 1.580000 USDC: exactly the $0.42 gate price.
The two rails settle differently and the agent says so. Hedera returns a consensus transaction id that is final the moment it resolves. Gateway returns a batch id — the payment is committed and the balance has moved, but it reaches the chain later, batched with others. The agent originally printed a HashScan link for the Gateway batch id, which produces a URL that can never resolve, and called it "settled". Both are now rail-aware, because "paid" and "on chain, final" are different claims.
Three things had to be true before the Arc leg would settle, and each failed in a way that named something else:
| Missing | How it presented |
|---|---|
GatewayEvmScheme registered client-side | the agent silently paid on Hedera instead |
The Arc asset in the agent's allowedAssets | "the gate advertised only hedera:testnet" — which is not what the gate said |
| A payee different from the payer | a bare 402 with an empty body |
The first two are spend controls doing their job. The third is worth stating plainly: with
PAY_TO_ARC set to the agent's own address the agent pays itself, and Gateway rejects the
authorization with nothing that hints at why.
Holding USDC in the wallet is not enough. Gateway nanopayments spend from a Gateway
balance — a separate deposit held by the GatewayWallet contract. A wallet with plenty of
USDC and no Gateway balance fails to settle with an error that reads like a signing problem.
Two on-chain steps against Arc's own RPC, neither of which touches circle.com:
1. USDC.approve(gatewayWallet, amount) 0x3600…0000
2. gatewayWallet.deposit(usdc, amount) 0x0077777d…
Funded and confirmed: wallet 19.995554 → 17.992384 USDC, Gateway balance 2.000000.
Two things worth knowing if you follow this path:
"2.000000"), not atomic units. BigInt()
on it throws — but an empty balance is "0", which parses fine, so the bug only appears
once money actually arrives.circle.com. The deposit is pure on-chain work. Since
that host is intermittently TLS-intercepted here, an unreachable balance is reported as
UNREACHABLE rather than 0: "you have no money" and "we could not ask" are very
different answers to act on, and conflating them would have blocked a deposit that was
perfectly able to proceed.Paying a witness only when a decision arrives prices availability at zero. Someone who keeps a phone on all day and receives nothing earns nothing, so a rota decays until nobody is reachable — and an oversight gate whose witnesses have drifted away fails closed on every decision, which is safe and useless. Real on-call pays standby.
It is a scheduled transaction rather than a cron job for the same reason the evidence log is on HCS: a cron job pays when our server chooses to; a scheduled transaction fires whether or not we are alive, honest, or still solvent. The witness can check their next payment exists before agreeing to be on call.
Verified end to end on testnet, schedule 0.0.10394981:
wait_for_expiry : true
expiration : 1788714592.890000000
executed : 1788714592.262342758 ← Hedera fired it, not us
witness balance : 112011420 → 112511420 tinybar (+500000, exactly the retainer)
Recurrence, stated honestly. Hedera schedules are one-shot. Recurrence here means the next retainer is committed when the previous one executes, so exactly one future payment is on chain at any moment. That is weaker than a native recurring primitive, and it is described that way rather than dressed up.
An admin key is retained, so a witness who leaves the rota can be removed. Without one the schedule could never be cancelled.
A scheduled payment fires without telling us, so reconcileRetainers() reads the mirror
node and records what actually executed, using the network's timestamp rather than our
intent. Skipping that is how a database ends up saying VERIFIED forever while the money
has already moved — the same drift that once had our demo printing one thing and the
evidence log recording another.
Hedera's extra-points list asks for "HTS tokens or custom fee schedules in the settlement path." We had neither in practice: USDC is an HTS token, but the faucet never delivered, so every settlement moved HBAR and the claim rested on an asset with no balance.
The gate already quoted an arbitrary token id correctly — verified against a live 402 — so supply was the only thing missing. So we minted one:
| Token | 0.0.10394781 — Proctor Gate Credit (PGC), 6 decimals |
| Custom fee | 1/100 fractional to the treasury, allCollectorsAreExempt |
feeScheduleKey | retained, so it is a fee schedule rather than a fee decision |
A real settlement, 0.0.7162784@1788713352.579834930:
0.0.10359475 -420000 the agent pays
0.0.10349677 +415800 the treasury receives
0.0.10349667 +4200 custom fractional fee, collected
"assessed_custom_fees": [
{ "amount": 4200, "collector_account_id": "0.0.10349667", "token_id": "0.0.10394781" }
]
Both halves of the bullet, on chain, settled through Blocky402. Six decimals deliberately,
so the same $0.42 → 420000 arithmetic holds and swapping back to USDC really is one
variable.
The agent refused the first attempt, correctly. Its own spend controls allow only assets its operator has approved, so a freshly minted token was rejected until it was added explicitly. That is the right default for software that spends money unattended, and it is worth more than the settlement it briefly blocked.
| Asset | HBAR, not USDC |
| Amount | metered from real review milliseconds, floored |
| Trigger | every resolved decision, approve and refuse |
| Idempotency | @@unique([decisionId, leg]) — three concurrent calls produce one payment |
HBAR rather than USDC is a deliberate accessibility decision, not a shortcut. A recipient that has never held a given HTS token cannot receive it: the transfer fails on association, not on balance. A witness is a person on a rota, not a crypto user, so requiring them to associate a token before they can be paid for thirty seconds of attention puts a wallet onboarding flow in front of a brake pedal. HBAR needs no association and auto-creates a hollow account on first receipt.
A refusal is paid identically to an approval. Paying only for approvals would price the witness to say yes, which is the exact incentive this product exists to remove. A test asserts it.
A witness with no payout account is recorded as owed, never silently skipped. The fee was earned; dropping it because onboarding is incomplete would let an org quietly consume human attention for free.
The payout runs off the critical path, like the attestation. The agent is released on the decision outcome, so a slow or failed transfer can never delay the release or change what the evidence says happened.
OversightLog.sol because nothing on the critical path needs it and no prize bullet requires it. Enrolment records and trust roots go to HCS, where the consensus timestamp cannot be backdated any more than a contract event can. Trade-off: no deployed contract to point at.x402.org as primary because the qualification bullet names Blocky402 with no "or equivalent". Trade-off: a third-party dependency with no published SLA, mitigated by the boot preflight below.| Component | What it is | How Proctor uses it | Product moment |
|---|---|---|---|
signal binding | Application-supplied value hashed into the proof | The mechanism the entire product rests on. The decision hash is the signal, so a liveness proof is bound to this decision and cannot be replayed against another. backend/src/lib/world/verify.ts:104 | Without it, any liveness proof satisfies any decision and Proctor is a paid captcha |
| Nullifier comparison | RP-scoped pseudonymous identifier | The witness nullifier is compared against the operator's, as bigint so hex casing cannot smuggle a match past. This is the evidence Article 14(5) asks for where it applies, and the strictest bar the Act names anywhere | The operator cannot approve their own agent |
| RP signature (v4) | Mandatory backend signature before the widget opens | ES256 over the RP payload, minted per decision rather than on page load, with a single-use nonce persisted. backend/src/lib/world/rp.ts | New in 4.0 and the single biggest schedule risk on this leg; it works |
| Desktop QR handoff | World's documented desktop flow: the challenge is shown on a screen and the phone scans it | /handoff/<token> renders a QR built from its own origin, so the link is correct by construction. Chosen over Web Push deliberately: push needs a service worker, a secure context, and permission granted beforehand on that exact origin — three ways for a handoff to fail silently in front of a camera | The witness needs nothing installed but the World ID App they were going to use anyway |
| Selfie Check | Medium-assurance liveness credential | Treated as an abuse-prevention and eligibility signal, behind one variable so the swap is a one-line change | It is what stops the approval being satisfied by the agent's own service account |
Proctor treats Selfie Check as an abuse-prevention signal, not an identity signal: it is what stops the approval step from being satisfied by the agent's own service account, which is the exact failure mode that makes today's human-oversight logs worthless to an auditor.
We do not claim Selfie Check proves one-person-one-account. It proves liveness and facial continuity. Our claim is narrower and sufficient: the approver was a live human, and their World ID account is cryptographically distinct from the operator's. Independence between the two humans is a policy property of the rota, and every attestation records which of the two it carries in an explicit
indfield.
| Property | Established by | Strength |
|---|---|---|
| A live human approved | Selfie Check | cryptographic |
| They approved this decision | decision hash as signal | cryptographic |
| Their account differs from the operator's | nullifier comparison | cryptographic, v3 credentials only |
| They are a different person | the rota, an org policy | policy |
| The record was not altered afterwards | HCS running hash, no admin key | cryptographic |
Two distinct nullifiers do not prove two humans. One person can hold two World ID accounts. classifyIndependence derives ind rather than asserting it, and degrades to policy on any credential whose nullifier is one-time-use.
World's own docs contradict each other, and the answer changes an integrator's design:
idkit/integrate: "The same person verifying the same action always produces the same nullifier."4-0-migration: "In 4.0, nullifiers are one-time-use, and session_id is the stable link across requests."The migration guide is newer and version-qualified, so it is the correction. The operator-independence check is meaningful on a World ID 3.0 credential and silently meaningless on a v4 uniqueness proof, where two proofs from the same account produce different nullifiers and witness !== operator passes trivially.
That is why hasStableNullifier() exists, and why a test asserts hasStableNullifier('ORB_PRESENCE') === false. If we are ever forced onto the v4 path, the test suite itself records that the property degraded.
Full write-up, with nineteen dated and reproducible items across all five required sections: WORLD_FEEDBACK.md.
proofOfHuman. Rejected the v4 uniqueness path because its one-time-use nullifier makes our core security property silently false. Trade-off: Selfie Check is a gated beta, so we build behind one variable and ship the compatible preset if the flag does not arrive.wa: "proof" | "token" to say so rather than overstate.| Component | How Proctor uses it | Status |
|---|---|---|
| Arc as a second payment rail | Every 402 advertises exact on eip155:5042002 alongside Hedera. Settled through Circle's Gateway facilitator, which serves Arc; USDC is the gas token and the asset, at the ERC-20 interface 0x3600…0000 | Live on the wire |
GatewayEvmScheme | Not ExactEvmScheme. The base class drops supportedKind.extra, and Gateway needs verifyingContract, name and version to reach the buyer or they sign against the wrong EIP-712 domain and the signature fails to verify for no visible reason | Live |
| Capability cache | /supported answers are cached per facilitator. A network that intermittently intercepts *.circle.com can no longer silently delete the Arc rail at boot. Capabilities only — never verify or settle, so it can never make a payment look settled | Live |
| Per-second meter | The witness is billed for real seconds of human attention, streamed to the agent at 4Hz over SSE. A single $0.42 payout does not need Nanopayments; a per-second meter does | Live. A real 10s review billed $0.021275 |
| Gateway balance | Nanopayments spend from a Gateway deposit, not the wallet balance — a funded wallet with no Gateway balance fails with an error that reads like a signing problem | Funded, and spent from |
| Why Paymaster is absent | Circle Paymaster has no Arc support, and on Arc gas is already USDC, so it is structurally redundant | Stated rather than silent |
Circle's Agent Marketplace is a curated catalog where agents discover and pay for x402 services. We queried its public Discovery API, no key required, and sampled 50 live services:
49 Base 31 Solana 30 Polygon 18 Ethereum
18 Avalanche 18 Arbitrum 18 Optimism 18 Unichain
Hedera services listed: NONE
Every listed service settles on an EVM chain or Solana. Reproduce it with
cd backend && bun run marketplace.
Our listing prerequisites are already met: a 402-returning service, a published OpenAPI
spec at /openapi.json, and a payout account. The remaining requirement is a public
URL, since listings are continuously health-checked. Detail in
docs/circle-marketplace.md.
We spent a day believing Arc was unsupported by x402, because the boot preflight kept dropping it. It was not. We were only ever asking Blocky402, which serves Hedera and not Arc, and reading "the one facilitator we asked does not serve this" as "no facilitator serves this". Circle's own Gateway facilitator serves Arc testnet, and says so publicly:
curl -s https://gateway-api-testnet.circle.com/v1/x402/supported \
| jq '.kinds[] | select(.network=="eip155:5042002")'
Two things made this hard to see, and both are now handled rather than worked around:
@circle-fin/x402-batching
documents url: "https://gateway.circle.com"; that domain returns a Cloudflare origin error
and fails to resolve. The working host is gateway-api-testnet.circle.com, which appears in
Circle's seller quickstart rather than in the package.*.circle.com is intermittently TLS-intercepted, and initialize() swallows a
facilitator that throws. So one flaky call silently removed an entire payment rail and the
only symptom was "no facilitator support" for a network that is fully supported. Measured at
roughly one boot in three. getSupported() is now retried with backoff, and the dropped-entry
warning says "either genuinely unsupported, or its /supported call failed at boot" instead of
asserting the first.Arc now settles. The Gateway balance is funded and a real payment has moved through it:
2.000000 → 1.580000 USDC, exactly the $0.42 gate price. What remains unshipped there is
mainnet, which does not exist yet.
Tested with a facilitator unreachable. It does not degrade gracefully — it takes the whole route down, including entries a healthy facilitator serves, returning a bare 500 on every request.
The trap has four steps: initialize() swallows the dead facilitator and resolves cleanly, paymentMiddleware() also succeeds because it validates lazily, the real validation throws RouteConfigurationError asynchronously, and that rejection is not caught by app.setErrorHandler. Boot looks healthy. Every request fails.
await server.initialize();
const accepts = desired.filter((o) => {
const ok = !!server.getSupportedKind(2, o.network, o.scheme);
if (!ok) console.warn(`DROPPED accepts entry: ${o.scheme} on ${o.network}`);
return ok;
});
if (accepts.length === 0) throw new Error('no_payment_option_available');
paymentMiddleware(app, { 'POST /v1/gate/decisions': { accepts } }, server);
Verified against the real scenario, not a synthetic one: asking Blocky402 for Hedera and Arc drops Arc and keeps Hedera payable.
[x402] DROPPED accepts entry: exact on eip155:5042002 (no facilitator support)
[x402] 1 payment option(s) live: hedera:testnet
This converts "Circle is down so nothing works" into "Circle is down so the demo runs on Hedera only", decided automatically at boot. backend/src/lib/x402/server.ts:63

Source: docs/architecture.mmd · also available as
SVG. Rendered from source, so it cannot drift from what is
written down — which it had, badly: the previous version predated the Arc rail, the
witness payout, the retainer and the completeness check, showed the wrong topic and the
wrong settlement asset, and did not mention Circle anywhere.
Read it in five parts: the agent stops and checks the offer is signed before paying; it settles on either rail, through a different facilitator each; a human on a phone proves liveness bound to the decision hash; the human is paid directly, with no facilitator in the way; and the outcome lands on a log anyone can check for both integrity and completeness.
Postgres is the index. HCS is the truth. The mirror node cannot filter on anything inside a message payload, so every console query is served from Postgres and every row carries the sequence number and consensus timestamp needed to re-fetch and re-verify independently.
| Path | What it is |
|---|---|
backend/ | Fastify 5 on Bun. The gate, the policy engine, the witness flow, the attestation builder, the evidence API |
verify/ | Zero-dependency offline verifier. node:crypto only, because the claim is only as strong as this dependency list |
agent/ | The paying agent. Stops on a 402, pays, proceeds |
web/ | Console and witness PWA |
docs/ | Architecture diagram, regulatory mapping, agent discovery, Circle marketplace, verifiable artefacts |
harness/ | Contribution to the Hedera Harness: an HCS topic validator for the CHAIN stage, with running-hash verification and a expectDenseSequence assertion nothing upstream has. 17 tests |
docs/ai/ | How this was built with AI, and every defect the AI-assisted work introduced, how each was caught, and what changed so it could not recur |
00_*.md … 25_*.md | The 26 research documents, written before any code. Superseded ones are kept: the reasoning that killed five candidate products is the most reusable thing in the set |
227 tests across three suites: 185 backend (866 assertions), 25 verifier, 17 harness.
verify/test/runningHash.test.ts — chain reproduction from genesis, one flipped bit, removed message, reorder, forged insert, backdated timestamp, and that the naive implementation is wrongcanonical.test.ts — RFC 8785 conformance, nested-object sorting at every depth, arrays-are-data, and a direct test of the broken naive canonicaliserworldVerify.test.ts — all seven assertions, a proof for another decision rejected, a missing signal_hash rejected rather than read as a pass, hex-casing operator matchlifecycle.test.ts — approve vs sweep raced 50 times in both directions: sweeper always wins when overdue, approver always wins when valid, invariant holds at the knife edgeattestation.test.ts — EIP-712 verifies, tamper invalidates, size assertion fires, refusal semantics, indeSource-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y proctor-mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-louissarvin-proctor": {
"command": "npx",
"args": [
"-y",
"proctor-mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceproctor-mcpnpmio.github.louissarvin/proctor works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.