第11巻 内部設計書(3) ― メッセージ定義・LSM・HTLC・Raft・FX/レガシー内部設計
目次
- 第13章 主要メッセージのボディ定義(実装テンプレ) <a id="appendix-e-messages"></a>
- 13.1 PaymentInitiated(参加行→ZC)
- 13.2 PayerExecRequested(ZC→参加行)
- 13.3 PayerExecConfirmed(参加行→ZC)
- 13.4 DecideToSettle(ZC→参加行)
- 13.5 HTLC Reveal(クライアント/参加行→ZC)
- 13.6 QueryResponse(ZC→参加行/Client表示用)
- 第14章 LSM(流動性節約)詳細 <a id="appendix-f-lsm"></a>
- 14.1 目的関数(設計意図)
- 14.2 入力・出力(監査再現性)
- 14.3 フォールバック(必須)
- 14.4 採否の扱い(規範)
- 第15章 HTLC(hashlock+timelock)詳細 <a id="appendix-g-htlc"></a>
- 15.1 適用範囲
- 15.2 セキュリティ・暗号プリミティブ制約
- 15.3 状態遷移(txid拡張とAPI)
- 15.4 重大な証跡とデータ保持の固定点
- 15.5 プログラマビリティの汎用化(条件の AND/OR 合成)
- 15.6 クロスチェーン確定の種別と量子リスク証跡
- 第16章 Raft運用(合意ログ)詳細 <a id="appendix-h-raft"></a>
- 16.1 1シャード=1クラスター
- 16.2 スナップショットとコンパクション
- 16.3 メンバーシップ変更
- 第17章 クロスカレンシーFX 内部設計 <a id="fx-internal"></a>
- 17.1 スキーマ
- 17.2 API
- 17.3 既存コードへの接地
- 17.4 既存コードへの接地
- 第18章 レガシー勘定系アダプタ 内部設計 <a id="adapter-internal"></a>
- 18.1 冪等性(非冪等コアの防御、read-then-write ではなく atomic claim)
- 18.2 適合性検証テストが固定していること(`adversarial.test.ts`, 34 ケース) <a id="conformance"></a>
- 18.3 監査で見つかった問題と是正 <a id="audit-fixes"></a>
第11巻 内部設計書(3) ― メッセージ定義・LSM・HTLC・Raft・FX/レガシー内部設計
本巻は
docs/specs/30_internal_design.mdの第13章〜第18章(主要メッセージのボディ定義/LSM詳細/HTLC詳細/Raft運用詳細/クロスカレンシーFX内部設計/レガシー勘定系アダプタ内部設計)を収める。前巻→第10巻の続き。
第13章 主要メッセージのボディ定義(実装テンプレ)
第12章(I/F契約)は“契約の骨格”であり、本章(第13章)は“実装の迷いを潰すための型”である。
13.1 PaymentInitiated(参加行→ZC)
{
"txid": "TX-...",
"lane": "EXPRESS|STANDARD|BULK|DEFERRED|RTP|HTLC|HIGH_VALUE",
"amount": { "value": 1200, "currency": "JPY" },
"payer": {
"bank_id": "B001",
"account_hash": "h:...", // 必須(永続):ソルト付ハッシュ
"vault_ref": "v:optional" // 任意(短寿命):Vault参照(表示/照会に必要な場合)
},
"payee": {
"bank_id": "B999",
"account_hash": "h:optional", // ExpressではIngress時は省略可
"vault_ref": "v:optional" // 任意(短寿命):Vault参照(表示/照会に必要な場合)
},
"purpose": "MERCHANT|P2P|BILL|SALARY|REFUND",
"pspr_ref": "optional",
"participant_ref": "optional",
"ref_issuer": "PAYER_BANK|PSPR|ZC",
"ref_proof": "optional",
"requested_at": "RFC3339",
"expires_at": "RFC3339",
"client_ref": "optional",
"risk_hint": { "vault_ref": "optional" }
}
値域の正(実装): src/types/states.ts#LaneType: EXPRESS | STANDARD | BULK | DEFERRED |RTP | HTLC | HIGH_VALUE / src/types/states.ts#PurposeType: MERCHANT | P2P | BILL |SALARY | REFUND。
pspr_refは optional。参加行が保持できないケースを想定し、participant_ref/client_refを代替キーとして併置する(発番主体はref_issuerで識別)。payee.account_hash/payee.vault_refは Express では optional(alias/宛先確認で補完可)としつつ、DECIDED_TO_SETTLE以降に 解決済み参照(例:account_resolved_ref)をRead Modelに保持して説明可能性を担保する。
規範
- Expressは
pspr_refを 推奨(参加行が保持できる場合)。保持できない場合はparticipant_refまたはclient_refの いずれかを必須 とし、照会・突合のキーを確保する。 - Expressでは
payee.account_hash/payee.vault_refは Ingress時は省略可能(alias/宛先確認で補完)だが、DECIDED_TO_SETTLEまでに解決済み参照へ確定させる(Read Modelに保持)。Express では PayeeBank がpspr_refから必要項目を解決する(ZC は参照番号と digest のみ保持)。 expires_at超過はREJECT_PRECHECK_EXPIRED。
13.2 PayerExecRequested(ZC→参加行)
{
"txid": "TX-...",
"amount": { "value": 1200, "currency": "JPY" },
"decision_proof_ref": "DP-...",
"h_reservation": { "reservation_id": "H-...", "mode": "RESERVED|LOCKED" },
"execution_deadline": "RFC3339",
"causation_id": "..."
}
13.3 PayerExecConfirmed(参加行→ZC)
{
"txid": "TX-...",
"result": "OK|NG",
"reason_code": "optional",
"bank_proof_ref": {
"issuer_bank_id": "B001",
"proof_type": "PAYER_EXEC_PROOF",
"proof_id": "...",
"retrieval_hint": "..."
}
}
13.4 DecideToSettle(ZC→参加行)
※ DecideToSettle は「決めた」ことの通知(EVENT)であり、実施を起動するトリガは PayerExecRequested / PayeeExecRequested(COMMAND)である。
{
"txid": "TX-...",
"decision": "DECIDED_TO_SETTLE",
"decision_proof_ref": "DP-...",
"finality_log_ref": "FL-...",
"reason_code": "optional"
}
13.5 HTLC Reveal(クライアント/参加行→ZC)
secret(preimage)提示メッセージのボディ。ZC は hashlock と検証証跡のみを保持し、secret は永続保存しない(20_method_design.md §3.2.2、本書 §15.4)。
{
"txid": "TX-...",
"htlc": {
"hash_alg": "SHA-256",
"hashlock": "hex",
"secret": "hex",
"timelock_expires_at": "RFC3339"
}
}
13.6 QueryResponse(ZC→参加行/Client表示用)
{
"txid": "TX-...",
"state": "RECEIVED|PRECHECKED|PRECHECKED_SUSPENDED|H_RESERVED|HTLC_LOCKED|HTLC_ONCHAIN_PENDING|HTLC_FULFILL_REQUESTED|DECIDED_TO_SETTLE|DECIDED_CANCEL|PAYER_EXEC_CONFIRMED|PAYEE_EXEC_CONFIRMED|SUSPENDED|FAILED_EXECUTION|CANCELLED|SETTLED",
"reason_code": "optional",
"decision": {"status":"NONE|DECIDED_TO_SETTLE|DECIDED_CANCEL", "decision_proof_ref":"optional"},
"execution": {"a":"NONE|OK|NG", "b":"NONE|OK|NG", "payer_bank_proof_ref":"optional", "payee_bank_proof_ref":"optional"},
"case": {"case_id":"optional", "status":"optional"},
"as_of": "RFC3339",
"freshness_level": "GREEN|YELLOW|RED",
"next_action_hint": "WAIT|RETRY_LATER|CONTACT_PAYER_BANK|OPEN_CASE",
"next_retry_at": "RFC3339 optional",
"watermark": 12345,
"watermark_detail": {
"shards": {"TX:TX-2026-0001": 12345, "GT:GTID-7": 67890}
}
}
watermark_detail.shardsのキー(規範)キーは 直列化キー=1本のハッシュチェーンを指す(§16.1「1シャード=1クラスター」の
シャードと同じ単位)。書式は
TX:<txid>/GT:<gtid>/DNS:<dns_cycle_id>であり、値はそのチェーンの
MAX(event_seq)。当該取引に関する事実を載せているチェーンを漏れなく並べること——GTID 脚の決定は脚の chain ではなく GT chain に載るので、脚の番号だけでは
監査が答えを再導出できない。参加していて未記帳のチェーンは省略せず 0 を返す
(「そのチェーンは無い」と「まだ何も無い」は別の主張である)。
watermarkは上記の最大値であり、内訳より新しいと主張してはならない。実装:
src/zc/finality/watermark.ts。
値域の正(規範)
next_action_hintの値域は閉じた 4 値であり、実装の正:src/types/api/transfers.tsのQueryResponse.next_action_hint:WAIT|RETRY_LATER|CONTACT_PAYER_BANK|OPEN_CASE事象ごとに新しい hint 値を作ってはならない。事象固有の含意は
reason_codeが担う。
設計規範(照会応答の役割分離)
窓口/顧客向けの画面では
watermark/watermark_detailを表示せず、freshness_levelとas_ofのみを表示する。
watermark系は監査・技術者向けに保持するが、業務説明の前面に出さない。補足:freshness_level の定義(規範)
測るのは「Read Model がどれだけ SoT に遅れているか」であって、取引が最後に動いた時刻ではない。具体的には 当該 txid の FinalityLog 先端(
MAX(occurred_at))と派生行のupdated_atの差を遅延(lag)とする。SoT に自分より新しいエントリが無い行は追いついているので、終端に達した取引は何年経っても GREEN である。「取引が最後に動いてからの経過時間」で測ってはならない——正常に完了した取引が 1 分後から一律 RED になり、下の窓口テンプレ(「照会が混み合っております」)を引いてしまう。指標の意味と顧客説明が同時に壊れるため、閾値調整では直らない。
境界は下側を含み上側を含まない(区間は左閉右開):
GREEN:lag < 10 秒(ほぼリアルタイム)
YELLOW:10 秒 ≤ lag <
PR-FRESHNESS-RED(既定 60 秒)(許容遅延)RED:lag ≥
PR-FRESHNESS-RED(§12.9。Read Model遅延/調査推奨)
窓口対応(推奨テンプレ:原因をぼかす)
- GREEN / YELLOW:表示どおり案内(通常応対)
- RED:「現在照会が混み合っております。少し時間を置いて再度ご確認ください。」
※資金不足や特定参加主体の事情を想起させる表現は避け、公式発表・公式ステータスに整合させる。
規範
as_ofは表示の鮮度(いつ時点の派生ビューか)を示す。watermarkは Finality Log群(シャード単位) の反映位置を示す。gtid 等の複合集約ではwatermark_detail.shardsに関与する全チェーンの watermark を並べ、監査再現性を担保する(上記のキー規範)。next_action_hintは窓口/コールセンターの定型応対を支援する(文言固定・閉じた 4 値)。next_retry_atは「この時刻より前に再照会しても状態が進みにくい」最短目安であり、電話・再照会の氾濫を防ぐ。
第14章 LSM(流動性節約)詳細
14.1 目的関数(設計意図)
Bulkは“遅くてもよい”のではなく、 大量件数を低単価で安定処理する ことが目的である。
よってLSMは以下の両立を狙う。
- H制約(安全弁)を超過しない (絶対条件)
- 期限(due_at)を守る (最優先の最適化)
- 公平性(特定参加行の飢餓を防ぐ) (運用品質)
- 処理量(スループット)を最大化 (コスト効率)
設計規範(LSMの優先順位:辞書式)
目的関数は同時に最大化できないため、実装ブレを防ぐ目的で 辞書式(lexicographic) に固定する。
- 期限遵守(due_at超過の最小化)
- 公平性(飢餓防止:待ち時間に基づく重み付け)
- 効率(スループット最大化)
14.2 入力・出力(監査再現性)
- 入力スナップショット:
input_snapshot_id(window締切時点の候補集合) - 制約ダイジェスト:
constraints_digest(H残高、期限、優先度、停止条件) - 出力集合:
execution_set_hash(採択leg/txの集合) - 追跡情報:
trace_digest(再現可能な根拠)
規範 :LsmRunCommitted は上記を必須とし、後日「なぜこの集合が採択されたか」を説明できること。
14.3 フォールバック(必須)
LSMが失敗した場合でも処理を停止させない。
LsmRunFallback(mode=FIFO):到着順処理LsmRunFallback(mode=PRIORITY):期限優先LsmRunFallback(mode=THROTTLE):参加行別の上限で間引き
規範 :フォールバック時は objective_metrics.degraded=true を立てて劣化を記録し、運用に自動通知する。
14.4 採否の扱い(規範)
- 非採択は reject しない:LSM に採られなかった候補は次ウィンドウへ Defer する。締切に間に合わなかったことを失敗として返すと、参加行側に再送ループを作る(§14.1 の「期限遵守」は最適化目的であって、不採択の理由ではない)。
- 採択の確定点は H 予約コミット:最適化の出力そのものではなく、
advanceBulkにおける H 予約コミット(安全弁)の成否をもって確定とする。最適化は提案、確定は安全弁——この順序を逆にしない。 - 実装の所在:
src/zc/liquidity/bulk_lsm.ts#runBulkLsm(候補集合は BULK / RECEIVED、EOD もこの経路で確定する)。
第15章 HTLC(hashlock+timelock)詳細
15.1 適用範囲
HTLC(Hashed Time-Lock Contract)は、デジタルアセットのクロスチェーン交換や、エスクロー(第三者預託)的な 「双方が条件(シークレット)を満たした時のみ成立し、期限が来れば確実に無効化される取引」 を整理するための機能である。成立後の取消(Reversal)等の用途には使用しない。
本基盤が規範化するのは hashlock(SHA-256ハッシュ) + timelock(ISO8601日時ベースの期限) のみであり、外部オラクル参照など責任分界が曖昧になる要素は規範の対象外とする。
15.2 セキュリティ・暗号プリミティブ制約
- ハッシュ関数アルゴリズム:
SHA-256のみを標準とし、他の危弱なアルゴリズムや複雑すぎるアルゴリズムを排除して検証コストを固定する。 - Preimage(シークレットパスワード)エントロピー: 生成されるシークレット(
preimage)は最低256ビット空間から十分なエントロピーを用いて生成されることを推奨し、ブルートフォース攻撃から保護する(参加銀行側の実装要件)。 - Timelockの基準時計: ZC(ワーカー群)のシステム時刻を正とし、ネットワークの遅延を考慮して余裕を持った
timelock(またはexpires_at)を設定させる。
15.3 状態遷移(txid拡張とAPI)
HTLCの主役は「保留(Lock)」と「解決(Claim / Reveal)」の2段階である。
状態名の正(規範):本節が用いる状態名は、
Transactions.stateについては
src/types/states.ts#TxState、HtlcContracts.stateについては同#HtlcStateを正とする。以下では実在する状態名のみを用いる。
RECEIVED(Transactions)/HTLC_RECEIVED(HtlcContracts):支払人がPOST /api/htlc/createで、送金金額とともにhashlockとtimelockを指定する。Transactions行は必ずRECEIVEDで生成する(HTLC_LOCKED直挿入は禁止。10_requirements.md§3.2.3.1-5)。HTLC_LOCKED:形式検証・AMLチェック後、ZCが仕向側限度(H)を予約し、資金がロックされる。
遷移はRECEIVED → HTLC_LOCKED(canonical 入口)。この間、日次DNSの清算対象には計上されない。HTLC_FULFILL_REQUESTED:受取人が期限内に正しいpreimageを提示(Claim)し、
ハッシュ値が一致(ZCが検証)した状態。この状態に入った後は取消不可であり、DECIDED_TO_SETTLE(成立)かFAILED_EXECUTION(実施未確定で終端)に収束する。
クロスチェーン脚ではHTLC_LOCKED → HTLC_ONCHAIN_PENDING → HTLC_FULFILL_REQUESTED
を経由する(§15.6)。DECIDED_TO_SETTLE:ハッシュ合致により決済確定(通常の送金と同様のa→bの状態へ進行する)。DECIDED_CANCEL→CANCELLED:期限(timelock)到来までにpreimageが提示されなかった
場合、自動で取消決定へ収束し、H予約は安全にロールバックされる。「期限切れ」を表す独立した
状態は持たない——期限到来はDECIDED_CANCELへの遷移イベントであり、理由はreason_code
(TIMELOCK_EXPIRED等)が担う。
正準の遷移グラフ(図)は 20_method_design.md §3.2.2、実装上の唯一の正はsrc/zc/orchestrator/state_machine.ts#ALLOWED_TRANSITIONS である。
15.4 重大な証跡とデータ保持の固定点
- ZCにシークレット(preimage)の平文を永続保存してはならない:ZCが保持するのは、検証用の
hashlockと、提示・検証結果の検証ログ(および監査ハッシュ)のみである。これにより、ZC自身がシークレット漏洩の起点となるリスクを根絶する。 - 否認防止(Non-repudiation)の担保:シークレット提示(Claim)は、提示側(PayeeBankまたは連携主体)による署名付きイベントとして受け付ける。
- 必須パラメータの例:
presenter_org_id,presenter_system_id,presented_at,preimage,present_signature
- 必須パラメータの例:
- 検証のアトミック性:シークレット提示は 一度だけ成功 させる。既に Claim 済みの取引への再 Claim は、冪等処理として成功(同じ結果)を返し、二重実行を防ぐ。
- タイムロック超過の完全性:
timelockが1ミリ秒でも超過した場合、ZCは如何なる理由(通信遅延、障害等)であっても Claim を拒否しDECIDED_CANCELで回収する。 - Claim 直前の AML 再照会も fail-closed:
timelockが当営業日末を越える HTLC は、資金解放の
直前に払い手銀行へ AML/制裁の再照会(check_type='RECHECK')を行う。この照会の答えは
OK / NG の 2 つではなく、「答えが返らない」が third case として存在する(回路 OPEN 等)。
判定不能を「NG でなければ解放する」と扱ってはならない——到達できなかった照会が非該当と
同じ効果を持つことになる(事前審査側の同じ規範は20_method_design.md§3.3.1 の T_auth)。- 判定不能の帰結は「claim の拒否」であり、取消でも待機でもない。 claim 経路には
PRECHECKED_SUSPENDEDに相当する待機状態が無く、待つこと自体が timelock と競合する。
したがって HTLC を一切変えずに claim を退け(reason_codeRECHECK_AUTHORITY_UNAVAILABLE)、再試行に委ねる。再照会は timelock が当営業日末を
越える場合にのみ走るので、拒否された claim には構造上その日の残り以上の再試行余地がある。
銀行が復旧しなければ外側 timelock が取消・返金する(未 claim の HTLC と同じ backstop)。 - 残余リスクは明示して受け入れる:他チェーンで既に preimage を明かした受取人は、
秘密を手放したまま再試行の成功を待つことになる。これは timelock で上限が付き再試行で
回復しうるのに対し、未審査の相手へ解放することは上限も回復手段も無い——ゆえにこちらを
採る。 - 拒否は遷移を伴わない証跡として FinalityLog に残す(
HtlcClaimRejected、state_from == state_to。INVALID_PREIMAGEの記録と同型)。審査できなかったことを
理由に決済を拒んだ事実こそ、監督当局が後から尋ねるものである。
- 判定不能の帰結は「claim の拒否」であり、取消でも待機でもない。 claim 経路には
15.5 プログラマビリティの汎用化(条件の AND/OR 合成)
本節(§15.5)は HTLC 固有の適用規範。条件式・k-of-n 定足数・equivocation・決定的述語・JST の設計思想と構成レイヤの一次記述は 第11章(プログラマビリティ)を参照(重複説明は同章に集約)。
preimage 提示に代えて、ホワイトリスト化された ConditionTemplate への署名付き Attestation(verified_result='PASS')でも HTLC を成立させられる(単一テンプレ)。これを 複数テンプレートの AND/OR 合成 へ一般化する。HtlcContracts.condition_expr_json が {template_id} | {op:'AND'|'OR', operands:[…]} の式木を保持し、claimHtlcByConditions(POST /api/htlc/:htlc_id/claim-by-conditions)が各リーフの Attestation を recordAttestation(署名・スコープ・鮮度を検証)で確認したうえで、PASS となったテンプレ集合に対し純粋評価器でブール式を判定する。
- 規範:ZC は条件の真偽そのものを判定しない。各リーフの Attestation が有効かつ PASS かのみを検証し、式の成立は集合に対する純粋なブール合成として評価する。式が満たされた場合のみ既存の成立経路(
HTLC_LOCKED → HTLC_FULFILL_REQUESTED → …)へ進み、いずれの場合もHtlcConditionsEvaluated(満たされたテンプレ集合・判定結果・定足数内訳)を証跡化する。単一リーフは従来の単一テンプレ挙動に一致する。 - 構造上限(濫用・無限再帰の防止):式木の深さ・オペランド数・参照テンプレ数に上限を設ける。
15.5.1 k-of-n THRESHOLD ノード
AND/OR に加え {op:'THRESHOLD', k, operands:[…]} を持つ。オペランドのうち 少なくとも k 個が真なら成立する(AND = THRESHOLD k=#operands、OR = THRESHOLD k=1 の一般化)。「n 個中 k 個」を AND/OR の組合せ展開なしに直接表現できる。k は 1..#operands の整数に限る。
15.5.2 テンプレート単位の distinct-operator 定足数と equivocation(HTLC 適用)
定足数(min_attester_quorum)と equivocation のモデル定義は §11.2-c を正とする。ここでは
HTLC claim 経路への適用のみを述べる。
- claim 判定時、各 Attestation リーフは §11.2-c の distinct-operator 定足数を満たしたときにのみ
「満たされた」と扱い、その内訳をHtlcConditionsEvaluatedの定足数内訳として証跡化する。 - equivocation 検出時は当該リーフを fail-closed とし、HTLC 自体は
HTLC_LOCKEDのまま
(取消はしない)。ATTESTATION_EQUIVOCATIONの CASE へ収束させ、timelock 満了までは
他の独立枝での成立を妨げない。
15.5.3 決定的述語(LedgerPredicate)(HTLC 適用)
述語の種別・意味・単調性の定義は §11.2-d を正とする(TX_REACHED_STATE /GTID_REACHED_STATE / TIME_AFTER / TIME_BEFORE の 4 種)。ここでは HTLC claim 経路への
適用のみを述べる。
ledger_predicate_jsonが非 NULL のテンプレートは Attestation の提示を無視し、claim 時点でevaluateLedgerPredicate(db, p, now)により解決する。- 評価に用いた
nowをHtlcConditionsEvaluatedに必ず記録する。TIME_BEFOREは非単調
(true→false)であるため、記録が無いと「なぜあの時点で成立したのか」を後から再現できない。 - 構成上の相互待ち(相互参照、到達しない
TIME_AFTER)は安全性ではなく liveness の問題であり、
timelock 満了による返金で解消する。ZC は循環検出を行わない。 - 時刻は JST(§11.4)。
TIME_*のatにオフセットが無い場合は JST とみなす。
15.5.4 ドライラン(読み取り専用)
副作用なしで条件式・マンデートを事前評価できる(POST /api/conditions/validate・/api/conditions/simulate・/api/mandates/check)。書き込み・資金移動・Attestation 記録は行わない(src/zc/query/simulate.ts)。
15.6 クロスチェーン確定の種別と量子リスク証跡
クロスチェーン HTLC のオンチェーン脚は、レールの確定モデルに応じて確認の扱いを変える。onchain_chain_class を PUBLIC(確率的確定:確認深度が意味を持つ)/ PRIVATE・PERMISSIONED(決定的確定) として記録し、そこから onchain_finality_class(PROBABILISTIC / DETERMINISTIC)を導出する。
- 確認深度ゲート:内側
onchain_timelock経過後の解放は十分な確認深度を要する。決定的チェーンは 1 確認で確定とみなしてゲートを縮約し、確率的チェーンは設定された深度を尊重する(requiredConfirmations)。 - 量子危殆化リスクの証跡:オンチェーン証明の署名スイートを
onchain_quantum_risk(VULNERABLE / RESISTANT / UNKNOWN)として分類・記録する。古典スイート(secp256k1・ed25519 等)は VULNERABLE、PQ 耐性スイート(Dilithium 等)は RESISTANT、未知は UNKNOWN(安全側に倒さない)。 - 分類は作成時に
OnchainFinalityClassifiedで証跡化する。チェーン登録の権威(どの source をどの class とみなすか)は制度設計に委ねる。
第16章 Raft運用(合意ログ)詳細
16.1 1シャード=1クラスター
- 取引の直列化キー(txid/gtid等)単位でシャードを切り、各シャードでRaft合意を取る。
- 過半数に到達できない場合はRead-only へ遷移し、誤決定(split-brain)を防止する。
16.2 スナップショットとコンパクション
- Finality Logは追記で増大するため、スナップショットを必須とする。
- 監査再現に必要な最小範囲(例:過去N日+係争中)を保持し、それ以前はWORM等へ退避する。
16.3 メンバーシップ変更
- ノード追加・除去は段階的に行い、変更操作自体も
evidence_refで証跡化する。
第17章 クロスカレンシーFX 内部設計
本機能の全体像 — 要件:
10_requirements.md/ 処理方式:20_method_design.md/ 内部設計:本章。
17.1 スキーマ
migrations/0001_consolidated_schema.sql は歴史的な個別マイグレーションを1ファイルに
統合した、現在の唯一のスキーマ定義ファイルである(新規変更も新しい番号付き
マイグレーションを切らず、この統合ファイルを直接編集する運用。§8・docs/specs/31_schema.md マイグレーション運用)。FX 関連の3テーブルもこの1ファイルの
中で定義されている。test/helpers/d1-mock.ts の SCHEMA_MIGRATIONS に登録済みで、
テストDBにも反映される。
FxQuotes(20_method_design.md§17.3.2)。- FXP 能力フラグ:
Participants.is_fx_provider INTEGER NOT NULL DEFAULT 0。
対応通貨はParticipantCurrencyLimitsの存在で判定する(別表は無い)。 FxTransfers: 導管 GTID の FX 固有事実(経路・実効レート・hashlock・quote_ids・status)をgtidで1:1に保持(20_method_design.md§17.4.5)。FxLegLocks:bind_htlc=trueの経路でのみ使われる、脚ごとのロック状態
(LOCKED/CLAIMED/REFUNDED、20_method_design.md§17.4.2・§17.4.3)。FinalityEventTypeに FX 専用の値は追加していない。FX の導管 GTID も通常の GTID が書く
既存のファイナリティログ(例:GtidRegistered)をそのまま使う。reason_code(REASON_CODE_CATEGORY、src/shared/errors.ts)に登録済みで FX が
実際に投げるもの:FX_NO_ROUTE(CONFLICT)、FX_QUOTE_EXPIRED(CONFLICT)、FX_ALREADY_REFUNDED(CONFLICT。払戻済みへの claim)、FX_RATE_MISMATCH(VALIDATION)、INVALID_FX_RATE(VALIDATION)、FX_FXP_ACCOUNT_MISSING(VALIDATION)。登録済みだが現在どこからも投げられていない
予約コード:FX_ROUTE_INCONSISTENT(VALIDATION)、FX_LIQUIDITY_INSUFFICIENT
(CONFLICT、20_method_design.md§17.5)。HTLC関連は FX 専用コードを新設せず既存の汎用コードを再利用する:PREIMAGE_MISMATCH(VALIDATION)、STATE_GUARD(CONFLICT)、GTID_NOT_FOUND
(NOT_FOUND)、UNAUTHORIZED(AUTH)。
17.2 API
実装は src/zc/fx/api.ts、ルーティングは src/router/zc.ts、OpenAPI はsrc/openapi/zc-api.ts の fx タグ。
PUT /api/fx/rates
FXP が自行の方向別レートを upsert する。
- リクエスト:
{ fxp_bank_id, from_currency, to_currency, rate, min_amount?, max_amount?, valid_from?, valid_to } - 検査: 通貨は ISO 4217・
from_currency !== to_currency・rateは正整数・valid_toは
RFC3339。fxp_bank_idのParticipantsが存在し、is_active=1かつis_fx_provider=1
であること。 - 失敗: 404
PARTICIPANT_NOT_FOUND| 409STATE_GUARD(非活性)|
401UNAUTHORIZED(FXP未登録。UNAUTHORIZEDは AUTH カテゴリ=401。docs/specs/32_api_contracts.mdのエラーカタログ写像に従う。以前は 403 をハードコードしていたが
カタログと矛盾していたため 401 に修正済み、api.ts)| 400INVALID_FX_RATEほか。 - 成功: 200
{ result: "QUOTE_ACCEPTED", quote }。
DELETE /api/fx/rates/:quote_id
見積の取り下げ。成功: 200 { result: "QUOTE_WITHDRAWN", quote_id }。対象が ACTIVE で
見つからない(既に取り下げ済み/存在しない)場合は 404。
GET /api/fx/rates?from=&to=
指定ペアの ACTIVE 見積一覧。{ from_currency, to_currency, quotes }。
POST /api/fx/quote
価格発見(コミットしない)。
- リクエスト:
{ from_currency, to_currency, amount, denomination, max_bridge_hops? } - 成功: 200
{ result: "ROUTE_FOUND", rate_scale, route }。 - 経路が無ければ 409
FX_NO_ROUTE。
POST /api/fx/transfers
- リクエスト:
{ gtid, idempotency_key, from_currency, to_currency, amount, denomination, payer: { bank_id, account_hash }, payee: { bank_id, account_hash }, fxp_accounts: { "<bankId>:<currency>": "<accountHash>" }, max_bridge_hops?, expires_at?, min_effective_rate?, bind_htlc? } - 冪等化:
idempotency_keyでresolveIdempotency(初回受理か再生かを判定。内部でacquireIdempotency/getIdempotentResponseを用いる)とcompleteIdempotency(確定した
応答を保存)を使う(api.ts)。2回目以降は1回目の応答を200で再生し、同一キーで本文が
異なる場合は 409IDEMPOTENCY_KEY_CONFLICTを返す。 - 権威的再プライシング: クライアントが
POST /api/fx/quoteで得た経路は信用せず、
ここでfindBestRouteを再実行する。経路が見つからなければ 409FX_NO_ROUTE。 min_effective_rateを指定していれば、再プライシング後のeffective_rateがそれより
悪ければ 409FX_RATE_MISMATCH。fxp_accountsに経路上の全 FXP×全通貨(各ホップの from/to)のキー"<bankId>:<currency>"が無ければ 400FX_FXP_ACCOUNT_MISSING。bind_htlcが真ならlockFxTransferを呼び、201{ result: "FX_TRANSFER_LOCKED", gtid, hashlock, secret?, amount_from, amount_to, legs, route }。- 偽(デフォルト)なら
initiateFxTransferを呼び、201{ result: "FX_TRANSFER_INITIATED", gtid, hashlock, amount_from, amount_to, effective_rate, route }。
POST /api/fx/transfers/:gtid/claim(HTLC経路)
- リクエスト:
{ secret }。 - 成功: 200
{ result: "FX_TRANSFER_CLAIMED", gtid, status: "SETTLED", gtid_state, already }。 - 失敗: 400
PREIMAGE_MISMATCH| 404GTID_NOT_FOUND| その他 409
(例:FX_ALREADY_REFUNDED=既に払戻済み)。
POST /api/fx/transfers/:gtid/refund(HTLC経路)
- 成功: 200
{ result: "FX_TRANSFER_REFUNDED", gtid, status: "REFUNDED", refunded_legs }。 - 失敗: 404
GTID_NOT_FOUND| その他 409STATE_GUARD(既に CLAIMED 済み、または
タイムロック未到来)。
GET /api/fx/transfers/:gtid
- 成功: 200
{ gtid, status, from_currency, to_currency, amount_from, amount_to, effective_rate, hashlock, quote_ids: string[], gtid_state, legs: GtidLegs[], leg_locks: [{ leg_index, currency, amount, timelock, state }, ...] }。 FxTransfersレコードが無ければ 404GTID_NOT_FOUND。leg_locksは HTLC 経路でのみ要素を持つ(デフォルトの即時経路では空配列)。
17.3 既存コードへの接地
| 機能 | 接地先 |
|---|---|
| 多脚協調・状態機械 | src/zc/lanes/gtid.ts(バレル), src/zc/lanes/gtid/{register,advance,legs}.ts, GtidTransactions/GtidLegs |
| 通貨別 H | src/zc/liquidity/h_model.ts(reserveH), ParticipantCurrencyLimits |
| 脚の中銀確定 | src/shared/central_bank.ts(CBT/settlementAccountId), src/zc/settlement/dns.ts, src/zc/settlement/igs.ts |
| 非JPY 確定証跡 | venue='CB_TOKEN' + src/shared/watcher.ts / src/shared/proof.ts |
| 通貨別ゼロサム | src/bank/ledger.ts(amount_currency) |
| 通貨許可リスト | src/shared/validator.ts(VALID_CURRENCIES) |
| GTID確定後のFX連動 | src/zc/orchestrator/gtid.ts(checkAndFinalizeGtid が FxTransfers.status を更新) |
| キュー処理 | src/zc/orchestrator.ts(processQueueMessage の ZC_BANK_LEG_READY ケースが advanceGtid を呼ぶ) |
| cronスイープ | src/cron/timeout_sweep.ts(sweepExpiredFxLocks、本書 §17.4) |
FX専用HTLC(GTIDレーンの htlc.ts とは独立) |
src/zc/fx/htlc.ts, FxLegLocks(生SQL+db.batch()。test/zc/lane_invariants.test.ts は src/zc/lanes/ のみを走査するため対象外) |
17.4 既存コードへの接地
実装メモ: 既存 GTID レーンは通貨別 H・通貨別中銀ファイナリティで多通貨脚を
すでに原子決済できる(
multi_currency/chaos_gtid#19)。FXP を明示的な導管(payer→FXP は source 通貨、FXP→payee は target 通貨。ブリッジは中間通貨脚を追加)
として置くと各通貨が両側で均衡し、
AMOUNT_BALANCE_MISMATCHに触れず決済コア無改修で成立する。
AMOUNT_BALANCE_MISMATCHが阻むのは「FXP不在の経済的に不完全な FX 表現」だけ。よって
20_method_design.md§17.5 の均衡検査置換は不要となり、代わりに「導管型 GTID を構築する層」を実装した(当初設計より低リスク・低侵襲)。
実装の所在
- レート市場・経路・導管決済:
src/zc/fx/rates.tsの整数固定小数レート演算
(RATE_SCALE=1e8、BigInt内部、forward=floor / backward=ceil、ブリッジ合成)。FxQuotes市場(migrations/0001_consolidated_schema.sql)+src/zc/fx/quotes.ts(upsert/取下げ/期限)。src/zc/fx/routing.tsの最良経路選定
(直接+単一中間通貨ブリッジ、payer/payee 建て)。FxTransfers(同マイグレーション)+src/zc/fx/transfer.ts(導管GTID構築・共有ハッシュロック生成・見積生存確認)。src/zc/fx/api.ts+src/router/zc.tsの配線(quote/transfers/rates/状態)。
検証:fx_rates/fx_routing/fx_transfer/fx_api/fx_settlement(E2E残高)。 - HTLCによるクロスレール原子性:
FxLegLocks(同マイグレーション)+src/zc/fx/htlc.ts(lockFxTransfer/claimFxTransfer/refundFxTransfer)。
各通貨脚を共有ハッシュロック+段階的タイムロック(上流ほど後に満了)でロック
し、決済は claim まで遅延させる。secret 公開でsha256==hashlockを検証→全脚を
一括 CLAIMED→導管 GTID を登録・前進させて初めて決済(全か無か)。未公開ならタイム
ロック満了で全脚 REFUNDED(無決済)。決済が「完全 claim 時に一度だけ原子的に起き、
refund がそれを締め切る」ため、レールをまたいでも部分決済は起こり得ない。決済コア
(共有オーケストレータ)は無改修。API:bind_htlcフラグ +/claim・/refund
(src/zc/fx/api.ts)。検証:test/zc/fx_htlc.test.ts(lock/claim/refund・誤secret
棄却・冪等・相互排他)。- 設計当初は
HtlcContracts拡張を想定したが、同表は単一HTLC決済に密結合のため、
FX専用の独立束ねテーブルFxLegLocksとして実装した(低リスク・決済コア無改修)。
- 設計当初は
- 運用仕上げ: 期限切れロックの払戻スイープ cron(
sweepExpiredFxLocksを毎分runTimeoutSweepに接続。src/cron/timeout_sweep.tsstep 16)。FxTransfers
SETTLED 連動: 導管 GTID がGT_SETTLEDに達したときcheckAndFinalizeGtidがFxTransfers.status='SETTLED'を立てる(デフォルト経路)。HTLC 経路ではclaimFxTransferがその場で即座に立てる(20_method_design.md§17.4.5の書き込みタイミングの違い)。
OpenAPI: 全 FX エンドポイントをsrc/openapi/zc-api.ts(fxタグ)に記載。
アトミシティの敵対的検証:test/integration/chaos_fx.test.ts(本書 §9 テスト戦略)。
検証:test/zc/fx_htlc.test.ts(sweep)、test/integration/fx_settlement.test.ts
(SETTLED連動)。
将来課題
流動性連動の動的上限(FXP 実残高に max を連動)、丸め益の明示的帰属ルール(現状は
デフォルトでFXP)、決済前のAML/トラベルルールフックの接続、紛争フロー
(Cases 連携)、HTLC レーン自体の通貨次元化(HtlcContracts の非JPY化=単一HTLCの
多通貨対応。FX は独立 FxLegLocks で完結しているため必須ではない)。
第18章 レガシー勘定系アダプタ 内部設計
本機能の全体像 — 要件:
10_requirements.md/ 処理方式:20_method_design.md/ 内部設計:本章。
18.1 冪等性(非冪等コアの防御、read-then-write ではなく atomic claim)
既存の IdempotencyKeys テーブルと resolveIdempotency/completeIdempotency
(src/shared/idempotency.ts)を再利用する。acquireIdempotency はINSERT OR IGNORE を最初の操作として実行するため、「まず確認してから
書き込む(read-then-decide)」方式にありがちな競合の隙間が無い。同じ
リクエストが二重に送られてきた場合、後から届いた方は、先行する呼び出しが
既に完了していればそのキャッシュ結果を返し(REPLAY)、まだ処理中であればLEGACY_ADAPTER_REQUEST_IN_FLIGHT(リトライ可能な DomainError)で
弾かれる。いずれの場合も、副作用(実際の入出金処理)が二重に実行される
ことはない。
drain 側は outbox の PENDING → CLAIMED → APPLIED の claim-then-apply
方式(下記)で、同時 drain 呼出し下でも一度しか適用しない。
18.2 適合性検証テストが固定していること(adversarial.test.ts, 34 ケース)
- ベースライン: 素のコアが本当に非冪等(再送で二重適用)、オフラインで
全 call 失敗、残高不足を同期で拒否する——再現した制約そのものを先に証明。 - #2: コア停止中に debit を即時承認 → outbox 保留 → コアは無傷 →
window オープンで一度だけ drain → 照合ドリフト 0。二度目の drain は no-op。 - 冪等性(逐次): 同一 request_id の execute-credit 二重送(逐次)で、
非冪等コアが一度だけ適用(キャッシュ結果を返す)。 - #4: reserve 無操作 → debit → 下流失敗 → 補償 Reversal → payer net zero →
drain 後もコア残高は開始値、ドリフト 0。txid で debit/reversal が対応付く。 - #5: 通知格納 → プル一度きり → 再送しても重複しない。
- #6: オフラインで N 件取込 → drain で全件一度だけ適用 → 全口座ドリフト 0。
- #3: クリーンフローは常にドリフト 0(outbox 保留中でも不変条件成立)。
out-of-band なコア変更は OPEN ドリフトかつ実際の CASE として検出。 - オーバードラフト保護: shadow 側の承認後、drain 時点で実コア残高が不足
していることが判明した場合、コアはマイナスにならず、outbox はBLOCKED
になり実際の CASE が開く(サイレントな無限リトライも money loss もしない)。 - タイムアウト: drain 中タイムアウト → outbox は PENDING のまま(ロスト
無し)→ 回復後に一度だけ適用(二重適用無し)。 - 並行性(TOCTOU 競合の再現と修正確認): 同一 request_id の
executeCredit 2 件をPromise.allで同時実行しても、コアには一度しか
適用されない。同一 PENDING outbox 行に対する 2 件の同時drainOutbox
も、claim-then-apply により一方だけが適用する。 - 失効 CLAIMED の回収:
recoverStaleClaimsが、drain 中断でCLAIMEDのまま取り残された行をPENDINGに戻し、再試行可能にする。 - name-check: 非リアルタイム行は
DEFERREDに降格、リアルタイム行でも
バッチ中は throw せずDEFERRED(送金を落とさない)。
18.3 監査で見つかった問題と是正
本節の位置づけ(§10.0 の規約における「設計経緯」):本節は、現行コードがなぜこの形かを
説明するための経緯記録である。ここに書かれた「是正内容」は、現行の規範ではなく当時の判断であり、
規範は各章の本文(§18.1 冪等性・
20_method_design.md§18.2 コア境界)を正とする。本節を残す理由は 2 つ——(1) 同じ罠(read-then-write 冪等、
db.batch()の無条件実行、コア内部テーブルへの依存)を再び踏まないため、(2) 本サブシステムが最初から無欠陥だったという
印象を残さないため。
初版実装を「勘定系のプロの目線」で監査した結果、以下の問題が見つかり、いずれも是正した。
| # | 問題 | 重大度 | 是正内容 |
|---|---|---|---|
| 1 | マイグレーション運用の鉄則違反: migrations/0002_legacy_adapter.sql という新規連番ファイルを切っていた。本リポジトリの鉄則(docs/specs/31_schema.md § マイグレーション運用)は「統合スキーマ 0001 を直接編集し、新規ファイルを切らない」。 |
重大(プロジェクト規約違反) | 0002 を廃止し、全テーブルを 0001_consolidated_schema.sql に直接統合。test/helpers/d1-mock.ts の SCHEMA_MIGRATIONS も単一ファイルに戻した。 |
| 2 | 冪等性が read-then-write で TOCTOU 競合に弱い: 独自の AdapterIdempotency テーブルに対し「SELECT で既存確認 → 無ければ実行 → INSERT OR IGNORE で記録」という順序だった。2つの同時呼出しが両方とも「未処理」を観測し、両方とも副作用(shadow 更新・outbox 追加)を実行してしまい得る——真の二重処理。既存の IdempotencyKeys/acquireIdempotency が使う「INSERT を先に行い、その成否だけで所有権を判定する」atomic-claim パターンをなぜか使わず、独自に劣った実装を再発明していた。 |
重大(二重処理・二重出金のリスク) | 独自実装を廃止し、既存の resolveIdempotency/completeIdempotency に置き換え。競合下の後着は LEGACY_ADAPTER_REQUEST_IN_FLIGHT(retryable)で弾かれる。test/bank/legacy/adversarial.test.ts の「concurrency: idempotency race」で固定。 |
| 3 | drain の二重適用: drainOutbox は「コア posting のステートメント(無条件実行)」+「outbox を PENDING→APPLIED にするガード付き UPDATE」を同一 db.batch() に積んでいた。しかし db.batch() は配列中の全ステートメントを無条件に実行する(前のステートメントの changes を見て後続を中断する仕組みが無い)ため、末尾のガードが 0 行しか更新しなくても、先行するコア posting は実行済み。2つの drainOutbox が同じ PENDING 行を同時に処理すると、コアに二重 postingされ得た。 |
重大(実コアへの二重記帳) | PENDING → CLAIMED → APPLIED の claim-then-apply 方式に変更。claim(WHERE status='PENDING' のガード付き UPDATE、meta.changes で所有権判定)に勝った呼出しだけが実際の posting を行う。test/bank/legacy/adversarial.test.ts の「concurrency: drain double-post race」で固定。claim 後にクラッシュした場合に備え、recoverStaleClaims(cron/timeout_sweep.ts から呼び出し)で失効回収。 |
| 4 | drain 時のオーバードラフト無検証: shadow 側で資金十分と承認された DEBIT でも、実コア残高が(ドリフト等により)不足している場合、drain は無条件にコア残高を減算しており、コアがマイナスになり得た。実際の勘定系はどれほどレガシーでも「残高マイナスを許す」ことは無い。 | 重大(会計上あり得ない状態を生成しうる) | LegacyCore.postDebit 内部で、資金十分性チェックと journal 記帳を同一の WHERE EXISTS 事前条件で自己ガードする形に変更(コア自身の2テーブルのみに閉じた同期。本表 #8 の是正後は、この判定はコアの外=アダプタ側からは見えない)。不足時は {applied:false} を返し、アダプタが outbox を BLOCKED にして実際の CASE を開く。test/bank/legacy/adversarial.test.ts の「drain-time overdraft protection」で固定。 |
| 5 | 照合ドリフトが「見られていない」テーブルへの記録のみ: AdapterReconDrift に status='OPEN' を書くだけで、ZC 本体の Cases/openCase() は一切呼んでいなかった。文書では「CASE に収束」と謳いながら、実際にはオペレーションが監視する経路に繋がっていなかった。 |
中(overclaiming) | reconcileAccount が openCase() を呼び、AdapterReconDrift.case_id で実際の Cases 行に紐づくようにした。 |
| 6 | 監査追跡性の欠如: AdapterOutbox と LegacyCoreJournal に txid 列が無く、ZC 取引 ID との対応が request_id 文字列への暗黙の依存になっていた。 |
中(監査・規制対応上の弱点) | 両テーブルに txid 列を追加し、PostingCmd.txid から一貫して伝播するようにした。 |
| 7 | 本番コードがテスト専用メソッドを呼んでいた: initAccount()(口座開設という正規の本番操作)が forcePostForTest()という明示的にテスト専用と書かれたメソッドを呼んでいた——本番経路とテスト経路の境界が曖昧だった。 |
軽微(コードの健全性) | seedOpeningBalance()(本番用)と injectDriftForTest()(テスト専用、ドリフト注入)に分離。 |
| 8 | 「勘定系ベンダーとして本当に接続できるか」の観点で見ると、アダプタがコアの内部テーブル(LegacyCoreAccounts/LegacyCoreJournal)に直接 SQL で書き込む設計になっていた(#4 の是正で導入した debitStatementsIfSufficient が典型)。これは「アダプタとコアが同一トランザクションを共有する」ことを前提にしており、現実のどの勘定系ベンダーも、外部の協調層に自社の元帳テーブルへの直接書き込みを許可しない。「勘定系には開発を追加させない」という設計思想の核心と真っ向から矛盾する、設計上の重大な手戻り。 |
重大(この設計のままでは実在ベンダーが誰も接続できない) | LegacyCore の公開面を postCredit/postDebit(不透明な呼出し、結果は {applied:boolean} のみ)に限定し、アダプタは二度と相手のテーブル名を書かない形に再設計。詳細は § ベンダー接続可否。 |
| 9 | API別要求仕様(10_requirements.md、旧 docs/specs/legacy_core_requirements.md)の初版で、13コマンド中2つ(leg-ready-check・rtp-notify)を「GTID/RTPの多者協調ロジックが必要」として範囲外に分類していたが、これは誤りだった(ユーザーからの技術的指摘により発覚)。実装を精査すると、leg-ready-check の PAYER脚は reserve-funds と、PAYEE脚は account-verify と全く同一の操作であり、rtp-notify は credit-notify と全く同一の純粋通知だった。GTID/RTP の協調ロジック自体は ZC オーケストレータ側に閉じており、銀行/コアの境界には現れない——「ZC側の内部オーケストレーションの複雑さ」と「コアに要求する能力の複雑さ」を混同していた。 |
中(不要な範囲外指定によるスコープの過小申告) | legReadyCheck を reserveFunds/accountVerify への薄い委譲として実装、rtpNotify を creditNotify と同じ AdapterNotifications 経由で実装。13/13コマンド対応に到達。詳細は § 要求仕様 4-a。 |
監査で見つかったが、意図的に是正していない既知の限界
LegacyCoreAccountsは複式簿記ではない: 単一のbalance列のみで、
相手勘定(貸方/借方のペア)を持たない。現実の勘定系は通常、内部的に
複式簿記でゼロサムを保つ(bank/ledger.ts/BankJournalsはその実装)。
本モデルはあくまで「アダプタから見える posting API の表面」を再現した
ものであり、コア内部の会計処理までは模していない——複式簿記の破れに
よる資金創出/消失のクラスのバグは、このモデル単体では検出できない。- 通貨次元が無い:
AdapterShadow/LegacyCoreAccountsに通貨列が無く、
単一通貨前提。BankJournalsはすでに通貨ごとにゼロサムを検証する
設計になっているのに対し、本サブシステムはそこまで拡張していない。 BLOCKEDになった posting の再処理経路は未実装: オーバードラフトでBLOCKEDになった outbox 行は、CASE を通じた人手(テラー)解決を前提とし、
自動再試行はしない。手動解決後にPENDINGへ戻す運用フック(API/画面)は
未実装。- 物理的に分離したコアでの冪等性: 本参照実装はアダプタと敵対的コアモデルが
同一 SQLite(同一プロセス)を共有するため、claim-then-apply の
各段階を単一のdb.batch()でアトミックに扱える。物理的に分離した実コア
(別ネットワーク越しの MQ/固定長 API)では、この一貫性はコア書込 API 側の
冪等キーとして実装する必要がある——本モデルが「同一 DB」という前提で
簡略化している点は明記しておく。