TOCTOUドリフト検出は窓を閉じずに、窓が動いたことだけを告げる
carina plan はstateロックを取らない。apply / destroy は排他ロックを取るのに、planは意図的に取らない。読み取り専用の操作にロックは過剰だし、バックエンドのロックAPIが排他しか持たないため、取れば並行するplan同士が直列化され、長いplanがデプロイを止める。予測にすぎない出力のために払う代償として重すぎる。
その結果、planがstateを読んでから差分を表示するまでのあいだに、並行する apply / destroy がstateを書き換えうる。TOCTOU窓である。ドリフト検出はこの窓を閉じる仕組みではない。窓は開けたままにして、窓の間に何か通ったかどうかだけを事後に報告する。
serial(書き込みごとに単調増加)と lineage(stateを作り直したときだけ変わる)の2つだけ。仕組み
- T0で指紋を取る。 最初のstate読み込み直後に
StateFileのserialとlineageの2つだけを控える。state全体のハッシュではない。 - 表示直前(T1)に読み直して比べる。
lineageの変化をserialの変化より優先して報告する。stateまるごとの置き換えのほうが強い信号だから。 - ドリフトがあれば警告を出し、planはそのまま表示する。
3つ目が肝である。ドリフトは決して致命的にならない。 planは予測であり、apply はロックを取り直して差分を再計算してから変更するので、最終的な正しさはapply側で担保される。読み直しに失敗した場合も同じで、「ドリフトを確認できなかった」と警告するだけでplanは表示される。契約は「ロック付きの読み取り」ではなく「ベストエフォートのスナップショット+ドリフト警告」である。
state自体が消えていた場合(StateRemoved)も検出対象に入る。逆に、T0時点でstateが無ければ検出そのものを丸ごと飛ばす — 比較の基準が無いので、陳腐化しようがないからである。
指紋がserialとlineageだけである意味
検出できるのは「誰かが書いた」ことであって、「何がどう変わった」ことではない。serial が1つ進んだという事実は、そのplanの差分計算が前提にしたstateがもう最新ではない、とだけ言う。どのリソースが動いたかは分からないし、そもそも表示中のplanと無関係な変更かもしれない。それでも十分なのは、ユーザーに求める行動が「carina plan をやり直す」の一つしか無いからである。判断に使わない情報は集めない。
これは検査は素通し、等価性は不成立と同じ非対称性の勘定に見える。偽陽性の警告(無関係な変更で再実行を促す)のコストは小さく、偽陰性(陳腐化に気づかせない)のコストは大きい。だから粗い指紋で保守側に倒す。
「TOCTOUドリフト検出」は全体の一段にすぎない
ここまでがコード中で唯一「TOCTOU」と名指しされている段である。しかし陳腐化した予測を実際に食い止めているのはこの段ではない。保存plan(plan --out → apply --plan)の経路には、ロック下で走る段がもう3つある。
| 段 | 何を比べるか | ロック | 不一致のとき |
|---|---|---|---|
| plan中(T0→T1) | serial + lineage |
取らない | 警告のみ、planは表示・保存される |
| 保存planのlineage | state_lineage |
取っている | エラー(拒否) |
| 保存planのserial | state_serial |
取っている | 警告 |
| 保存planのリソース | 全リソースをプロバイダから実読みして属性を総当たり比較 | 取っている | 中止(「planをやり直せ」) |
| 保存planのupstream | upstream_state の属性マップ全体 |
取っている | 中止 |
指紋で済ませているのは上の3段だけで、4段目の detect_drift は指紋ではない — sorted_resources を1つずつ provider.read() で生の世界から読み直し、plan時に保存した属性マップと突き合わせる。存在の有無が反転していないか、属性が変わっていないか、plan時に無かった属性が増えていないかの3種を見る。除外されるのは _ 始まりの内部属性だけで、スキーマも見ないしUnknownの扱いも型を考えた等価性判定もしない。生の != である。
そして通常の apply(保存planを使わない)にはこの段が無い。必要が無いからで、ロックを取ってから差分を丸ごと計算し直す。ドリフト検出が要るのは「plan時に凍らせた予測を、後からそのまま実行する」保存planの経路だけである。
だから plan --out はドリフトを検出しても保存plan自体は書く — 警告はstderrへ行き、planファイルはコマンドの成果物として出る。安全なのは、後段のapply側がロック下で拒否してくれるからである。
保守側に倒す代償
detect_drift の比較が生の != であることは、方向としては保守的で正しい(見逃すより止める)が、偽陽性を生む。プロバイダがplan時に返さなかったサーバー側計算のフィールドを後から返せば、それは「増えた属性」としてapplyを中止させる。差分計算側の比較経路(differ)はスキーマを見て write_only を尊重し型を考えた等価性を使うのに、detect_drift はどれもしない。同じ「変わったか」の判定なのに、片方だけが素朴なままである。
しかもこの段には --force に当たる逃げ道が無い。中止すればユーザーはplanをやり直すしかない。保守側に倒した判断の代償は「止まらなくてよい場面でも止まり、迂回する手段が無い」ことになる。
「1回だけ読む」ことの帰結
窓が閉じないので、T1の読み直しの直後からapplyがロックを取るまでの間にも、やはり誰かが書きうる。名前の付いた「TOCTOUドリフト検出」はこの残りの隙間を扱わない。扱う必要が無い — planは予測であり、真実はapplyが確立するの契約のもとでは、planの鮮度は正しさの問題ではなくUXの問題だからである。この段は正しさの機構ではなく、「あなたが今見ている画面はもう古いかもしれない」と伝えるためだけの機構である。
正しさを担保しているのは、通常のapplyではロック下での差分の再計算であり、保存planのapplyでは上の表の下3段である。名前が付いている段と、効いている段が別というのが、この仕組みを読んで一番意外だった点である。
裏を返せば、買えるのは「有界の古さ+変更量比例のコスト+applyでの最終検証」であるで挙げたようなworld取得の近似(所有権による読み飛ばし、イベント駆動のdirty追跡)を入れても、この構造は変わらない。近似が効くのはplanがworldを組み立てる手前の段であり、その手前がどれだけ雑でも、ロック下の再計算(通常のapply)かリソース単位の実読み(保存planのapply)が最後に受け止めるからである。
根拠の確度
ソースで確認済み:serial / lineageの2フィールドを指紋とすること、lineageをserialより優先して報告すること(destroy + 再applyでserialは下がるので、serialを先に見ると誤分類する)、plan中のドリフトが警告どまりであること(carina-cli/src/commands/plan.rs)。detect_drift が全リソースを provider.read() で実読みし、_ 始まりの属性だけを除いて生の != で比較すること、中止に逃げ道が無いこと、そしてこれが run_apply_from_plan_locked からのみ呼ばれる — つまり通常のapplyにはこの段が無い — こと(carina-cli/src/commands/apply/mod.rs)。ロックを取らない判断の理由は CLAUDE.md の「Plan Concurrency Contract」節に明記。
解釈:「判断に使わない情報は集めない」という読み、検査は素通し、等価性は不成立との対応づけ、サーバー側計算フィールドによる偽陽性の見立て(差分計算側の比較経路がスキーマを見るのに detect_drift が見ないことは事実だが、実際に偽陽性が出た事例を確認したわけではない)。
初稿は保存planのapply時照合を「同じ2フィールドを見ている」と書いていたが、これは誤りで、実際にはリソース単位の実読み比較が別に走っている。