# feat #910 Phase 0 — ファイル書き込みを条件付きにする（409）

`PUT /api/files/browse/write` は現状ブラインド書き込みで、`GET .../text` もバージョンの手がかりを
返さない。つまり **Files view でファイルを開いている間にエージェントがそのファイルを書き換えると、
Cmd+S がエージェントの版を黙って消す**。

#910 のペインは「離れるときに自動保存」なので、この事故がズームを歩くたびに起きうる。ただし穴自体は
**今の全画面 Files view に既にある**ので、ペインとは独立に先に塞ぐ。

## 決めたこと

**バージョンはサーバだけが計算する。** クライアントは受け取ったトークンをそのまま返すだけ。

- `GET .../text` → `{ text, version }`
- `PUT .../write` → body `{ text, baseVersion }`。ディスク側が `baseVersion` と違えば **409**、
  書かずに現在の version を返す

### なぜ mtime ではなくハッシュか

mtime は「1 秒解像度のファイルシステム」「同じ ms 内の 2 回書き」で*変わっていないように見える* —
それはまさにこのガードが守ろうとしている競合そのもの。内容ハッシュなら取りこぼさないし、
「変えて戻した」が競合にならないという意味論も正しい。write のたびに ≤2 MB を読み直すコストは
無視できる（`MAX_EDIT_BYTES` の上限がある）。

### なぜ生バイト列で計算するか

`readFileSync(abs, "utf8")` で得た文字列を再エンコードした結果は、BOM や不正な UTF-8 があると
元のバイト列と一致しない。サーバが常に **Buffer から**計算し、クライアントは自分では計算しないので、
表現の食い違いが起きる余地がない。

### `baseVersion` は必須

省略を許すとブラインド書き込みの抜け道が残る。呼び出し元は `FilesOverlay.vue` の 1 箇所だけなので、
必須にしても壊れるものがない。**存在しないファイルは `null`** で表す（新規作成の意思表示になる）。
`undefined`（キーごと無い）は 400。

## 409 のときの UI

バナーで 2 択。差分ビューは出さない（この repo に diff / merge の依存が無く `@codemirror/merge` の
追加が要る。後から 3 つめのボタンとして足せる）。

```
このファイルはディスク上で変更されました
[ 読み直す（自分の編集を捨てる） ]  [ このまま上書き（ディスク側を捨てる） ]
```

- 読み直す → ディスクから再読込。バッファは捨てる
- このまま上書き → 409 が返してきた version を `baseVersion` にして再送

どちらも破棄を伴うが、**ユーザーが明示的に選んだ破棄**である点が現状（黙って上書き）との差。
#910 Phase 2 で 3 世代バックアップが入れば、捨てる側を先に退避してどちらも取り消せるようになる。

## テスト

- ルート（supertest）: `text` が version を返す / 正しい `baseVersion` で書けて新 version が返る /
  古い `baseVersion` は 409 でファイルが変わらない / `baseVersion` 欠落は 400 /
  `baseVersion: null` で新規作成できる / 既存ファイルに `null` は 409
- コンポーネント（`FilesOverlay.spec.ts`）: 保存が `baseVersion` を送る / 409 でバナーが出て
  ファイルは書かれない / 「読み直す」でディスクの内容に戻る / 「このまま上書き」が新しい
  `baseVersion` で再送する

## やらないこと

- `fs.writeFileSync` → `atomic-write.ts` への差し替え。クラッシュでユーザーのソースが切り詰められる
  リスクはあるが、rename は inode を差し替えるのでエージェント側の watch との相互作用が別途要検討。
  本 PR のスコープ外
- バックアップ（3 世代）— #910 Phase 2
- 外部変更の検知（hook / ポーリング）— #910 Phase 3
