第9巻 内部設計書(1) ― システム階層・共通基盤・単一所有者則
目次
- 第1章 システム階層 <a id="s1-layers"></a>
- 第2章 構造化エラー — `src/shared/errors.ts` <a id="s2-errors"></a>
- 設計意図
- コア API
- カテゴリの拡張ルール
- Queue リトライポリシー
- 第3章 構造化ロギング — `src/shared/logger.ts` <a id="observability"></a>
- 1 リクエスト 1 コンテキスト
- イベント命名規約
- 第4章 レーン共通プリミティブ — `src/zc/lanes/_helpers.ts` <a id="s4-primitives"></a>
- 動機
- 提供 API
- 不変条件(実装で担保)
- GTID 状態機械(`gtid_state_machine.ts`)
- 新規 lane 追加時のチェックリスト
- `lane_invariants.test.ts` が落ちたとき
- 第5章 単一所有者則(Single-Owner Rule) <a id="single-owner"></a>
- 5.1 owner モデル — 「動かせる者は常にちょうど一人」
- 5.2 4 つの handoff(所有権の受け渡し)
- 5.3 `transferOwnership` と owner の CAS 再表明
- 5.4 段階導入(観測→移転→強制の三段:Phase 1/2/3)
- 5.5 スコープ外(将来): リース+フェンシングトークン
- 5.6 一貫性モデルとの接続
- 第6章 一貫性モデル(Consistency Model) <a id="consistency-model"></a>
- 6.1 何が「正」か
- 6.2 保証する一貫性(規範)
- 6.3 不確定時の縮退(原則10)
- 6.4 規範↔実装ギャップ(誠実に)
- 第7章 可搬性と保管バックエンド移植契約(Portability) <a id="portability"></a>
- 7.1 縮退の制御側は移植不要
- 7.2 書き換えが必要な SQLite/D1 方言(保管側) <a id="event-seq"></a>
- 7.3 受入条件(Acceptance Criteria)
- 7.4 データ層の可搬と切替時のファイナリティ整合(未着手)
- 7.4.1 資源バインドの宣言(規範)
- 7.5 S3 回答: 移植時に隠れる二つの再設計と設計規律 <a id="s3-hidden"></a>
- 第8章 データベース改善 <a id="s8-database"></a>
- 第9章 テスト戦略 <a id="s9-testing"></a>
- 既存
- 横断プリミティブ
- 静的解析インバリアント(`test/zc/lane_invariants.test.ts`)
- 残高インバリアントの統合テスト(`test/integration/balance_invariants.test.ts`)
- ZC→Bank ingress 接合部(`test/integration/ingress_commands.test.ts` / `test/invariants/ingress_seam.test.ts`) <a id="ingress-seam"></a>
- 追加テスト
- 第10章 既知の制約・将来項目(Roadmap) <a id="s10-roadmap"></a>
- 10.0 実装状況の書き方(本書群共通の規範) <a id="asbuilt-policy"></a>
- 10.1 セキュリティ・運用基盤
- 10.2 規範に対する実装の残件
- 10.3 制度設計に委ねる論点(方式は固定済み・最終仕様は制度合意待ち)
- 第11章 プログラマビリティ(外部アテスター前提の条件付き決済) <a id="s11-programmability"></a>
- 11.1 基本思想 — 判定はエッジ、コアは決定的
- 11.2 構成レイヤ
- 11.3 claim 経路と証跡
- 11.4 システム時刻 = JST
- 11.5 ドライラン(読み取り専用)
- 11.6 意図的な非対象と将来
- 11.7 テスト
第9巻 内部設計書(1) ― システム階層・共通基盤・単一所有者則
本巻は
docs/specs/30_internal_design.mdの第1章〜第11章(システム階層/構造化エラー/構造化ロギング/レーン共通プリミティブ/単一所有者則/一貫性モデル/可搬性/データベース改善/テスト戦略/既知の制約・将来項目/プログラマビリティ)を収める。続きは→第10巻。
第1章 システム階層
┌─────────────────────────────────────────┐
│ src/index.ts (Worker entry / router) │ ← X-Request-Id 採番
└───────────┬─────────────────────────────┘
│
┌───────────────────┼───────────────────────┐
▼ ▼ ▼
/api/* (ZC) /bank/* (Bank) /internal/*
ZC Ingress ZC→Bank Ingress Cron / Seed
│ │
▼ │
src/zc/lanes/* │ ┌──────────────────────┐
(state machines) │ ◀──── │ src/shared/errors.ts │ DomainError, errorResponse
│ │ └──────────────────────┘
▼ │ ┌──────────────────────┐
src/zc/orchestrator │ ◀──── │ src/shared/logger.ts │ newRequestLogger
(queue consumer + │ └──────────────────────┘
state transitions) │ ┌──────────────────────┐
│ │ ◀──── │ src/zc/lanes/ │ 4 primitives:
▼ │ │ _helpers.ts │ transition/cancel/
│ │ │ insert/transferOwner
FinalityLog ▼ └──────────────────────┘
(append-only) Bank ledger
(zero-sum journal)
横断モジュール(右側)は どのレーン・どの ingress からでも安全に呼べる
副作用最小のプリミティブとして設計されている。新規エンドポイントや新規
レーンを追加する場合、まずここから組み立てる。
第2章 構造化エラー — src/shared/errors.ts
設計意図
- かつてはレーン・ingress に「
console.error()してから silent return」
というアンチパターンが散在していた。これはプロセス境界で失敗を観測
できない という致命的な問題を生む。 DomainErrorを持ち上げて統一することで、HTTP / Queue / FinalityLog
すべてで同じ識別子(reason_code)と分類(category)を使える。
コア API
new DomainError(reason_code, message, details?, { category?, cause? })
errorResponse(err, request_id?) // → Response (HTTP)
isDomainError(e) // 型ガード
isRetryable(category) // Queue の retry 判定
httpStatusOf(category) // HTTP マッピングの SoT
カテゴリの拡張ルール
- 新しい
reason_codeはREASON_CODE_CATEGORYに登録する。登録忘れの影響は
経路によって異なるので、区別して理解すること。throw new DomainError(code, …):未登録だとcategoryOf()がINTERNAL(500・retry 不可)へ落ちる。HTTP ステータスの手がかりが無いため
fail-soft が効かない。登録は必須。jsonError(status, code, …):未登録でもcategoryFromStatus(status)が
HTTP ステータスから category を導出するため安全側に落ちる
(src/zc/ingress/_shared.ts)。登録は推奨だが必須ではない。- この非対称性は
32_api_contracts.md§ エラーカタログ の
「DomainErrorを経由しない reason_code」節に規範として明記してある。
categoryを増やす場合はhttpStatusOfとisRetryableの両方を
更新する。32_api_contracts.md§ Error Catalog の表も同じ PR で更新する。
この 2 つの一致はtest/invariants/spec_refs.test.tsが機械照合する
——片方だけ更新した PR は CI で落ちる。- 業務ルール由来(H_LIMIT_EXCEEDED 等)は
CONFLICT、外部系障害はDOWNSTREAMまたはTIMEOUTを選ぶ。「リトライしていい失敗かどうか」
が分類の本質。
Queue リトライポリシー
src/index.ts#queue は DomainError の category を見て:
DOWNSTREAM/TIMEOUT/RATE_LIMIT→msg.retry()- それ以外(
VALIDATION/CONFLICT/INVARIANT/INTERNAL)→msg.ack()
ack 側は無限ループせず、Cases テーブルにエスカレーションされる。
DomainError 以外の throw は従来通り全て retry する(互換性のため)。
第3章 構造化ロギング — src/shared/logger.ts
1 リクエスト 1 コンテキスト
const log = newRequestLogger({ method: 'POST', path: '/api/transfers' })
const child = log.child({ txid, lane: 'EXPRESS' })
child.info('lane.dispatch')
- 出力は 1 行 1 JSON。Cloudflare の Logpush /
wrangler tailがそのまま
パースできる。 - 自動付与フィールド:
ts,level,event,request_id。 request_id:受信ヘッダX-Request-Idを honor し、無ければreq-<uuid>。
全レスポンスにもX-Request-Idを返す → エラー報告→ログ突合が自明。- PII セーフ:
vault_ref,preimage,secret,password,_pii末尾な
キーは自動[REDACTED]。 Errorインスタンスはname/message/reason_code/detailsの
4 フィールドに圧縮して出力。
イベント命名規約
<scope>.<verb> 形式。
| Scope | 例 |
|---|---|
http.* |
http.request, http.not_found, http.unhandled_error, http.domain_error |
queue.* |
queue.dispatch, queue.ack, queue.failed |
lane.* |
lane.dispatch, lane.transition, lane.cancel |
bank.* |
bank.call, bank.timeout, bank.error |
dns.*, eod.* |
cron 系 |
新スコープ追加時は本表も更新する。
第4章 レーン共通プリミティブ — src/zc/lanes/_helpers.ts
動機
全 8 レーン(express, standard, htlc, gtid, rtp, highvalue, bulk, htlc_auth)
は 同じ 2 パターンを独自実装していた:
Transactionsの状態遷移(CAS UPDATE。CAS=Compare-And-Swap の略で、
「読んだ時点の値と一致している場合だけ書き込む」楽観ロック更新のこと)
→FinalityLog書き込み。この2つは原則ペアであるべきだが、別々の SQL
として発行すると片方だけ成功する余地が生まれ、アトミック(不可分)ではない。- 取消時に「状態ガード → H 解放 → ログ → 終了状態化」という順序で進める。
これは TOCTOU(Time-Of-Check to Time-Of-Use、確認した時点と実際に使う
時点のズレを突く事故)を避けるための安全な順序である。
これらが各ファイルにコピペされており、片方を直し忘れる事故が発生して
いた(GtidLegs(txid) インデックス漏れ — 現在は idx_legs_txid として
統合スキーマに収録 — や、htlc.ts cancelHtlc の TOCTOU 事故が再発した)。
提供 API
_helpers.ts は 4 つのプリミティブを提供する(3 つの mutate/create
+ 1 つの所有権移転)。3 番目までが Transactions 行を「進める/作る」プリミティブ、
4 番目 transferOwnership は「状態を変えずに所有権だけ動かす」プリミティブで、
§5 単一所有者則 の choke point になる。
transitionWithLog(db, {
txid, fromState, toState, eventType,
payload?, setColumns?, sideUpdates?, strict?,
issuer?, // 単一所有者則: 既定 'ZC'。行の現 owner と一致しないと OWNERSHIP_VIOLATION(無条件)
}): Promise<{ applied: boolean; previousState: string | null }>
cancelInFlightTx(db, {
txid, reasonCode, fromStates?, skipReleaseH?, sideUpdates?, eventType?, payloadExtra?,
issuer?, // 単一所有者則: 既定 'ZC'。取消も owner だけが発行できる。取消 CAS は owner を 'ZC' に返す
}): Promise<boolean>
insertTxWithLog(db, {
txid, lane, initialState, amount, payer*, payee*,
idempotencyKey, eventType, payload?, extraColumns?, sideUpdates?,
}): Promise<{ inserted: boolean }> // 新規行は owner='ZC'(列 DEFAULT)で生まれる
transferOwnership(db, {
txid, fromOwner, toOwner, eventType?, payload?, setColumns?,
}): Promise<{ applied: boolean; previousOwner: string | null }>
// owner の CAS + FinalityLog(OwnershipTransferred / OwnershipReclaimed) を単一バッチで発行。
// transitionWithLog の鏡像。state は動かさず custody だけ動かす。
transitionWithLogからskipStateMachineCheckは撤去済み。状態機械検証は無条件であり、バイパスするパラメータは存在しない(実装 §状態機械検証を参照)。
不変条件(実装で担保)
- アトミック CAS + ログ:
transitionWithLogは CAS UPDATE とFinalityLogINSERT を 1 つのdb.batch()で発行する。INSERT は直前
UPDATE のchanges() > 0をガードに用いる条件付き INSERT のため、CAS
に勝てなかった呼び出しはログも書き込まれない。バッチ内で例外が起きれば
両方ロールバック。これにより「状態だけ進んで監査ログが残らない」窓は
存在しない。transferOwnershipも同じアトミシティ契約(owner CAS + 条件付き
FinalityLog INSERT を単一バッチ)で所有権 handoff を記録する。 - 単一所有者則(OWNERSHIP_VIOLATION):
transitionWithLogとcancelInFlightTxは CAS 前にSELECT state, version, ownerを読み、issuer !== ownerなら無条件でOWNERSHIP_VIOLATIONを投げる。strict:falseでも降格されない——INVARIANT_VIOLATIONと同格の「バグ」扱い
(行を所有しない発行者の操作は、owner 列が構造的に潰そうとしている二元帳乖離
そのもの)。さらに CAS UPDATE のWHERE句にAND owner = ?を加え、
コミット時に owner を再表明する。DNS バルクスナップショット(kickDns)は
意図的にversionを上げないため、SELECT と UPDATE の間に割り込んだ handoff を
version ガードだけでは検知できないからである。詳細と 4 つの handoff は
§5 単一所有者則。 - 状態機械検証:
isValidTransitionを CAS UPDATE 前に常に呼ぶ。ALLOWED_TRANSITIONSに無い遷移は DB に到達せず、INVARIANT_VIOLATION
を投げる(またはapplied:falseを返す)。この検証は無条件でバイパス不可——
将来レーンを足しても、未登録の遷移がこっそり通ることはない。 - CAS 並列安全:
version = ?楽観ロック。並列 N 本でもapplied:true
は最大 1 本(テスト:lane_helpers.test.ts、atomic_finality.test.ts)。 - 取消順序:
cancelInFlightTxは 状態ガード成立後にのみ H 解放を
行う。逆順だと並列 decision 経路に勝った場合に LOCKED 予約を誤って解放
してしまう(ZC で発生済みのバグと同型)。DecidedCancelログも同じdb.batch()でアトミックに書き込む。取消された行は終端であり、CAS はowner='ZC'へ返す(custody をコーディネータに戻す)。 - read-only 縮退ゲート: 4 プリミティブすべて(および
suspendTx)は確定前にassertWritableDbを通る。quorum 喪失・BCP 中は書き込みを拒否する
(§6 一貫性モデル)。owner CAS と同じ choke point。 - event_seq 単調性:
writeFinalityLogはFinalitySeq.next_seqをUPDATE ... RETURNINGでアトミック増分し event_seq を割り当てる
(src/zc/orchestrator/finality.ts#allocateEventSeq)。Date.now() + 乱数- UNIQUE リトライ方式は廃止。単一ノード D1 ではこの 1 行採番がコミット順の
全順序を与える。分散 SQL への移植時に大域単調性をどう保つかは
§7.2 event_seq 採番。
- UNIQUE リトライ方式は廃止。単一ノード D1 ではこの 1 行採番がコミット順の
GTID 状態機械(gtid_state_machine.ts)
TxState に ALLOWED_TRANSITIONS(state_machine.ts)があるのと対称に、
GTID 集約レベルの状態(GtidState: GT_RECEIVED → GT_PRECHECKED → GT_DECIDED_TO_SETTLE → GT_SETTLED ほか)にも単一の正となる遷移表ALLOWED_GTID_TRANSITIONS を src/zc/orchestrator/gtid_state_machine.ts
に置く。導入前は GTID の合法遷移グラフが lanes/gtid/ とorchestrator/gtid.ts の生 UPDATE GtidTransactions ... WHERE state='GT_X'
の WHERE 句にのみ暗黙的に存在し、複数行アトミック決済(複数銀行に同時に
資金移動する集約)に、各 TX が持つ検証付き遷移表が無い状態だった。
- 宣言:
ALLOWED_GTID_TRANSITIONS(全GtidStateを網羅)と、INSERT 入口
state のALLOWED_GTID_ENTRY_STATES(GT_RECEIVED=通常登録 /GT_SETTLED=settleDnsが直接生成する DNS ネット清算 GTID)。規範参照は20_method_design.md§3.5(第3章 取引ライフサイクルと状態遷移)。 - 実行時ガード(多重防御): 遷移元が DB 由来(リテラルでない)の
終端化経路でassertValidGtidTransition(from, to)を CAS 前に呼ぶ
(checkAndFinalizeGtidのGT_SUSPENDED/GT_SETTLED、advanceGtid
のGT_PRECHECKED)。不正遷移はINVARIANT_VIOLATION。 - 静的ガード:
test/zc/gtid_state_machine.test.tsが GTID ソースを走査し、
すべてのUPDATE GtidTransactions SET state='GT_Y' ... WHERE ... state='GT_X'
とINSERT ... VALUES (?, 'GT_X')を抽出して宣言グラフに含まれるか検証する。
生 SQL で未宣言の遷移(例:GT_RECEIVED → GT_SETTLED、決定の飛ばし)を
追加すると落ちる。TxStateをlane_invariantsが守るのと同じ役割。
新規 lane 追加時のチェックリスト
CI で test/zc/lane_invariants.test.ts が以下を静的にチェックする
(regex によるソース走査)。チェックリストはそのまま自動 enforce される。
- 既存状態を進めるなら
transitionWithLog。CAS が他レーン側状態と
並走するならsideUpdatesで同一バッチに入れる(HtlcContracts が参照実装)。 - キャンセル経路は
cancelInFlightTxを使う。sideUpdatesで別表の
キャンセル CAS も同時にロールバック可能にする(HtlcContracts の cancel が参照実装)。 - 新規行をレーン特有の入口 state で作る場合は
insertTxWithLogを使い、
必要であればALLOWED_ENTRY_STATESに入口 state を追加する。FinalityLog は
INSERT と同じバッチで書かれるので「行はあるが audit が無い」窓は構造的に閉じる。purposeのような一回限りのカラムはextraColumnsで渡す。 - 新規イベント名は
src/types/api/messaging.ts#FinalityEventTypeの union に追加する
(未登録のイベント名は lane_invariants が落ちる)。 test/zc/<lane>.test.tsに lane 単体テストを足し、test/integration/balance_invariants.test.tsに 1 ケース追加する。- 新規 lane file は
test/zc/lane_invariants.test.ts#LANE_FILESに登録する
(ファイル名 → 論理 lane 名 + Transactions.lane カラム値)。
lane_invariants.test.ts が落ちたとき
| エラー | 直し方 |
|---|---|
UPDATE Transactions SET state が検出された |
transitionWithLog か cancelInFlightTx に置き換える |
INSERT INTO Transactions が検出された |
insertTxWithLog に置き換える。フィールドが足りなければ extraColumns を使う |
lane file に _helpers の import が無い |
上記いずれかの helper を呼ぶ実装に直す |
LANE_FILES と src ディレクトリが乖離 |
テスト側の LANE_FILES 表を実態に合わせて更新 |
| lane の unit test が無い | test/zc/<stem>*.test.ts を作る |
| balance-invariant ケースが無い | test/integration/balance_invariants.test.ts に 1 ケース足す(または KNOWN_GAPS に登録) |
event 名が FinalityEventType 未登録 |
src/types/api/messaging.ts の union に追加 |
第5章 単一所有者則(Single-Owner Rule)
本節の位置づけ: 本章は「なぜ owner 列を一級化したか」と「4 つの handoff がどこで起きるか」を集約する。
二元帳アトミシティと、タイムアウト掃引の正しさは、いずれもここに帰着する。
実装の入口は
src/zc/lanes/_helpers.tsの 4 プリミティブ(§4)。
5.1 owner モデル — 「動かせる者は常にちょうど一人」
これまで「掃引の対象から除外する条件」(dns_cycle_id IS NULL、external_settlement_status != 'REQUESTED'、レーン除外…)が増え続けていた。
その正体を突き詰めると、所有権の記録が複数の列にばらばらに散らばっていた
だけだったとわかる。そこで所有権を一つの列に昇格させ、一元管理する:
- 各
Transactions行はちょうど一つのownerを持つ。値は'ZC'|'CYCLE:<cycle_id>'|'VENUE:<venue_id>'|'CHAIN:<watcher_set>'。
定数は_helpers.tsのOWNER_ZC='ZC'/OWNER_VENUE_BOJ='VENUE:BOJ'/OWNER_CHAIN_DEFAULT='CHAIN:default'/cycleOwner(id)='CYCLE:<id>'。 - スキーマは
Transactions.owner TEXT NOT NULL DEFAULT 'ZC'、索引idx_tx_owner(owner, state)
(migrations/0001_consolidated_schema.sql)。新規行はinsertTxWithLogにより
DEFAULT の'ZC'で生まれる。 - 掃引は
owner='ZC'の行しか触らない。src/cron/timeout_sweep.tsの T2/T3 クエリはWHERE state=? AND owner='ZC' AND updated_at<?。- 補足(誇張しない): T2 掃引には今も
T2_EXEMPT_LANESによるレーン除外が残るが、
これは custody ではなく SLA(そのレーンに T2 タイムアウトが無い) という
タイムアウト方針であり、owner が置換したのは custody 述語だけである
(timeout_sweep.tsの該当コメント参照)。
- 補足(誇張しない): T2 掃引には今も
これにより、二元帳乖離の典型パターン(src/cron/timeout_sweep.ts のコメントに二元帳乖離
カタログとして記録されている)は例外規則から定理になる:
- BOJ 遅延決済 vs T3 放棄: money leg が外部決済 venue に in-flight の間は
owner='VENUE:BOJ'(external_settlement_status='REQUESTED'と原子的に押される)。
T3 掃引からは構造的に不可視——中央銀行が確定するまで T3 は放棄できない。 - DNS スナップショット vs 個別掃引: kickDns がスナップショットした瞬間
owner='CYCLE:<id>'になるため、個別掃引は構造的に不可能。
5.2 4 つの handoff(所有権の受け渡し)
所有権が動く瞬間は実測で 4 群だけ。いずれも FinalityLog 上のイベントとして記録され、
「いま誰がこの取引を握っているか」も説明可能性の一部になる(原則4 の自然な拡張)。
| # | handoff | コード site | owner の動き |
|---|---|---|---|
| 1 | DNS kick(ネットポジション取り込み) | src/zc/settlement/dns/cycle.ts(kickDns スナップショット) |
dns_cycle_id を押す同一バッチで 'ZC' → 'CYCLE:<id>'(cycleOwner) |
| 2 | DNS settle / サイクル取消 | src/zc/settlement/dns/settle.ts |
決済・取消の確定バッチで 'CYCLE:<id>' → 'ZC' に返還 |
| 3 | IGS submit / callback | src/zc/settlement/igs.ts |
提出時 transferOwnership('ZC'→'VENUE:BOJ')(external_settlement_status='REQUESTED' と同時)、確定コールバックで 'VENUE:BOJ'→'ZC' に返還(IGS_FAILED は issuer='VENUE:BOJ' の suspend で owner='ZC' へ) |
| 4 | HTLC crosschain enter / exit + 外側タイムロック lease 回収 | src/zc/lanes/htlc/crosschain.ts・_fulfill.ts・src/cron/timeout_sweep.ts |
HTLC_ONCHAIN_PENDING 突入で transitionWithLog(setColumns owner='CHAIN:default')、Watcher 由来の解放は issuer='CHAIN:default' + setColumns owner='ZC'(_fulfill.ts) |
- handoff #1・#2 の DNS バルク UPDATE は
transferOwnershipを経由しない唯一の
例外(複数行を 1 文で押すため)。この 2 site だけがownerを直書きしてよい
ホワイトリストで、test/invariants/ownership.test.tsの静的ガードが他の直書きを禁じる。 - handoff #3・#4 のような単一行の handoff は
transferOwnershipを使う。 - 外側タイムロック lease 回収(期限切れ→回収):
HTLC_ONCHAIN_PENDING
行は Watcher 集合('CHAIN:default')が所有し、外側タイムロックはそのリースの契約上の
満了である——ZC が watcher の応答を待たずに行を取り戻してよい唯一の瞬間。満了時は
まずtransferOwnership(..., eventType:'OwnershipReclaimed')で所有権を ZC に戻し、
その後に ZC 自身として cancel する。watcher 由来の claim がレースに勝っていれば
reclaim の CAS が外れ、cancel の状態ガードが no-op になる——claim が行を保持する。
この経路はcrosschain.ts#recordOnchainFulfillment(内側と外側の分岐)とtimeout_sweep.ts(期限切れ HTLC 掃引)の双方にある。
5.3 transferOwnership と owner の CAS 再表明
transferOwnership は transitionWithLog の鏡像で、state を動かさず owner だけを CAS で
動かし、FinalityLog(OwnershipTransferred) を単一バッチで書く(state_from = state_to =
現 state)。lease 満了回収は eventType:'OwnershipReclaimed'。
決定的なのは CAS によるコミット時の owner 再表明である。transitionWithLog /cancelInFlightTx / suspendTx は CAS UPDATE の WHERE 句に AND owner = ?(発行者)を
含める。理由: DNS バルクスナップショット(kickDns)は意図的に version を上げないため、SELECT と UPDATE の間に割り込んだ handoff を version 楽観ロックだけでは検知できない。
owner を WHERE で照合することで、SELECT 後にスナップショットされた行への ZC の書き込みは
CAS が外れて no-op になる。
suspendTx だけは扱いが異なる: owner !== 'ZC' のとき throw ではなく skip(console.error
してリターン)。呼び手が cron/queue であり、正当な並行 handoff(例: 掃引の SELECT と本呼び出しの
間に kickDns がスナップショット)に行を奪われるのはバグではなく良性のレースだからである。
これに対し transitionWithLog / cancelInFlightTx の OWNERSHIP_VIOLATION は throw——
明示的な発行者が所有しない行を動かそうとするのはバグである。
5.4 段階導入(観測→移転→強制の三段:Phase 1/2/3)
決済システムの流儀に従い、いきなり強制せず観測→移転→強制の三段で入れる。
Phase 1 — スキーマ+シャドーモード(挙動変更ゼロ):
owner列と索引を追加し、
既存列から機械的にバックフィルする(=所有権が既に暗黙に存在していたことの証明)。transitionWithLogの SELECT をstate, version, ownerに広げ、issuer?(既定'ZC')を
受ける。不一致はブロックせず記録のみ。ライブ移行時のバックフィル SQL:UPDATE Transactions SET owner = CASE WHEN dns_cycle_id IS NOT NULL THEN 'CYCLE:' || dns_cycle_id WHEN external_settlement_status = 'REQUESTED' THEN 'VENUE:BOJ' WHEN state = 'HTLC_ONCHAIN_PENDING' THEN 'CHAIN:default' ELSE 'ZC' END;(本リポジトリの統合スキーマは新規適用のみのため、
ownerはDEFAULT 'ZC'で足りる。
上の CASE は既存テーブルへのライブ移行時にのみ使う。)Phase 2 — 所有権移転の一級化:
transferOwnershipを_helpers.tsに追加し、
§5.2 の 4 handoff site を呼び替える。Phase 3 — 強制と掃引の置換: シャドーを強制へ——
issuer !== ownerはOWNERSHIP_VIOLATION(INVARIANT_VIOLATIONと同格)。掃引の除外述語を撤去してowner='ZC'に統一。不変条件テスト 2 本——(a) 全 (state × owner) 組で「動かせる主体が
ちょうど一つ」を総当たり固定、(b)ownerの手書き UPDATE を静的禁止
(test/invariants/ownership.test.ts)。
この参照実装は Phase 3 まで完了・強制有効で出荷している。 加えて
timeout_sweep.tsには「旧述語 vs 新 owner 列の一回限りの突き合わせ検証」(
owner='ZC'なのに旧述語が「他者所有」と言う行を CASE 起票する一回限りの検証。1 リリースで撤去)が残っている——新旧の言い分が食い違う行こそ handoff バグの在り処。
5.5 スコープ外(将来): リース+フェンシングトークン
所有者障害からの回収(期限切れリース+単調エポックのフェンシングトークン)は
将来項目。owner 列はその土台としてそのまま使えるが、フェンシングトークンは
受け手(銀行側アダプタ・venue ゲートウェイ)が stale トークンを拒否して初めて
意味を持つ=信頼境界を越えるため、参加者の接続認定試験(certification suite)の
整備と同じタイミングに送る。受入条件「stale トークンの拒否」は10_requirements.md の要求仕様章
に一行追加する。詳細は §10 既知の制約 にも Roadmap として連ねる。
5.6 一貫性モデルとの接続
owner の CAS は、read-only 縮退の assertWritableDb ゲートと同じ choke pointである
(§4 の不変条件、§6 一貫性モデル)。したがって
「所有権を持たない書き込み」も「縮退中の書き込み」も、同一の狭い関門で構造的に閉じる。
ここで誠実に認めておくべき譲歩がある——原則1 の再スコープである。FinalityLog が
唯一の正なのは協調事実についてであり、金銭事実の正は各元帳にある。ZC の主張は
「乖離しない」ではなく「すべての乖離は有界時間内に検出・帰責される」というものであり、
これは §6.4 と噛み合う。本節が単一所有者則の一次記述である。
第6章 一貫性モデル(Consistency Model)
設計原則10「単一正本性は合意ログで担保し、不確定時は Read-only へ縮退する」を、
何を・どこまで保証するかとして明文化する。実装の現状(D1 単一ノード)と、本番化時に
満たすべき規範の両方を、同じ語彙で区別して書く。
関連: 規範は
10_requirements.md設計原則10、20_method_design.md第6章(整合性モデルとファイナリティ設計)、縮退の実装は
../src/zc/platform/system_mode.tsと
../src/zc/platform/quorum.ts、移植契約は所有権の choke point は §5 単一所有者則。
6.1 何が「正」か
唯一の正本は FinalityLog(追記専用・チェーン単位ハッシュ連鎖)。Transactions などの
派生ビューは捨てて FinalityLog から再構築できる(原則1)。したがって一貫性の議論は
「FinalityLog への追記がどの順序で・どの保証で確定するか」 に帰着する。
なお、§5.6 で述べた誠実な再スコープを踏まえると、原則1 が「唯一の正」と
言い切れるのは協調事実(決定・所有権移転・受領した署名付き証明)についてであり、
金銭事実の正は ZC が触れない各行の元帳にある。ZC の一貫性保証はこの前提の上で読む。
6.2 保証する一貫性(規範)
| 対象 | 保証 | 根拠 |
|---|---|---|
| 単一取引/チェーンの状態確定 | 線形化可能(linearizable) | 状態遷移は CAS+FinalityLog 追記を単一トランザクションで発行。確定は合意ログの linearization point で一意に順序づく |
| チェーン内のイベント順序 | 全順序(per-chain total order) | prev_hash 連鎖+ event_seq 単調。改ざんは後続全エントリを無効化 |
| 取引照会(trace / verify) | read-your-writes / monotonic reads | 確定済みエントリは同じ取引番号で誰が照会しても同じ説明を返す(原則の中核 UX) |
| 異なるチェーン間の順序 | 因果順序のみ(大域全順序は要求しない) | チェーンは txid/gtid 単位で独立。大域整列が要るのはアンカー透かしのみ(§7.2 event_seq 参照) |
linearization point:合意ログが書き込みを quorum にコミットした瞬間。これより前に観測される
状態は存在しない(部分適用窓が無い=CAS+ログのアトミック契約、§4)。
6.3 不確定時の縮退(原則10)
合意ログが quorum(合意を成立させるために必要な最小限の過半数のレプリカ数)を喪失
した区間では、少数派パーティションで誤った確定(mis-decision)を書かないために、システム
全体を read-only に縮退 する。これは場当たり的な例外処理ではなく、制度化された状態遷移
(SystemMode: NORMAL → QUORUM_LOSS_READONLY)として扱う(原則9)。
- 縮退の判定:
quorum.ts#reconcileQuorum。メンバ集合(ZC_QUORUM_REPLICAS)に対する到達性を
受け取り、厳密過半数floor(N/2)+1を割ったら縮退、回復で自動復帰。 - 縮退の強制点: 書き込みプリミティブ(
transitionWithLog/insertTxWithLog/cancelInFlightTx/transferOwnership/suspendTx)が確定前にassertWritableDbを通る。
CAS+ログアトミシティ・owner 再表明と同じ choke point なので、read-only 中に状態だけ進む窓は
構造的に存在しない。 - 失敗の意味論: 縮退中の確定要求は
SYSTEM_QUORUM_LOSS_READ_ONLY(categoryDOWNSTREAM=
retryable)。queue は ack(破棄)せず retry で in-flight を保持し、NORMAL復帰後に再開する。
同期 API には 502 系で返す(資金移動は受理されていない、という契約; 原則5)。 - 縮退中も許すもの: すべての照会(状態・残高・trace・verify)。読み取りは linearization point
より前のコミット済み状態だけを返すため、誤決定を生まない。
本番での reconcileQuorum の給餌: 実在の分散 SQL(特に Spanner)は
レプリカのメンバシップを外に見せない。したがって本番は「レプリカ到達性の注入」ではなく
カナリア書き込みループ(合成トランザクションの成功率+レイテンシ=「いま、コミット
できるか」の直接計測)を quorum-report の給餌源にする。詳細は §7.5。
強制配線(
assertWritableDbゲート)はどちらの給餌でもそのまま機能する。
NORMAL ↔ 縮退の所有権
| モード | 所有者 | 解除条件 |
|---|---|---|
QUORUM_LOSS_READONLY |
quorum 整合(自動) | quorum 回復で自動復帰のみ。運用者トグル不可 |
BCP_READONLY |
運用者(宣言) | 運用者の bcp-deactivate |
両者は独立。quorum 回復は運用者宣言の BCP_READONLY を上書きせず、bcp-deactivate はQUORUM_LOSS_READONLY を解除しない(reconcileQuorum と deactivateBcpReadOnly のガードで担保、
テスト: ../test/zc/quorum.test.ts)。
6.4 規範↔実装ギャップ(誠実に)
本参照実装は正本性を D1(単一ノード SQLite) で簡易に実現している。したがって:
- 充足(制御側): 原則10の 縮退半分 ——quorum 評価・自動縮退・read-only の強制配線・
自動復帰・監査(GLOBAL チェーンへのSystemQuorumLoss*イベント)。観測された到達性をquorum-reportで注入して end-to-end に動作・テスト可能。 - 未充足(保管側): 実体としての地理分散合意ログそのもの(第10章 Roadmap で追跡)。単一ノードには観測すべき
multi-replica quorum が無い。本番では合意を内包した分散 SQL(Spanner / Aurora DSQL /
CockroachDB / YugabyteDB)が正本を保管し、その メンバシップ健全性をreconcileQuorumに
供給 する(実務はカナリア書き込み、§7.5)。強制配線(書き込みプリミティブのassertWritableDbゲート)はそのまま機能する。- 単一ノードの PostgreSQL は正本性・quorum 要件を単体では満たさないため、置くなら
クラスタ/HA 構成が前提。
- 単一ノードの PostgreSQL は正本性・quorum 要件を単体では満たさないため、置くなら
- 移植時に方言依存で書き換えが要る点(
changes()ガード、INSERT OR IGNORE、単一行UPDATE...RETURNING採番など)と受入条件は §7 可搬性 に集約。 - 移植時に隠れている再設計が二つある(in-doubt/retry 意味論、quorum 健全性の測り方)。
これは縮退ロジックの再設計ではなく移植タスクだが、工数は小さくない。詳細は
§7.5 に明記した。
第7章 可搬性と保管バックエンド移植契約(Portability)
設計原則10は、単一正本性を合意を内包した分散 SQLで保管することを規範としている。本参照
実装は D1(単一ノード SQLite)で簡易に実現しているため、本番化にあたっては保管バックエンドを
差し替える必要がある。本章は、「差し替え時に何を書き換え、何をもって完了とみなすか」の
契約を一箇所に集約する。
関連: 一貫性の保証は §6 一貫性モデル、縮退の実装は
src/zc/platform/{system_mode,quorum}.ts、横断アーキ(実行環境の抽象化)は
7.1 縮退の制御側は移植不要
quorum 健全性の評価・自動縮退(QUORUM_LOSS_READONLY)・read-only の強制配線・自動復帰は
保管バックエンドに依存しない(実装: quorum.ts / system_mode.ts)。移植側がやることは
「保管バックエンドのメンバシップ健全性を reconcileQuorum(env, reachableIds) に供給する」
ことだけ。書き込みプリミティブの assertWritableDb ゲートはそのまま機能する。
供給経路は2つ:
- 既存の
/internal/system-mode/quorum-report(外部ヘルスモニタが到達レプリカを POST)。 - 分散 SQL ドライバの cluster health API を cron で polling し
reconcileQuorumを呼ぶ。
ただし Spanner のようにメンバシップを外に見せないエンジンでは、上記「到達レプリカ」の
概念そのものが取れない。本番の推奨配線はカナリア書き込み(§7.5)——
4 候補すべてで同一配線が動く。
7.2 書き換えが必要な SQLite/D1 方言(保管側)
決済コアは方言依存を意図的に集約している。差し替え時に等価へ書き換える対象は以下。いずれも
パイプライン上の少数の choke point に閉じているため、全 32k 行の散在修正にはならない。
| 方言イディオム | 使用箇所(代表) | 移植先の等価表現 |
|---|---|---|
changes() > 0 ガード付き条件 INSERT(CAS 成否でログ INSERT を発火) |
orchestrator/finality.ts#buildFinalityLogConditionalInsert、lanes/_helpers.ts |
直列化トランザクション内で UPDATE ... RETURNING の戻り有無でアプリ分岐、または INSERT ... SELECT ... WHERE EXISTS(更新後行) 単文 |
db.batch() の暗黙トランザクション |
_helpers.ts(CAS+ログ+sideUpdates)、bank/ledger.ts#insertJournalGroup |
明示的 BEGIN ... COMMIT(分離レベル = SERIALIZABLE / external consistency) |
INSERT OR IGNORE(冪等 INSERT) |
lanes/_helpers.ts#insertTxWithLog、seed |
INSERT ... ON CONFLICT DO NOTHING |
INSERT OR REPLACE |
各所の upsert | INSERT ... ON CONFLICT (...) DO UPDATE SET ... |
単一行 UPDATE FinalitySeq ... RETURNING による event_seq 採番 |
orchestrator/finality.ts#allocateEventSeq |
リスク#2参照(下記) |
version = ? 楽観ロック |
_helpers.ts 全 CAS |
そのまま(SSI と素直に整合。競合は abort→retry) |
WHERE txid = ? OR gtid = ? のチェーン整列 |
finality/finality_chain.ts |
そのまま(per-chain 全順序のみ要求、大域整列は不要) |
event_seq 採番(リスク#2 と結合)
FinalitySeq 単一行の UPDATE...RETURNING は単一ノードでは無料だが、分散 SQL では
全書き込みが1行で大域直列化 されクロスリージョン合意のボトルネックになる。event_seq の
大域単調性は ハッシュチェーンの正当性要件ではない(prev_hash が per-chain 順序を独立に
担保)。移植時は次のいずれかへ:
- HiLo / ブロック割当(短期・低リスク): isolate ごとに N 個まとめて採番、競合を 1/N に。
- per-chain シーケンス+HLC(分散 SQL 向け): チェーン単位単調+無協調な大域整列。大域全順序が
必要なのはアンカー透かし(finality/finality_anchor.ts#MAX(event_seq))のみで、これは HLC
または per-chain アンカーへ置換可能。
単調性は壁時計ではなく DB のコミット順序から導く: event_seq(ないしその
後継の大域整列値)の単調性は
Date.now()などの壁時計からではなく、エンジン native の
コミット順序——Spanner のコミットタイムスタンプ、CockroachDB の HLC——から導くこと。壁時計はノード間でずれ、アンカー境界(下記 UUID v7 評価参照)がコミット順を要するこの
設計では不整合の源になる。
UUID v7 はこの用途に最適ではない(評価)
「event_seq を UUID v7 に替える」案は、ボトルネック(大域 1 行採番)は確かに消える(無協調で
生成でき時刻順にほぼ整列する)一方、この設計では割に合わない:
- アンカー包含証明の完全性を弱める。アンカーは
getChainTipHashAsOf(chain, maxSeq)でevent_seq <= 透かしを満たすチェーン先端を確定し「この透かしまでの全イベントを被覆した」と
主張する。UUID v7 は同一ミリ秒内・ノード間の順序がランダム下位ビット依存で コミット順 を
反映しない。採番(=コミット順、単一ノードのUPDATE...RETURNING)と違い、透かし生成時に
in-flight だったイベントが後でコミットし、その UUID v7 時刻が透かしより前に並ぶと
「被覆されるべきなのに被覆されない」隙間が生じ得る。アンカーの境界は コミット順序付きの
切れ目 を要するが、UUID v7 のランダム末尾はそれを保証しない。 - 要らない大域順序を過剰供給する。§6.2 はチェーン間を因果順序のみと
規定し、大域全順序が要るのはアンカーだけ。UUID v7 は弱い大域時刻順序を全書き込みに付与するが
要件超過で、しかも (1) の保証は満たさない。 - 列・索引の作り替え(
INTEGER4 索引+FinalityAnchor.high_watermark_seq→TEXT/BLOB)と
既存の厳密単調テスト(chaos_nasty #N10)の書き換えが要る。
推奨: 単一ノード D1 では event_seq はそもそもボトルネックではない(単一ライタで全書き込みが
直列するため、追加の 1 行 UPDATE...RETURNING は実質無料でコミット順の全順序が手に入る)。分散化
時は UUID v7 ではなく (a) per-chain seq(チェーン内単調・prev_hash と素直に整合)+(b) アンカー
境界をエンジン native のコミット順序値(Spanner commit timestamp / Cockroach MVCC)または HLC で
定義 する。UUID v7 は「因果保証を欠いた HLC ライト」であり、アンカーの切れ目がコミット順序を要する
この設計には HLC / native-commit-ts が正解。
7.3 受入条件(Acceptance Criteria)
保管バックエンド差し替えが「完了」とみなせる条件:
- 方言契約コンフォーマンス緑:
test/integration/portability_conformance.test.tsが新バックエンド
上で全通過(§7.2 の方言表を実行可能な表明に落としたもの——changes()ゲート条件 INSERT・db.batch()の原子性・INSERT OR IGNORE冪等・event_seq 厳密単調)。移植時の最初のゲート:
アダプタをcreateTestDbの背後で差し替えてこのファイルを回す。緑=決済コアが依存する 4 挙動が成立。
ただしこれは 契約の明文化 であって、実マルチリージョンクラスタ上の(§7.3 条件1)chaos 全通過や
(§7.3 条件3–4)の linearizability / split-brain 検証の代替ではない(それらは実バックエンドが要る)。 - 全 chaos スイート緑:
test/integration/chaos_*.ts(並行・冪等再送・TOCTOU 取消順序・
ゼロサム不変条件・ハッシュチェーン監査)が新バックエンド上で全通過。 - 原則10 の縮退が観測可能: quorum 喪失注入で
QUORUM_LOSS_READONLYへ縮退し、書き込みがSYSTEM_QUORUM_LOSS_READ_ONLYで拒否され、回復で自動復帰する(test/zc/quorum.test.ts
相当をバックエンド上で再現)。 - 線形化可能性: 確定済みエントリの読み取りが linearization point 以前のみを返す
(§6.2)。 - split-brain 安全: 少数派パーティションが新規確定を書けない(縮退が機能する)こと。
- 対話型トランザクションを持ち込まない: 移植後も interactive read-then-write
トランザクションを導入しないこと。D1 の「静的バッチ」(全文が事前確定した single-round-trip)は
制約に見えて、実は資産である——分散 SQL 上で競合リトライが最少になる形そのものだからだ。
これを受入条件へ昇格させる。
7.4 データ層の可搬と切替時のファイナリティ整合(未着手)
Cloudflare 固有バインディング(Workers/D1/Queues/R2)の抽象化層と、データ層(D1)の可搬先・
切替時のファイナリティ整合は本章のロードマップとして残る(§10 既知の制約)。
本章は 保管バックエンドの移植契約 に範囲を限定し、実行環境の抽象化(可搬性)は別件。
7.4.1 資源バインドの宣言(規範)
Cloudflare の資源バインド(D1 / Queues / R2 / KV / Durable Objects)は Env 上で?:(optional)として型付けしてよい。Worker はバインド不在でも起動でき、コードは意図的に
縮退する(ALS はキャッシュ無しで解決し、RichData はしきい値超過でもインラインに保持し、/api/stream/connect は 500 を返す)から、型が「常に在る」と偽るほうが誤りである。
ただし、その縮退モードに“気づかないまま本番が入る”ことは許さない。 optional 記法は
宣言漏れを不可視にする——実際 ALS_KV は src/zc/directory/als.ts が読んでいるのにwrangler.toml.example には存在しなかった。したがって optionality は型に残したまま、
配布する設定ファイルの完全性を機械検査で担保する:Env 上で資源型を持つフィールドは
すべて wrangler.toml.example に宣言されていなければならない
(test/invariants/worker_bindings.test.ts)。検査の対象は資源バインドに限る——シークレット
(ZC_SIGNING_KEY_PKCS8 等)は秘密ストアに置くものであり、リポジトリ内の toml に現れては
ならない。
7.5 S3 回答: 移植時に隠れる二つの再設計と設計規律
一貫性・移植契約の充足可能性について: 契約は充足可能——ただし書かれていない再設計が
二つ隠れている。どちらも縮退ロジックの再設計ではなく移植タスクである。
まず良い報せ: 主張されている保証(チェーン単位の線形化・チェーン内全順序・チェーン間は因果のみ、
§6.2)は per-key 線形化で足りるため候補4製品すべてが満たす。大域外部整合は
要求しておらず、Spanner 専用機能への依存もない。「D1 バッチ→単一 ACID トランザクション」
「changes() ガード→UPDATE ... WHERE state=? RETURNING」は §7.2 の通り機械的に写る。
隠れた再設計① — リトライ意味論(in-doubt)。分散 SQL はトランザクションを abort→retry させ、
コミット直後のクラッシュでは「コミットされたか不明」(in-doubt)が生じる。D1 単一ノードには
この曖昧さが存在しないため、現コードは無防備。トランザクション結果表(txn-outcome table)+
冪等ラッパを全書き込みプリミティブに被せる必要がある。ただし結果表は新設不要——
既存の Idempotency 表(src/shared/idempotency.ts、IdempotencyKeys:acquireIdempotency / completeIdempotency / resolveIdempotency)を
「コミット済み結果の権威」(txn-outcome table)として流用し、書き込みプリミティブを冪等に
包む。縮退ロジックの再設計ではないが、工数は小さくない。
隠れた再設計② — quorum 健全性の測り方(カナリア書き込み)。reconcileQuorum に
レプリカ到達性(メンバシップ)を注入する現設計は、Spanner がそもそもメンバシップを外に
見せない時点で破綻する。測るべきは「今、コミットできるか」そのもの——カナリア書き込み
ループ(合成トランザクションの成功率とレイテンシ)を quorum-report の給餌源にせよ。これなら
4 製品すべてで同一の配線が本当に動き、「観測すべき quorum が無い」という現行の言い訳ごと消える。
これが本番の具体的な production wiring である(§6.3 / §7.1
の給餌経路をこれで置き換える)。
移植時の設計規律(受入条件へ昇格済み: §7.3 条件5): D1 の静的バッチは資産で
あり、対話型 read-then-write トランザクションを導入しない。あわせて event_seq の単調性は
DB のコミット順序(Spanner commit timestamp / CockroachDB HLC)から導く(§7.2)。
第8章 データベース改善
詳細は 31_schema.md に集約。要点だけここに残す:
- スキーマは
0001_consolidated_schema.sqlに統合済み(旧 0001–0042 の
連番チェーンを最終形のテーブル定義・index・seed に畳んだ唯一の正)。
hot-path index(timeout sweep, lane×state ダッシュボード, audit by
time-range, expired RTP/HTLC sweep など)もこの中に含む。 - スキーマ変更はこの統合ファイルを直接編集する(新しい連番ファイルは
切らない)。列の追加・修正は対象CREATE TABLEに直接反映し、31_schema.md
も同じ変更で更新する。test/helpers/d1-mock.tsのSCHEMA_MIGRATIONSは
この 1 本を毎回新規適用するため、配列への追記は不要。詳細な鉄則は31_schema.md§ マイグレーション運用。 - Foreign Key は意図的に最小(mock であり、参照整合は ZC 側状態機械と
FinalityLog で担保)。本番化方針は31_schema.md§ Foreign Key 戦略。
第9章 テスト戦略
既存
- Vitest + better-sqlite3 in-memory D1 mock による約1,000ケースのテストスイート(正確な数はリポジトリを正とする)。
横断プリミティブ
test/shared/errors.test.ts— DomainError/errorResponse/カテゴリ写像test/shared/logger.test.ts— JSON shape, redaction, child baggagetest/zc/lane_helpers.test.ts— CAS / 並列 N 本 / TOCTOU 取消順序 / sideUpdates / insertTxWithLog
静的解析インバリアント(test/zc/lane_invariants.test.ts)
目的: 「新規 lane 追加時のチェックリスト」§4 を機械化する。src/zc/lanes/
配下のソースを regex で走査し、helper を回避する直書き SQL(UPDATE Transactions SET state / INSERT INTO Transactions)、FinalityEventType
union 未登録のイベント名、test 漏れを検出する。runtime suite が「動く」を
確認するのに対し、こちらは「規約を守って動く」を確認する。
残高インバリアントの統合テスト(test/integration/balance_invariants.test.ts)
目的: 「状態機械が正しい」だけでなく「最終的に顧客口座の数字が合う」までを
往復で固定する。state-machine 系の単体テストはレーンの遷移条件を見るが、
仕訳まで追うものが無かったため、過去に以下のような仕訳起点のバグが摺り抜けた:
| バグ | 内容 | 修正 |
|---|---|---|
| double-credit | onPayeeExecConfirmed が無条件に credit-notify を呼び、その bank ハンドラがもう一度 Customer(+)/ZCS(-) を仕訳していた。EXPRESS / STANDARD / HTLC / HTLC_AUTH / HIGH_VALUE / BULK のすべてで payee が 2 倍着金。 |
bankCreditNotify を仕訳しない通知層に変更(BankAuditLog + DELIVERED 応答のみ)。execute-credit 経由の仕訳が唯一の真実。 |
| HTLC_AUTH stuck | approveAuthRequest が Transactions(state='H_RESERVED') で INSERT。claimHtlc の CAS は WHERE state='HTLC_LOCKED' のため Transactions が動かず、Bank だけ debit されて payee は永遠に着金しない。 |
(当時) INSERT 時の state を HTLC_LOCKED に変更し HtlcContracts と整合。(現行) その後、HTLC_LOCKED 直挿入は PaymentInitiated の証跡を残さないため禁止され、RECEIVED で INSERT → transitionWithLog で RECEIVED → HTLC_LOCKED を通る canonical 入口に再是正された(下記注記)。 |
| GTID leg pairing | 2×2 で PAYEE が leg_id 昇順以外で挿入されると、PAYER↔PAYEE のペアが取り違わって誤った銀行に着金。 | payerLegs / payeeLegs を leg_id でソートし、同じ index で組む。 |
注記(上表 HTLC_AUTH stuck 行の「当時の修正」は現行規範ではない)
上表はバグが摺り抜けた経緯の記録であって、現行の規範ではない。HTLC_AUTH 行の
「INSERT 時の state を
HTLC_LOCKEDに変更」は当時の是正であり、その後
HTLC_LOCKEDでの直接 INSERT は禁止された——RECEIVEDで挿入し
transitionWithLogでRECEIVED → HTLC_LOCKEDを正規遷移することで、
PaymentInitiatedが必ず FinalityLog に残る(canonical 入口の証拠)。規範は
10_requirements.md§3.2.3.1-5 および20_method_design.md§3.2.1、実装は
src/zc/lanes/htlc_auth/approve.ts、固定は
test/zc/htlc_auth_canonical.test.ts/htlc_auth_regression.test.ts。本表の「修正」欄を現行の実装指針として読んではならない。
カバー範囲(11 テスト):
- 各レーン(EXPRESS / STANDARD / HTLC / HTLC_AUTH / HIGH_VALUE / BULK / GTID 1×1 / GTID 2×2 逆順 / 複数レーン同時)について、
- payer 顧客 Δ == −amount
- payee 顧客 Δ == +amount
- 各行内ゼロサム
- BOJ 系全行合計の保存則(RTGS 経由でも 0 保存)
新規バグ修正はこの suite の expect() が落ちることで検出できる。新レーン
追加時はこの suite に 1 ケース足すのを義務付けたい。
ZC→Bank ingress 接合部(test/integration/ingress_commands.test.ts / test/invariants/ingress_seam.test.ts)
目的: 13 コマンド(10_requirements.md §7.2.1)の
呼び手と受け手が同じ電文を指していることを、機構と往復の二段で固定する。
きっかけは account-verify で実際に見つかった不具合である。呼び手と受け手が同じコマンドの電文を
別々に宣言し、項目名が食い違ったまま両側が型検査を通っていた。受け手は口座を一度も
解決できず、呼び手は受け手が返さない値を期待して既定分岐(ERROR)に落ちていた。
それでもスイートは緑だった——当該コマンドの唯一のテストが、呼び手のマッピング関数を
手書きの応答オブジェクトで単体呼び出ししていたためである。
接合部を跨がないテストは、片側の思い込みを言い直して「思い込み=思い込み」を確認する。
したがって規約は次の 2 つである。
- 電文型は 1 箇所でのみ宣言する——
src/types/api/bank-ingress.ts。呼び手も受け手も
ここから import する。コマンド名から電文型への写像BankIngressRequestMapと、
コマンドの正本一覧BANK_INGRESS_COMMANDSを同ファイルに置き、src/bank/ingress.ts
の dispatch はこの写像で型付けされたテーブルとする。どちらかの端が項目名を変えればtscが落ちる——規律ではなく機構で守る(第4章_helpers.tsと同じ方針)。
受け手が独自に電文型を宣言していないことはinvariants/ingress_seam.test.tsが静的に検査する。 - 13 コマンドすべてに往復テストを置く——
integration/ingress_commands.test.ts。
各テストは 呼び手が組んだ電文(本番の call site が使うのと同じ builder /callBank*ラッパ)を 実物の受け手に通し、戻り値を 呼び手のマッピングへ流す。
テスト中に電文・応答のリテラルを手書きしてはならない(手書きした瞬間、それは
接合部ではなく片側の再宣言になる)。- 電文は dispatch 前に JSON 往復させる(
overWire)。内部呼び出しはオブジェクトを
そのまま渡すが、handleBankIngressHttpは電文を回線から parse する。
片方でしか成立しない契約は契約ではない。 - 表明は応答の形だけでなく 受け手の効果(仕訳・行の状態)に置く。
undefinedを
読んだ受け手も、それらしい形の応答は返せるためである。 - 網羅は
BANK_INGRESS_COMMANDSに対して機械検査する。往復テストの無いコマンドが
増えること自体が、account-verifyが死んだまま出荷された経路だった。
- 電文は dispatch 前に JSON 往復させる(
この 2 つは役割が違う。1 は「二度と食い違わない」を保証し、2 は「そもそも繋がっている」を
保証する。型が一致していても呼び手が受け手を一度も呼んでいなければ(rtp-notify が
実際にそうだった)1 は何も言わない。
追加テスト
- 冪等キー再送(
test/integration/idempotency_replay.test.ts)— EXPRESS / STANDARD / HTLC で
同一 idempotency_key の 2 回目リクエストが同一レスポンスを返し、Transactions 行が 1 本のみであることを確認。 - Queue retry/ack ポリシー(
test/integration/queue_retry_policy.test.ts)— DomainError
category ×msg.retry()/msg.ack()の対応を全カテゴリで検証。non-DomainError も retry 対象であることを確認。 - HTLC cancel payer 残高(
test/integration/htlc_cancel_balance.test.ts)—TIMELOCK_EXPIRED
および直接 cancel の 2 経路で payer suspense が普通預金に戻り、行内ゼロサムが保たれることを確認。
第10章 既知の制約・将来項目(Roadmap)
10.0 実装状況の書き方(本書群共通の規範)
設計書は 規範(そうでなければならないこと)を現在形で書く文書 であり、実装の進捗表ではない。
両者を同じ本文に混ぜると、規範が commit ごとに腐り、しかも腐り方が非対称に危険である——
「未実装なのに実装済みと書いてある」側にしか倒れないからである。したがって本書群は、
実装状況を次の 非対称なルール で扱う。
| 種別 | 本文に書くか | 書き方 |
|---|---|---|
| 規範に実装が達している | 書かない | 規範を現在形で書くだけでよい。「実装済み」と書き足さない |
| 規範に実装が達していない | 必ず書く | 当該箇所に 1 行(「未充足:〜。本書 第10章で追跡」)を置き、実体は本章に登録する |
| 実装の所在(どのファイルにあるか) | 本書(内部設計書)に限り書いてよい | 「実装: src/...」。状態ではなく所在なので腐りにくく、file_structure.md の機械検査が守る |
| 設計経緯(なぜ採らなかったか) | 判断の再利用に必要な範囲で書いてよい | 「経緯」と明示し、規範と混ぜない。作業履歴(いつ何件実装したか)は git が持つ |
根拠:10_requirements.md §8.8 は「未充足のものを充足しているかのように書かない」を既に規範として
定めている。上表はその裏側——充足していることをわざわざ書かない——を対にして固定したものである。
片側だけを規範にすると、「実装済み」注記が増えるほど、注記の無い箇所が「未検証」に見え、
注記のインフレが起きる。沈黙が「規範どおり」を意味するという約束が、この文書群を短く保つ。
本章について:本章は 残っている作業だけ を載せる。項目が完了したら、その行は消し、
必要なら本文側の規範へ記述を移す(「実装済み」として本章に残さない)。
10.1 セキュリティ・運用基盤
- 参加者鍵の登録を全行へ広げる: 照会の主体認証は
KeyRegistryのowner_type='PARTICIPANT'鍵によるリクエスト署名として実装済みで、強制は鍵の登録状態で
決まる(登録済みの行は署名必須、未登録の行は申告のみで読めるが監査台帳に未認証として残る。32_api_contracts.md§ 照会の認可)。したがって残件はコードではなく運用——全参加行の
鍵登録と、その期限の制度化(10_requirements.md§3.3.4 の 4 眼統制に乗る)。
ブラウザから直接叩く参照ダッシュボードは鍵を持てないため、運営スコープ(X-Cron-Secret)で
読む前提のままである。 - OpenAPI 自動生成:
src/openapi/*.ts(YAML 文字列定数)は手書き。コードの
ルーティングと型から再生成する仕組みを入れたい。
10.2 規範に対する実装の残件
- 保管側の単一正本性: 地理分散合意ログの実体が未実装(制御側——quorum 評価・
自動縮退・read-only 強制配線・自動復帰・GLOBAL チェーン監査——は実装済み)。保証する
一貫性と移植契約は §6 一貫性モデル と §7 可搬性、
移植時に隠れる二つの再設計は §7.5 が正。 - 実行環境の可搬性: Cloudflare 固有バインディングを抽象化層の背後へ隔離し「第二の
実行環境で動く」ことを受入条件化する横断アーキ変更は未着手(BCP 縮退モード自体は
実装済み)。データ層(D1)の可搬先と切替時のファイナリティ整合も未検討
(10_requirements.md§8.7 の要件 M-2 が未充足であることの実体はここ)。 - リース+フェンシングトークン: 所有者障害からの回収。
owner列(§5 単一所有者則)
をそのまま土台に使えるが、受け手が stale トークンを拒否して初めて意味を持つ=信頼境界を
越えるため、参加者の接続認定試験と同じタイミングに送る
(10_requirements.md要求仕様章)。 - CBT 確定の自動 EOD 取り込み: 現状の非JPY DNS 取り込みは内部仕訳までで、発行体の
署名付き確定観測を待ってからの finality 連結は外部 Watcher 経路に委ねている。 - ILP / ISO 20022 への対外接続: 元帳の通貨次元化(
BankJournals.amount_currency)を
対外プロトコルへ接続する部分。 - Watcher 定足数:確定種別の権威と独立性の制度化: 既定値はレール別に効いており
(onchain_chain_class='PUBLIC'は 2、決定的チェーンは 1、src/zc/lanes/htlc/create.ts)、
種別を欠くクロスチェーン脚はONCHAIN_CHAIN_CLASS_REQUIREDで拒否する。残るのは
(a) 申告された種別を誰も検証しないこと——PUBLICの鎖をPRIVATEと申告すれば
定足数は 1 に落ちるので、sourceと種別の対応はチェーン登録として制度側に置く必要がある、
(b) Watcher の独立性(鍵・運用組織・ネットワーク・チェーンノード)の制度化と実地検証
(20_method_design.md§7.7.3。10_requirements.md§8.5 の要件 S-4 に残るのはこの 2 点)。 - 禁止クエリパターンの内容による検出:
10_requirements.md§3.3.2.2.1.1-2。一覧・フィード系は
経路として運営スコープに限定済みだが(10_requirements.md§3.3.2.2.3 の取得制限を経路で実装した形)、単件照会の
内容が濫用的な相関探索かどうかの判定は無い。AccessAuditLogはその判定に必要な材料
(主体・目的・対象・時刻)を既に残しているので、次は事後検知(10_requirements.md§3.3.2.2.1.1-3)から着手できる。 export_refの発行経路:AccessAuditLog.export_refは列として在るが、エクスポートを
伴う照会(帳票・CSV。20_method_design.md§10.4.2)が未実装のため常に NULL。- FX の任意の発展: 流動性連動の動的上限・丸め益の帰属ルール・決済前 AML フックの接続・
紛争フロー・HTLC レーン自体の通貨次元化(§17.4「将来課題」。FX は独立FxLegLocksで
完結しているため必須ではない)。 - プログラマビリティの発展余地: 決定的に評価できる範囲での述語追加、定期/据置の
第一級オブジェクト化、外部イベント購読(§11.6)。 - 未開示の挙動の棚卸し: 「実装にあって文書に無い」を受動的に見つけるのではなく
能動的に探す。候補は変換を行う箇所——正規化(GTID 脚)・識別子の書式生成・既定値の充填——で
あり、いずれも「参加者が送ったものと、系に残るものが違う」場所である。過去にこの型で
3 件(脚正規化・照会の認可・origin_leg_id)が同時に見つかっており、まだあると見るのが妥当。 - 時間軸で崩れる前提の棚卸し: 「単体では正しいが、別のスイープ・別のジョブが
時間とともに前提を崩す」箇所を能動的に探す。CASE の重複判定がstate='OPEN'だったために、20_method_design.md§10.7.4 の昇格スイープが走った翌日から
監査チェーン破断の CASE を毎晩複製していた例(UNRESOLVED_CASE_STATESで解決済み)がある。
候補の型は同じ列の値域を 2 箇所以上が手書きしている場所であり、Transactions.state・DnsCycles.state・IgsRequests.statusについて同じ棚卸しをしていない。 - 状態
reason_codeの family 判定の機械化: 命名規約
(32_api_contracts.md § 状態 reason_codeの待機理由/事象理由)は文章であり、CANCELLEDの行にSUSPEND_*が載るような矛盾した組み合わせを検査していない。
拡張の可否は誤検知率を実測してから決める。
10.3 制度設計に委ねる論点(方式は固定済み・最終仕様は制度合意待ち)
dns_recovery_reserve の算式(本実装は shortfall+バッファの方式モデル)、Bulk LSM
の目的関数の重みづけ・近似度(20_method_design.md 第11章 RFP)、エージェント identity の発行主体、
HTLC 条件テンプレートの第三者提案プロセス、オンチェーン確定種別の分類権威
(チェーン登録)、FinalityAnchor の配布先(全参加行配信/公開トランスペアレン
シーログ/公開チェーン)の選定。
解消済み(副署の基準点):本節はかつて「副署が覆う時点の確定」を制度合意待ちの
論点として掲げ、基準点を現 tip・b を記録したエントリ・日次アンカーのいずれに置くかは
未決である、としていた。これは論点の立て方自体が誤っていた——現 tip は選択肢ではない。
定足数は「相異なる k 者が同一のハッシュに署名した」ことであり、tip は通常の業務追記の
たびに動くから、tip に署名させると
min_cosigners >= 2が原理的に充足不能になる(制度の好みの問題ではなく、構成上そうなる)。基準エントリは「不可逆点のエントリ、
無ければ直近アンカーの tip」と固定し、
resolveCosignBasisで解決する。規範は
32_api_contracts.md § GET /api/transactions/:txid/verify。
これらは本書の Roadmap として残し、優先度の高いものから別途 issue 化する。
第11章 プログラマビリティ(外部アテスター前提の条件付き決済)
本章はプログラマビリティの思想・構成レイヤの一次記述である。HTLC claim 経路での適用規範(状態遷移・
claim-by-conditions・証跡)は 第15章 §15.5 を正とする。両者は対であり、重複する詳細は互いに参照で解決する。
条件付き・合成可能な決済に関する設計を集約する。規範は本書
第15章 HTLC(hashlock+timelock)詳細 §15.5、API は32_api_contracts.md、スキーマは 31_schema.md。
11.1 基本思想 — 判定はエッジ、コアは決定的
ZC のプログラマビリティは、「真偽の判定は外部(エッジ)に任せ、コアは決定的に評価するだけ」という考え方を大前提とする。これは設計原則(証跡・単一正本・説明可能性)から導かれる、意図的な機能の抑制である。そのため、イーサリアムの EVM のようなチューリング完全な VM(任意のプログラムを実行できる仮想機械)は載せない。
- 外部アテスター前提: 「検品完了」「書類充足」「制裁非該当」等、現実世界の条件そのものを ZC が判定することはない。ZC が行うのは、
KeyRegistryに登録済みの外部アテスター(第三者の検証者)が署名した Attestation(証明、verified_result='PASS'|'FAIL')を検証することだけである。真偽の判断・複雑な業務ロジック・数量やレートやデータに依存する判断は、すべてアテスター側に委ねる。コア側の表現力をあえて薄くしているのは、判定を担うエッジ側の自由度に上限が無いからである。 - コアの性質: 決定的(同じ入力なら常に同じ結果になる)/fail-closed(条件が未充足・不整合の場合は必ず「不成立」側に倒す安全側の設計)/全証跡(評価結果は必ず FinalityLog に残る)/非チューリング完全(任意のプログラムを実行できる汎用計算能力を持たないため、停止性判定・ガス代・非決定性といった問題が構造的に発生しない)。
- HTLC を普遍プリミティブに: 条件付き決済はすべて「ロック → 条件が成立すれば解放/タイムロック期限が来れば返金」という HTLC の形に落とし込む。HTLC 本来の「preimage(秘密の値)の提示=条件成立」という考え方を、Attestation・条件式・決定的述語を使ってより一般化したものと捉えるとよい。
ただし、「HTLC + 外部アテステーションだけ」ではカバーしきれない領域(流動性効率・複数拠点にまたがる同期的なアトミシティ・時刻の扱い)もある。これらは別のプリミティブ(DNS/H・GTID・決定的述語)で補っている。
11.2 構成レイヤ
condition_expr(純粋ブール合成: AND / OR / THRESHOLD k-of-n)
├─ Attestation リーフ ── 外部署名 + ConditionTemplate whitelist + KeyRegistry scope/鮮度
│ └─ テンプレ単位 distinct-operator 定足数(min_attester_quorum)+ equivocation → CASE
└─ 決定的述語リーフ ── ZC 自身が確定状態/時刻で解決(LedgerPredicate)
Mandate(委譲権限: 別軸。「誰の権限で」を金額/目的/レーンで縮小のみ委譲)
a. 条件式 condition_expr — src/zc/platform/condition_expr.ts{template_id} リーフ、{op:'AND'|'OR', operands}、{op:'THRESHOLD', k, operands}(k-of-n。AND=THRESHOLD n / OR=THRESHOLD 1 の一般化)から成る式木の純粋評価器+検証器。深さ・オペランド数・参照テンプレ数に上限(濫用・無限再帰防止)。ZC は式の真偽そのものは作らず、満たされたテンプレ集合に対するブール合成を評価するだけ。
b. Attestation + ConditionTemplate — src/shared/attestation.tsConditionTemplate は許可制 whitelist(第三者提案可、統制思想は HtlcAuthWhitelist と同型)。recordAttestation が署名(KeyRegistry、31_schema.md § KeyRegistry)・スコープ(allowed_attester_scope)・鮮度(既定 60 分)を検証。原文は保持せず statement_hash と verified_result のみ。
c. distinct-operator 定足数 + equivocation — src/shared/attestation_quorum.ts
テンプレ単位 min_attester_quorum(既定 1)により、条件リーフは k 個の distinct な attester operator(KeyRegistry.owner_ref、鍵ではなく)の鮮度内 PASS が揃って初めて満たされる。同一 operator の複数鍵は 1。同一 (template, subject) に PASS/FAIL 混在の equivocation は fail-closed(当該リーフ不成立)+ ATTESTATION_EQUIVOCATION の CASE 収束(他の独立枝での成立は妨げない)。
純粋な定足数・equivocation 判定は src/shared/operator_quorum.ts に集約し、Watcher のオンチェーン n-of-m(onchain_min_watchers)と同一ポリシーを共有(「鍵ではなく operator で数える/不一致は一致でない」を一箇所に)。
d. 決定的述語 LedgerPredicate — src/zc/platform/ledger_predicate.tsConditionTemplate.ledger_predicate_json が非 NULL のテンプレートは、外部表明を取らず ZC 自身が決定的に解決(allowed_attester_scope='{}'=誰も表明できない fail-closed)。汎用 VM に広げず、確定状態か固定時刻から再現可能に真偽が定まる少数の述語のみ:
| 種別 | 意味 | 単調性 |
|---|---|---|
TX_REACHED_STATE {txid, states[]} |
確定 FinalityLog に txid の state_to ∈ states |
単調(追記専用) |
GTID_REACHED_STATE {gtid, states[]} |
同上(GTID 集約状態) | 単調 |
TIME_AFTER {at} |
now >= at |
単調(false→true) |
TIME_BEFORE {at} |
now < at |
非単調(true→false) |
*_REACHED_STATE は linearization point 以前の確定エントリのみ読む → 「txid#2 が b に到達したら解放」型 DvP を、自台帳状態を外部署名者へ外注せず表現。TIME_* は据置(先日付)・期限を条件として表現(スケジューラ本体ではない)。時刻は JST(§11.4)。
e. Mandate(委譲権限) — src/shared/mandate.ts
「どの権限でその指図が出たか」を金額/目的/レーンの scope 付きで委譲チェーン化(parent_mandate_id)。縮小のみ(sub-mandate は親を緩められない)。条件成立の真偽とは直交する別軸(agentic commerce 前提)。
11.3 claim 経路と証跡
claimHtlcByConditions(src/zc/lanes/htlc/claim.ts)は式が参照するテンプレートを Attestation テンプレ/決定的述語テンプレに分け、前者は提示 Attestation を記録+定足数/ equivocation 判定、後者は evaluateLedgerPredicate(db, p, now) で解決し、満たされたテンプレ集合で式を評価する。いずれの場合も HtlcConditionsEvaluated(満たされた集合・required・定足数内訳・ledger 内訳・equivocation・評価に用いた evaluated_at・met)を FinalityLog に残すため、時刻ゲート成立も監査で完全再現できる。成立時のみ既存の成立経路(HTLC_LOCKED → HTLC_FULFILL_REQUESTED → …)へ進む。
11.4 システム時刻 = JST
システムの業務・表示時刻は JST(Asia/Tokyo, UTC+09:00)。単一の正は src/types/primitives.ts。
- 瞬時値の保存は UTC(
nowISO, RFC3339Z)— 辞書順=時系列を保ち、FinalityLog ハッシュ連鎖とoccurred_at ≥ cutoff比較を一意に保つため。 - 業務・表示は JST —
businessDateJST()(業務日)・systemMinutesOfDay()(稼働ウィンドウ)・parseSystemTime()(オフセット無し入力を JST 解釈)。TIME_*のatにオフセットが無ければ JST とみなす。全ての業務日/バリュー日派生・稼働ウィンドウ・HTLC の翌日境界 AML 再チェックを JST に統一。
11.5 ドライラン(読み取り専用)
副作用なし(書き込み・資金移動・Attestation 記録なし)で条件式・マンデートを事前評価する。既存の純関数を再利用(src/zc/query/simulate.ts)。
POST /api/conditions/validate— 構造検証+参照テンプレ列挙POST /api/conditions/simulate— 仮の satisfied 集合に対する met/missingPOST /api/mandates/check— マンデートの scope 判定
11.6 意図的な非対象と将来
コアに入れない(=この設計の一貫性を保つための線引き):
- 汎用計算/スクリプト VM、および数量・レート・添付データ依存の述語。これらは非決定/外部データ依存なので、アテスターが判定して PASS 署名する既存経路に載せる。
- スケジューラ本体(push 実行・定期・冪等再送)。
TIME_AFTERはpull 型の時刻ゲート条件どまり。 - 外部開発者向け pub/sub(署名付き Webhook)。現状
EventStreamは銀行宛て配信で、反応型プログラムはポーリング前提。
将来の発展余地(決定的に評価できる範囲での述語追加、定期/据置の第一級オブジェクト化、外部イベント購読)は Roadmap(§10)に連なる。
11.7 テスト
- 純粋層:
test/shared/operator_quorum.test.ts、test/zc/ledger_predicate.test.ts、condition_expr(test/zc/htlc_conditions.test.ts/test/zc/htlc_programmability.test.ts)。 - 統合: THRESHOLD・定足数・equivocation・決定的述語(TX/GTID/TIME)は
htlc_programmability.test.ts、ドライラン API はtest/integration/simulate_endpoints.test.ts。