# 古いバージョンが config.json を書き戻すと新しい設定キーが消える

Issue: #966

## 症状

`~/.mulmoterminal/config.json` は全インスタンスで共有される 1 ファイル。新しいキーを知らない
バージョンが書き戻すと、そのキーが警告もログもなく消える。ダウングレードでも同じ。

## 原因

`server/config/app-config.ts` の読み書きが既知フィールドの allowlist で組み立てられていて、
未知キーは `sanitizeAppConfig()` の時点で落ちる。`POST /api/config` はディスクから読み直した
サニタイズ済みの値を base にするので、書き戻す時点ですでに存在しない。

allowlist 自体は #741 の判断で正しい（壊れた 1 エントリで設定全体を捨てない）。問題は
**未知キーを不正値と同じ扱いにしている**こと。不正値は捨ててよいが、未知キーは「まだ知らない」だけ。

## 変更

読み込みで未知のトップレベルキーを別に持ち、書き出しでそのまま戻す。既知フィールドの扱い
（サニタイズ、`mergeConfigUpdate`）は一切変えない。

1. `unknownConfigKeys(raw)` — トップレベルのうち既知でないキーを集める純関数。既知の集合は
   `Object.keys(emptyConfig())` から取る（`emptyConfig` は `AppConfig` 型なので、フィールドを
   足し忘れると型エラーになる = 一覧が二重管理にならない）。
2. `AppConfigLoad` の `ok` に `unknownKeys` を追加し、`loadAppConfigResult` が入れる。
3. `saveAppConfig(file, config, unknownKeys)` — **第 3 引数は必須**。書き手が忘れるとバグが
   静かに戻るので、型で忘れられないようにする。`unknownKeysOf(loaded)` で load 結果から取る。
4. 書き手 2 か所（`POST /api/config`、`server/cli-init.ts`）を通す。ほかに書き手は無い。

既知フィールドが常に勝つ（未知キーが既知の名前を名乗っても上書きしない）。衝突は定義上
起きないが、防御的に `Object.hasOwn` で判定する — `in` だと `toString` のようなキーが
プロトタイプ経由で「既にある」と判定されて落ちる。

`GET /api/config` の応答は変えない。未知キーはこのバージョンが解釈できない値で、UI に渡す
意味がない。ファイル = 全バージョンの和集合、応答 = このバージョンの見え方、という非対称を
コメントで明示する。

ネストした未知キーは対象外（issue の判断どおり、実際に踏むのはトップレベル）。

## テスト

- `unknownConfigKeys`: 未知だけ拾う / 既知は拾わない / 非オブジェクトは `{}` /
  `__proto__`・`toString` という名前のキーもただのデータとして拾う
- 往復（#966 の芯）: 未知キーを含むファイルを load → 別の既知フィールドを更新して save →
  未知キーが値ごと残っている。ネストした値も byte 等価
- 既知フィールドは未知キーに上書きされない
- `unknownKeys` が空なら、ディスク上の形は従来と 1 バイトも変わらない
- `POST /api/config` 経由でも残る（ルートを実際に叩く）

## ドキュメント

`docs/guide/{en,ja}/config.md` に一行。keymap の項がすでに「新しいバージョン向けの設定を
読んでも壊れない」と約束しているので、書き手側も同じであることを明記する。
