コンテンツにスキップ

台帳と照合

サイドカーに届いたリクエストは、すべて台帳(.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 を合計します。それ以外のステータスが予算を消費することはありません(ポリシーリファレンス)。

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 回あれば数え直しです。

サイドカーは 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 として現れます。

台帳がまだやっていないことです。検討中 と付けた項目に期日はありません。先を見越して計画できるように載せています。

  • unknown は「未確認」であって「決済済みだが未配送」ではありません。 売り手のレシートなしに決済を断定せず、照合は手作業です。検討中: 記録された支払いについてファシリテータやチェーンに照会し、人手なしで attempt を解決する自動照合。
  • ホステッドサンドボックスでは unknown を再現できません。 モックの売り手はプロセス内で動き、タイムアウトしないためです。この経路を試せるのは、ローカルで決済後に応答を返さない売り手を相手にしたときだけです。検討中: 決済してからタイムアウトするサンドボックスのエンドポイント。実資金なしでエージェント側の処理を練習できるようにします。
  • MCP クライアントからの承認。 保留モードは HTTP リクエストを最長 holdMax(既定 4 分)開いたまま待ちます。CLI やスクリプトには向きますが、ツール呼び出しを約 60 秒で打ち切る MCP クライアントには合いません。MCP エージェントにはサンドボックスと同じく approvals.hold: false にして、承認 ID をすぐ返すようにしてください。0.6 からは await_approval MCP ツールがクライアントのタイムアウト内で判断を長ポーリングし、時間切れなら pending を返すので、エージェントは有料呼び出しを再発行せずに待てます。0.7 からは withdraw_approval で、タスクに不要になった保留中の支払いをエージェント側から取り下げられます。否認・期限切れ・取り下げは別々の結果です(approval_denied、approval_timeout、approval_withdrawn)。