Mapae docs

3. Error model

A discriminated union that assigns a tag to every failure mode on the settlement path (packages/shared/src/errors.ts).

Blockchain code has a wide error surface — RPC timeouts, rate limits, reverts, nonce contention, signature verification failures, relayer gas exhaustion. Collapsing these into a single catch destroys the information recovery needs. Each tag distinguishes:

Why DomainMismatch is its own tag: an EIP-712 domain mismatch is the most common failure in an x402 integration, and if it goes out as a generic 500 the cause cannot be pinpointed.

The two paths have different response policies (deliberately). It is the EIP-3009 direct payment path (apps/seller) that carries the tag union verbatim in the response body. The ERC-7710 delegated path behaves differently.

Direct payment (apps/seller) Delegated payment (apps/delegated-seller, apps/facilitator-erc7710)
External response SettlementError._tag + describe() cause Opaque reasons such as delegation_rejected / settlement_unknown
Status code httpStatusFor() 402 / 400 / 403 / 422 / 504
Client branching Tag DelegatedPaymentFailureCode (the agent's own classification)

The delegated path is opaque because of the threat model. Returning detailed failure reasons would let an attacker probe the caveat boundaries — remaining allowance, expiry status, re-delegation structure — from responses alone. The cause goes to the server log. redactForLog keeps the revert reason (ERC20PeriodTransferEnforcer:transfer-amount-exceeded) and removes the bearer-length hex viem embeds in the error (the signed permission context), leaving only its size. The operator sees the cause; the caller does not.

How absent state is judged

One rule applies across the guard code: a value that could not be read, or does not exist, placed in a judgment position must produce a refusal or a distinct reason — never satisfaction.

Question tightest === undefined
judgePreflight (runtime) Will the chain refuse this payment? Pass — without a cap it is not refused
giwa-preflight (human gate) Does the configuration match the intent? Fail — there is no value to check against

The computation (tightestPeriodRemaining) is shared in packages/delegation; each side keeps its own judgment, and the two are linked by cross-reference comments.

Standing gates

bun run check mechanically verifies the claims of documentation and configuration alongside the code. Every gate runs without keys or network, and gives the same result on a clean clone.

Gate What it verifies
check:docs That every bun run and make command in the documentation actually exists, that every relative link resolves, and that every address matches the deployment artifacts and the canonical token source
check:gitbook That the GitBook chapters, SUMMARY, and configuration match, byte for byte, what is derived from the canonical source (docs/tech-notes.md)
check:logging That no raw error reaches a console.* argument in apps/, packages/, or scripts/ — viem embeds the transport URL in error messages, so this blocks the path by which an RPC URL with a key in its path leaks into logs
check:storage That no browser-storage write anywhere in apps/web/src happens outside the one sanctioned module (lib/grant-store.ts) — that module's projection is an allowlist which omits the agent session key, and a second write elsewhere would inherit neither the allowlist nor the test that pins it. Reads and removals are allowed everywhere
check:advisories That every bun audit finding is either fixed or an acceptance carrying a prove function that is re-measured on every run
check:counts That the test counts the repository README states match what bun and forge actually collect — it checks agreement with the suites, not agreement among the numbers in the documentation

The semantic distinction between 422 and 504

Among the opaque reasons, these two must be distinguished. settlement_failed (422) means "the payer was not charged"; settlement_unknown (504) means "whether the payer was charged could not be established." The former is an answer that invites a retry, and retrying an unconfirmed state becomes a double payment.

The distinction is grounded in a real incident. GIWA 0x533c5cb2…9964c (block 31634935) actually transferred 1.00 mUSDC from the payer, yet the caller received PAYMENT_REJECTED — the receipt-wait timeout had been configured longer than the seller's HTTP timeout. The timeout budgets were then redesigned to grow toward the outer layers (25 → 35 → 45 → 50 s), and a payment whose outcome is unresolved is returned as SETTLEMENT_UNKNOWN.

The judgment is isolated in a pure function, decideSettlement() (packages/delegation/src/x402.ts), and the criterion string (SETTLEMENT_UNCONFIRMED) and the response type are taken from the same module by producer and consumer alike. The decision ladder leans toward unknown.

Observation Result Reason
No response received (connection refused, non-2xx, not JSON, timeout) unknown 504 "The request never arrived" and "the response was lost after broadcast" cannot be told apart
errorReason === SETTLEMENT_UNCONFIRMED unknown 504 (+hash) Without the hash the caller has no way to verify
success !== true failed 422 Explicit refusal — no funds moved
success === true, payer mismatch unknown 504 A broadcast was claimed but the identity did not line up, and the balance was not confirmed
success === true, payer match settled 200

This path can be forced on a fork — shrinking the facilitator's receipt wait to 1ms exercises the unconfirmed-after-broadcast branch.

cd apps/delegation-lab && SETTLEMENT_RECEIPT_TIMEOUT_MS=1 bun run test:e2e:mcp

The run does not only check status codes; it reads the enforcer events directly from the fork and cross-checks whether funds actually moved.

Effect migration plan

The current implementation is a discriminated union, with the _tag discriminator kept isomorphic to Effect's Data.TaggedError.

Stage State
Now Discriminated union + explicit branching. Every failure mode enumerated at the type level
Next Migrate the settlement path to Effect<A, SettlementError, R> — typed error channel, Schedule-based retry/backoff, resource-safe RPC connections

It was not adopted during the MVP window because partial adoption is hard. Effect propagates through the entire call chain, so adopting it before the core payment loop was proven would have been an execution risk. Fixing the shape of the error model first turns the migration into a mechanical substitution rather than a rewrite.