台帳と照合
サイドカーに届いたリクエストは、すべて台帳(.spendctl/ 配下の SQLite ファイル)の 1 行として終わります。この行を attempt と呼びます。その ID は応答ヘッダ X-Spendctl-Attempt、拒否応答の本文、MCP の pay ツールの要約行でエージェントに返ります。spendctl ledger、ダッシュボード、spendctl export、MCP の list_receipts ツールは同じ行を読みます。
| ステータス | 意味 | 予算に計上 |
|---|---|---|
denied |
ポリシーが拒否した、または人間が承認を否認した。何も署名していない。 | しない |
dry_run |
--dry-run / dryRun:判定だけを計算し、何も送っていない。 |
しない |
pending_approval |
通信上で人間の判断を待っている(保留モード)。 | しない |
held |
承認 ID を付けてエージェントに差し戻した(再送モード、または holdMax 超過)。 |
しない |
superseded |
承認 ID を付けた再送がこの行を置き換えた。再送は別の行。 | しない |
expired |
誰も判断しないまま承認が期限切れになった。 | しない |
reserved |
許可。署名と送信の間、金額を予約している。 | する |
settled |
売り手が決済レシート付きで 2xx を返した。トランザクションハッシュを保存。 | する |
failed |
署名に失敗した、または売り手が支払いを拒否した(再度の 402、またはそう告げる決済ヘッダ)。 | しない |
unknown |
署名済みの支払いがサイドカーから出たが、結果を確認できなかった。 | 照合までする |
予算の期間(budget.hourly など)は reserved・settled・unknown を合計します。それ以外のステータスが予算を消費することはありません(ポリシーリファレンス)。
結果が unknown になるとき
Section titled “結果が unknown になるとき”attempt が unknown になるのは次の 3 つの場合です。
- 署名済みの支払いがサイドカーから出た後に、有料リクエストが失敗またはタイムアウトした(ネットワークエラー、または
server.upstreamTimeout。既定 30 秒) - 売り手が支払い後に 402 以外のエラーを返した
- x402 の決済ヘッダが pending で返ってきた。売り手がトランザクションハッシュを返していれば保存します
売り手のレシートがなければ、サイドカーは「決済済みだが応答が失われた」と「決済されていない」を区別できません。そのためどちらとも断定せず、エラー内容と売り手が返したものをそのまま行に残します。
unknown から決まること:
-
予算に残ります。 未確認の支払いは人間が照合するまで計上され、「無料の支出」にはなりません。
-
エージェントに伝え、再試行はしません。 サイドカーは
502を返します。{"error": "spendctl_upstream_error","attempt": "pay_…","message": "The paid request failed (…). spendctl will not retry automatically to avoid paying twice."}MCP の
payツール経由では同じメッセージがツールの結果になり、最終行にLedger entry: pay_…が付くので、モデルは ID を人間に伝えられます。 -
そのホストのサーキットブレーカーで失敗として数えます。
failedまたはunknownがcircuitBreaker.failures回(既定 3 回)続くと、そのホストへの支払いはcircuitBreaker.cooldown(既定 5 分)の間circuit_openで拒否されます。settledが 1 回あれば数え直しです。
二重に払わない
Section titled “二重に払わない”サイドカーは 1 判定につき 1 回だけ署名し、送信した支払いを自分から再送することはありません。エージェント側の再試行には冪等性ウィンドウで対応します。
- 再試行には同じ
Idempotency-Keyヘッダを付けます(MCP のpayツールではidempotencyKey)。同じエージェントが同じホスト・パスにそのキーをidempotencyWindow(既定 10 分)以内に送っていて、その attempt がreserved・settled・unknown・pending_approvalのいずれかなら、再試行はduplicate_requestで拒否され、メッセージに元の attempt が示されます。エージェントは払い直す代わりに、その attempt を見ることになります。 - キーなしの再試行は新しい判定になり、ポリシーが許せば新しい支払いになります。呼び出しが本来冪等でないなら、エージェントに必ずキーを付けさせてください。
エージェント側の ID で行を突き合わせる
Section titled “エージェント側の ID で行を突き合わせる”spendctl 0.5 以降、エージェントがリクエストに Idempotency-Key か X-Request-Id ヘッダを付けると、サイドカーは両方を attempt に保存し(前後の空白を除き、それぞれ最大 255 文字)、その attempt が現れる場所すべてで返します。
- 拒否・承認待ち・エラーの応答本文に、
attemptと並んでidempotencyKeyとrequestIdとして入ります。 spendctl exportでは最後の 2 列idempotency_keyとrequest_idになります。既存の CSV の列位置は変わりません。- MCP の
payツールは要約行と拒否文にこれらを記し、list_receiptsは各行に付けます。
複数の呼び出しを出したエージェントでも、host と path で突き合わせずに、どの拒否が自分のどの呼び出しのものかが分かります。以前のリリースで作られた台帳は、サイドカーが開くときに request_id 列が追加されます(スキーマバージョン 3)。
照合は現時点では人間の手順です。
spendctl ledger --limit 20 # unknown の行は強調表示spendctl trace pay_… # その attempt と、前後のエージェント間チェーンspendctl resolve pay_… --settled --tx 0x… # 支払いは通っていたspendctl resolve pay_… --failed # 通っていなかった。金額は予算の期間から外れる先に売り手のダッシュボードやチェーンで確認してください。行には支払元アドレスと、売り手が返していればトランザクションハッシュが記録されています(spendctl export --format json で出力できます)。resolve が受け付けるのは unknown の attempt だけで、それ以外は 409 で拒否します。--settled は金額を計上したままハッシュを保存し、--failed は計上から外します。承認と同じく resolve には .spendctl/admin.token の管理トークンが必要で、エージェントからは呼べません。spendctl export には status 列があるので、未照合の行は経費エクスポートに unknown として現れます。
現時点の制約と検討中の項目
Section titled “現時点の制約と検討中の項目”台帳がまだやっていないことです。検討中 と付けた項目に期日はありません。先を見越して計画できるように載せています。
unknownは「未確認」であって「決済済みだが未配送」ではありません。 売り手のレシートなしに決済を断定せず、照合は手作業です。検討中: 記録された支払いについてファシリテータやチェーンに照会し、人手なしで attempt を解決する自動照合。- ホステッドサンドボックスでは
unknownを再現できません。 モックの売り手はプロセス内で動き、タイムアウトしないためです。この経路を試せるのは、ローカルで決済後に応答を返さない売り手を相手にしたときだけです。検討中: 決済してからタイムアウトするサンドボックスのエンドポイント。実資金なしでエージェント側の処理を練習できるようにします。 - MCP クライアントからの承認。 保留モードは HTTP リクエストを最長
holdMax(既定 4 分)開いたまま待ちます。CLI やスクリプトには向きますが、ツール呼び出しを約 60 秒で打ち切る MCP クライアントには合いません。MCP エージェントにはサンドボックスと同じくapprovals.hold: falseにして、承認 ID をすぐ返すようにしてください。0.6 からはawait_approvalMCP ツールがクライアントのタイムアウト内で判断を長ポーリングし、時間切れならpendingを返すので、エージェントは有料呼び出しを再発行せずに待てます。0.7 からはwithdraw_approvalで、タスクに不要になった保留中の支払いをエージェント側から取り下げられます。否認・期限切れ・取り下げは別々の結果です(approval_denied、approval_timeout、approval_withdrawn)。