# failures.md 踏んだ罠の台帳。**症状 → 真因 → 処方 → 一般化** の順で書く。 書く条件は「同じ形で再発しうること」。一度きりのタイプミスは書かない。 「気をつける」で終わる項目も書かない — 処方は機構でなければ意味がない。 --- ## #1 平文の設定ファイルに API キーが保存された **症状**: 運用初日、`world.json` の `apiKeyEnv` に Anthropic の実キーが平文で保存された。 **真因**: この欄は型が `String` で、防御は UI のラベル(「API キー変数」)と注意書きだけだった。 README と data_contract には「秘密を書ける場所を構造から取り除いてある」と書いていたが、 実際に取り除いていたのは**説明文であって構造ではなかった**。 **処方**: 秘密を保持できるフィールドごと廃止し、OS の資格情報ストアへ移した(#2 参照)。 過渡期には書式検査で入口を塞ぎ、読み込み時のスクラブと起動時の書き戻しで既存の漏洩分を回収した。 **一般化**: **入力欄に書いた注意は制御ではない。** 自由入力欄に秘密が入りうるなら必ず入る。 自分の書いた台帳が、設計主張と実装の乖離を隠す道具になりうることにも注意する。 --- ## #2 環境変数方式がデスクトップ GUI に不適合だった **症状**: キーを設定したのに `[APIキー未設定のためエコー応答]` が返り続けた。 **真因**: 環境変数はサーバー / CLI の作法。デスクトップ GUI に持ち込むと、 利用者に端末操作とアプリ再起動を要求する。しかも **Windows は設定済みの環境変数を 起動済みプロセスへ伝播しない**ため、「設定したのに効かない」が常態化する。 `tauri dev` を再起動してもリビルドされるのは子プロセスだけで、親の環境は古いまま。 **処方**: OS の資格情報ストア(Windows 資格情報マネージャー / macOS キーチェーン / Secret Service)へ移し、アプリ内で登録が完結するようにした。 **一般化**: **「書けなくする」だけでは半分。** 利用者が正しく置ける場所を用意するまでが設計。 秘密の置き場は、その製品の利用者像から決める。 --- ## #3 「未設定」と「認証不要」を同一視して 401 を出した **症状**: `x-api-key header is required` (401)。ローカルで捕まえられるはずの設定不備が、 サーバー側の失敗になった。 **真因**: `CredentialSource` が `None` ひとつで「まだ入れていない」と「要らない」を兼ねていた。 キー未登録のテンプレートが「認証不要」と解釈され、認証ヘッダ無しで外部へ送られた。 **処方**: `Unset` / `NotRequired` / `Keyring` の 3 状態へ分割。`Unset` は送信前に弾く。 旧 `"none"` は serde alias で `Unset` へ落とす。`clear_credential` も `Unset` へ戻す。 **一般化**: 「まだ決まっていない」を「決まっている値」に畳むと、**安全側でないほうへ倒れる**。 既定値は必ず安全側に置く。 --- ## #4 退避(fallback)を黙って行った **症状**: 設定不備で `EchoBackend` へ退避していたが、接頭辞が `[APIキー未設定のためエコー応答]` という固定文字列で、どの設定が欠けているのか分からなかった。 偽の応答が返り続け、原因に辿り着けなかった。 **真因**: `BackendFactory::create` の戻り値が `Arc` だけで、 **退避したという情報がどこにも載らない**構造だった。呼び出し側は退避を検知できない。 **処方**: `BackendResolution { backend, degraded_reason }` で型に載せる。 退避先の応答本文にも理由を書く。`CoreEvent::BackendDegraded` で通知する。 **退避結果はキャッシュしない**(覚えると原因を直しても復帰できない)。 **一般化**: 劣化縮退の失敗様式は「落ちること」ではなく「**落ちたと気づかせないこと**」。 代替へ落とす実装には、落ちた事実・理由・復帰条件の 3 点を必ず出口に付ける。 --- ## #5 再同期が呼び出しごとの判断だった **症状**: 保存やトポロジー変更が画面に反映されない。削除に失敗した行が一覧に残り、 押すたびに `MODEL_TEMPLATE_NOT_FOUND` が積み上がった。 **真因**: 変更系の後にコアから読み直すかどうかを、呼び出しごとに手で書いていた。 当然、書き落とした経路ができる(`setConnections` / `reorder`)。 **処方**: `mutate()` を導入し、**成否によらず必ず** `refreshAll` する。参照系は `guard()`。 **一般化**: 「各所で正しく判断する」設計は、判断の数だけ落とし穴を作る。 **判断を機構に置き換える。** 投影を更新するかどうかは判断の対象にしない。 --- ## #6 保存するとフォームが閉じ、保存結果が画面から消えた **症状**: 「保存ボタンを押してもダイアログを開き直さないと反映されない」。 **真因**: 保存後に `draft = null` としていたため、フォームごと消えていた。 保存した値が見えるのは項目を選び直した後で、ユーザーからは反映されていないようにしか見えない。 **処方**: 保存後もその項目に留まり、コア側の値で下書きを作り直す(`reseedDraft`)。 **一般化**: 手元で「こうなったはず」と代入せず、**コアが受け取った結果を読み直して表示する**。 代入するとコアが別の判断をしたときに食い違う(キー削除後は `NotRequired` ではなく `Unset`)。 **追記(2026-07-31)**: この処方が次の不具合を作った。「保存を押しても画面に 何も起きない」— 機能は正常で、**読み戻した値が打った値と同一なので画面の 差分がゼロ**だった。フォームを閉じない設計は正しいが、そのぶん **成功の合図を別に用意する責任**が生まれていた。処方はボタン自身を 1.6 秒だけ緑 + 「保存しました ✓」に変えること(視線は押した場所にある)。 併せて `upsertTemplate` が失敗を `void` へ丸めていたのを成否を返すよう直した — 失敗時に成功表示を出すと、トーストとボタンが矛盾したことを言う。 **一般化(追記分)**: **「画面が変わらない」を直す処方は、「画面が変わらない」を 作りうる。** 不具合の裏返しがそのまま次の不具合になる形があり、 今回は #6(結果が消える)を直した設計が #6' (結果が見えたまま無反応に見える)を 生んだ。**状態を保つ設計に変えたら、変化を伝える手段が別に要るかを確かめる。** --- ## #7 識別子を編集可能にしたため、改名が複製になった **症状**: 削除しても保存しても前回の情報が残る。 **真因**: upsert は ID を鍵にする。既存テンプレートの ID を変えて保存すると、 改名ではなく**新規挿入**になり、古い行がそのまま残る。 さらに ID は資格情報ストアの鍵とエージェントからの参照先を兼ねているため、 変えると登録済みキーが孤児になり、参照元が宙に浮く。 **処方**: 新規作成時のみ編集可、登録後は読み取り専用。 **一般化**: **鍵は鍵として扱う。** 複数の意味を担う識別子は、後から変えられるようにしない。 --- ## #8 描画エラーで画面全体が落ちた **症状**: エラーが起きるとログの区画が消え、アプリを再起動しないと戻らない。 **真因**: Vue は描画中の例外でその部分木を破棄する。境界が無いため、 1 区画の失敗がアプリ全体を白紙にしていた。 **処方**: `ErrorBoundary` で 3 ペインを個別に包み、落ちた区画だけ理由つきで差し替える。 捕まえきれなかった例外は `app.config.errorHandler` が拾う。 **一般化**: 単一障害点を UI に作らない。**壊れた区画の隣は生きているべき**。 --- ## #9 serde の変換規則を信じて TS と食い違った **症状**: 推論の深さに `xhigh` を選ぶとテンプレート保存が必ず失敗する。 **真因**: `Effort::XHigh` に `rename_all = "snake_case"` を効かせると `x_high` になる。 TS 側と `as_str()` は `xhigh` を使っており、片側だけずれていた。 **処方**: `#[serde(rename = "xhigh")]` で固定し、 **serde 値と `as_str()` の一致をテストで固定**した。全列挙値の往復テストも追加。 **一般化**: 自動変換規則は「読めば分かる」ものではない。 二言語にまたがる列挙は、**全値の往復をテストで固定**する(`tests/ipc_contract.rs`)。 --- ## #10 コンポーネント単位の `onUnmounted` が全体を巻き添えにした **症状**: モーダルを 1 つ閉じるだけで、アプリ全体がイベント更新を受け取らなくなる。 **真因**: `useOrchestrator()` が `onUnmounted` でイベント購読を解除していた。 この composable は 8 個のコンポーネントから呼ばれるため、**どれか 1 つの unmount で購読が切れる**。 **処方**: 購読の寿命をページと一致させ、コンポーネントの寿命に紐付けない。 **一般化**: 共有リソースの解放を、共有している側の寿命に結び付けない。 --- ## #11 会話を終える機構が無かった(設計の欠落) **症状**: エージェント同士の会話が延々と続き、hop 上限(8)でしか止まらない。 1 文を伝えるだけの往復に約 1,200 トークンを消費した。 **真因**: 終了の機構を**機械的な上限しか持っていなかった**。 `max_hops` は LangGraph の `recursion_limit` と同じ安全網であって、会話の終わり方ではない。 さらに構造が逆で、**応答すれば必ず転送する**ため、モデル側に終わる手段が存在しなかった。 **接地(2026-07-28 調査)**: 主要フレームワークはいずれも終了を **2 層**で持つ。片方だけの実装は無い。 | | 層 1: 意味的な終了 | 層 2: 機械的な上限 | |---|---|---| | OpenAI Agents SDK | **ツール呼び出しが無い**テキスト出力=最終出力 | `max_turns` 超過で `MaxTurnsExceeded` | | AutoGen v0.4 | `TextMentionTermination` / `HandoffTermination` / `FunctionCallTermination` ほか | `MaxMessageTermination` / `TokenUsageTermination` / `TimeoutTermination` | | AutoGen v0.2 | `is_termination_msg`("TERMINATE" を含む等) | `max_consecutive_auto_reply` | | LangGraph | 条件付きエッジで `END` へ到達 | `recursion_limit`(既定 25) | - AutoGen: 「複雑で自律的なワークフローでは、**いつ止めるかを知ることが決定的に重要**」「実行は永遠に続きうる」 - Anthropic: 「停止条件(最大反復回数など)を含めて制御を保つのが一般的」 **処方**: OpenAI Agents SDK の規則を採用した。 `handoff` ツールを提示し、**呼べば転送、呼ばずに本文だけ返せば会話終了**(ユーザーへ返る)。 `max_hops` は本来の役割である安全網に戻る。 ツール非対応サーバ向けには終了マーカー(AutoGen v0.2 の `is_termination_msg` 同型)を用意する。 **一般化**: 上限は終わり方ではない。**意味的な終了と機械的な上限は別の層で、両方要る。** 出典: [AutoGen Termination](https://microsoft.github.io/autogen/stable//user-guide/agentchat-user-guide/tutorial/termination.html) / [AutoGen 0.2 Chat Termination](https://microsoft.github.io/autogen/0.2/docs/tutorial/chat-termination/) / [OpenAI Agents SDK Running agents](https://openai.github.io/openai-agents-python/running_agents/) / [LangGraph GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT) / [Anthropic Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) --- ## #12 エージェントに会話履歴が無かった **症状**: 同じ相手に同じ発言を 4 回繰り返した (アウグストゥス「ローマは一日にしてならず」× 4)。 **真因**: `handle_message` がプロンプトを `[system] + [受信メッセージ]` だけで組んでいた。 毎回コールドスタートで、**直前に自分が何を言ったかを知らない**。 同じ入力に同じ出力を返しているだけで、本人の側から見れば一貫している。収束しようがない。 **接地**: LangGraph の診断がこの症状そのものを説明している — 「上限に達する最も多い理由はタスクが複雑すぎることではなく、エージェントが行き詰まって、 **まったく同じ入力で同じことを繰り返している**こと」。 **処方**: エージェントごとに直近 N ターンの履歴を保持し、自分の発言も `assistant` として積む。 履歴はプロセス寿命に閉じ(保存しない)、起動時にクリアする。 **一般化**: **終了条件の前に、収束の条件を用意する。** 状態を持たない参加者しかいない会話は、原理的に収束しない。 --- ## #13 リネームがセッション自身にロックされた **症状**: リポジトリのディレクトリ名の綴り誤り(`ConcordiaOrcehstrator`)を直そうとしたが、 `The process cannot access the file because it is being used by another process` で失敗。 **真因**: 作業しているセッション自身が、そのディレクトリを作業ディレクトリとして掴んでいた。 **処方**: セッションとエディタを閉じてから、外部のターミナルで `mv` する。 git はリポジトリ自身のパスを記録しないので、リネーム後もそのまま使える (ただし `target/` には絶対パスが焼かれており全体再ビルドになる)。 **一般化**: 自分が中に立っている足場は、自分では動かせない。 --- ## #14 CSS Grid の `1fr` に押し出されて入力欄が画面外へ沈んだ **症状**: 会話が伸びると、中央下部の送信欄が画面の下へ沈んで入力できなくなる。 一度そうなると戻らない。 **真因**: `grid-rows-[1fr_minmax(200px,34%)]` の **`1fr` は最小値が `auto`**、 つまり中身の最小コンテンツ高さである。中身(Vue Flow)が大きいとトラックが取り分を 超えて伸び、グリッド全体が `h-full` を超過して下端が画面外へ出る。 **`1fr` は「余りを分ける」だけで「縮む」とは言っていない。** **処方**: `minmax(0, 1fr)` で最小値を 0 に固定し、はみ出しは各ペインの内部スクロールに 引き受けさせる。あわせて各ペインの固定要素へ `shrink-0`、スクロール域へ `min-h-0` を付けた。 **一般化**: Grid / Flex で内容がはみ出す不具合は、ほぼ常に **`auto` 最小サイズ**が犯人。伸縮させたいトラックは `minmax(0, 1fr)`、 Flex の伸縮子には `min-h-0` / `min-w-0` を明示する。 --- ## #15 `exit code 255` は異常ではなく、dev サーバの正常な後片付けだった **症状**: `bun run tauri dev` の終了時に `error: script "dev" exited with code 255` が出る。起動直後にも見えたため、 当初は「起動に失敗している」と読んでいた。 **真因**: 255 を出しているのは**アプリ本体ではなく `beforeDevCommand`(vite)**。 `tauri dev` は終了時に子プロセスの dev サーバを強制終了するので、 その子の `dev` スクリプトが必ず非ゼロで終わる。**正常な終了経路でも出る。** **処方**: **見るべきは `dev` ではなく `tauri`(= 全体の終了コード)。** `dev` の 255 だけなら正常終了。`tauri` まで 255 なら本物の異常終了。 判別の根拠は次の表(2026-07-28、同日 3 回の実測で確定): | 操作 | `concordia.exe` | `dev` スクリプト | `tauri` スクリプト | |---|---|---|---| | ウィンドウを閉じる(正常) | 0 | **255** | **0** | | プロセスを kill | `0xffffffff` | 255 | 255 | | ポート 1420 使用中で起動失敗 | 起動せず | 1 | 1 | **併せて判明した罠**: `tauri dev` を終えても **vite が生き残ってポート 1420 を 掴み続けることがある**。次の起動が `Port 1420 is already in use` で失敗するのは これが原因で、アプリ側の不具合ではない。`Get-NetTCPConnection -LocalPort 1420` で掴んでいるプロセスを確認してから落とす(**他のセッションの dev サーバを 巻き添えにしないよう、起動時刻とコマンドラインを必ず確認する**)。 **一般化**: **複数プロセスを束ねるランナーでは、終了コードは「どの層のものか」を 確かめるまで意味を持たない。** 子プロセスの後片付けは非ゼロで終わるのが普通で、 それを全体の失敗と読むと、正常動作を延々と調査することになる。 なお当初この項目を「未検証」と明記して残したのは正しかった — 断定せずに 置いたおかげで、後から実測で埋められた。 --- ## #16 API キー登録後、テンプレート保存で `keyring` が `unset` へ巻き戻った **症状**: Gemini のキーを ⚙ で登録した(Windows 資格情報マネージャーに実在を確認)のに、 発話すると「API キーが未登録です」でエコー退避する。Claude は正常。 `world.json` を見ると当該テンプレートだけ `credential: "unset"` だった。 **真因**: `upsert_template` がクライアントから来た `credential` を素通しで書いていた。 `credential` は `set_credential` / `clear_credential` だけが書けるはずの **コア所有の派生状態**なのに、UI の下書き(キー登録前に開いたスナップショット)で テンプレートを保存し直すと、`unset` が `keyring` を黙って上書きする。 キー本体は資格情報ストアに残るため、⚙ 画面は「登録済み」と表示し続け、矛盾が見えない。 **処方**(fuseforks-core 側の二段): 1. `upsert_template` で `credential` を正規化する。既存が `keyring` なら維持 (離脱は秘密の削除と一体の `clear_credential` に限る)。秘密の裏付けが無い `keyring` 主張は `unset` へ引き戻す。`unset` ⇄ `not_required` は チェックボックス由来の正当な遷移として通す。 2. `bootstrap` で「`unset` なのに秘密が実在する」テンプレートを `keyring` へ昇格して 書き戻す。この組み合わせは正規の操作では作れない(= 過去の巻き戻り事故の化石) なので、昇格して安全。 **一般化**: **派生状態の所有者を一人に絞ったら、書き込み API 全部でそれを強制する。** 専用の遷移 API(`set_credential`)を用意しても、汎用の upsert が同じフィールドを 受け付けたままなら、古いスナップショットがいつか必ず上書きする。 「読み出して・書き戻す」型のクライアントは全員、時間差の伏兵になる。 --- ## #17 並列ツール呼び出しの 2 本目以降が黙って捨てられていた **症状**: 「ザリさん、みんなに挨拶して」で、接続先 2 体のうち 1 体にしか 転送されない。もう 1 体(ジェミー)はトークン 0 のまま。エラーは一切出ない。 **真因**: `Outcome::Handoff { to, message }` が**単一宛先の型**で、 `decide()` は応答内の `transfer_to_*` を走査して最初の 1 本で `return` していた。 Claude / Gemini は 1 応答で複数の tool call(並列ツール呼び出し)を普通に返すので、 モデルが「全員へ渡す」意図を正しく表明しても、機構が 2 本目以降を黙って落とす。 モデルの選択に見えるが、機構の制約だった。 **処方**: `Outcome::Handoff { deliveries: Vec<(AgentId, String)> }` へ拡張し、 `decide()` は転送要求を全部拾う(同一宛先は先勝ちで 1 通に畳む)。 記録は宛先ごとに 1 通、tokens は二重計上を避けて先頭にだけ載せ、 1 宛先の配送失敗は他を道連れにしない。あわせて protocol_note に 「複数へ渡すときは同じ応答内で同時に呼ぶ」を明記した——機構を開けても、 モデルに教えなければ発火しない。 **一般化**: **複数が来うる入力を単数の型で受けると、余剰は黙って消える。** 「最初の 1 件を取る」実装は、書いた時点では自明でも、上流が複数を送り始めた 瞬間にサイレントなデータ損失になる。型を `Vec` にするか、余剰を検知して イベントに出すか、どちらかを選ぶこと。「黙って捨てる」だけが常に誤り。 --- ## #18 Gemini がツールを 1 回呼ぶと、次の往復が必ず 400 で落ちた **症状**: ジェミー(gemini-3.6-flash)が `remember` ツールを呼んだ直後、 `[LLM_API] API エラー (status=400)`: `Function call is missing a thought_signature in functionCall parts ... INVALID_ARGUMENT`。 テキストだけの会話(伝言リレー)は正常に通る。 **真因**: Gemini 3 系は「思考署名(thought signature)」を**ツール呼び出しごとに** `extra_content.google.thought_signature` として返し、履歴として assistant の tool_calls を再送するとき**同じ値をそのまま**返すことを要求する。 ツール実行ループは「呼び出しと結果を対で積んでもう一度呼ぶ」ので、 2 周目のリクエストに 1 周目の functionCall が必ず入る——そこに署名が無かった。 decode は `extra_content` を serde の未知フィールドとして黙って捨て、 encode は canonical から再構成するため付けようがない。**#17 と同じ類型 (複数が来うる/知らないフィールドが来うる入力を、狭い型で受けて黙って落とす)が、 今度はワイヤ層で起きた。** codex CLI / openai-agents-python など主要 SDK も 同時期に同じ穴を踏んでいる。 **処方**: canonical `ToolCall` に不透明な `extra: Option` を追加。 OpenAI 互換 adapter の decode が `extra_content` を丸ごと保持し、encode が そのまま書き戻す。**canonical は中身を解釈しない**(署名の構造に依存しない)ので、 Google が随伴データを増やしても再修正は不要。署名の無いサーバ(OpenAI / Grok / ローカル互換)へはキーごと省く。Anthropic adapter は `extra: None` 固定。 **一般化**: **ラウンドトリップが契約のフィールドは、解釈せず原文ごと運べ。** wire → canonical → wire と再構成する設計は、知らないフィールドを必ず落とす。 「受けた値をそのまま返せ」系の要求(署名・トークン・継続 ID)は、 opaque な passthrough 席を型に最初から用意しておくのが正しい。 一次資料: https://ai.google.dev/gemini-api/docs/thought-signatures --- ## #19 削除・保存の結果が画面に反映されず、再起動まで直らなかった **症状**: エージェントを削除してもリストに残る。設定を保存しても表示が古いまま。 アイコンを変えても反映されない。いずれも**内部データは正しく更新されており** (再起動すると正しい状態で表示される)、失敗の通知も一切出ない。 **真因**: 並行する `refreshAll`(一覧の取り直し)の**後勝ち上書き**。 コアは全 CRUD で `TopologyChanged` を emit し、フロントは 「イベント受信時の `void refreshAll()`」と「mutate 完了時の `refreshAll()`」の 2 系統を並行で走らせる。IPC の**完了順は開始順と一致しない**ので、 古い状態を掴んだ取り直しが後から着地すると、新しい状態を黙って上書きする。 連続操作(保存→閉じる、削除→次の操作)ほど並行本数が増えて発生しやすい。 さらに取り直しの失敗が `.catch(() => undefined)` で完全黙殺されており、 鮮度事故が起きても痕跡が残らなかった。 **処方**: 世代(epoch)ガード。`refreshAll` の各呼び出しに単調増加の世代番号を振り、 応答が着地した時点で自分より新しい世代が始まっていたら**その応答を捨てる**。 最後に開始した取り直しだけが状態を書ける。あわせて黙殺していた失敗に `console.warn` の痕跡を残した。vitest を導入し、着地順を制御した再現テストで Red(幽霊が復活する)→ Green を実証。 **一般化**: **「常に取り直す」は「最後の取り直しが勝つ」を保証しない。** fetch-then-assign 型の同期は、並行実行された瞬間から着地順の競合を内包する。 取り直しを増やして鮮度を上げようとするほど並行本数が増え、むしろ悪化する。 処方は取り直しの削減ではなく、**書き込み権を最新世代に限定する**こと (epoch / AbortController / 単一飛行のいずれか)。そして黙殺された非同期失敗は 「たまに表示が古い」という再現困難な報告に化ける — 握り潰すにしても痕跡は残す。 --- ## #20 同報したら、エージェントたちが互いに伝令し合って反響した **症状**: 「みんなこんにちは!」を 3 体へ同報したところ、各エージェントが 接続先へ「ユーザーから挨拶がありました。挨拶をお願いします」と転送し合い、 同じ挨拶が何周も反響した。意味的終了で数周後に収束はするが、トークンが 無駄に燃える(オーケストレーションツールにとって最重要級のコスト問題)。 **真因**: 同報のメタ情報欠落。同報は UI が N 回独立送信する実装で、受信側から 見ると単独宛メッセージと区別が付かない。「みんな」と呼びかけられたのに 自分しか受け取っていないように見えるため、**各エージェントの転送判断は 局所的には完全に合理的** — 欠陥はモデルの判断ではなく、封筒に情報が 無いことにある。局所合理の集合が全体では反響になる、創発的な暴走。 **処方**: AgentMessage に co_recipients(同報の全宛先)を持たせ、受信者の プロンプトへ「あなたを含む N 体(名前列挙)へ同時に届いている。全員が既に 受け取っているため転送は不要」という注記を差す。転送を**禁止**するのではなく、 転送する**理由そのものを消す**。注記は宛先本人の封筒にだけ載り、宛先外へは 配送自体が無いので発話の存在ごと見えない。2 体未満なら注記は付かない (1 対 1 に「同報です」と書くのは嘘になる)。 **一般化**: **マルチエージェントの暴走は、各個体の非合理ではなく 「共有されていない前提」から生まれることが多い。** 個体の挙動を縛る前に、 全員が同じ判断材料を持っているかを疑う。処方が「禁止」になりそうなときは、 その行動を選ぶ理由を消す情報を与えられないか先に考える — 禁止は例外との 戦いが続くが、理由の消えた行動は選ばれない。 --- ## #21 エージェントがユーザーの言葉を「他のエージェントの言葉」と取り違えた **症状**: ユーザーが直接話しかけたのに、エージェントは他のエージェントから 転送されてきた言葉として扱う。逆も起きうる(転送をユーザーの直接発話と誤認)。 **真因**: 送り手情報の欠落。受信した発話はユーザー由来もエージェント由来も **同じ user ロールの本文**としてプロンプトに積まれ、送り手が誰かは どこにも書かれていなかった。`AgentMessage.from` は UI 表示のためにコアが 持っているのに、**肝心のモデルにだけ渡していなかった** — 画面を見る人間には 「誰から誰へ」が見え、当のエージェントには見えない、という逆転。 **処方**: 受信本文の先頭に「【送り手: 名前】」の封筒を付け、プロンプトと 履歴の両方へ同じ形で入れる(片方だけだと過去のターンが出所不明に戻る)。 名前は Endpoint から解決(ユーザー / システム / エージェント表示名)。 RAG の検索クエリには入れない(検索ノイズ)。 **一般化**: **#20 と同族 — 封筒のメタ情報(同報の事実、送り手)を人間向けの 表示層だけが持ち、判断主体のモデルに渡していなかった。** マルチエージェントでは 「UI に見えている文脈」と「モデルに見えている文脈」の差分がそのまま誤動作になる。 新しいメタ情報を UI に足すときは、モデル側の封筒にも要るかを同時に問うこと。 --- ## #22 保存もアイコン差し替えも「成功しているのに失敗扱い」だった **症状**: 条例・SKILL・Memory を保存しても「未保存」表示が消えない。 アイコンを差し替えても再起動するまで反映されない。**実データは正しく 保存されている**(ファイルを見れば書かれている、再起動すれば出る)。 失敗通知も一切出ない。 **真因**: `null` を失敗の印にしていた。フロントの `mutate` / `guard` は 「失敗なら `null` を返す」契約だったが、**Rust の `CoreResult<()>` は JSON で `null` にシリアライズされる**。つまり戻り値が void のコマンド (`write_ordinance` / `write_agent_config` / `set_agent_icon` / `clear_agent_icon` / `delete_agent`)は**成功しても `null`** を返し、 呼び出し側は全員それを失敗と読んで、成功時にだけ走る処理 (`saved = text` / `original = content` / アイコンキャッシュの差し替え)を スキップしていた。`create_agent` / `update_agent` は `AgentSnapshot` を 返すので無傷 — だから「効く操作と効かない操作がある」ように見えた。 **処方**: 失敗の印を専用の番兵 `const FAILED = Symbol("ipc-failed")` にし、 判定を `succeeded(result)` へ統一。コマンドの戻り値が何であっても (`null` でも `0` でも `""` でも)失敗の印と衝突しない。TypeScript の 型ガードで呼び出し側の取りこぼしも防ぐ。 **一般化**: **ドメインの正当な値を「異常の印」に流用してはいけない。** `null` / `-1` / `""` は必ずいつか正当な値として現れる。ここでは 「Rust の unit 型が JSON の null になる」という言語境界の性質が、 たまたま UI 層の番兵と衝突した。境界を跨ぐ層では、番兵は **その境界を通れない値**(Symbol など)にするか、`{ok, value}` の 判別共用体にする。あわせて教訓が 1 つ: **「実データは正しいのに表示だけ古い」 という報告は、描画の問題とは限らない。** 成否判定そのものを疑うこと。 **発見の経緯**: 実機の WebView2 へ CDP(`--remote-debugging-port`)で接続し、 Vue コンポーネントの `setupState` を直接読んで確定させた。 「保存ボタンを押しても `saving` が true にならない」→「`save()` は走っている」 →「`saveOrdinance` が false を返す」→「例外は無い、トーストも無い」 →「では `done === null` しかありえない」と経路を 1 つずつ潰した。 UI バグでも**実行中の状態を直接観測する**手段を用意したほうが速い。 --- ## #23 一人三役 — エージェントが全員のセリフを自分で書いた **症状**: 3 体へ同報したところ、ジェミー(gemini-3.6-flash)が 「**【ジェミー】**「やっほー!」/**【ロボットくん1号】**「ピピッ!」/ **【ザリ・ロブステル】**「オウオウ!」」と**全員分のセリフを 1 人で書いた**。 ザリは「そのセリフはオレじゃない。オレの発言を勝手に創作するのはやめてくれ」と 抗議。さらにユーザーが話しかけても、本人が答えず別のエージェントへ転送していた。 **真因**: 3 つの欠落が重なった。いずれも「モデルに渡していなかった情報」。 1. **名前と ID を結ぶ情報がどこにも無い。** 会話は表示名(「ザリ・ロブステル」)で 流れるのに、転送ツールは内部 ID でしか相手を示していなかった (`transfer_to_agent_2` / 説明文も「`agent_2` へ渡す」)。モデルから見ると、 会話に出てくる名前とツールが別世界の記号で、対応づけは推測になる。 2. **役の境界が書かれていない。** システムプロンプトの自己紹介は `# エージェント: ジェミー` の**見出し 1 行だけ**。「他にもエージェントが居て、 それぞれが自分で喋る」という前提はモデルにとって自明ではない。 書かれていない境界は存在しない。 3. **誰に答えるべきかの指針が無い。** 送り手の封筒(#21)で「誰から来たか」は 渡るようになったが、「だから**あなたが**答える」までは言っていなかった。 **処方**: - 転送ツールの説明を**表示名**で書く(ツール名は ID のまま — 関数名に使えるのは `[a-zA-Z0-9_-]` で、日本語名は潰れて識別できなくなる)。 - 手順の説明に**この場の名簿**を出し、「いずれも自分で考えて発言する別の エージェントであり、あなたが彼らの発言を書くことはない」と明記。 - システムプロンプトに役の境界を明示(「あなた自身の発言だけを書く」 「代筆・代弁しない」「自分の名前を書く必要はない」)。 - 手順の冒頭を「まず送り手を見る。**話しかけてきた相手へ、あなた自身の言葉で 答えるのが基本**」に変更。 **一般化**: **識別子の二重帳簿を作らない。** 人間向けの表示名と機械向けの ID が 両方あるとき、モデルには**両方を結び付けた形**で渡さなければ、対応づけを 幻覚で埋める。ここでは UI が名前、ツールが ID という分裂が、そのまま 「誰が誰か分からない」になった。#20・#21 と同じ根 — **UI に見えている文脈と モデルに見えている文脈の差分が、そのまま誤動作になる。** 今回の差分は 「名前と ID の対応表」だった。 --- ## #24 頼んだ答えが依頼主に戻らず、互いの発言も聞こえなかった **症状**: 「ロボットくん1号、自己紹介をお願いします」とジェミーが頼んだのに、 ロボットくんの答えは**ユーザーへ**飛んだ。ジェミーは自分が頼んだ結果を知らない。 さらに各自の自己紹介は互いに届かず、「みんなに自己紹介して」が成立しなかった。 **真因**: 会話の型が 1 つしか無かった。実装していたのは **handoff(転送)** だけで、これは電話の転送と同じく制御ごと相手へ渡す機構 — 答えがユーザーへ返るのは 仕様どおり。足りていたのは型の正しさで、**足りなかったのは型の数**である。 接地した結果、世間には 3 つの型がある: | 型 | 誰が聞くか | 答えの行き先 | 代表例 | |---|---|---|---| | handoff(転送) | 渡された相手だけ | ユーザー | OpenAI Agents SDK / AutoGen v0.4 Swarm | | agent-as-tool(委譲) | 呼ばれた相手だけ | **呼んだエージェント** | OpenAI Agents SDK `as_tool()` | | group chat(広場) | **全員** | 共有スレッド | AutoGen GroupChat / MS Agent Framework | しかも Concordia は handoff 型でありながら、本家と違い**文脈が引き継がれない** (各エージェントが私的な履歴だけを持つ孤島)。 **処方**: 2 つを足した。 1. **広場ログ** — 他エージェントの発話を「聞こえていた会話」としてプロンプトへ 載せる。鍵は「**聞こえる**」と「**反応する**」を別の軸にしたこと。配送 (=ターンの発火)は宛先だけに保つので、反響(#20)もトークン爆発も起きない。 ユーザーが宛先を選んだ発話は載せない(聴衆の選択を迂回する裏口にしない)。 2. **委譲(`ask_*`)** — 受信箱が返信路(`oneshot`)を運べるようにし、 委譲で来た発話は `Finish` の宛先がユーザーではなく依頼主になる。 相互委譲の待ち合わせに備えて必ず有限時間で戻す(`ask_timeout`)。 **一般化**: **「正しい機構」と「足りている機構」は別の問題。** handoff は正しく動いていた — 誤りではなく**不足**だった。こういう欠落は バグとして現れず、「なぜか噛み合わない」という感触でしか surface しない。 ユーザーが「これが正しいのか?」と聞いてきたとき、答えるべきは実装の正誤ではなく **選択肢の地図**である。既存研究や競合実装の型を並べて初めて、欠けている型が見える。 --- ## #25 ユーザーからの同報を UI から外した(機構ではなく既定の道を変える) **症状**: 複数エージェントへ同時に話しかけると、毎回どこかで噛み合わない。 互いに伝令し合う(#20)、他人の分まで促す(#20 の別出口)、同じ相手が二度答える。 機構を継ぎ足すたびに別の出口が生えた。 **真因**: 同報は**全員のターンが並列に走る**。誰も他の答えを見ないまま応答するので、 仕切ろうとした個体は「もう答え終わっている」を知りようがない。これはモデルの 賢さでは埋まらない — 実際、gemini-3.6-flash も claude-sonnet-5 も同じ形で失敗した。 **プロンプトで直せる問題ではなく、時間軸の問題だった。** **処方**: 機構を直すのをやめ、**既定の道を変えた**。ユーザーからの送信は 1 宛先に 限り、複数を動かしたいときは進行役 1 体に頼んで `ask_*` で展開させる (orchestrator-workers)。この形は各エージェントがちょうど 1 回ずつ話し、 答えが依頼主へ戻ってからまとめられる — 重複が構造的に起こらないことを テストで固定した。 **残したもの**: コア側の同報機構(`co_recipients` / 注記 / 表示集約)は**残す**。 エージェント発の fan-out が今も使っており、剥がすとそちらが壊れる。 制約は UI 層だけに置いた。 **一般化**: **直らない機構は、直すより既定の道を変えるほうが速い。** 継ぎ足しで塞いだ穴の隣に必ず別の穴が空くとき、それは実装の不備ではなく 「その形が構造的に成立していない」合図である。ここでは並列実行と 「他者の応答を前提にした判断」が両立しないという、時間軸の非対称が正体だった。 なお**機構そのものは消さないこと** — 別の呼び出し元が使っている場合がある。 UI の制約と機構の削除は別の判断で、混ぜると巻き添えが出る。 --- ## #26 「Claude Desktop の設定をそのまま貼れる」が Windows で嘘だった **症状**: MCP の設定に Claude Desktop と同じ `"command": "npx"` を書くと、 `program not found` で接続できない。`npx` はグローバルに入っており、 端末では普通に動く。 **真因**: Windows の PATH には `npx.cmd`(実行可能)と **拡張子なしの `npx`** (Git Bash 用のシェルスクリプト)が両方居る。Rust の `Command::new("npx")` は 後者に当たり、`CreateProcess` が実行できずに落ちる。**未インストールではなく 拡張子の解決**の問題で、エラーメッセージ(`program not found`)が 「入っていない」と誤読させるのが厄介だった。 **処方**: `rmcp` の `which-command` feature を有効にし、`which_command()` で PATH から実体を解決してから起動する。あわせて、コマンドが見つからないときの 文言を「コマンド `npx` が見つかりません(PATH を確認してください)」に変え、 どのコマンドの話かを名指しするようにした。 **一般化**: **互換性の主張は、主張した形そのままでテストする。** 私は「Claude Desktop の設定をそのまま貼れる」と書きながら、テストでは `npx.cmd` と書き換えて通していた。書き換えた時点で検証しているのは 互換性ではなく自分の実装だけになる。**主張を書いたら、その主張の文言を そのままアサーションにする** — ここでは「加工しない設定で繋がること」を テスト名と本文に据えた(`a_claude_desktop_config_works_verbatim`)。 --- ## #27 長い URL・パスが折り返されず、会話全体に横スクロールバーが生えた **症状**: メッセージに長い URL やファイルパス(折り返し位置の無い 1 トークン)が 入ると、吹き出しが右へはみ出し、会話ペイン全体に横スクロールバーが出た。 Markdown 描画のインライン `code` に入れたパスでも同じことが起きた。 別プロジェクト(outcasts)でも同じ形で踏んでいる。 **真因**: 二段の複合。 (1) 吹き出しの列は `items-start` / `items-end` で shrink-to-fit のため、 吹き出しの幅は **min-content を基準に**算出される。`break-words` (`overflow-wrap: break-word`)は「あふれたら折る」規則で、 **min-content の算出では単語を折らない** — つまり長い 1 トークンの幅が そのまま最小幅になり、折り返しが発動する前に箱ごと親からはみ出す。 (2) スクローラは `overflow-y: auto` 指定で、CSS の規則により `overflow-x` も `visible` から `auto` へ昇格する。はみ出した分が そのまま会話全体の横スクロールバーになった。 **処方**: 折り返し規則と幅の上限を 1 セットで入れる。 - `overflow-wrap: anywhere`(Tailwind の `wrap-anywhere`)— `break-word` と 違い**任意の位置を min-content の折り返し候補に数える**ので、 幅の算出段階から収まる - 吹き出しに `min-w-0`(flex item の暗黙の `min-width: auto` を外す)と `max-w-full`(親列の上限を超えない) - フェンスコード(`pre`)だけは折らず `overflow-x: auto` + `max-width: 100%` で**内部スクロール**に引き受けさせる(コードの折り返しは意味を壊す) - スクローラに `overflow-x-hidden` の保険(通常は何も隠れない) **一般化**: **`break-word` は「表示の規則」であって「幅の規則」ではない。** shrink-to-fit の文脈(`items-start` の flex 列、`width: auto` の float、 テーブルセル)では幅の算出が min-content を見るため、折り返しに効くのは `overflow-wrap: anywhere` のほう。チャット UI のように「幅が中身で決まる箱」へ ユーザー由来・LLM 由来の任意テキストを流すときは、anywhere + `min-w-0` + `max-w-full` を最初から 1 セットで入れる。 --- ## #28 「設定フォルダを開く」が拒否され、無関係な区画が巻き添えで落ちた **症状**: 設定ダイアログの 📁 を押すと `Not allowed to open path ...` で失敗し、 しかもエラーは「エージェント一覧の表示に失敗しました」として**別の区画**に 表示され、一覧ペインごと ErrorBoundary に置き換わった。 **真因**: 二段の複合。 (1) capability に `opener:allow-open-path` を**権限名だけ**で書いていた。 opener プラグインのパス系権限は、名前がコマンドの呼び出しを許すだけで、 実際に開けるパスは scope(`allow` の path 列挙)で別途与える必要がある。 scope 無し = **全パス拒否**であり、「権限を書いたのに動かない」という 見た目になる。 (2) `openFolder()` が `openPath` の reject を素通ししていた。ハンドラ内の 未処理例外は Vue の errorCaptured へ昇り、ダイアログを抱えている 「エージェント一覧」の ErrorBoundary が区画ごと畳んだ。失敗の主語 (フォルダを開く操作)とエラーの表示先(一覧の表示)が食い違い、 原因の探し先を誤らせる。 **処方**: - capability の `opener:allow-open-path` を scope 付きオブジェクトにし、 `$APPDATA/workspace` と `$APPDATA/workspace/**` だけを許可 (開く先はワークスペース配下しかないので、それより広げない) - `openFolder()` を try/catch で受け、`useOrchestrator` に公開した `notify()`(IPC を通らない UI 操作の失敗用の通知口)でトーストに落とす **一般化**: **権限は名前とスコープの 2 段で、名前だけでは付与にならない。** scope を要求する権限系(パス・URL)は「書いた = 許可した」と読めてしまうのが 罠で、動作確認するまで気づけない。もう 1 つ: **操作ハンドラの例外は その操作の名前で表面化させる。** ErrorBoundary は描画の失敗の網であって、 操作の失敗をそこへ流すと主語が入れ替わる。 --- ## #29 空の応答が次のターンを毒し、エージェントごと止めた **症状**: 調査系の依頼(「data_contract.yaml で重要な箇所を抜き出して」)への 応答が**空のバブル**として表示された(トークンは 14 万消費)。続けて 話しかけると `[LLM_API] 400: messages text content blocks must be non-empty` でエージェントが「失敗」に落ち、以後そのエージェントは使えなくなった。 **真因**: 三段の複合。 (1) モデルがツールを呼び続けて `max_tool_iterations`(既定 6)に達し、 打ち切られた周の応答が「ツール呼び出しだけでテキスト無し」だった。 最終出力は空文字のまま記録され、履歴にも空の assistant が積まれた。 (2) Anthropic adapter は「content が完全に空のとき**空のテキストブロックを 詰める**」実装だった — コメントには「空のテキストブロックは拒否される」と 書いてあり、**作者は規則を知っていたのに、別の分岐で同じ規則を破っていた**。 (3) 400 は再試行不能 → fatal → エージェント停止。空の履歴は消えないので、 再起動しても同じ会話を続ける限り**全リクエストが失敗し続ける**。 **処方**: 四層で断つ。 - orchestrator(第一): 上限で打ち切られてテキストが無いときは、**ツール無しで 最後に 1 回だけ呼び、ここまでのツール結果を文章化させる**。中間結果は そのターンにしか存在しないため、まとめずに捨てると「続けて」のたびに ゼロから調査をやり直して同じ上限に当たる(当初のフォールバック文言は 「続けてと頼めば続きから進める」と**嘘を案内していた** — 実機で 3 ターン連続 146k tok が燃えて発覚)。上限はエージェント個別に引き上げ可 (`AgentSpec.maxToolIterations`。調査の多いコーディング用は既定 6 では足りない) - orchestrator(最終網): まとめも失敗して空なら正直な文言に置き換える (黙って空を返さない) - 履歴: `push_exchange` は空の発言を「(発言なし)」へ置き換えて積む (対を崩すと役割の交互性が壊れるため、落とすのではなく置換) - ワイヤ: Anthropic encoder は「テキストもツール呼び出しも無い」発話を **送らない**。万一の素通りにも空ブロックではなく目印テキストを入れる **一般化**: **空という値は連鎖的に毒になる。** 空の出力は (a) その場の表示 (b) 永続する履歴 (c) 次のリクエストのワイヤ、と 3 つの層を伝播し、 発生から離れた場所で「以後ずっと失敗」という最悪の形で発火する。 空を許すか・何に置き換えるかは層ごとに明示的に決めること。もう 1 つ: **規則を知っているコメントの隣に、規則を破るコードが居ることがある。** 「〜は拒否される」と書いたら、その関数の全分岐がその規則を守っているかを その場で確かめる。 --- ## #30 「JSON Schema を渡せる」が Gemini では嘘で、しかも二度落ちた **症状**: Gemini ネイティブ経路で発話した瞬間に 400。 ``` Unknown name "additionalProperties" at 'tools[1].function_declarations[0].parameters' Unknown name "$schema" at 'tools[1].function_declarations[7].parameters' ``` **真因**: Gemini の `functionDeclarations[].parameters` は JSON Schema ではなく **OpenAPI 3.0 の部分集合**で、未知キーを黙って無視せず弾く。他プロバイダは 余分なキーを素通しするので、`ToolSpec.parameters` を「そのまま渡せるもの」だと 思い込んでいた。 そして**同じエラーの中に既に 2 種類の未対応キーが並んでいた**。 0〜6 番(同梱ツール + `transfer_to_*`)は `additionalProperties`、 7 番以降(**MCP ツール**)は `$schema` も。前者だけを消す実装にしていたら、 MCP サーバーを 1 つ足すたびに同じ形で落ち続けた。 **処方**: adapter で**許可リスト**で削る(除外リストにしない)。 **MCP ツールのスキーマは接続先のサーバーが書くもので、こちらから中身を 制限できない**以上、知らないキーが来ることが前提でなければならない。 意味を保てるものは写す(`const` → `enum`、`type` 配列 → 単一 `type` + `nullable`)。 `$ref` / `$defs` は解決せず 400 として表に出す — 型を推測して埋めるのは、 嘘のスキーマでモデルを動かすことになる。 **一般化**: **外部が書いたデータを転送する層では、除外リストは必ずもう一度 落ちる。** 自分が生成しているものだけなら列挙で足りるが、他人が書いたものが 1 つでも混ざった時点で、列挙は終わらない。判別の起点を「悪いものを除く」から 「良いものだけ通す」へ倒すこと。もう 1 つ: **エラーメッセージが複数種類を 同時に報告しているときは、それ自体が「列挙では足りない」という証拠**である。 --- ## #31 接地したエージェントが、実在しない出典 URL を作った **症状**: Google 検索で接地したエージェント(ジェミー)に出典を求めたところ、 1 回目はドメインのルート(`https://www.yomiuri.co.jp/`)に記事の見出しを 添えた引用を返した。指摘を受けた 2 回目は改善したように見えたが、実測すると **4 本中 2 本が 404**(気象庁 PDF・消防庁白書)で、残り 2 本は実在した。 偽の URL は要約役(ザリ)を素通りして利用者まで届いた。 **真因**: Google 検索の接地は**答えの中身は運ぶが、参照元 URL を渡さない** (`toolResponse` の中身は検索候補チップの HTML だけで、記事 URL は無い)。 その事実がプロンプトに書かれていないため、出典を要求されたモデルは 「引用の形をした文字列」を生成した。 副次的に 2 つ。(1) adapter が `groundingMetadata` を wire 型に持っておらず、 仮に返っていても serde が黙って捨てていた。(2) 要約役は**内容**を照合したが **URL を 1 本も叩いていない**ため、検証の粒度が捏造の起きている層に届かなかった。 **2 回目のほうが危険だった。** 1 回目は全部がドメインのルートで一目で分かるが、 2 回目は半分が本物で、しかも本物のほうが具体的だった。**生きている URL が 死んでいる URL に信憑性を貸す。** **処方**: `googleSearch` が有効なときだけ、システムプロンプトで 「参照した URL は手元に渡ってこない/代わりに**検索した語と発表元の名前**は 言える」を伝える。**禁止ではなく欠落の告知**にする。 人格ではなくワイヤ経路の性質なので、SKILL.md ではなく実装側から入れる (`googleSearch` を有効にした全員に等しく効き、書き忘れが起きない)。 併せて `groundingMetadata` を wire 型に起こし、`ChatResponse.grounding` へ載せる (`sources` が空であること自体が「出典は存在しない」の判定になる)。 **一般化**: **モデルが持っていない情報を要求される場面では、禁止ではなく 欠落を告知する。** 「URL を書くな」だけでは、利用者が「出典URL付きで」と 要求した瞬間に競合して折れる。「URL は手元に無い」は事実なので競合しない。 これは作業フォルダの実パス開示(#未採番 / README)と同じ処方で、 **判断材料の欠落は、禁止ではなく情報で埋める**の 2 例目。 もう 1 つ: **検証は捏造が起きている層で行う。** 内容の照合は URL の実在を 保証しない。引用を通す役には、リンクの生死を確かめる手段を持たせること。 **追記(2026-07-29、実機で決着)**: 処方を入れた後、同じ形の圧力 (学習時点より後の事実 = 放送中のテレビ番組を調べさせる)でジェミー (`gemini-3.6-flash`)を走らせた。**URL は 1 本も出ず**、代わりに検索語 (`ファーストクライ 番組` / `ファーストクライ`)と発表元の名前(日本テレビ 公式サイト / Wikipedia / TVer)が返った。捏造は起きなかった。 同時に `groundingMetadata` の実データも確定した。**`sources` は空**で、 参照元 URL は返ってこない。「型は書いたが実データを見ていない」という保留 (Spec 05 Notes 5)は「来なかった」側で閉じ、**告知だけが処方として残る**。 表示層は 0 件を 0 件として書く(`GroundingNote.vue`)。 ただし `groundingMetadata` が来て `groundingChunks` が空だったのか、 `groundingMetadata` 自体が来ず検索語が `toolCall.args.queries` からだけ 取れたのかは、表示からは判別できない。運用上の結論が同じ(参照元 URL は 出せない)なので追っていない。**実測は 1 件**なので「原理的に取れない」へは 戻さない — 一度その断定をして取り下げている。 **一般化(追記分)**: **欠落を告知する処方は、告知した内容が実際に欠落して いるときにだけ正しい。** ここでは「URL は手元に来ない」が実測で真だったので 処方が成立した。もし後日 `sources` が埋まるようになったら、同じ文言は嘘に 変わる。**外部プロバイダの挙動を前提に置いた告知は、その前提を検証する経路 (ここでは `sources` の中身)を同じ画面に出しておく。** --- ## #32 「無いものは無い」と伝える節が、無い能力を「ある」と教えた **症状**: `world.json` を直接編集して `provider: null` + `googleSearch: true` に すると、設定ダイアログはチェックを隠すのに、システムプロンプトには 「あなたは Google 検索で裏を取れます」が入った。互換経路なので検索は起きない。 **検索できないモデルに、検索できると教えていた。** **真因**: 判定に `ModelTemplate.google_search` フラグを単独で使っていた。 接地が実際に起きる条件は「フラグが真」**かつ**「実効プロバイダが Gemini」で、 後者を見ていなかった。UI はこの組み合わせを作らせないので、UI で塞いだ気に なっていた。**`world.json` は手で編集できる**という前提が抜けていた。 より悪いのは、この節が **#31 の処方そのもの**だったこと。「モデルが持って いない情報は禁止ではなく欠落の告知で埋める」ために足した節が、告知の中身が 嘘になった瞬間に、足す前より悪い状態を作った。**埋める処方は、埋めた情報が 嘘だと逆向きに効く。** **処方**: - `ModelTemplate::grounding_active()` を新設し、フラグと実効プロバイダの AND で判定する。**フラグを直接読む経路を残さない**(`LlmConfig::from_template` のプロバイダ解決も同じ関数へ寄せ、真実の源を 1 つにした) - UI は隠す代わりに理由と直し方を出す。**隠すだけだと真のまま見えなくなる** — 効かない設定が黙って残っているのが一番たちが悪い **一般化**: **「〜は手元に無い」と宣言する節を足したら、本当に無いことを 別の経路で確かめる。** 能力の告知は、フラグではなく**実効値**で判定すること。 設定は 2 つ以上の条件の AND で有効になることが多く、UI が片方しか見せていない とき、残りの組み合わせは設定ファイルの直接編集で必ず作られる。 もう 1 つ: **UI で隠すのは、バックエンドで無効化することの代わりにならない。** 隠した設定は消えたのではなく、見えなくなっただけで真のまま残る。 --- ## #33 プロンプトキャッシュが一度も効いていなかった(文字数で測っていた) **症状**: 請求ダッシュボードの「入力トークンの構成」が、Kataribe から Concordia へ 切り替えた日を境に**ほぼ全部が「未キャッシュ」(灰色)**になった。アプリ内の 表示は累積トークン数だけなので、画面からは何も分からなかった。 **真因**: `cache_control` を出す判定が 2 つとも外れていた。 (1) **判定対象が system の安定部分だけで、`tools` を数えていなかった。** キャッシュされるのは `tools + system`(ツール定義は system より前に置かれ、 system に打った `cache_control` の内側に入る)。**道具を多く提示している エージェントほど判定を外す** — 提示量が多いほど利得が大きいのに、そこで切る。 (2) **閾値を「文字数」で持っていた。文字数は言語をまたげない。** 英語は 4 文字 ≈ 1 トークン、日本語は 1 文字 ≈ 1 トークンで、同じ 4,000 でも 要求が 4 倍変わる。`MIN_CACHEABLE_CHARS = 4_000` は英語で較正された値だった。 実機の設定は日本語で、バイト数の 1/3 しか文字数が無い。5 体全員の安定部分が **863〜1,123 文字**で、閾値の 1/4 前後。**全員が無キャッシュのまま数日走っていた。** **処方**: - 判定を概算トークン数へ変える(ASCII は 4 文字 = 1、非 ASCII は 1 文字 = 1)。 `tools + 安定部分` の合計で判定する - 閾値はモデル階層で一番厳しい側(2,048 トークン)に合わせる - 可変部分が空(`Memory.md` 未記入)のとき、境界探索が末尾を越えて `None` を 返し**無キャッシュに落ちていた**のも同時に修正。割らずに全体へ打つ - **`AgentSnapshot.cached_tokens` を足し、カードに「(キャッシュ 72%)」を出す。** 0% は警告色。設定を変えた次のターンで効果が見える **一般化**: **単位を間違えた閾値は、境界付近ではなく全域で外れる。** 「安全側に倒した」つもりの値が、別の言語・別の構成では 4 倍の要求になっていた。 閾値を定数で持つときは、**その単位が入力の性質で変わらないか**を確かめる。 文字数・バイト数・トークン数は互いに定数倍ではない。 もう 1 つ: **効いていないことが画面から分からない指標は、いずれ静かに壊れる。** 累積トークンは無キャッシュでも同じ数字を出すので、異常が数字に現れない。 最適化を入れたら、**それが効いているかを同じ画面に出す**までが実装。 今回は請求グラフを見るまで数日気づけなかった。 **追記(同日、TTL)**: 閾値を直しても命中率は 24% で頭打ちになり、しかも **ターンが進むほど下がっていた**(37% → 24%)。原因は生存期間の既定 5 分。 **キャッシュの既定 TTL は「チャットの速度」に合わせて設計されている。** 同じ設計者が作った別プロジェクト(TRPG のセッション進行)は 2 時間で約 100 発話、 平均 72 秒間隔で、5 分の窓に収まっていた。こちらは読んで・考えて・コードを見て から次を打つので、**間隔が普通に 5 分を超える**。超えた瞬間、次のターンの 1 回目は 読み取りではなく書き込みになる。 損得は明快で、**余計な書き込みが 1 回でも減れば長い TTL のほうが得**: | | 書き込み | 読み取り | |---|---|---| | 5 分 | 1.25× | 0.1× | | 1 時間 | 2.00× | 0.1× | 5 分で切れて 2 回書けば 2.5×、1 時間なら 1 回で 2.0×。1 回の追加書き込みで逆転する。 `ttl: "1h"` と `anthropic-beta: extended-cache-ttl-2025-04-11` を送って **75%** へ。 ヘッダを欠くと `ttl` は**黙って無視され既定へ戻る**(指定したのに伸びない形になる)。 **追記 2(2026-07-31、経路版)**: 表示を実装したあと、**再起動して会話だけ していると率が欄ごと消える**という形で再発した。原因は表示側ではなく **データの通り道**だった — 統計はプロセス寿命で、定期イベント `AgentStatsUpdated` は `total_tokens` しか運んでおらず、率の分母・分子は `refreshAll`(起動時と設定変更時)でしか届かない。再起動 + 純粋な会話では 合計だけが生で増え、率は 0 除算のガードで消える。以前見えていたのは、 設定やトポロジーを頻繁に触っていて `refreshAll` が偶然走っていたから。 処方はイベントに `prompt_tokens` / `cached_tokens` を載せること。 **一般化(追記 2)**: **「効いているかを画面に出す」は、表示を書いた時点では 終わっていない。** 数字がその画面へ**常に届く経路**があって初めて成立する。 偶然にしか通らない経路(別の操作の副作用で走る再同期)に載っている指標は、 出ていない時間が長いほど「そういうものだ」と受け入れられ、静かに壊れる。 **指標を足したら、その値が更新される契機を数え上げること。** **一般化(追記分)**: **プロバイダの既定値は、誰かの利用リズムに合わせて選ばれている。** その誰かが自分と同じ速度で操作しているとは限らない。時間に関わる既定(TTL・ タイムアウト・再試行間隔)は、**自分の製品の 1 ターンの実測間隔**と突き合わせてから 採否を決める。「既定のままにしておく」は中立な選択ではなく、他人の使い方への 暗黙の同意である。 --- ## #34 二言語の契約を凍結するテストが、任意フィールドの追加を 2 回見逃した **症状**: `tests/ipc_contract.rs` の `wire_field_sets_are_frozen` は 「`AgentMessage` のフィールドが変わったら落として `types.ts` を直させる」 ためのテストなのに、`co_recipients`(Spec 03)と `grounding`(Spec 05 Phase 4)を 足しても**緑のまま通った**。TS 側が古いままでも誰も気づけない状態が 2 回できた。 **真因**: どちらも `#[serde(default, skip_serializing_if = ...)]` の任意 フィールドで、**空の値では JSON に現れない**。テストが凍結していた標本は `AgentMessage::new(...)` の直後の値だけ — 生成直後は両方とも空なので、 `wire_keys()` の返す集合は追加前と 1 文字も変わらない。 フィールドを増やしたのに期待値の更新が要らなかったことが、そのまま 「検出できていない」の証拠になっていた。 **処方**: 値を入れた標本をもう 1 つ作って凍結する。`co_recipients` に 1 件、 `grounding` に検索語 1 件と参照元 1 件を入れ、`coRecipients` / `grounding` を 含むキー集合を固定した。入れ子(`Grounding` / `GroundingSource`)も同じ形で 凍結する — 親のキーだけ見ていると、子の欄の増減が同じ穴を通る。 **一般化**: **省略されうるフィールドは、空の標本を凍結しても守れない。** `skip_serializing_if` / `Option` / 空配列で消える欄は、既定値の標本では 存在しないのと区別がつかない。ワイヤ形を凍結するテストは、 **全フィールドが値を持つ標本**で取ること。既定値だけを見るテストは、 「既定の形が変わっていない」ことしか言っていない。 もう 1 つ: **テストの意図は、テストが落ちたときにしか検証されない。** このテストは「落ちたら types.ts を直せ」と自分の目的を明記していたが、 落ちない経路が存在することは誰も確かめていなかった。守るはずの変更を 実際に加えて**落ちることを見る**まで、防護は仮説にすぎない。 --- ## #35 委譲した相手が転送すると、依頼主に「答えが返らなかった」と嘘が返った **症状**: 委譲(`ask_*`)で頼んだ相手が、自分で答えずに接続先へ `transfer_to_*` で会話を渡すと、依頼主のモデルは **「相手から答えが返りませんでした。」**をツール結果として読む。 だが答えは返っている — 宛先が違うだけで、会話は第三者へ渡り、最終的に ユーザーへ流れる。依頼主は「無応答だった」と判断して同じ依頼を投げ直しうる。 **真因**: `reply_to`(`oneshot::Sender`)が `Outcome::Finish` 分岐でしか 使われず、`Outcome::Handoff` では送られないまま `handle_message` の終わりで drop されていた。受信側の `rx` は `Ok(Err(RecvError))` を受け、その分岐に 「答えが返りませんでした」という文言が割り当ててあった。 **チャネルが閉じた理由を区別せずに 1 つの文言へ丸めていた**のが真因で、 `Sender` の drop は「相手が停止・失敗した」ときにも「相手が別経路で完了した」 ときにも起きる。受信側から見える `RecvError` は両者で同一である。 **処方**: `Handoff` 分岐で `reply_to` へ事実を送る (「相手はこの依頼に自分で答えず、〇〇 へ会話を渡しました」)。 **転送そのものは抑制しない。** ワーカーの正当な選択を握り潰すと、 「呼んだのに何も起きない」という別の穴に変わる。直すのは文言だけ。 並列委譲(`plan` / Spec 04)は同じ配送経路に載るので、宛先の数だけ同じ穴を 踏みやすくする。両方を `deliver_and_wait()` に寄せて、失敗の文言と境界を 1 箇所に集約した。 **一般化**: **チャネルが閉じたことは、閉じた理由ではない。** `oneshot` / `mpsc` の `Sender` が drop される経路が複数あるとき、受信側は それを区別できない。**送信側に「黙って落ちる」経路を残すと、受信側の文言は 必ずどこかで嘘になる。** 送信側の全分岐について「ここで送らないとき、 受信側は何を読むか」を数え上げること。分岐が 2 つあって文言が 1 つなら、 そのうち少なくとも 1 つは嘘である。 もう 1 つ: **正常系で使われる資源を、異常系の判定材料に流用しない。** `reply_to` は「答えを返す道」として作られたが、その**不在**が 「答えが無かった」の判定に使われていた。道の有無と結果の有無は別の事実で、 片方をもう片方の代理にすると、代理が成り立たない経路が現れた時点で壊れる。 --- ## #36 ツール上限後の「まとめ呼び出し」が、ワイヤで死んでいた **症状**: plan の実機確認(ザリが 3 体へ failures.md の調査を撒いた)で、 ワーカー 2 体が「ツール実行の上限 6 回に達し、**まとめの生成にも失敗しました**」 を返した。上限到達の救済としてまとめ呼び出し(#最終フォールバックの前段)を 用意してあったのに、その救済自体が失敗している。理由はどこにも出ていない。 **真因**: 2 層。 1. **まとめリクエストが `tools: Vec::new()` で送られていた。** 履歴には直前の ツール往復(`tool_use` / `tool_result` ブロック)が積まれたままなので、 Anthropic API の「tool ブロックを含むリクエストは `tools` の定義が必須」に 当たって 400 になる。**まとめはモデルに届く前にワイヤで死ぬ。** 「ツールを取り上げる」を「定義を消す」で実装したのが誤りで、履歴に痕跡が 残る限り、定義はワイヤ契約の一部である 2. **その失敗が `if let Ok(...)` で握り潰されていた。** 理由はログにも イベントにもフォールバック文言にも残らず、現場からは「まとめに失敗した」 以上のことが分からない。#4 で「退避には落ちた事実・理由・復帰条件の 3 点を 必ず出口に付ける」と自分で書いたのに、その退避の中の退避で同じことをした **処方**: - まとめ呼び出しは **`tools` の定義を残し、明示の `tool_choice: "none"` で 使用だけを禁じる**。Anthropic / OpenAI 互換の adapter は `ToolChoice::None` を 「tool_choice 欠落」(= 既定 auto)へ写していたため、明示の `"none"` を 送れなかった — これも同時に修正(Gemini ネイティブは最初から正しかった) - 失敗の理由を `summary_error` として保持し、フォールバック文言へ載せる (「失敗の理由: {err}」)。空応答と API 失敗も文言で区別する - テスト用モックのまとめ判定を `tools.is_empty()` から `tool_choice == None` へ。 モックの中に「まとめでも tools は空にならない」の assert を置き、 ワイヤ契約の退行をテストの構造で捕まえる **一般化**: **「ツールを取り上げる」は定義を消すことではない。** 履歴に ツールの痕跡(呼び出し・結果)が残る限り、`tools` の定義は過去の発話を 解釈するための契約であり、消すと履歴ごと不正になる。禁じたいのは**使用**で、 それは `tool_choice` の仕事。定義と使用許可は別のノブである。 もう 1 つ: **救済経路の失敗は、元の失敗より丁寧に記録する。** 救済は 「失敗したときに走る」経路なので、そこで黙って落ちると利用者は 元の失敗しか見えず、救済が存在しないのと区別がつかない。救済の中の `if let Ok` は、握り潰しの中でも最も見つかりにくい場所に埋まる。 --- ## #37 Edit がセクション境界を跨いで見出しを消し、40 分後に他人の報告を疑った **症状**: `failures.md` の `## #32` の見出しと直前の `---` が消え、本文だけが `#31` の下にぶら下がっていた。気づいた経路が問題で、**実機のエージェント (ザリ)が「#32 は欠番」と報告したのを、こちらが捏造と疑った**。 検証した結果、報告のほうが正しく、消したのはこちら(40 分前の自分の編集)だった。 **真因(2 つ重なった)**: 1. **`Edit` の `old_string` が次セクションの見出しまで含んでいたのに、 `new_string` で書き戻さなかった。** `#31` の末尾へ追記する意図で、 一致範囲を「末尾の一般化 + `---` + `## #32` の見出し」まで広げ、 置換後の文字列には追記だけを書いた。**見出しは一致の文脈として使ったつもりで、 実際には置換の対象**だった。文字列置換に「文脈」と「対象」の区別は無い 2. **`git status` が綺麗なことから「着手前に落ちた」と断定した。** 作業ツリーの綺麗さが排除するのは**適用された書き込み**だけで、 読み取り系の活動(grep・探索)は痕跡を残さない。実際にはエージェントは 16 回ツールを回していた **処方**: - 見出しを復元し、番号の連続性を**機械で**確かめた(`for i in $(seq 1 35)` で 欠番検査)。以後、台帳の構造を触ったら見出し数・節数を数える - `Edit` で追記するときは、**一致範囲を追記位置の直前で閉じる**。 次の見出しを含めると「その見出しを消さない」という保証が消える - 番号つきの台帳は、`grep -c '^## #'` と欠番検査を変更後に回す **一般化**: **文字列置換には「文脈」という概念が無い。** 一致のために含めた 範囲は、すべて置換の対象である。追記のつもりで広く取った一致は、 広く取った分だけ**消す権利**を持ってしまう。一致範囲は必要最小に閉じること。 もう 1 つ: **痕跡の不在は活動の不在ではない。** 観測系が何を記録し何を 記録しないかを確かめる前に、「痕跡が無い」から「何もしていない」を導いてはいけない。 書き込みだけが痕跡を残す観測系(`git status`)では、`clean` が言えるのは **「書かなかった」**までである。**断定の前に「この観測系で見えないものは何か」を 一度問う。** さらにもう 1 つ: **自分の変更を、他人の誤りとして読み違える形がある。** 今回は「相手が捏造した」の一歩手前まで行った。機械で照合できることを 照合する前に、相手の報告を疑うほうへ倒れていた。**検証可能な主張は、 疑う前に検証する。** --- ## #38 回帰テストが正しくても、例の選び方でバグの半分しか捕まえない **症状**: Spec 07 の発火規則で、rev1 のバグ(猶予を全種別へ掛けていたため `interval` が再開後 0 回になる)の回帰テストを `interval { 10 分 } / 2 日停止` で 書いた。テストは通ったが、**rev1 の規則へ戻しても通ってしまう**ことに 赤の確認(規則を一時的に戻して走らせた)で気づいた。 **真因**: 修正は 2 つ揃って成立していた — (1) `due` を「now 以前の最後の点」に 直す、(2) 猶予を壁時計系だけに限る。最遅点化後の `now - due` は `[0, everyMinutes)` に落ちるので、テストの例(now = 9:03、`now - due = 3 分`)は **たまたま猶予 5 分の内側の位相を選んでいた** — (1) だけで通ってしまう。 (2) の不在は `now - due > 猶予` になる位相、実用上は **`everyMinutes > 猶予` の例(60 分ごと・2900 分停止 → `now - due = 20 分`)** でしか表に出ない。査読も「最遅点にすれば grace 内に入る」と読んでいた — 同じ死角。 **処方**: 修正が N 個の合わせ技であるとき、回帰テストは**修正 1 個ずつを 無効化して赤を確認する**。全部入りの緑は「どれかが効いている」しか言わない。 例のパラメータは修正ごとの境界(ここでは `everyMinutes` と猶予の大小関係)を 跨いで選ぶ。 **一般化**: 合わせ技の修正に対する 1 本のテストは、いちばん強い修正の 影に残りを隠す。テストの本数は修正の数に合わせる。 ## #39 できない操作のエラーが「綴りの問題」に見え、エージェントが 12 ラウンド空転した **症状**: 実機のザリに README の英訳を頼んだ。訳文を新しいファイルへ書き出す 必要があるが、同梱ツールに**新規作成が無い**。エージェントは `sd` で作成を 試み、失敗するたびにパスを変えて再試行し、ツール上限(12 ラウンド)まで 走り続けた。利用者から見ると「延々と同じことをやっている」状態で、 **止める手段も無い**(`stop_agent` は飛行中のターンの完了を待つ設計)。 **真因**: `sd` / `yq` のファイル不在エラーが `「{path} は作業フォルダ内に見つかりません。」`だけだったこと。この文言は **「探し方が悪い」としか読めない** — 実際には「このツールには作成能力が無い」 のが真実で、そこへ辿り着く情報がエラーに含まれていない。モデルの再試行は 与えられた情報の下では合理的で、性能の問題ではない。 **処方**: 2 つを同時に入れた。(1) 能力を足す(`file` ツール。Spec 09)、 (2) 不在の文言に「**書き換え専用で新規作成はできない。作るなら `file` の `write`**」を添える。禁止(再試行の抑制)は選ばない — 抑制しても 「呼んだのに何も起きない」穴に変わるだけで、空転の理由は残る。 **一般化(#39)**: **できない操作のエラーは、できない理由と代わりの手段を書く。** 「見つからない」「失敗した」だけの文言は、モデルに「やり方を変えれば通る」と 読ませ、同じ壁への再試行を誘発する。ツールの失敗文言は人間向けの報告ではなく **次の一手を決めるための入力**であり、次の一手が書いていない失敗は空転を生む。 これは failures.md #20(反響)と同型 — 個体の挙動を縛る前に、その行動を選ぶ 理由を消す情報を渡せないかを先に考える。 ## #40 出力上限で落ちたターンが、依頼ごと記憶から消えた **症状**: 進行役(claude-sonnet-5)に「README.md の英語版を作って」と頼んだ。 7 ツール(fd / file / grep×4)まで進んだあと `LLM_EMPTY_RESPONSE` で停止。 **2 回試して 2 回とも同じ所で落ちた。** さらにその直後、利用者が 「先ほどの続きをお願い」と言うと、進行役は**直前の依頼を何も覚えておらず**、 他のエージェントへ「進行中のタスクは何だった?」と聞いて回った(相手も知らない)。 本人は「会話がリセットされた可能性もある」と述べたが、リセットは起きていない。 **真因**: 独立した 3 つの欠陥が重なっていた。 1. **上限超過が「空の応答」に化けていた。** 英訳全文を `file` の `write` 引数へ 載せる必要があり、出力が `max_output_tokens`(既定 4,096)を超えた。 Anthropic は `stop_reason: max_tokens` を返し、本文もツール呼び出しも 成立しないまま `Finish::Length` になる。これを `EmptyResponse` へ畳んで いたため、画面には「LLM が空の応答を返しました」としか出ず、 **上限に当たったことが利用者に伝わらなかった** 2. **その失敗を一過性として再試行していた。** 上限は入力に対して**決定的**で、 同じ依頼を再送すれば必ず同じ所で切れる。再試行はバックオフと課金だけを増やした 3. **失敗したターンの依頼が、どのプロンプト経路にも載らなかった。** 履歴への 書き込みは `handle_message` の終盤にあり、途中の `?` で抜けると受信した依頼ごと 残らない。一方 `compose_room_log` はユーザー発を対象外にし、自分宛も `is_mine` で 除外する — **どちらも「それは履歴にある」前提で組まれている**。会話ログには 残って画面には見えているので、利用者からは健忘の理由が見えない **処方**: 1. `LlmError::OutputTruncated { limit }` を新設し、適用中の上限を文言に載せる (利用者の次の一手は「いくつまで上げるか」なので、現在値が要る) 2. 非一過性にして再試行を止める。ただし **`is_retryable` と `stops_the_agent` を分離**した — 前者は「この 1 件を再送して意味があるか」、 後者は「以後の全依頼が同じく失敗するか」で、**別の問い**。1 つの述語に 畳んだままだと、上限超えのたびにエージェントごと停止する 3. `record_failed_turn` を追加し、失敗時も受信した依頼を成功時と同じ封筒で履歴へ 積む(応答側は失敗した旨の目印。往復の対を崩すと 400 になる — #29) 4. 既定の `max_output_tokens` を 4,096 → 8,192 へ **一般化 1(失敗の非対称性)**: **設定値の既定は「どちらに外れたときに気づけるか」で 選ぶ。** 上限が大きすぎれば API が 400 で理由つきに弾くので気づける。小さすぎる側は 生成物の大きさ次第でしか表に出ず、しかも別の失敗(空応答)に化ける。 安全側に倒したつもりの小さい既定値が、診断できない方の失敗を引いていた。 **一般化 2(前提の相互依存)**: **2 つの機構が互いに「相手が持っている」前提で 書かれていると、両方が落ちる経路が必ず残る。** 広場ログは「自分宛は履歴にある」から 除外し、履歴は「成功したターンだけ」書く。どちらも単体では正しく、**失敗時にだけ 両方の網から漏れる**。この repo には既に対になる原則があった — hop 打ち切りの 「記録してから打ち切る」と `reset_rule` の「発話は起きた事実でありログに残す」。 失敗経路だけがそこから外れていた。除外の条件を書くときは、**除外先が本当に 持っているかを、失敗経路も含めて確かめる**。 **一般化 3**: 1 つの述語が 2 つの問いに使われていたら、名前がどちらか一方しか 言えていない。`is_retryable` は「再送の可否」と「稼働の継続」を兼ねており、 新しい失敗種別を足した瞬間に両者が食い違った。 ## #41 回数の上限はコストを縛れない — 失敗ループでトークンを使い切った **症状**: 進行役が同じ失敗を繰り返し、トークンを使い切る(利用者の言葉で 「一昔の VSCode Copilot のよう」)。個々の失敗は既に #39・#40 で処方したが、 **燃焼そのものが止まらない**。 **真因**: 3 つが重なっている。 1. **歯止めが全部「回数」建て**で、コスト建てのものが 1 つも無い — `max_tool_iterations` 12 回 / `max_retries` 3 回 / `max_hops` 8 回 / `ask_timeout` 180 秒。**回数の上限がコストの上限になるのは、1 単位あたりの コストが一定のときだけ** 2. **1 周あたりのコストが増え続ける。** ツールループの各周回は、それまでの 全履歴とツール結果を毎回送り直す。grep 1 回の出力上限が 12,000 字なので、 周回のたびに入力が数千トークン太る。1 周目 ≒ 10K 入力が 12 周目には ≒ 40K になり、**失敗した 1 ターンの合計は 30 万トークン規模**になる。 さらに `plan` の宛先数と `max_retries` が掛かる 3. **同一失敗を検出する仕組みが無い**(grep で確認)。同じツールを同じ引数で 呼んで同じエラーが返っても、12 周目は 1 周目と同じに扱われる。モデルから 見れば履歴に同じ失敗が並ぶだけで、「繰り返していること自体が問題だ」という 信号はどこからも来ない **処方**(効く順。1 だけが病を直し、2・3 は症状を抑える側): 1. **同一失敗の検出**(2026-07-31 実装済み) — ツール名 + 引数 + 結果本文が 完全一致で 2 回返ったら、3 回目は実行せず短い通知だけを返し、 `ToolRepeatBlocked` を発行する。数えは (ツール名 + 引数) ごとに独立で、 隣接は要求しない。ループを切るのは**その周のツールが全部止まったとき**だけ。 実装と実機で分かったことが 3 つある: **(a) 「エラー文言」では 1 件も検出できなかった。** 同梱ツール(`file` / `sd` / `grep` / `fd`)は失敗を `Err` ではなく **`Ok(<エラー文の本文>)`** で返す。 「ツールの失敗は会話を止めない」という既存規律の帰結で、正しい設計だが、 その帰結として**失敗が型に載っていない**。`Result::is_err` で数えると、 実際に燃えた経路(#39 の `sd` 12 周)は 1 件も引っかからない。判定材料を モデルへ返る**結果本文**の完全一致に置いた。文言を parse して失敗かどうかを 推定するのはやらない(Spec 08 で「分類は文言 parse でなく型で運ぶ」と決めた側)。 副作用として、成功の繰り返しも同じ扱いで止まる — 同じ入力に同じ出力が返る以上、 3 回目に新しい情報は無いので、これは受け入れた。 **(b) 既存のテストが 1 本引っかかった。** `SilentToolBackend`(無言を貫く 最悪経路の検証用)は `remember` を毎周**同じ note** で呼んでおり、`remember` の 重複排除で結果本文まで毎回同一だった。上限 12 周を検証していたテストが、 4 周目の繰り返し検出で切れた。テスト側の note を毎周変えて上限側の経路を 保った。**検証用のダミーが本番の病と同じ形をしていた**ということでもある。 **(c) 最初の判定基準(隣接する 2 回)は実機で 1 件も発火しなかった。** 同日 10:47 のターン(36 周・入力 2,052,314 トークン・`stop=tool_limit`)で、 同じ結果を返した呼び出しが 4 組あったが 1 件も止まらなかった。 ```text ターン 1: file(args=45) → 12054 字 … round 24, 25, 28 ターン 2: file(args=35) → 12045 字 … round 2, 3, 5 ``` round 24・25 は連続していたが、26 の `grep` で数えが切れて 28 の 3 回目が 素通しした。ターン 2 の round 3 は**同じ周に 3 本の並列呼び出し**があり、 2 本目・3 本目が挟まって切れた。真因は**判定が直列ループを想定して較正されて いた**こと — モデルは 1 周に 2〜3 本を並列で呼ぶので、同じ読み直しは周をまたいで 現れ、隣接することがほとんど無い。 処方は 2 つ。**(ツール名 + 引数) ごとに独立して数える**(緩めたのは 「隣り合っているか」だけで、一致の条件は完全一致のまま)。**止めるのは その 1 本であってループではない**(並列の 1 本が重複しただけで進行中の作業を 殺さない。ループを切るのはその周のツールが全部止まったとき = 新しいことを 何もしていない周だけ)。返す通知を短くするのが効きの本体で、同じ 12,000 字を もう一度積むと以後の全周回でそれが再送される。 2. **割り込み停止** — 人間側の非常口(README 未実装表) 3. **トークン予算** — 歯止めを 1 つコスト建てにする。Anthropic の Task Budgets(`output_config.task_budget`、beta `task-budgets-2026-03-13`、 最小 20,000)が claude-sonnet-5 で使えるが、**これはモデルへの申告であって 強制ではなく**、Gemini 系には無い。村全体の天井はハーネス側に別途要る **一般化 1**: **回数の上限は、1 単位のコストが一定のときだけコストの上限になる。** 履歴を毎回送り直す設計では単位コストが単調に増えるので、回数だけで縛ると 上限は「最悪ケースが有限である」ことしか保証しない。**有限と安全は別物。** **一般化 2(#39 の続き)**: 同じ入力を N 回渡せば同じ判断が N 回返る。エラー文言を 直す(#39)のは「入力を変える」処方で、繰り返しそのものは止められない。 **判断材料を直す処方と、繰り返しを検出する処方は別に要る。** **一般化 4(処方 1 の較正から)**: **繰り返しの検出は、実際の通信の粒度で較正する。** 「隣接する 2 回」は直列ループなら正しいが、モデルは 1 周に複数のツールを並列で呼ぶ。 呼び出しの列は交互に混ざるので、隣接を要求した瞬間に検出はほぼ成立しなくなる。 判定を書くときは、**その判定が想定している列の形が実際の列と一致しているか**を ログで確かめる。一致の条件(何を同じとみなすか)と、並びの条件(どこを見るか)は 別の軸で、緩めてよいのは後者だった。 **一般化 5(打ち切りの粒度)**: **検出したものを止めるとき、止める単位は検出の 単位と揃える。** 1 本の呼び出しの繰り返しを検出したのにターンごと止めると、 並列に走っている他の仕事まで巻き添えになる。止める単位を「その 1 本」に置き、 ループを切るのは「その周が丸ごと空振り」という別の条件にしたことで、 検出の感度を上げても正当な作業が死ななくなった。 **一般化 3(処方 1 の実装から)**: **失敗を戻り値の型に載せていない層では、 失敗を数える機構は必ず本文の一致で代用することになる。** `Ok(String)` で エラー文を返す設計は「ツールの失敗で会話を止めない」ためには正しいが、その層より 上で「失敗が何回続いたか」を問う機構を足すとき、問いに答えられる欄がどこにも無い。 文字列を parse して失敗を推定するのは次の罠なので、完全一致で代用するか、 戻り値の型を変えるかの二択になる(今回は前者を選び、後者は台帳に残した)。 --- ## #42 キャッシュの境界が system で止まっており、ツールループが毎周を素の値段で払っていた **症状**: 1 ターンの入力が 2,052,314 トークン(36 周・`stop=tool_limit`)。 同日の別ターンでも 730,406 トークン。キャッシュ読み取りは前者で 226,205、 後者で 116,334 しか無く、入力に対する命中率は 11% と 16%。 **真因**: `cache_control` を打っているのは **system ブロックだけ** (`anthropic.rs` の `build_system_blocks`)。`messages` には 1 つも打っていない。 ツールループは周回ごとに `messages` へ追記して**全体を送り直す**ので、 履歴とツール結果は毎周フルプライスで通る。 数字が真因を名指しした。同日の 1 周だけのターンが `cached=6463` を返しており、 これが安定プレフィックス(tools + system)の実測値。そして: ```text 226,205 ÷ 6,463 = 35.0 (36 周のうち 35 周ぶん。1 周目は書き込み) 116,334 ÷ 6,463 = 18.0 (19 周のうち 18 周ぶん) ``` **整数倍でぴったり割れる**。つまり 36 周を通じてキャッシュに載っていたのは 6,463 トークンの安定プレフィックスだけで、**それ以外は 1 バイトも載っていない**。 2,052,314 のうち 1,826,109 が素の値段だった。 **処方**(実装済み 2026-07-31。`anthropic.rs` の `place_message_breakpoint`): 1. `messages` の末尾へ breakpoint を回す。ツールループは追記しかしないので、 次の周の前方一致は構造的に保証される。Anthropic の breakpoint 上限は 4 で、 system 用に 1 つ使っているので枠はある 2. 打つのは**最後の発話の最後のブロック 1 箇所だけ**で、種別は問わない。 `tool_result` に打てないとツールループの周回だけ境界が抜ける 3. 見積もり(repo 自身の TTL 表 — 1h 書き込み 2.0× / 読み取り 0.1× で計算): 現状 625K 相当 → 約 200K 相当。**同じ仕事が約 1/3 の値段になる** **実測(2026-08-01、修正後)**: claude-sonnet-5 のザリで 8 ツール呼び出し (`file` / `fd` / `grep`×5 / `file`)158.4K トークンの turn が **入力の 87% をキャッシュ**。修正前の同種の turn は 11% と 16%。 判定には一般化 1 を逆向きに使った — 安定プレフィックスは 6,463 トークンで固定なので、 5〜6 周で載る上限は 6,463 × 周回数 にしかならず、87% には**構造的に届かない**。 率が上がったことではなく、**固定長では説明がつかない量が載っていること**が根拠になる。 **実装時に潰した未決 2 点**(Anthropic の仕様に接地): - **`tool_choice` の変更は履歴層のキャッシュを落とす**(tools 層と system 層は 保つ)。まとめ呼び出しの 1 周だけ messages が素の値段になる。1 ターンに 1 回 なので打ち消す仕掛けは置かない - **境界の後方探索は 20 ブロックまで**。1 周の追記がそれを超えるとその周だけ 書き直しになる。実機の 2〜3 本並列は 1 周 7 ブロック前後で収まる **一般化 3**: **同じ機構でも、値の置き場所が違えば別の無効化規則が効く。** `tool_choice` は tools 定義でも system でもないのに、変えると履歴層だけが落ちる。 「キャッシュが効くか」を機構単位(プロンプトキャッシュ)で考えると外し、 **層単位**(tools / system / messages)で考えて初めて周ごとの値段が読める。 **一般化 1**: **キャッシュの効きは率ではなく「割り算が整数で割り切れるか」で診る。** 命中率 11% という数字は「効いていないわけではない」と読めてしまうが、 `cached ÷ 1 周ぶんのプレフィックス` が周回数にぴったり一致した瞬間に、 **載っている範囲が固定長である**ことが確定する。率は連続値なので原因を指さないが、 整数倍は境界の位置を名指しする。 **一般化 2**: **増え続ける側にこそ境界を置く。** 安定プレフィックスは定義上 小さくて変わらない部分で、そこに breakpoint を置くのは自然に見える。だが コストを支配するのは**増える側**(履歴・ツール結果)で、そちらに境界が無ければ 節約の上限は「小さくて変わらない部分」に閉じる。#41 の 「回数の上限は単位コストが一定のときだけコストの上限になる」と同じ形で、 **変わらないものを守っても、変わるものは守られない**。 --- ## #43 デスクトップアプリの CSS が、起動のたびに外部からフォントを取りに行っていた **症状**: タイトルバーの意匠に使うドット字体が、オフラインでは黙って sans-serif に落ちる。失敗もエラーも出ない。 **真因**: `style.css` の先頭に `@import url("https://fonts.googleapis.com/css2?family=DotGothic16&display=swap")` があり、 **ビルド後の CSS にもその行がそのまま残る**(バンドラは remote `@import` を インライン化しない)。結果、パッケージされたデスクトップアプリが起動のたびに 外部の CDN へ取りに行く形になっていた。`tauri.conf.json` の `csp` は `null` なので 遮断もされない。`font-display: swap` は**フォントが来ないときに黙って代替へ倒す** ための指定なので、失敗が表に出ない経路まで揃っていた。 **処方**: フォントを同梱し `@font-face` で読む。**収録は Latin サブセットだけ** (10,508 バイト)— 必要なのは "Outcasts" の 8 文字で、日本語グリフを含む本体 (数 MB)は 1 つも要らない。DotGothic16 は SIL Open Font License 1.1 なので同梱に 制限は無く、頒布時のライセンス同梱要求に合わせて全文も置いた。 `.gitattributes` の binary へ `*.woff2` を追加(`* text=auto eol=lf` の下で eol 正規化に巻き込まれるとフォントが壊れる)。 **一般化 1**: **「ローカルで完結する」という主張は、意匠の依存で崩れる。** 秘密の置き場・作業フォルダの境界・外部への送信は設計で守っていたのに、 装飾のための 1 行が製品を外部の可用性に結びつけていた。**接地を検査する対象に CSS を含めていなかった**のが穴で、機能を見ているだけでは見つからない。 **一般化 2**: **失敗を黙って隠す指定は、失敗を検知する経路も同時に塞ぐ。** `font-display: swap` は正しい UX 指定だが、「取得できなかった」を観測不能にする。 劣化縮退に理由を付ける(#4)という既存の規律は、CSS のような宣言的な層では そもそも書く場所が無い。**宣言で縮退する層は、縮退したことを実行時に知れない** という前提で、依存そのものを外すしかない。 --- ## #44 打ち切りを正直に申告したが、続きを取る経路が無かった **症状**: ザリに `data_contract.yaml` から型名を拾わせたところ、`file` を同じ引数で 呼び続けて `RepeatGuard` に止められ、ターンごと落ちた。利用者に出たのは 「ツール実行を打ち切りました。まとめの生成にも失敗しています。モデルは応答しましたが 本文が空でした」だけで、成果物は 1 バイトも書かれていない。 **真因**: `file read` の出力上限は 12,000 字で、`data_contract.yaml` は 61,891 字。 先頭 19% と「残り 49,891 字は省略しました」が返る。そして **`file` に offset も range も無い**(op / path / content / to / overwrite の 5 引数)。続きを取る経路が 構造的に存在しないので、同じ引数で呼び直す以外の手が無い。2 回同じ結果 → 3 回目をブロック → その周のツールはその 1 本だけだったので打ち切り条件 (#41 の一般化 5)を満たしてループが切れた。まとめる材料が無く本文が空になった。 **この打ち切りだけが次の手を書いていなかった**。同梱ツールの打ち切りは 5 箇所あり、 4 箇所は既に代替を名指ししている: | 箇所 | 打ち切り時の文言 | |---|---| | `grep` | 「`path` で範囲を絞るか、パターンを具体化してください」 | | `diff` | 「ファイルを分けて比較してください」 | | `sd` | 「パターンを具体化するか、範囲を分けて置換してください」 | | `yq` | 「対象を絞ってください」 | | **`file read`** | **(なし)** | **処方**: 打ち切り文に 2 つ足す — **「続きを読む方法はありません。同じ引数で 読み直しても同じ範囲が返ります」**と「残りが要るなら `grep` で必要な箇所を 探してください」。前者が要るのは、代替を示すだけでは「もう一度読めば残りが来るかも」 という筋が残るため。`grep` のファイル数超過(同じ形で次の手が無かった)も併せて塞いだ。 固定は `a_cut_read_names_what_to_do_instead`(打ち切り文が「続きは無い」と 代替の道具名の両方を含むこと)。 `file read` に offset は**足さない**。`grep` で既に書けるうえ、offset は 「全部読む」を助長して入力トークンを増やす側に効く(#42 で入力を安くした ばかりの所へ逆向きに働く)。機構を足す前に既存の機構で書けるか確かめる。 **一般化 1**: **出口の無い打ち切りは、正直であっても失敗ループを作る。** 「落とした量を書く」は silent truncation を防ぐための規律(#43 の一般化 2 と同じ筋) だが、それだけでは足りない。**打ち切りは「何が起きたか」と「次に何をするか」を 両方書いて初めて完成する。** 片方だけだと、モデルに残る唯一の選択肢が同じ呼び出しの 反復になる。 **一般化 2**: **同じ規律が 5 箇所にあり 4 箇所で守られているとき、残り 1 箇所は 最も見つかりにくい。** 規律が確立して見えるほど、抜けている 1 箇所を疑わなくなる。 `mandate_removal_requires_ledger_grep` の「撤去は既存節の読み直しを要求する」と同じ形で、 **多数派が正しいことは少数派の検査を省く理由にならない**。この 1 件は静的な読みでは 見つからず、実機でモデルが踏んで初めて露見した。 **一般化 3**: **失敗ループの防御は、失敗ループを作れる場所を減らさない限り、 通知が増えるだけで終わる。** `RepeatGuard`(#41 の処方 1)は設計通り止めたが、 止めた先に道が無かったのでターンごと落ちた。**歯止めは行き止まりを塞がない。** 歯止めを入れたら、次は「歯止めが引かれる場所」を数えて、それぞれに出口があるかを見る。 ### 追記: 理由まで伝えると、モデルは代替経路を自分で見つける(Spec 23・2026-08-06) **この規律で書いた文が、診断を超えて機能した実測。** 画像の転送では添付を落とし、 転送先の本文の先頭へ `(画像 1 枚は転送されません)` を入れている (`attachment_contract` 凍結 5)。**目的は診断**で、「転送先がなぜ画像を 見られないのか誰にも分からない」状態を避けるためだった。 実機では**送る側がその 1 行を読んで、画像をテキストへ書き起こして渡した** — 「管理人から画像が届きました。画像内の文字は『MEMORIA MAP』です。黒〜濃紺の背景に、 MEMORIA は金色、MAP は白色で表示されています。」。受け取った側はその記述を材料に 構造化した記録を返しており、**落ちたのは画素だけで情報は落ちていない。** **一般化 4**: **禁止だけを伝えるとモデルは止まるが、理由まで伝えると回り道を探す。** 歯止めの文面を「できません」で終えず「**何ができないか**」まで書くと、 その差分から代替経路が出てくる。#44 の規律は「後で人が診断できるように」の つもりで書いたが、**その場でモデルが回復するためにも効く** — 出口を人が 用意しなくても、**出口の形が読み取れれば足りることがある。** --- ## #45 可変な文脈を `Role::System` で積んでおり、位置を直しても先頭へ畳まれ続けた **症状**: Gemini ネイティブ経路(gemini-3.6-flash)で `cachedContentTokenCount` が 0 のまま。同じ村の Anthropic のエージェントは入力の 87% をキャッシュしていた。 「Gemini の暗黙キャッシュはマルチターンでは効かない仕様」という説明が外部から出ていた。 **外部の説明は 2 点とも誤りだった**(公式へ接地して確認、2026-08-01): - 「Native はキャッシュ非対応」は `gemini-live-*-native-audio` / Live API の話で、 この製品が使う `generateContent` とは別物。Live API は 1 行も使っていない - 「マルチターンではほぼヒットしない」は公式の記述と逆。公式は 「Try to send requests with **similar prefix**」「stateful / stateless の 両方に効く」と書いている。同一リクエストが条件ではない **真因**: `encode` は `Role::System` のメッセージを**配列のどこにあっても全部 引き抜いて** 1 つの `system` / `systemInstruction` へ連結する (`gemini.rs:81` / `anthropic.rs` の同型のコード)。参照資料・広場ログ・入退室・ 同報の注記はすべて `ChatMessage::system(...)` で積まれていたため、**配列上の位置に 関係なく前方一致の先頭を占めていた**。これらは毎ターン変わるので、先頭が毎ターン 変わる = 会話は 1 バイトもキャッシュに載らない。 `system_chars=1555`(約 1,500 トークン)は Gemini 3 系の最小長 4,096 を下回るので、 暗黙キャッシュは**無言で no-op** する。エラーもログも出ない。 **処方**: 可変な文脈を `Role::System` から外し、**今回の受信と一緒に 1 本の `user` 発話へ畳む**。 - System で積まない = adapter に先頭へ畳ませない - 別々の user 発話にせず 1 本に畳む = user ロールが連続してロールの交互を要求する プロバイダで壊れるのを避ける - 履歴へは `attributed` だけを積む = 今回だけの文脈を履歴へ焼き付けると、 以後の全ターンのプレフィックスに残り続ける **一度目の処方は失敗した。** 「可変ブロックを履歴の後ろへ動かす」という並べ替えを 先に入れたが、**ワイヤ上は完全な no-op** だった — Role::System である限り adapter が先頭へ畳み直すため。配列上の位置だけを見て、adapter が何を畳むかを 見ていなかった。 **計装も間違っていた。** `SystemDigest` は `compose_system_prompt` の戻り値だけを ハッシュしており、**実際に `systemInstruction` へ入る連結後の文字列ではなかった**。 そのため可変ブロックが毎ターン変わっても digest は動かず、それを見て 「前方一致は壊れていない」と読んだ。**あれは否定ではなく、その計装では検出不能 だっただけ**である。連結後で数え直し、system ブロックの本数も出すようにした。 ## #45 の第 2 段 — その処方が、次の前方一致の破れを作った 畳んだとき、**送るのは `context + attributed`、履歴へ保存するのは `attributed` だけ**に した。「今回だけの文脈を履歴へ焼き付けない」ための判断だった。これが次の破れになる。 次のターンでは履歴側の短い文字列がその位置に来るので、**送った側と保存した側が その 1 点で食い違う**。前方一致はそこで切れ、以後どれだけ会話が伸びても載るのは system + tools までで止まる。 実測が名指ししていた — ザリ(claude-sonnet-5)の `cached=6,465` は、#42 で測った 安定プレフィックス(tools + system)の **6,463** とほぼ同じ値だった。 **履歴は 1 通も載っていなかった。** **証明は実機ではなくテストで取れた**。2 ターン送って、1 ターン目に送った最終発話が 2 ターン目の履歴にそのまま現れるかを見るだけでよい: ```text 1 ターン目に送った: "## 入退室(…)\n- agent_a が稼働を開始しました\n\n【送り手: ユーザー】\n一度目" 2 ターン目の履歴: "【送り手: ユーザー】\n一度目" ``` **処方**: 送った文字列をそのまま `push_exchange` へ渡す。`push_exchange` の直上には 元から「**プロンプトと履歴の形を揃えないと、過去のターンだけ出所不明に戻る**」と 書いてあった — 自分の変更でその規則を破っていた。揃えるほうが記録としても正しい。 エージェントは実際にその文脈込みで受け取っており、`attributed` だけを積むのは 受け取った内容についての嘘になる。 **実測(2026-08-01、修正後)**: ザリで 6 チャット。 | | prompt | cached | 率 | |---|---:|---:|---:| | ターンをまたぐ 1 | 17,339 | 16,117 | 93% | | ターンをまたぐ 2 | 17,500 | 17,337 | **99%** | | ターンをまたぐ 3 | 17,688 | 17,498 | **99%** | | ツールループ round 8 | 20,607 | 20,532 | 99.6% | | ツールループ round 12 | 29,039 | 28,839 | 99.3% | `cached(N)` が `prompt(N-1)` と 2 トークン差で一致する(17,337 ≈ 17,339 / 17,498 ≈ 17,500)。**前のターンのプロンプト全体が載っている** = 完全な再利用。 修正前は 6,465 で頭打ちだった。 **残す穴**: ターンが失敗した経路(`record_failed_turn`)は文脈を再構成できないので `attributed` だけを積む。失敗のたびにキャッシュが 1 回リセットされる。 失敗は例外的なので許容する。 **一般化 1**: **層を移しただけでは何も変わらない。ロールが同じなら位置は無意味。** プロンプトの組み立てを直すときに見るべきは配列上の位置ではなく、**adapter が どの単位で畳むか**。`Role::System` は「安定なもの専用の枠」であって、 「system プロンプト以外も入れてよい置き場」ではない。 **一般化 2**: **計装が測っている文字列と、プロバイダへ実際に渡る文字列が同じか 確かめる。** 別物を測っていると、指標は「変わっていない」と表示し続ける。 それは「壊れていない」ではなく「見えていない」であり、**両者は画面上で区別が付かない**。 **一般化 3**: **「容疑を潰した」と言えるのは、その容疑を検出できる計装があるときだけ。** 3 つの容疑のうち (b) は、当時の digest では原理的に検出できなかった。にもかかわらず 「digest が同一だから (b) は否定」と報告した。**計装を作った本人が、その計装の 射程を確認しないまま結論に使うのが最も危ない。** 固定は 2 本。`volatile_blocks_sit_after_the_history_so_the_cached_prefix_can_grow` (位置**と**ロールの両方を見る)と `what_we_send_is_what_the_next_turn_replays_as_history`(送信と保存の一致)。 **一般化 4**: **順序のテストは、順序が原因のときしか守れない。** 最初に足した回帰テスト(可変ブロックが履歴より後ろにあること)は、 **Role::System のまま履歴の後ろへ置いた壊れた状態でも通る**。不変条件は 「後ろにあること」ではなく「System で積まないこと」だったので、 ロールの検査を足した。**テストが守っているものと、守りたいものが同じか確かめる。** **一般化 5**: **前方一致を直す変更は、別の前方一致を壊しやすい。** 第 1 段の処方(可変文脈を最終発話へ畳む)が、そのまま第 2 段の原因(送信と保存の 食い違い)になった。プロンプトの組み立てを触ったら、**送っている文字列と、次の ターンにその位置へ来る文字列が同じか**を毎回確かめる。これは 1 つの結合テストで 機械化できる(`what_we_send_is_what_the_next_turn_replays_as_history`)。 **一般化 6**: **同じ数字が 2 回出たら偶然を疑わない。** `cached=6,465` は #42 で 測った安定プレフィックス 6,463 とほぼ同じ値だった。「率が低い」ではなく 「**見覚えのある値で止まっている**」ことが、履歴が 1 通も載っていないという 真因を名指しした。#42 の一般化 1(割り算が整数で割り切れるかで診る)と同じ筋で、 **連続値としてではなく既知の量との一致として読む**と境界の位置が分かる。 **一般化 7**: **決定論的な計器を選ぶ。** 測りたかったのはプロバイダに依らない 前方一致の性質だったので、best-effort な Gemini の暗黙キャッシュ(実測で `0,0,0,hit,0,hit`)ではなく、明示的な breakpoint を持つ Anthropic 側で測った。 **外れたら壊れたと断定できる計器**でしか、破れの有無は決められない。 最終的には実機すら要らず、結合テスト 1 本で決着した。 ### 追記: この機序の 2 例目 — 意図的な例外でも同じ代償が出る(Spec 23・2026-08-06) **画像の添付は #45 の規律に対する意図的な例外**(送ったのは「画像 + テキスト」だが、 履歴へ積むのはテキストだけ = D1「1 ターン限り」)。**同じ機序がそのまま出た**: | ターン | `prompt` | `cached` | 率 | |---|---|---|---| | 1(画像あり) | 23,437 | 0 | 0% | | 2(画像なし・本文 5 字) | 23,584 | **18,897** | 80.1% | 2 ターン目の前方一致は**最初のコンテンツブロックで切れ**、`cached` は **tools + system(18,897)で頭打ち**になった。画像を通していない同型の 2 ターン目は 95%(9,738 / 10,234)。**戻るのは 3 ターン目から。** **第 2 段との違いは射程だけ** — あちらは畳んだ文脈を丸ごと外したので `cached` が 安定プレフィックスに張り付き**永続的に**効かなくなった。こちらは画像ブロック 1 つぶんの食い違いなので**1 ターンで済む**。 **一般化 8**: **#45 の規律に例外を作ってよい場合はあるが、代償は必ず「食い違う 位置から先が丸ごと載らない」形で出る。** 例外を入れるときは、**食い違いが履歴の どこで起きるか**を数える — 先頭なら 1 ターンぶんの履歴が全部落ちる。 **この件では代償と証拠が同じ数字だった**(`cached` が前ターンの `prompt` 付近に ならないことが、画像を履歴へ残していない証拠になる)。 ## #46 Vue Flow の `` を描画したのに、画面には何も出なかった **症状**: 村の地図へフィットボタンを足すため `@vue-flow/controls` を導入し、 `` をテンプレートへ置いた。ビルドは通り、エラーも警告も出ないのに、 画面にボタンが表示されない(2026-08-02。実装は codex、調査は別セッション)。 **真因**: `@vue-flow/controls/dist/style.css` を main.ts で読み込んでいなかった。 Vue Flow は core / controls / minimap がそれぞれ**別体の style.css を持つ**設計で、 main.ts には core の 2 本(style.css / theme-default.css)しか無かった。 CSS が無いと `.vue-flow__controls-button` は寸法も背景も持たず、 **DOM には存在するのに視覚上は何も無い**。パッケージ追加もコンポーネント配置も 正しく、足りなかったのは import 1 行だけ。 **処方**: main.ts へ `import "@vue-flow/controls/dist/style.css";` を追加。 既定テーマは白地なので、ダーク配色のトークン(--color-surface-1 / --color-line / --color-ink)で `:deep` 上書きも同時に行う。 **一般化 1**: **「エラーが出ない」と「表示される」は別の検査。** CSS 欠落は コンパイラ・型検査・ビルドのどれにも掛からず、実行時エラーも出さない。 描画物の追加は、ブラウザの要素検査で **DOM に居るか**と**高さ・幅が 0 でないか**を 分けて見ると、この型の欠落を 1 手で切り分けられる(DOM に居れば配線の問題ではなく スタイルの問題)。 **一般化 2**: アドオン分割されたライブラリ(@scope/core + @scope/addon 群)は、 **アドオンごとに CSS も分割されていることがある**。core の CSS を読んでいることは アドオンの CSS を読んでいる根拠にならない。導入時は当該アドオンの README の import 節を写す。 ## #47 呼び出しの失敗をモデルにもログにも伝えない経路が、静かに答えを壊す **症状**: ザリ(claude-opus-5、MCP 27 本)へ Yahoo ニュースの取得を頼んだら、 会話ペインに `MCP_DOCKER__fetch">` から始まる**ツール呼び出しの XML がそのまま 答えとして出た**(2026-08-02 21:39)。MCP は動いていないように見えた。 **調査で分かったこと(実測)**: MCP は健全だった。gateway は 2 本とも生存し、 温間で initialize 6.7 秒 / tools/list 7.0 秒(接続タイムアウト 30 秒に対し余裕)。 提示ツールは 27 本で `fetch` を含む。`enabledTools` は MCP を弾かない (同梱ツール以外は素通し)。ザリの SKILL.md / Memory.md に XML 構文の混入なし。 ログには `ok=false` も MCP ツール行も 1 件も無い。会話ペインの markdown 描画は `` を正しくエスケープする(実験で確認)。 **真因は 2 つの層に分かれる**: (a) **モデル側** — ネイティブの `tool_use` ブロックではなく、本文へツール 呼び出しの XML を書いた。届いた本文は先頭の `` の `path` だけで、「⚙」という文字列はコードのどこにも 現れない。ゆえに: - **grep する名前が思いつかない。** 撤去したのが `enum` 値や関数名なら その識別子で全台帳を引ける(`mandate_removal_requires_ledger_grep` の (a))。 だが撤去したのが**字形**のとき、コード側に検索語が 1 つも残らない - **機械の網が 1 枚も掛からない。** 型検査も clippy も vue-tsc も、 「README が古いアイコンを指している」を検出する経路を持たない。 未知フィールド lint のような構造的検査の射程にも入らない(README は構造を持たない) **処方は 2 段**。(1) 回収そのもの。(2) **置換先を絵文字にしない** — 新しい字形を書けば次のアイコン変更で同じ穴が開く。**ボタンのラベルと置き場所で 指す**形へ統一した(「タイトルバーの「予定」」「カードの設定ボタン」)。ラベルは `locales/*.json` に実在する文字列なので、次に変わるときは**辞書の鍵で grep できる**。 **同時に見つかった別原因の追従漏れ 3 件**(こちらは「機能の着地時に台帳を数えて いない」側): README に Spec 13 の節が 1 つも無かった(`grep "システム設定" README.md` が 0 件)/ `tokenBudget` の説明が「world.json の 1 つだけ」のままで P1 の画面が入っていない / ディレクトリ木と workspace 木に直近 2 Spec の産物 (`SettingsDialog.vue` / `SessionDialog.vue` / `ConfirmHost.vue` / `locales/` / `useUiSettings.ts` / `sessions.redb` / `exports/`)が無い。 **一般化 1**: **検索語の残らない撤去がある。** 撤去した対象が識別子なら grep で 追えるが、**字形・配色・レイアウト・アイコンのような「見た目そのもの」を替えた とき、台帳を引くための語がコード側に残らない**。この種の変更では、grep の代わりに **その見た目に言及していそうな台帳を、名前でなく主題で読み直す**しかない (#48 と同型の「人工的に読み直しを発生させる」)。 **一般化 2**: **台帳は変わりやすいもので対象を指してはいけない。** 「⚙ から」は アイコンという**最も変わりやすい層**で機能を指していた。同じ機能を「ラベル + 置き場所」 で指せば、アイコンが変わっても記述は生き残る。これは型名を「エージェント」で 固定し UI 表示だけ「サーヴァント」にした判断(`vocabulary`)と同じ形 — **変わりやすいものと変わりにくいものを同じ語に縛らない**。 ### 一般化 2 の二例目: 行番号(同日・別経路で出た) 同じ日、村に書かせた `docs/progress.md` が**行番号で台帳を指していた**。 `トークン予算 | 1385 | README 未実装表に掲載` と書いてあったが、README は 982 行しかなく、`1385` は **`failures.md` の #42 配下**(`#42` が 1328 行、 「一般化 1」が 1382 行、`#43` が 1397 行なので 1385 は 1382〜1396 の内側)。 **ファイル名を書かずに行番号だけを引いた**ため、読んだ側は README の行だと読んだ。 腐り方は ⚙ より一段悪い。**行番号は、指している先のファイルへ 1 行挿すだけで 全部ずれる**。実際このセッションでは、同じ `progress.md` の総行数の測定で 四者が 920 / 949 / 951 / 982 とばらけ、原因の一部が「測っている間に `README.md` 自体が編集されていた」だった。**測定値と行番号は同じ性質**で、 どちらも「いつ測ったか」を添えないと再現できない。 **処方**: **他のファイルを指すときは、行番号ではなく見出し番号で指す** — `failures.md #42` / `Spec 13 P3b` / `data_contract の settings_contract`。 見出し番号は挿入で動かず、その文字列でそのまま grep できる。 **行番号を使ってよいのは、同一ファイル内か、その場限りの会話の中だけ。** 台帳に残る文には書かない。 **根が同じであること**が重要。⚙(字形)も行番号(位置)も、**指し先そのもの ではなく、指し先の「今たまたまそうなっている属性」を指している**。 grep できる語(識別子・見出し番号・辞書の鍵)で指すかぎり、この一族は生えない。 ## #52 保存先で分類したくなったら、それは分類ではなく実装の都合 **症状**: Spec 14 P3 で役職ダイアログをシステム設定の左メニューへ入れた (全般 / コスト管理 / **サーヴァント** / ユーザーインターフェース の 4 グループ目)。 実機で見た利用者が差し戻した — 「役職はシステム設定のカテゴリではなく、 **村のカテゴリ**だから、タイトルバーの条例の右に置いてほしい」。 **真因**: 置き場を**保存先で決めた**。起票時の理由は 「役職は `world.json` に住む=村の共有物で、トークン制限・言語と同じ棚」。 だが `world.json` に住むことは**保存の都合**であって、利用者にとって何の話かを 決めない。条例も `Ordinance.md` として村に住むが、誰も条例を「設定」とは呼ばない。 **正しい境界は「村の内容物か、アプリの設定か」。** - **村の内容物** = この村がどういう村かを決めるもの(条例・役職) - **アプリの設定** = アプリがどう振る舞うかを決めるもの(言語・トークン制限・ 確認ダイアログ) 保存先はこの境界と**直交している**。言語もトークン制限も `world.json` に住むが 設定側で、条例と役職はファイルの置き場が違っても内容物側。 **同じ誤りを 1 日前に潰していた**(ここが本題)。Spec 13 の分類は初版が 「村の設定 / この画面の設定」= **保存先での 2 分割**で、実機の指摘 (「あまりにも砕けすぎていて逆にわかりづらい」)を受けて主題で切り直している。 そのときの真因の記述がそのまま今回にも当てはまる — **「実装の都合がそのまま目録に出ていた」**。 **同じ人間が、同じ週に、同じ形の誤りを、直した直後に踏み直した。** **一般化 1**: **「どこに保存されるか」は分類の根拠にならない。** 分類は **利用者にとって何の話か**で切る。保存先が同じものを同じ棚に置きたくなるのは、 **書いている側がその瞬間に実装を見ているから**で、読む側はファイルの置き場を 知らない。設計中に「これは `world.json` だからこっち」と考え始めたら、 そこが分岐点。 **一般化 2**: **直したばかりの誤りほど、隣の機能で再発する。** 一度直すと 「その件は済んだ」と記憶されるが、直したのは**その箇所**であって**判断の癖**では ない。Spec 13 の分類を直した経験は Spec 13 の中にしか残っておらず、Spec 14 で 新しい置き場を決めるときには 1 ミリも効かなかった。**台帳へ書くのは箇所ではなく 判断基準**(この節の一般化 1 のような形)にしないと、次の機能で同じ穴が空く。 **一般化 3**: **分類の誤りはテストで落ちない。** 型検査も単体も結合も全部緑で、 機能としては完全に動いていた。**誤っていたのは「どこにあるか」だけ**で、それは 人が画面を見るまで誰にも見えない(#49 と同型 — 境界値の「意味の正しさ」と 「位置の適切さ」は別)。UI の置き場を決めた Phase は、実機確認を完了条件から 外さない。 ## #53 契約に「できる」と書いた操作に、画面の入口が無かった **症状**: Spec 14 の実機確認で、利用者が「サーヴァント編集の中に役職を選ぶ項目が ない」と指摘した。役職を選べるのは**新規作成のときだけ**で、既に居る個体に 後から付ける手段が画面に無かった。 **真因**: **契約は「できる」と書いており、コアもそう実装されていた。** `role_contract` 凍結 4 は「既存のサーヴァントに後から役職を付けられる。ただし `role_id` だけ」と明記し、`update_agent` は実際にそれを通す。結合テストも 「既存の個体に役職を付けても設定が 1 欄も変わらない」を固定していた。 **足りなかったのは入口だけ**で、そこは誰も数えていなかった。 **Spec 13 の起票理由とまったく同じ形**: トークンの天井は `world.json` に存在し、 コアは依頼ごとに読んでいたのに、**手編集以外に触る方法が無かった**。 「既にある設定の可視化」を主目的に Spec を 1 本立てた直後に、隣の Spec で 同じ穴を作っている(#52 の一般化 2「直したばかりの誤りほど隣の機能で再発する」の 二例目)。 **処方**: `AgentSettingsDialog` へ役職の選択を 1 つ。`role_id` だけを差し替え、 設定は 1 欄も流し込まない(凍結 4)。**dirty 判定にも足す** — 入れ忘れると 選び直しても保存ボタンが有効にならず、「選べるのに変えられない」という 第 2 層の穴になる。 **一般化 1**: **契約に「できる」と書いた操作は、入口があるか数える。** 凍結した各項目に対して「これは画面のどこから起きるか」を 1 行で言えなければ、 その操作は**存在しないのと同じ**。テストは「コアが受け付けること」しか見ておらず、 **人が到達できるか**は 1 本も見ていない。 **一般化 2**: **「作るときだけ選べる」設定は、後から変えられないことを疑う。** 新規作成のフォームに置いた選択肢は、既存の個体には届かない。作成フォームへ 何かを足したら、**編集フォームにも要るかを必ず問う**(`AgentSpec` に欄を足したら 投影にも要るかを問う、という #51 の系と同じ形 — **足す先は 1 つではない**)。 ## #54 テストの中の文字列リテラルが、壊れた機能を 1 つだけ延命していた **症状**: 役職バッジに 8 色を用意したのに、実機では**白と緑しか出ない**(利用者の 指摘)。単体テストは全部緑で、`roleBadge` は `var(--color-role-violet)` のような 正しい文字列を返していた。 **真因**: **Tailwind v4 の `@theme` は「使われていない変数」を出力から落とす。** 役職色は `var(--color-role-${色})` と**実行時に組み立てて**使うので、Tailwind の スキャナからは 1 つも使われていないように見え、ビルド後の CSS から全部消えていた。 `var()` が未定義に解決されると宣言ごと無視され、既定の枠線・字色(白っぽい)に 落ちる。 **では緑はどこから来たのか。** ビルド後の CSS を調べると、残っていた変数は **`--color-role-teal` ただ 1 つ**。理由は `roleLabel.test.ts` に expect(roleBadge("reviewer", colored)).toEqual({ name: "査読役", color: "var(--color-role-teal)", }); と書いてあり、**Tailwind のスキャナがこの文字列リテラルを「使用」と読んだ**から。 **テストが、自分が検証しようとした機能を 1 色だけ延命していた。** しかも 「色は動く」という**誤った確信**まで与えていた — テストは緑で、その 1 色は 実機でも本当に出ていたので、部分的に正しい観測が残りの 7 色の不在を隠した。 **処方**: 色の定義を `@theme` から素の `:root` へ移す。`@theme` に置くべきなのは `text-accent` のように**ユーティリティを生やす**色で、役職色はユーティリティを 使わない実行時選択の値(同じファイルの `--scrollbar-neon` と同じ棚)。 併せて `roleColorTokens.test.ts` を新設し、(a) 8 色すべてが定義されている (b) `@theme` の中に無い (c) 明度と彩度が全色で揃っている、を機械で留めた。 **一般化 1**: **「正しい値を返すこと」と「その値が指す先が存在すること」は別の 検証で、前者しか書いていないことが多い。** `roleLabel.test.ts` は `roleBadge` が 正しい CSS 変数名を組み立てることだけを見ており、**その変数が定義されているか**は 1 行も見ていなかった。参照を組み立てる関数を書いたら、**参照先の実在も**別に留める。 (#49「意味の正しさと位置の適切さは別」の、値と参照先の版) **一般化 2**: **ビルド時に消えるものは、単体テストの射程の外にある。** 単体は ソースを見るが、利用者が見るのはビルド成果物。tree-shaking・minify・CSS の purge のように**ビルドが中身を減らす仕組み**が居るところでは、成果物を 直接確かめる検証を 1 本置く(今回は `dist/assets/*.css` を grep して 8 色を数えた)。 **一般化 3**: **部分的に動いている状態は、全く動かない状態より診断が遅れる。** 8 色すべてが白なら「色の仕組みが繋がっていない」と即座に読めた。1 色だけ正しく 出たせいで「実装は合っていて、値か選択の問題だろう」という誤った方向へ寄る。 **「一部だけ動く」を見たら、動いている側が偶然である可能性を先に疑う。** ## #55 grep が「打ち切った」とは言ったが、何件のうち何件かは言っていなかった **症状**: 5 体のサーヴァントに `README.md` の同じ見出し「## 意図的に未実装の部分」の 行番号を報告させたら、920 / 949 / 951 / 963 / 982 と**全員が違う数字**を返した。 多数決も取れず、どれが正しいかを台帳側から判定できなかった。 **真因**: `run_grep` は 101 件目で `break 'files` して走査ごと止め、 `"{} 件が一致"` に **`lines.len()`(表示した数、最大 100)** を書いていた。 `total_matches` は数えていたのに出力に使わず捨てており、しかも break するので **真の総数はそもそも誰も知らない**。モデルは 100 件を全件だと読み、 足りないぶんをパターンを変えた再検索で埋めようとして、検索ごとに違う母数の 数字を得ていた。 **約束のほうは 2 箇所に書いてあった。** `tools/fs.rs` の module doc (「打ち切りは黙って行わず、何件落としたかを結果に書く」)と `data_contract.yaml` の `bundled_tools_contract` (「silent truncation は『全部見た』と誤読される」)。 **契約は正しく、実装だけが守っていなかった。** **処方**: 上限を表示側だけに掛け、走査と数えは最後まで通す。出力は 「全 N 件が一致(M ファイル)。うち先頭 K 件を表示」+ファイル別の件数 (多い順・20 ファイルまで)。併せて `count_only: true` を足した — 一致行を 返さず件数だけを返す集計モード(村のサーヴァント 6 名のうち 2 名が要望)。 **一般化 1**: **「黙って切らない」は「切ったと言う」ではなく「切る前の量を言う」。** 打ち切りの事実だけを告知しても、読み手が得る数字は切った後の数字なので、 silent truncation とほぼ同じ誤読が残る。**告知すべきは行為ではなく母数。** **一般化 2**: **上限は「走査」「数え」「表示」の 3 つに分かれる。混ぜると、 節約にならない側で切ることになる。** 今回は走査コストが変わらないのに数えを 止めていた — 一致ゼロの grep は今でも全ファイルを走査するので、数え続けたときの 最悪コストは**すでに毎日払っている**。打ち切って節約できるのは出力だけだった。 **一般化 3**: **契約の文が正しく実装だけがずれている形は、名前の grep では 見つからない。** #51 で標準化した「変えた名前で全台帳を grep する」規律は、 名前が一致しているこの形を素通りする(`grep` も `MAX_MATCHES` も全台帳で 整合していた)。**契約に「〜を結果に書く」と書いたら、その文を assert する テストを 1 本置く** — 文章どうしの照合は人にしかできないが、 文章と振る舞いの照合は機械にさせられる。 ## #56 「意図的に未実装」の表に、その機能の判断が既に書いてあった **症状**: Spec 15(コマンド実行)を rev1 で起票し、「コマンド名の許可リスト」を 設計の中核に据えた。査読で拒否。`README.md` の「## 意図的に未実装の部分」に **2026-07-31 の利用者判断が既にあった** —「**Hook に登録したコマンドだけを 実行できる**形。任意コマンド + 危険なものを弾く除外リスト、ではない — **閉じた許容**であることが設計の本体」。rev1 は開いた許容で、判断の正反対。 **真因は「引かなかったこと」だが、引かなかった理由に構造がある。** 起票時に引いた 台帳は `data_contract.yaml`(契約)と `CLAUDE.md`(現在地)で、どちらも **実装済みのものを記録する場所**だった。判断だけが先に決まっていて実装が無い機能は、 そのどちらにも載らない。載るのは README の「意図的に未実装の部分」で、 そこは**読み物の側**なので作業中に開かない。 **もっと悪い点**: rev1 の Notes 4 は「`run` を有効にした個体では作業フォルダ境界が 実効的に無くなる」と書いた。README の同じ行の後半は 「作業フォルダ境界はコマンドには効かない(`cd` で出られる)ので、**囲いは登録の側に 置くしかない**」。**同じ観察に自力で辿り着き、台帳が既に出していた結論の逆へ進んだ。** 観察が一致していたのに結論が割れたのは、台帳の側が観察を**結論まで運んでいた**のに 対し、こちらは観察を Notes に置いて設計は別に決めたから。 **処方**: **Spec を起票する前に、その機能名で「意図的に未実装の部分」の表を引く。** `data_contract` と `CLAUDE.md` を引くだけでは足りない。あの表は **「やらないと決めた」ではなく「やり方を決めてまだ作っていない」の置き場**で、 未実装であることと判断が無いことは別。 **一般化 1**: **未実装の機能ほど、判断だけが先に台帳へ落ちている。** 実装があれば コードと契約に判断が刻まれるが、実装が無い機能の判断は散文にしか残らない。 **散文にしか残っていない判断は、実装を始める人が最も引かない場所にある。** **一般化 2**: **自分の Notes に書いた観察が既存の判断と同じなら、その判断の結論も 探す。** 観察が一致するのは偶然ではなく、同じものを見ているから。**観察が一致した 時点で「先に見た人は何と結論したか」を引く**のが、独立に考え直すより速くて正確。 ## #57 独立検証を「別の人」で作ると、誤りは相関する **症状**: Spec 15 rev1 の査読で、村の 5 号が「`failures.md #41 の一般化 3` は 該当趣旨が確認できない。指すべきは処方 1」と報告。検証役が**独立に再測定**して 「5号の判定と一致。成立」と裏書きした。**両方とも誤り** — `#41` の一般化 3 は 実在し(`failures.md:1319`)、内容は「失敗を戻り値の型に載せていない層では、 失敗を数える機構は必ず本文の一致で代用することになる」で、まさに RepeatGuard が 結果本文で数える根拠そのもの。 **真因**: `#41` の一般化は **1 → 2 → 4 → 5 → 3 の順**に並んでおり、 一般化 3 だけが最後(1319 行)にある。行順に走査すると、`## #41` の見出し(1225 行)の 手前にある **#40 の一般化 3**(1221 行)を先に拾い、「#41 の範囲には無い」と結論する。 **5 号も検証役も同じ走査をした。** **独立検証が効かなかったのは、独立していなかったから。** 条例の束ねの検証は 「束ねに参加していない**サーヴァント**へ ask」と書いてある — つまり要求しているのは **別の人**。今回はその要求を満たしていたのに誤りが通った。**別の人が同じ方法を使えば、 誤りは相関する。** **しかも検証を通ったぶん確信だけが増えた。** 検証役の報告は「独立測定で覆った」 「成立を確認した」という強い語彙で書かれており、査読の他の 11 件(すべて正しい)と 同じ確度で並んでいた。**正しい 11 件が、誤った 1 件に信用を貸していた。** **処方**: 条例の検証の節へ 1 行足す — **「同じ調べ方で確かめない。番号や行で引いた 主張は、本文の語で引き直す」**。今回なら `grep "一般化 3"` ではなく `grep "本文の一致で代用"` で引けば一発で出た。 **一般化 1**: **独立検証の独立性は、人ではなく方法で数える。** 「参加していない人へ 頼む」は自己評価バイアスを避けるための条件で、**方法の誤りには何も効かない**。 検証を頼むときは「別の角度から」を明示的に指定する。 **一般化 2**: **台帳の項目が番号順に並んでいない場所では、番号での引用が壊れる。** `#41` の一般化が 1・2・4・5・3 の順なのは、後から追記したものが末尾に付いたため。 **追記で番号順が崩れる台帳では、引用は番号ではなく本文の語で書く** (#51 の「変わりやすい表示名で指さない」の、台帳内引用の版)。 **一般化 3**: **正しい報告の束の中の 1 件の誤りは、束の正しさに守られて通る。** 11 件が正しい査読は信用に足るように見え、12 件目を個別に検算する動機が消える。 **受け取る側の規律**: 束で来た指摘は、束の質ではなく 1 件ずつの根拠で判定する。 今回それができたのは、たまたま引用元が自分の書いた行だったから。 ## #58 打ち切りの規律を、同梱ツールにだけ適用していた **症状**: 利用者から「チャットの全文がエージェントへ渡っていない。途中で 途切れているとソネットが報告する」。調べると広場ログの 200 字打ち切り (`room_log_excerpt_chars`)で、**機構は設計どおり**だった。 **真因は打ち切りそのものではなく、打ち切りの伝え方。** 届く本文は `…` が付くだけで、モデルには「省略された」のか「相手がそこで言い終えた」のかを 区別する材料が無い。元の長さも、全文の取り方も書いていない。 **規律は既にあった。適用範囲が狭かっただけ。** `data_contract.yaml` の `bundled_tools_contract` は「打ち切りは黙って行わず件数を書く」 「打ち切り文は**何が起きたか**と**次に何をするか**を両方書く」 「続きを取る経路が無い打ち切りでは『読み直しても同じ範囲が返る』ことまで書く」と 定めている。**同じ日に #55 で grep の件数を直したばかりだったのに、 同じ形が 30 行離れた `compose_room_log` に残っていた。** **処方**: 切った行へ `(全 N 字)`、見出しへ「直近 N 件まで・各行は先頭 N 字の抜粋」、 切った行があれば「全文が要るなら発言した相手へ `ask`」。広場ログは読み直す経路を 持たないので、次の手は本人へ聞くことしかない。結合テスト 1 本 (`the_room_log_declares_it_is_an_excerpt_and_how_to_get_the_full_text`)。 **続報(2026-08-06)**: 「読み直す経路を持たない」は Spec 22 が撤回した — 切れた行の行頭に発話 ID が付き、`room_log` ツールで全文を読める。次の手の文面も `ask` から差し替えた(`ask` は上限超・リング溢れのフォールバックとしてだけ残る。 撤回の記録は `data_contract` の `room_log` 節)。本エントリの一般化 3 本は そのまま生きている。 **一般化 1**: **規律は「どの契約の節に書いたか」で射程が決まってしまう。** 打ち切りの規律は `bundled_tools_contract` の中に書かれており、 **ツールの話として読まれる**形になっていた。プロンプト合成も同じ性質の打ち切りを しているのに、別の節(`room_log`)にあるので規律が及ばなかった。 **契約の節は実装の置き場で分かれており、規律の射程とは一致しない。** 横断する規律を書いたら、**その規律が及ぶべき節を数えて相互参照を張る**。 **一般化 2**: **切り詰めを実装したその場で「読み手はこれを全体だと思うか」を問う。** `truncate_chars` は「マルチバイト文字の途中で切らない」ことは丁寧に守っていた — **壊れないことは気にしていたが、誤読されることは気にしていなかった。** 出力を削る関数を書いたら、削った事実と削る前の量を返り値に含められるかを先に見る (`truncate_chars` は `String` を返すので、呼び出し側が元の長さを数え直す形になった)。 **一般化 3**: **人が気づけたのは、エージェントが自己申告したから。** ソネットは `…` を見て「途切れている」と言えた。**気づかなければ、抜粋を発言の全体として そのまま推論に使う** — その失敗は画面のどこにも出ない。**モデルの自己申告に 依存している検知は、検知ではない。** ## #59 辞書のテストが鍵集合しか見ておらず、本文がコンパイルできるかを見ていなかった **症状**: `run.json`(当時の名は `command.json`)のプレースホルダ文言を辞書へ 足したら、**サーヴァント一覧の ペインが丸ごと描けなくなった**(実機、2026-08-04)。画面には `Message compilation error: Invalid token in placeholder` と JSON の断片。 **真因**: vue-i18n は `{` を**補間の開始**として解釈する。プレースホルダに `{ "version": 1, ... }` という JSON の例を書いたので、`"version"` が プレースホルダ名として読まれて壊れた。リテラルの波括弧は `{'{'}` と書く必要がある。 **既存の `mcpPlaceholder` が無事だったのは偶然ではない** — あちらは 「Claude Desktop と同じ `mcpServers` 形式の JSON」と**説明しただけで例を書いていない**。 波括弧を含む文言がこれまで 1 つも無かったので、罠が踏まれていなかった。 **すり抜けた網**: `vue-tsc` も `vite build` も通る(JSON として正しい文字列)。 辞書には**鍵集合の一致テスト**があるが、**鍵しか見ていない**ので本文は素通り。 vitest 116 本が全部緑のまま出荷され、**画面を開いた人にしか分からない**形で出た。 **処方**: 全言語の全メッセージを `createI18n` で実際にコンパイルするテストを 1 本 (`i18n/index.test.ts`)。毒を仕込んで**落ちることを先に確かめてから**入れた。 **一般化 1**: **「鍵が揃っている」と「値が使える」は別の検証で、前者しか書いていない ことが多い。** 辞書・設定・スキーマのように**キーと値の対**を持つものは、 キー側の検査を書くと**値側も見た気になる**。#54 の「正しい値を返すことと、その値が 指す先が存在することは別」の、辞書版。 **一般化 2**: **文言に「別の言語のコード例」を書くとき、その言語の記号が 文言の側の記法と衝突しないかを疑う。** JSON の `{`、Markdown の `*`、 正規表現の `$` — **例として貼りたいものほど記号が多い。** **一般化 3**: **1 つも前例が無い書き方は、機構が対応しているかを確かめる契機。** 辞書に波括弧を含む文言がこれまで無かったのは偶然だが、**「初めて書く形」は 初めてその経路を通るということ**。既存の同種の文言(`mcpPlaceholder`)が どう書いているかを先に読めば、例を貼っていないことに気づけた。 ## #60 canonicalize の Windows 冗長プレフィックスが、モデルの読む本文へ漏れていた **症状**: `run` の実機初走行(2026-08-04、ザリが `curl` を実行)で、ログと結果本文に `resolved=\?\C:\Windows\System32\curl.exe` が出た。`\?\` は `canonicalize()` が Windows で返す**冗長プレフィックス**で、OS の内部表現。 **実害は小さいが、性質が悪い**。モデルはこれを見て「そういうパスなのだ」と読み、 以後の推論やユーザーへの説明にそのまま持ち出す。**間違ってはいないが、人間が 書かないものが会話に混ざる。** **この罠は 2026-07-31 に予告されていた。** Spec 09 Notes 1 — 「`dunce` は canonicalize の `\?\` 前置が**モデルの読むエラー文言に漏れる**ことが 実装中に観測されたら入れる(今は表示前に相対化しているはずなので保留)」。 **当時は正しく保留し、条件が満たされたのは 1 週間後の別 Spec だった。** **なぜ Spec 09 では漏れなかったか**: `file` / `grep` は結果を**作業フォルダからの 相対パス**へ落としてから返す(`strip_prefix(&root)`)。相対化の副作用で前置も 一緒に消えていた。`run` は**絶対パスをそのまま見せる**のが仕様(どのバイナリが 走ったかを毎回見せる = rev1 査読の CRITICAL 1 の処方)なので、初めて素通りした。 **処方**: `display_path` で前置 4 文字だけを剥がす。**crate は足さない** (`dunce` を入れるほどの話ではない)。**UNC 形(`\?\UNC\...`)は触らない** — 剥がすと `\host\share` になり、**別のホストを指すパスになる**。 **一般化 1**: **「観測されたら入れる」と書いた保留は、条件が満たされたことを 誰も知らせてくれない。** Spec 09 の Notes は正しく保留を書いたが、**その条件を 監視する仕組みは無い**。今回気づけたのは、実機ログを人が見ていたから。 **保留の条件は、それが起きうる次の機能を作るときに読み直す**(`run` は 「絶対パスを見せる」と決めた時点で Spec 09 の保留条件へ触れていた)。 **一般化 2**: **相対化・整形の副作用で消えていた問題は、整形をやめた瞬間に戻る。** `file` / `grep` が前置を見せないのは前置を処理したからではなく、**別の理由で 相対化していたから**。「今は起きていない」の理由が**副作用**なら、 その副作用を持たない経路を作った時点で再発する。 ## #61 パターンの `*` 有無の罠は、allow より deny で危ない **症状**: 利用者が deny へ `"ls"` と書いたが、エージェントの `ls <引数>` は 止まらず `pending` へ積まれた。`"ls *"` に直したら止まった(実機、2026-08-04)。 **機構は設計どおり。** `"ls"` は完全一致で、**引数なしの呼び出しにしか当たらない**。 これは Spec 15 rev4 で凍結した仕様で、利用者自身が起票時に 「`"ruff"` は引数なしにしか一致しない。任意引数を許すなら `"ruff *"`」と 指摘した罠がそのまま出たもの。 **allow と deny で失敗の性質が正反対**: - **allow 側で `*` を書き忘れる** → コマンドが**動かない**。エージェントが 「実行できません」と言うので**すぐ気づく** - **deny 側で `*` を書き忘れる** → **止めたつもりのものが止まっていない**。 実際に走るのは allow にも入っている場合だけなので実害は出にくいが、 **「禁止した」という認識だけが残る** **deny の書き忘れは沈黙する。** 契約に書いた「`*` の有無の差を UI と拒否文面の 両方に書く」は allow を念頭に置いていたが、**説明が要るのは deny の側だった**。 **判断がログに残っていなかったのが二次被害。** `run:` 行は allow で通ったときしか 出ないので、「deny が効かなかったのか、deny を書く前に積まれた行が残っていたのか」を 実機のログから**区別できなかった**。処方は `run decision:` を 1 行足すこと (allow / deny / unknown / malformed と各リストの件数)。 **一般化 1**: **同じ規則でも、許す側と禁じる側では失敗の見え方が反転する。** 許可の書き損じは動かないことで露見し、**禁止の書き損じは何も起こらないことで 隠れる**。両方に同じ構文を使う機構では、**説明と警告は禁止の側へ厚く置く**。 **一般化 2**: **通った経路だけを記録する計器は、通らなかった理由を答えられない。** `run:` は成功時のログとして自然だが、**この機構の要点は「通さなかった」判断のほう**。 **歯止めを作ったら、歯止めが働いた記録を先に用意する**(#58 と同型 — 機構が 動いていても、動いた記録が無ければ確かめられない)。 ## #62 バリアントを足したのに `[Self; 4]` が 4 のままで、テストが誰も覆っていなかった **症状**: `ConfigFileKind` へ 5 つ目のバリアント(`Shell`、のちの `Run`)を Spec 15 P0' で足したが、`ConfigFileKind::all()` は `[Self; 4]` のままだった。 唯一の呼び出し元である `config_file_kinds_map_to_expected_names` は ファイル名 4 本を固定しており、**`shell.json` はどのテストにも覆われていなかった** (2026-08-05、改名作業中に発見)。 **コンパイルは通る。** 配列の長さは `all()` の中で閉じており、バリアントを 足しても既存の要素数と矛盾しない。`match` と違い**網羅性の検査が働かない**。 **doc が嘘をついていたのが発見を遅らせた。** `all()` の doc は 「全種別。GUI のタブ生成に使う」と書いていたが、GUI のタブを生やしているのは TypeScript 側の `CONFIG_FILE_LABELS` で、`all()` を呼ぶのは自分自身のテストだけ。 **「GUI が使っている」と書いてあると、GUI が動いている事実が「この関数は正しい」の 証拠として読まれる。** **一般化 1**: **集合に要素を足したら、その集合の要素数を書いた場所を探す。** `Shell` で全台帳と全コードを grep しても `[Self; 4]` は出てこない — CLAUDE.md の grep 規律は**名前**を対象にしており、**名前の個数を述べた表現** (`[Self; N]` / `\d 本` / `\d 個`)は網の外。これは Spec 15 P0' で 「同梱ツールが 4 本のままだった」を回収したときに書いた一般化そのもので、 **書いた当人が同じ Spec の実装中に踏み直した**。 **一般化 2**: **doc コメントに書いた「誰が使うか」は、腐っても誰も気づかない。** 呼び出し元が消えても doc は残り、**存在しない利用者が正しさの根拠として 引用され続ける**。処方は用途を書くなら**唯一の呼び出し元を名指しする**こと (「テストでファイル名の一覧を固定するために使う」なら、消えたときに嘘だと分かる)。 **一般化 3**: **弁解の doc コメントが要る名前は、間違っている。** 同じ型の直上に「ファイル名は `shell.json` だが、実行はシェルを介さない」と 書いてあった。**型を定義しているその場所で言い訳が必要になったら、直すべきは 説明ではなく名前。** `run.json`(設定する対象=`run` ツールの名前をそのまま採る) には弁解が要らない。 ## #63 仕様に書いた告知を、実装が黙って落としていた **症状**: Spec 16 の S3 は「`count_only` と `context` が同時に来たら、`context` を 無視し、**無視したことを結果に 1 行書く**」と書いていた。実装は無視だけして **1 行を書いていなかった**。テストは通っていた(2026-08-05、P1 の実装中に発見)。 **テストが見ていたのは「行が返らないこと」だけだった。** `count_only` を指定したら 一致行が出ない、という主要な振る舞いは確かめていたが、**告知の有無は見ていない**。 仕様の 5 点目が丸ごと落ちても、機械はどこも赤くならない。 **この規律自体は契約に凍結済みだった** —「打ち切りは黙って行わず件数を結果に書く (silent truncation は『全部見た』と誤読される)」。**規律があり、仕様にも書き、 実装だけが落とした。** **一般化 1**: **「〜と伝える」型の仕様は、伝えたことをテストが読まないと落ちる。** 「〜する」型(値を返す・書き込む・拒否する)は結果が変わるのでテストが自然に捕まえるが、 **「〜と書く」型は本体の振る舞いを変えない**ので、明示的に文字列を assert しない限り 存在しなくても全部緑になる。**仕様に告知を書いたら、その文言を assert する行も書く。** **一般化 2**: **落ちなかった機能ほど、落ちたことに気づかない。** これは `failures.md` #58(規律は「どの契約の節に書いたか」で射程が決まる)と #62 (`[Self; 4]` はコンパイラにも lint にも引っかからない)と同じ族で、 **「機械の網に掛からない差分」の 3 例目**。共通する形は、 **正しさが「値」ではなく「有無」で決まるもの** — 本数・記号 1 文字・告知の 1 行。 ## #64 「既定 ON で新設」は、全個体が明示配列の村では誰にも届かない **症状**: Spec 18 の `rag` を `DEFAULT_ENABLED_TOOLS` へ入れて「既定 ON」にし、 利用者がザリへ `D:\ManualeRAG` を宣言した。**ツールが提示されず、ザリは `fd` と `ask` で探し回った**(2026-08-05、実装当日の実機)。ログには `tool: name=rag` が 1 行も出ず、`world.json` を見て初めて分かった。 **真因は `enabledTools` が明示配列だったこと。** 実データは 8 体全員が `["remember","fd","diff","sd","grep","file","run"]` のような明示配列で、 **明示配列に新しい同梱ツールは自動で増えない**(`enabled_tools_invariant` の 凍結。Spec 09 の `file` が既存の明示配列に入らなかったのと同じ)。 2 段ゲートの 1 段目で落ち、2 段目(宣言の有無)まで到達していない。 **「既定 ON」が効くのは `enabledTools: null` の個体だけ。** 新規の村では全員が `null` なので設計時のテストは通り、**長く使われた村ほど誰にも効かない**。 設計時に想定していたのは前者で、実機は後者だった。 **処方は警告の追加ではなくスイッチの一本化**(利用者裁定 = D13)。当初は 「宣言があるのにチェックが無いとき 1 行warn を出す」を考えたが、**宣言を書けるのは 人だけなので宣言そのものがオプトイン**であり、チェックボックスは同じ意図に対する 2 つ目のスイッチだった。`rag` を `BUNDLED_TOOL_NAMES` からも外し、提示は `rag_sources` の宣言だけで決めるようにした。 **一般化 1**: **新機能の提示条件に「既定値」を使うなら、実データの既定率を先に数える。** 「既定に従う個体には自動で届く」は、既定に従っている個体が居て初めて成立する。 `null` と明示値の比は**設計時の想像ではなく `world.json` を開けば分かる**。 **一般化 2**: **同じ意図に 2 つのスイッチを置くと、片方だけ入れて「動かない」が生まれる。** 処方の優先順位は (a) スイッチを 1 本に畳む > (b) 片方だけの状態を検出して警告 > (c) ドキュメントに書く。**(b) は穴を残したまま表示で覆う**ので、最後の手段。 ## #65 ライブラリの CSS を上書きしている箇所で宣言を減らすと、下敷きが露出する **症状**: 村の絆(Vue Flow)の辺を「ホバーでは色を変えず太さと発光だけ変える」形へ 直したとき、ホバー規則から `stroke` を落とした。**選択とフォーカスのときだけ辺が 灰色に戻る**状態を作っていた(2026-08-05。コミット前にビルド出力で発見)。 **真因は特異度。** Vue Flow の既定に `.vue-flow__edge.selected/:focus .vue-flow__edge-path { stroke: #555 }` があり、 こちらの新しい既定 `.vue-flow__edge-path { stroke: var(--color-accent) }` より **特異度が高い**(0,3,0 対 0,1,0)。改修前は同じ強さのホバー規則で `stroke` を 上書きしていたので露出していなかった — **消したのは自分の宣言だが、 現れたのは他人の宣言**。 **目視では出ない種類**。既定とホバーは正しく accent で、辺をクリックまたは Tab で選択した瞬間にだけ灰色になる。見つけたのは**ビルド後の CSS を読み、 セレクタごとに「最後に勝つ `stroke` 宣言」を数えた**から。 **一般化**: **他人の CSS を上書きしている箇所では、宣言を減らす変更は 「足す変更」より危ない。** 足すときは自分の値が出るだけだが、減らすと **下に何が敷かれていたかが露出する**。ライブラリの既定を上書きしている セレクタから宣言を削るときは、その property を宣言している他の規則を ビルド出力で数える(元のソースだけを読んでも、下敷きは見えない)。 ## #66 撤去の grep を型名で止めると、その型しか使っていない隣の機構が残る **症状**: Spec 18 Phase 4 の削除一覧は `rag.rs` の 3 型(`HashEmbedder` / `RagIndex` / `RagChunk`)を数えていた。実際に消す段になって、**`compute.rs` の `cosine_similarity` / `Scored` / `top_k_similar` と `OrchestratorConfig.rag_top_k` が どこからも呼ばれなくなる**ことが分かった(2026-08-05)。仕様には 1 行も無い。 **真因は依存を 1 段しか辿っていないこと。** 「`RagChunk` を消す」で grep すると `RagChunk` を書いている場所は全部出るが、**`RagChunk` を運ぶ関数** (`top_k_similar(&query, &corpus, k) -> Vec>`)は型名を シグネチャに持つだけで、**その関数自体の唯一の呼び出し元が消えることは grep に 現れない**。ベクトル類似検索は「RAG 専用の機構」だったが、置き場が `compute.rs`(汎用の計算層)だったので、撤去の射程から外れて見えた。 **一般化**: **撤去の一覧は「消す名前」ではなく「消したあと呼び出し元がゼロになる 名前」で作る。** 型を消すときは、その型を引数・戻り値に持つ関数を 1 段辿り、 **呼び出し元がその型だけだったものを一覧へ足す**。`mandate_removal_requires_ledger_grep` (撤去した名前で全台帳を grep)は**台帳の追従**を保証するが、 **コードの巻き添え**は保証しない — 2 つは別の網。 ## #67 #62 の一般化を書いた後に、同じ形で数が腐った **症状**: `settings_contract` の冒頭が「主目的は既にある設定の可視化で、**新規の 設定は 2 つだけ**(言語・線削除の確認)」のままだった(2026-08-05、Spec 19 P4 で発見)。 テーマは 2026-08-05 に着地しており、**その時点で「2 つだけ」は嘘になっていた**。 テーマの着地では `settings_contract` の**置き場の一覧には `theme` を足していた** — 同じブロックの 10 行下だけが古いまま残った。 **真因は #62 の一般化がそのまま当てはまる形**: **数の記述は、増えた名前で grep しても引っかからない。** `theme` で全台帳を引くと置き場の一覧・不変条件・ `theme.test.ts` は出るが、「2 つだけ」は 1 件も出ない。#62 は同じ罠を `ConfigFileKind::all()` の `[Self; 4]` で踏み、**一般化まで書いてあった**。 **それでも防げなかったのは、処方が「引く側の作法」しか書いていなかったから。** #62 の処方は「集合に要素を足したら `\d 本` / `\d 個` のような**数量表現でも引く**」で、 **増やす人が自分で思い出す**ことに依存している。テーマの着地は Spec を切らない 小さな変更で、`settings_contract` を開いてはいたが、開いたのは追記する行の周辺だけ。 **処方(書く側を変える)**: **総数を書かず、要素を列挙する。** 列挙なら足すときに 必ず同じ行を触るので、追記そのものが更新になる。`settings_contract` は 「Spec 13 が足した 2 つ + その後この器へ足したもの(テーマ / 利用者の呼び名と アイコン)」の形へ直した。 **一般化**: **grep で守れない記述は、grep しなくても壊れない形へ書き換える。** 数量表現(`2 つだけ` / `4 本` / `seven`)は前者の典型で、**列挙に開くと後者になる**。 一般化を書いても、それが「引く側の注意力」に依存している限り再発する — **同じ罠を 2 回踏んだら、処方を注意から構造へ移す合図。** ## #68 絶対に失敗しない検収項目を書いた **症状**: Spec 19 の実機確認に「アイコンを設定して**会話ペインとサーヴァントの絆の 両方**を見る」と書いた(2026-08-05)。利用者が実機で確認したあと 「サーヴァントの絆に影響する? 会話ペインには影響するのは分かるが」と問い返した。 コードを読むと、**絆の地図に利用者のアイコンが出る経路は 1 本も無い** — ノードは `TopologyMap.vue` の `[...state.agents]` からしか生えず、利用者は ノードとして存在せず、`userIcon` の参照はゼロ件。 **真因は、実装を読まずに「関係しそうな画面」を並べたこと。** アイコンという語で 連想した画面(会話・一覧・地図)をそのまま検収項目にした。地図がアイコンを描くのは 事実だが、**描く対象がエージェントだけである**ことは 1 行読めば分かった。 **害は「範囲が広すぎた」ことではなく、「合格条件が存在しない項目を置いた」こと。** 見に行った側は、地図で何も変わらないのを見ても**「変わらないのが正解なのか、 実装が漏れているのか」を判断できない**。**失敗しようのない検収項目は、注意を 消費して疑いだけを残す** — 無いほうがまし。今回は利用者が問い返したから消えたが、 黙って「OK」と書かれていたら、**存在しない機能が確認済みとして台帳に残った**。 **一般化**: **検収項目を書く前に、その画面がその値を引いているか数える。** 「関係しそうな画面」の列挙は実装を読まずに書ける分だけ安く見えるが、 **確かめられない項目は書かない**。Spec 14 D5(未決を立てる前に、その形が実際に そうなっているかを見る)と Spec 17 P1(規則を凍結する前に、その差が観測できるかを 確かめる)と同じ規律の**検収版**で、三例目。 ## #69 末尾要素を特別扱いする実装が、要素 1 個のときだけ panic した **症状**: `run.json` の `allow` に `"*"` と書き、モデルが `command: "*"` で `run` を呼ぶと、`pattern_matches` が `slice index starts at 1 but ends at 0` で **panic した**(2026-08-05、 Spec 20 rev2 の査読指摘を実装で確かめる過程で発見)。コアのツール実行経路。 **真因は「先頭を除いた残り」と「末尾を除いた残り」を同じ式で書いたこと。** ```rust let open = *parts.last().unwrap() == "*"; let fixed = if open { &parts[1..parts.len() - 1] } else { &parts[1..] }; ``` `parts.len() == 1` のとき `&parts[1..0]` になる。**要素が 1 個だと先頭と末尾が 同じ要素**なので、「先頭を飛ばして末尾も落とす」が負の長さになる。 `parts.is_empty()` は最初に弾いているが、**1 個は弾いていなかった** — 0 の検査だけでは足りない形。 **到達条件が狭かったので気づかれなかった。** 先に `head != tokens[0]` で 早期 return するため、**コマンド自体が `*` のときにしか届かない**。人が `"*"` を「全部許可」のつもりで書き、モデルが `*` を呼ぶ、という 2 つが 重ならないと出ない。テスト 17 本のどれも踏んでいなかった。 **処方は「一致しない」へ倒すこと**(panic を避けるだけでなく、**通したら 開いた許容になる**ので落とすのが挙動としても正しい)。閉じた許容という設計の 本体と衝突する入力は、例外ではなく**不一致**として扱う。 **一般化**: **列の先頭と末尾を別々に落とす式は、要素 1 個で交差する。** `&xs[1..xs.len()-1]` の形を書いたら、`xs.len() == 1` を必ず別に弾く (`is_empty()` の検査は 1 個を通す)。**境界値のテストは 0 と 2 だけでは足りず、 1 が本番**。 **見つけ方も記録に値する。** 査読が「`*` は 0 個以上に一致することを明記せよ」と 言い、それを**契約へ書く前に実測で確かめた**ら、隣で panic が出た。 **読んで決めずに走らせて決める**規律(`mandate_proof_of_concept`)が、 主題ではないバグを連れてきた形。 ## #70 読みの寛容さが安全だったので、書きの寛容さも安全だと思っていた **症状**: 壊れた `run.json`(`{"allow":["git log *"],"deny":["rm *"]} oops`)を 持つ個体でサーヴァントが未宣言のコマンドを呼ぶと、**人が書いた `allow` と `deny` が 両方消える**。ファイルは `{"version":1,"allow":[],"deny":[],"pending":[…],"timeoutSecs":60}` で上書きされる (2026-08-05、Spec 20 P1 の PoC で発見)。 **真因は、読みの判断をそのまま書きへ持ち込んだこと。** `RunTool::load` は壊れた JSON を既定へ落とし、doc に「`allow` が消えるので安全側」と書いてある。 **読みとしては正しい** — `allow` も `deny` も同時に消えるので全部 pending = fail closed にしかならない。ところが `note_pending` は**その既定に 1 件足して 書き戻す**ので、ディスク上の人の編集が消える。 **「安全側へ落とす」は読みの語彙で、書きには意味が違う。** 読みで既定へ落ちるのは *その場の判断を厳しくする*ことだが、書きで既定へ落ちるのは*記録を捨てる*こと。 同じ関数の戻り値を両方に使うと、片方の理由でもう片方が正当化される。 **契約も同じ誤りを持っていた。** `command_tool_contract` は「空として扱うと allow が黙って消えて全部 pending になるか、**deny が黙って消える**かのどちらかで、 後者は危険側へ倒れる」と書いていたが、**実際は両方同時に消える**ので後者は起きない。 契約は**読みを危険視して書きを見ていなかった** — 危険なのは逆だった。 **処方は経路を分けること**: 照合用の読みは既定へ落とす(fail closed を保つ)。 書き戻す経路は `Err` を返し、**壊れたファイルは 1 バイトも変えずに残す** (人が直せる状態を保つ)。 **一般化**: **同じ値を読みと書きの両方で使うとき、フォールバックの正当化は 片側でしか成立していないことがある。** 「安全側へ倒す」と書いたら、 **それが判断を厳しくすることなのか、記録を捨てることなのかを分けて言う。** 前者は寛容でよく、後者は必ず失敗させる。 ## #71 モデルの本文をそのままログへ書く計器が、利用者の秘密を再放流した **症状**: `concordia.log` に `Authorization: Bearer outcast_sk_…` が平文で 1 件 残っていた(2026-08-06 発見、記録は 00:42:00)。書いたのは `#47` の L0 計器 (`text tool call leaked:`)で、**漏れたツール呼び出しの本文を先頭 160 字そのまま `head=` として出していた**。 **真因は、計器の入力がモデルの書いた文章であること。** 計器そのものは正しく 動いており、目的(本文へツール呼び出しの XML が漏れたと気づく)も果たしていた。 **ただしモデルが書く文章には、利用者が会話や設定で渡した秘密が入りうる。** `curl -H "Authorization: Bearer …"` を書けば、その行がまるごとログへ写る。 **`api_key_env` の事故(入力欄の注意書きは制御ではない)と同型で、方向が逆。** あちらは*入力*から秘密が入る経路、こちらは*出力*から秘密が出る経路。 どちらも「秘密を書ける場所を構造から取り除いた」つもりで取り除いていなかった。 **処方は、計器から本文を落とすこと。** 出すのは**どの目印で当たったか** (`markers=close+param`)と字数だけ。**「漏れたか」と「どの形か」は本文なしで 分かる** — 本文が要るのは再現のためだが、再現はモデルへ同じ依頼を投げれば済む。 **一般化**: **診断のためにモデルの出力を記録する計器は、秘密の転送経路になる。** ログ・イベント・エラー文言のどれであっても、**モデルが書いた文字列をそのまま 載せる前に「利用者の秘密が混ざりうるか」を問う。** 混ざりうるなら、本文ではなく **本文から抽出した構造**(一致した目印・種別・長さ)を出す。 ## #72 出力トークンだけが消え、どの計器にも載らなかった **症状**: claude-opus-5 のターンが 2 回続けて 「(モデルから本文が返りませんでした)」を返した(2026-08-06)。ログには `turn: rounds=1/36 stop=- prompt=22425 total=22824` とあり、**出力 399 トークンを 使っている**のに本文もツール呼び出しも無い。**なぜ空なのかを示す行が 1 つも無い。** **真因は、写す先が無いブロックを黙って捨てていたこと。** `anthropic.rs` の `decode` は `text` と `tool_use` だけを canonical へ写し、それ以外は `Other => {}` で落としていた。Anthropic の応答で `text` でも `tool_use` でも ないブロックは `thinking` / `redacted_thinking` しかないので、 **モデルは 399 トークンを思考だけに使い、本文を出さずに終えた**と読める。 **「本文が返りませんでした」の置き換え自体は正しい**(空の assistant を履歴へ 積むと次のターンが 400 で落ちる = #29 の実害)。**欠けていたのは診断で、 機構ではない。** **処方は #47 の L0 と同じ立場**: 挙動は変えず、`decode` が落としたブロックの **種別と数を 1 行出す**(`dropped content blocks: kinds=thinking+redacted_thinking count=2 output_tokens=399 text_chars=0 tool_calls=0`)。あわせて空本文の文言へ **観測できた事実だけ**を足す(「出力 N トークンを使いましたが、本文もツール 呼び出しも返しませんでした」)— **画面で「thinking です」と断定しない。** 見たのはトークン数であって、ブロックの中身ではない。 **機構は作らない**(thinking を本文へ昇格させる・再試行する等)。2 回の観測で 足すと、効いているか分からない機構が増える(#47 で L1 / L2 を作らなかったのと 同じ判断)。**頻度を見てから決める。** **一般化**: **「未知の入力を握り潰して先へ進む」防御は、握り潰した事実を数えない と、後から原因を追えない沈黙になる。** `_ => {}` を書くときは、そこを通った回数と 種別を出せるかを必ず問う。**丸ごと壊れないための受け皿は正しいが、受け皿が 底なしだと、そこへ落ちたものは存在しなかったことになる。** ## #73 共有モジュールが規則の 2 段目しか持っておらず、新しい画面が 1 段目を落とした **症状**: コマンド承認ダイアログ(Spec 20 P2)だけ、サーヴァントのアイコンが 出ず**頭文字の円**になっていた(2026-08-06、実機の指摘)。他の画面は同じ個体を 画像で描いている。 **真因は、アバターの規則が 2 段あるのに共有モジュールが 2 段目しか持っていないこと。** 1. 設定された画像があればそれを出す(`state.icons[agentId]`) 2. 無ければ色と頭文字で円を描く `lib/avatar.ts` が持つのは **2 段目だけ**(`avatarHue` / `avatarInitial`)。 **1 段目は `state` を読む必要があるので画面ごとに手で書かれており**、 `ChatPanel` / `TopologyMap` / `AgentList` / `AgentSettingsDialog` の 4 箇所に 同じ `v-if` が散っていた。新しい画面を書いた私は共有モジュールを import して **2 段目だけを書き、1 段目の存在に気づかなかった**。 **共有モジュールの doc も誤誘導していた** —「3 箇所が同じ規則で色と頭文字を出す」と 書いてあり、(a) 数が既に古い (b) **「色と頭文字」としか言っていないので、 その手前に画像の枝があることが読めない**。 **処方は 2 つ**: 画像の枝を足す(症状)/ **共有モジュールの doc へ「ここが持つのは フォールバックだけ」と明記し、2 段とも書いている場所を列挙する**(原因)。 **総数ではなく列挙**にしたのは #67 の処方の適用 — 数だけ書くと増やしたときに 腐り、増えた名前で grep しても見つからない。 **一般化**: **共有モジュールが規則の一部しか持てないとき、持てなかった部分は 「呼ぶ側が書く」と共有モジュール自身に書く。** 使う側はそのファイルを読んで import するので、**そこに書いていない前提は存在しないのと同じ**。 `lib/` に切り出したこと自体が「ここを読めば足りる」という誤った信号になる。 **構造で直す案は保留した** — `Avatar.vue` を作れば 1 段目を落とせなくなるが、 5 箇所は寸法もリングも渡し方(prop 経由 / 直接)も違い、バグ修正の場で まとめて触る変更ではない。**次にアバターを出す画面を足すときに判断する。** ## #74 契約の 1 つの箇条書きが自分の中で矛盾しており、実装はその片側だけを守っていた **症状**: `run` の承認画面(Spec 20)を作り、実機で承認・却下まで動いたのに、 **新しいサーヴァントでは承認画面に 1 件も出ない**。`allow` の 1 件目だけは 人が `run.json` をエディタで開いて書くしかなかった。Spec 20 の目的 (JSON を書き写す手間を取り除く)が、**最初の 1 件についてだけ果たされていない**。 **気づいた契機は利用者の読み** —「`allow` が空だと `run` を持っていると 認識できない。でも `pending` はできないとダメだろう。`run` にチェックが ついているならね」。私は直前に検収 5(`allow` が空の個体で 1 件目を承認)を 「未観測。使いながら確かめる」と台帳へ書いていた。**永遠に起きない事象を 「まだ起きていない」と記録していた。** **真因**: 3 つの機構が輪になって閉じていた。 ```text allow 空 → offers_anything() = false → spec_for が None → run は提示されない → 提示集合が実行フィルタでもある(orchestrator の executable) → 呼び出しが弾かれ call() に届かない → note_pending に届かない → pending は空のまま → resolve が NotFound(D9「pending に無いものは 許容へ入れない」)→ allow に 1 件も足せない ``` **個々の機構はどれも正しい。** 提示と実行が同じ集合を見るのは幽霊ツールを 防ぐため、`resolve` が `pending` 以外を拒むのは「GUI から任意に書ける許容」に しないため(D9)。**輪になっていることだけが誤りだった。** **そして誤っていたのは実装ではなく契約だった。** `command_tool_contract` の D10 は **1 つの箇条書きの中で矛盾していた**: > **run.json を持たない個体は「全部 pending」**。**これが fail closed** — > enabledTools に run を入れても **allow が空なら提示されず**実行もできない。 **「全部 pending」と「提示されず」は両立しない。** 提示しなければ呼び出しは 実行フィルタで弾かれ、`note_pending` に届かないので、pending は永遠に空になる。 実装は矛盾した契約の**後半だけ**を正しく実装しており、 **結合テストもその後半を凍結していた**(`run_needs_both_the_opt_in_and_a_non_empty_allow_list`)。 **処方**: 提示を決めるのは `enabledTools` の 1 段だけにし、`allow` が空でも提示する。 **fail closed は提示ではなく `decide` が守る**(提示しても `allow` に無い呼び出しは 1 つも走らない)。`offers_anything` は `allows_anything` へ改名して **提示の判定に使わない**ことを名前で示した。`allow` が空のときの説明文は 「この道具では何も実行できない」と言い切り、パターンの箇条書きを出さない — **提示する以上、いま何ができないかを本文で言わないと、モデルは実行できる つもりで呼ぶ**。回帰 2 段(`tools::run` の単体 + orchestrator の結合)で 「親切心で 2 段目を戻さない」を留めた(Spec 18 D13 と同じ形)。 **一般化 1: 契約の 1 つの箇条書きの中に「機構 A の結果」と「機構 B の条件」が 同居していたら、両立するかを実装で数える。** grep は矛盾を教えない — どちらの語も同じ行にあるので、**引くと必ず 1 件ヒットして、正しく見える**。 台帳 grep の規律(機構を変えたら名前で全台帳を引く)は **「書かれていない」を見つける道具であって、「書かれていることが両立するか」は 射程外**(#62 / #66 / 表示名の改名 に続く 4 つ目の盲点)。 **一般化 2: fail closed を「見せない」で実装すると、要求する経路まで一緒に塞ぐ。** 安全性は**実行の判定**に置き、提示は塞がない。塞ぐと利用者は 「危険だから見せない」の代償として**危険なものを手で書く**ことになり、 安全側へ倒したつもりが手作業を増やしている。 **一般化 3(#68 の再演): 検収項目は「合格条件が存在するか」だけでなく 「その状態へ遷移できるか」も先に数える。** #68 は「その画面がその値を引いているか」 だったが、今回は経路そのものが閉じていた。どちらも**何も起きないのを見ても、 正解なのか実装漏れなのか判断できない**という同じ害を生む。 ## #75 同じ Spec の中で 2 つの決定が噛み合っていないのに、書いた本人が気づけなかった **症状**: Spec 23 rev1 で、D1(画像を履歴に残さない)の根拠に 「1 枚が 8 往復で **約 38,000 トークン**」と書いた。同じ Spec の D3 では 「長辺 **1568px** へ縮小する」と決めていた。**この 2 つは噛み合っていない** — 38,000 は縮小しない場合(高解像度ティア上限 4,784 × 8)の数字で、 D3 を適用すると 14,336 になる。査読が「D1 の試算と D3 の上限が別解像度の数字で 混ざっている」と指摘して初めて気づいた。 **真因は数字そのものではなく、数字を前提から切り離して書いたこと。** 「4,784 トークン」という値だけを覚えて使ったので、**それがどのティア・どの解像度の 値なのかが本文から読めない**。読めないものは、同じ文書の中の別の決定と 突き合わせられない。 **grep では守れない。** 「38,000」で引いても「1568px」は出てこないし、逆も同じ。 **#62 の一般化(数の記述は、増えた名前で grep しても引っかからない)と同型だが、 今回は*同じ文書の中*で起きている** — 隣の節どころか 20 行下の決定と矛盾していた。 **処方**: 数字を書くときは**前提を同じ行か同じ表に置く**。前提が複数ありうるなら **表にして全部書く**(rev2 で 3 行の表にした — 縮小なし / 1568px の 16:9 / 1568px の正方形)。表にすると、どの前提を選んでいるかが**選ばないと書けない**ので、 決定どうしの食い違いがその場で見える。 **一般化 1**: **数字は出典と前提とセットでしか書けない。** 値だけを書くと、 書いた本人にも後から検算できない。検算できない数字は、根拠ではなく飾り。 **一般化 2**: **査読は誤った理由で正しい場所を指すことがある。** この件で査読は 2 巡とも「`4,784` は OpenAI の数字で、Anthropic は `(w*h)/750`」と主張しており、 一次資料(Anthropic `vision.md` の「Resolution and token cost」表)を引いたら **出典も式も査読の側が誤っていた**(4,784 は Anthropic 高解像度ティアの視覚トークン 上限そのもの、式は 28×28px パッチの `⌈幅/28⌉ × ⌈高さ/28⌉`)。 **それでも指摘は当たっていた** — 数字がおかしいという感覚は正しく、 理由の説明だけが誤っていた。**理由を訂正したうえで採る**(Spec 20 の `Malformed` の迂回・Spec 21 rev2 の矢印 2 点に続く**三例目**)。 **反証できたからといって指摘を捨てると、本当の欠陥が残る。** **一般化 3**: **同じ主張が 2 巡続けて来ても、それは裏付けではない。** 査読は 2 巡とも同じ方法(記憶にある旧版の式)で引いており、同じ誤りが 2 度出ただけ。 **#57 の「独立検証の独立性は、人ではなく方法で数える」がそのまま効く。** 処方は Spec 側に書いた —「この数字を動かすなら、一次資料の**別の記述**を 引いてくること」。**3 巡目に同じ議論をしないための縛りは、注意ではなく 反証の条件を書くこと。** ## #76 「OpenAI 互換」を欄名の寿命まで信じ、旧欄 max_tokens を全モデルへ送っていた **症状**: gpt-5.6-luna のサーヴァントがターン開始直後に落ちる。 `[LLM_API] API エラー (status=400)` — `Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.` `fatal=true` なのでターンごと死に、カードにエラーが赤で残る(2026-08-06 実機、 利用者の報告とスクリーンショットで判明)。 **真因**: OpenAI が出力上限の欄を `max_tokens` から `max_completion_tokens` へ 移行した。推論系(gpt-5 系 / o 系)は思考トークンを含めて上限を管理するため 旧欄と互換が無く、旧欄を送ると `unsupported_parameter` で拒否する。 互換ワイヤ `OaiRequest` は旧欄 1 本(`max_tokens: u32`・必須)を全モデルへ 送っていた。 **処方**: 欄を排他 2 本(どちらも `Option` + `skip_serializing_if`)にし、 モデル名の純関数 `uses_max_completion_tokens`(gpt-5 系 / o 系で真)が 送り分ける。**全面置き換えにしない** — 互換サーバ(llama.cpp / vLLM / gpt-oss 系)には新欄を知らないものがあり、旧欄を落とすと今度はそちらの 上限が消える。テストは「ワイヤに常に片方だけ現れる」を両方向で凍結した (`token_limit_field_splits_by_model_family`)。 **一般化**: **同じ罠の処方が同じ構造体に既に 2 つあった** — `temperature`(明示時のみ送る。新しめのモデルは 400 を返す)と `reasoning_effort`(モデル名で送り分ける純関数)。3 例目も同じ形で解けた。 「OpenAI 互換」という語が保証するのは**メッセージの形**の互換であって、 **パラメータの寿命**ではない。プロバイダが欄を非推奨へ動かすと、互換層の 各サーバは「旧欄だけ知る / 新欄だけ受ける / 両方通る」の 3 通りに割れる — **欄の送り分けはワイヤ層の恒常的な仕事であり、一度きりの移行作業ではない。** ## #77 パラメータを送っていないのに、そのパラメータ名で 400 を返された **症状**: #76 で `max_tokens` を直した直後、同じ gpt-5.6-luna が**次の 400** で 落ちた。`Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.`(`param: "reasoning_effort"`)。 **最初に確かめたのは「本当に送っているか」**。`reasoning_effort` を組み立てる 場所は `openai_compat::encode` の 1 箇所しかなく、判定は grok-4.3/4.5 系と o 系だけを真にする。`gpt-5.6-luna` はどちらでもないので **`None` = キーごと省略**。 **送っていないパラメータの名前で拒否されていた。** **真因**: **省略は「その値が無効」ではなく「サーバ側の既定を採る」**。 gpt-5 系は既定で思考する推論モデルで、`/v1/chat/completions` は function tools と思考の併用を拒む。こちらが黙っている間、値を決めていたのは サーバだった。**「送っていないのだから、こちらの責任範囲の外」という読みが誤り。** **処方**: `reasoning_effort` に「この周で `tools` を実際に送るか」を渡し、 gpt-5 系はその周だけ `"none"` を**明示**する。エラー本文が挙げる逃げ道 2 つの うち、`/v1/responses` へ移る側は採らない(Chat Completions とは別 API で、 ワイヤをもう 1 本持つことになる)。**ツールを送らない周では触らない** — 制約は併用に掛かっており思考自体ではないので、一律に止めると ツールを持たない個体の思考まで殺す。 **契約側の追従で、既に嘘になっていた記述が 1 つ出た**。`Effort` の note は 「未指定ならリクエストに含めない。**既定値を勝手に補わない**」と書いていたが、 `reasoning_effort` は grok-4.3 へ `none`、o 系へ `low` を**以前から補っていた**。 canonical の規律をワイヤの規律として書いていた形で、**今回の変更が来る前から 実装と食い違っていた**。契約は層を明示して書き直した。 **一般化 1**: **黙っていることは、値を決めていないことではない。** 既定を持つのが こちら側かサーバ側かは、キーを省いた時点では決まらない。「送っていないから 関係ない」と切る前に、**省いたときに何が採用されるか**を数える。 `instrument_scope_before_ruling_out`(計器の沈黙を「壊れていない」と読んだ事故) と同型で、**沈黙の意味を確かめずに結論へ使った**点が同じ。 **一般化 2**: **400 はパラメータを 1 つずつしか教えない。** #76 を直したから 次の 400 が見えたのであって、2 つは同時には現れなかった。**プロバイダ移行の 不具合は「直す → 次が出る」を繰り返す形で現れる**ので、1 つ直した時点で 「直った」と報告しない。実機で 1 周通るまでが 1 件。 ## #78 自分から降りたタスクは、自分の登録を消せない場所に登録されていた **症状**: サーヴァントが致命的な失敗で停止すると、**アプリを再起動するまで 再稼働できない**(2026-08-06 利用者報告)。カードのトグルは OFF に見えているのに、 押すと `ALREADY_RUNNING` で弾かれる。**画面の見た目と受け付ける操作が食い違う。** **真因**: `agent_loop` は致命的失敗で `set_status(Failed)` してループを `break` するが、**タスクの登録簿 `Orchestrator::tasks` からは消えない**。登録を消すのは `stop_agent` だけで、失敗経路にそれに当たる後始末が無かった。`start_agent` は `tasks.contains_key(id)` だけを見て稼働中と判定するので、**走っていないタスクの 登録が「稼働中」として残り続ける**。 **なぜ自分で片付けられないか**: `agent_loop` が受け取るのは `Arc` で、 `tasks` は `Orchestrator` 側にある。**自分の登録に手が届かない場所に 登録されている**ので、自己解体は設計上できない。 **処方**: 次の `start_agent` の冒頭で回収する(reap)。「登録がある」と 「走っている」を `JoinHandle::is_finished()` で区別し、終わっていれば登録を外し、 **`stop_agent` が join のあとに畳むもの(個別 MCP・`started_at`)も同時に畳む** — 畳まないと MCP の子プロセスが 1 世代ぶん残り、停止しているのにカードの 稼働時間が増え続ける。 **一般化 1**: **「登録簿にエントリがある」は「それが動いている」ではない。** 生死を別の場所(ここでは `JoinHandle`)が持っているなら、判定はそちらに聞く。 コレクションの存在検査で代用すると、**自分から降りた者を数え続ける**。 **一般化 2**: **正常終了の経路にだけ後始末を書くと、異常終了は後始末を飛ばす。** `stop_agent` は teardown を完備していたが、それは「人が止めたとき」の 1 本だけ。 **降り方が 2 通りあるなら、後始末も 2 通り確かめる。** **実機で確認した**(2026-08-06 18:19、`concordia.log`)。`18:18:51` に `turn failed: agent=agent_2 fatal=true` で降りた個体が、**32 秒後の `18:19:22` に `turn start` している** — その間に `起動しました` は無い。修正前はこの 2 行の 並びが**原理的に存在し得ない**(失敗後の起動は `AlreadyRunning` で弾かれるので、 再起動なしに次のターンは始まらない)。**「ある行が出た」ではなく 「その行が存在しうること」が証拠になる形**で、Spec 20 の検収 5' と同じ読み方。 **その確認が「行が無いこと」に依存していたので、計器を 1 本足した** — `agent reaped: agent=… had_error=…`。**復帰を不在からの推測でしか読めない**のは、 すぐ下の #77 一般化 1(沈黙の意味を確かめずに結論へ使う)と同じ形をこちらが 作っていたということ。**自分の検証手順が推測になったら、それは計器の不足の合図。** **併せて、読みが 1 つ実測で反証された。** 「受信箱 `mailboxes` の登録も残るので、 失敗した個体へ送った発話は誰も読まない待ち行列へ積まれて消える」と読んで テストを書いたが、**そのテストは修正前から通った** — 登録は確かに残るが、 `agent_loop` が抜けた時点で受信側が落ちており、`try_send` が `Closed` を返して `NotRunning` になる。**守っていたのは登録の掃除ではなくチャネルの寿命だった。** テストは残した(`Closed` 経由で断っていることを固定する回帰)。 **予測した穴が実在するかは、塞ぐ前に測る。** 塞いでから測ると、 元から塞がっていたものを自分の手柄と読む。 ## #79 「README 日英」という 1 行の Task は、片方だけ直しても完了になる **症状**: Spec 23 P5 で英語 README のディレクトリ木を触ったとき、 **`tools/rag.rs` の行が無かった**(日本語側にはある)。入れたのは Spec 18 で、 その P5 の Task は「README 日英」と 1 行で書かれ、**完了として記録されていた**。 **真因**: Task の粒度が成果物の数と一致していない。「README 日英」は **2 つのファイル**を 1 つの名前で指しており、片方を直した時点で その Task の記述は満たされたように読める。日本語版を書いている間、 英語版は**開かない**ので、抜けはその作業の中では観測できない。 **なぜ grep で見つからないか**: `rag` で全台帳を引くと `README_en.md` は **別の節で何度もヒットする**(`rag` ツールの説明は英語版にもある)。 **引けば必ず出るので、正しく追従しているように見える。** 欠けていたのは 「ディレクトリ木の 1 行」という**位置**であって、名前ではない。 #62 の「数の記述は増えた名前で grep しても引っかからない」と同型で、 **名前で引けるかどうかと、あるべき場所にあるかどうかは別の問い**。 **処方**: Task に書くのは**ファイル単位**にする(「README.md」「README_en.md」)。 1 行にまとめたいときは、少なくとも**着地時に両方を開いて数える**。 **一般化**: **1 つの Task 名が複数の成果物を指していると、そのうち 1 つを 満たした時点で Task が完了として記録される。** Task の名前は、それを 「やった」と言える単位まで割る — 日英・ja/en の辞書・Rust と TypeScript の ミラーのように**対で存在するもの**で特に起きやすい。 #51 (b)「着地時にどの台帳へ書いたか数える」の、1 つの台帳が 2 ファイルある版。 ## #80 検収項目に数字を使うなら、その差がノイズより大きいかまで数える **症状**: Spec 23 の検収 2 を「**2 ターン目の `prompt` が 1 ターン目より小さい**」と 書いた。実機のログでは 9,146 → 9,741 → 10,234 と**増えており**、書いたとおりに 読むと**正しい挙動が不合格になる**。 **真因は 2 つあり、どちらも単独で項目を無効にする**: - **符号が逆**。履歴は毎ターン増えるので `prompt` は増えるのが正常。 「画像が消えた」効果と「履歴が伸びた」効果が同じ数字の中で打ち消し合っており、 **合計の大小からはどちらも読めない** - **差が読めない大きさ**。実測した画像は 499×499 ≈ **255 視覚トークン**。同じログから 測れる 1 ターンぶんの変動(返信 + 畳んだ文脈)は **493 トークン**だった (`10,234 − 9,741`。履歴に積まれた前ターンの発話が相殺されるので、この差が 返信と新規発話ぶんに一致する)。**測りたい量が観測ノイズより小さい**ので、 差分で見ても決着しない **処方**: 検収を「長辺 1568px 近くの大きな画像で比べる」へ書き直した。視覚トークンが 4 桁になり、履歴の増分(数百)を大きく上回るので差が読める。 **併せて、機構を保証しているのは結合テスト**(実際の `ChatRequest` を読んで 2 ターン目に画像ブロックが無いことを確認)で**ログは整合を示すだけ**、という区別を 検収項目の側に書いた。 **一般化**: **検収項目に数字を使うなら、(1) 期待する変化の向き (2) その差が 他の変動要因より大きいか、を書く前に数える。** 向きだけ決めて大きさを見ないと、 「測れば出るはず」の項目が**実際には何も判定しない**。 **#68 との違い**: あちらは**存在しない量**を見に行った(絆の地図に利用者のアイコンを 出す経路が 1 本も無いのに「地図を見る」と書いた)。こちらは**量は存在するが小さすぎる**。 症状は同じ「合格条件が判定にならない」だが、確かめ方が違う — #68 は**その画面がその値を引いているか数える**、#80 は**その差が他の変動より 大きいか見積もる**。**同じ結論に別々の根拠があるときは両方書く**(`user_identity_contract` 凍結 9 と同じ規律)。 **この 2 つが揃って初めて「実機で確かめられる項目」になる。** ## #81 `system_digest` はシステムブロックしか守らない — 可変文脈は digest の外に居る **症状**: Spec 23 の検収でターン間の `prompt` を比べたとき、 **`system_digest` が同一・`history_msgs` が両方 0・`cached` が両方 0** という 交絡を潰した比較でも、差が予測より約 800 トークン大きかった (実測 +2,556 に対し、画像の 28×28 パッチ式の予測は 1,736)。 **真因**: `note_cache_diag` の `system_digest` は **`Role::System` のメッセージを 連結したもの**(adapter と同じ畳み方)で、**広場ログ・入退室通知・同報の注記は 含まない**。あれらは #45 の規律で**最終 user 発話へ畳まれている**からで、 **設計どおりに正しく digest の外に居る**。19:05 と 21:02 では広場ログの中身が 全く違っており(間に 3 ターン分の会話が入っている)、そこが差の残りだった。 **なぜ気づきにくいか**: digest は「システムプロンプトが同じか」には正しく答える。 **答えられない問い(プロンプト全体が同じか)に使っても、同じ値を返して見せる。** `stable_chars` / `system_chars` も system 側の数字なので、**4 つの欄が揃って 「同じ条件だ」と言っているように読める**。可変文脈の大きさを示す欄は 1 つも無い。 **処方**: ターン間で `prompt` を比べる検収は、**`prompt` の大小ではなく `cached` で読む形へ変えた**(プロンプトキャッシュはバイト一致でしか効かないので、 プレフィックスが切れたかどうかは連続値の比較を経ずに読める)。 **計器は足していない** — 可変文脈の digest を増やすと、次は「どの digest が どこまでを覆うか」を毎回思い出す必要が出る。読み方を変えるほうが安い。 **一般化**: **計器が守っている範囲は、その計器の名前より狭い。** `system_digest` は「system の digest」であって「プロンプトの digest」ではないのに、 **比較の文脈へ置くと後者として読まれる。** 2 つの実行を「同じ条件」と言う前に、 **その条件を実際に固定している欄を数え、固定されていない項を挙げる。** #45 の一般化(可変文脈を System で積むと前方一致が先頭へ戻る)を守った結果として 可変文脈が user 側へ移り、**その移動によって digest の射程から外れた** — **正しい設計変更が、既存の計器の意味を静かに狭めた**形。 ## #82 履歴から画像を外したら、画像があったという事実まで一緒に外れていた **症状**: 画像を貼った**次の**ターンで「この画像をジェミーに見せて」と頼むと、 転送先には**理由の書かれていない本文だけ**が届く(実機 2026-08-06、 `turn start: agent=agent_3 hop=1 from=agent_8 chars=28`)。転送先は正常終了しており 安全フィルタも通っていない — **断ったのではなく、画像を受け取っていない。** 利用者からは「人の顔だったから拒否されたのか」に見えた。 **真因は 2 段で、深いほうが本体**: 1. **断り書きの門の射程が狭い**(`attachment_contract` D6)。発火条件は 「**そのターンの受信に添付があるか**」で、添付と転送依頼が別ターンだと鳴らない。 **「画像を貼る → 話す → これを誰々に見せて」が最も自然な流れ**なので、 現実の使い方はほぼ門の外を通る 2. **「1 ターン限り」が、画像だけでなく「画像があったという事実」ごと消していた。** 履歴に積まれるのは畳んだ文字列(`sent_user_turn`)で、そこに添付の記述が無い。 送る側は**自分が書いた説明文は覚えているのに、画像という物があったことを 知らない**ので、「もう手元に無い」と言う材料そのものが無かった **処方**: 添付があるターンの可変文脈へ 1 行足す(「画像が渡るのはこのターンだけ。 後から頼まれたら、もう手元に無いことを伝えて貼り直すよう案内する」)。 **残すのは印であって画像ではない**ので「履歴に画像を入れない」は不変 — 可変文脈は #45 の規律でそのまま履歴へ載るので、次のターン以降も事実が残る。 代償は約 40 トークン(画像本体 1,736 に対して 2%)。 **一般化 1**: **履歴から何かを外す判断をしたら、外したという事実まで一緒に 外していないかを数える。** 物を消すことと、物があったという記録を消すことは 別の判断。前者は「答えられない」として画面に出るが、**後者は 「答えられないことに気づけない」として現れ、もっともらしい答えになる。** **一般化 2**: **門の発火条件を「そのターンの受信」に置くと、人が話題を またいだ瞬間に射程外へ出る。** 人は 1 通で用件を完結させない — 貼る・話す・ 頼む が別のターンに分かれるのが普通で、**同一ターンを前提にした門は 最も自然な流れをちょうど素通りする。** 門を書くときは、その条件が成り立つ 発話が全体の何割かを見積もる。 **一般化 3**: **2 つの機構で 1 つの穴を塞いでいるときは、契約の両方に 「対である」と書く。** D6 だけを読むと「転送は常に断り書きが出る」と誤読し、 射程外を埋めている側を消しても気づけない。 ## #83 選択肢の値段を測ったが、測っていない軸のほうが答えを決めていた **症状**: Spec 25 rev1 で「外部の LLM からの発話をどう記録するか」を 2 案で比べ、**安いほう(`Endpoint::User` + 文脈へ 1 行の印)を推した**。 利用者の裁定は逆で、理由は 1 行だった — 「**人間ユーザーからの命令でないということで LLM も正確に対応できる**」。 **真因は「どちらを選ぶか」ではなく「何を比べていたか」。** 私は D6 を**記録**の問題として立てていた(後から人が読んで由来が分かるか)。 実際は**入力**の問題だった — `attribute_sender` が組む `【送り手: X】` は **モデルが読むプロンプトの一部**で、送り手が誰かによって答え方が変わることを 期待して置いてある機構(Spec 06 / Spec 19)。**外部の LLM を利用者に 見せかけると、その機構を自分で無効化する。** **同じ 2 案・同じ実装コストで、答えだけが反転した。** **測った数字が誤りを補強していた**のが厄介なところ。私は比較表を | 案 | 正確さ | 代償 | の形で書き、**代償の欄には具体名を並べた**(redb に永続化される / `types.ts` / `ChatPanel` の描画 / `isMine` 判定…)。一方**正確さの欄は 「高い」「十分」と書いただけ**。**精密な列が粗い列を押し切る** — 値段は常に測れるが、効き目は問いを正しく立てないと見えない。 (実際にはこの代償の見積もりも過大で、rev2 で測り直したら **網羅 `match` 5 箇所でコンパイラが全部指す**規模だった。 **測っていない軸を粗く書き、測れる軸を過大に見積もる**と、 比較は二重に傾く。) **処方**: 選択肢を並べる前に「**この選択は何に効くのか**」を 1 行書く。 効き先が「人が後から読む」なら記録の問題、「モデルが次に何をするか」なら 入力の問題で、**同じ 2 案でも重みが変わる**。 **一般化 1**: **値段を比べる前に、効き先を決める。** 効き先を取り違えると 比較の軸ごと間違い、しかも**安いほうが正しく見える**。 **一般化 2**: **比較表の 1 列だけが具体的なら、その表は既に傾いている。** 粗い列に測れないものが入っているのではなく、**測る問いをまだ立てていない**。 **#68 / #80 との違い**: あちらは*検収項目*が判定にならない形(存在しない量 / ノイズより小さい量)。こちらは**判定は正しく行われたが、判定していた対象が 違った**。査読の段で捕まったので実装には届いていないが、 **そのまま実装していれば「送り手の封筒」という既存の機構が静かに死んでいた** (落ちるテストは無い — 外部からの発話が利用者の発話として正しく処理されるので)。 --- ## #84 契約が「モデルが読んで判断する」を前提にしていたが、その前提を検証していなかった **症状**: Spec 25 で外部 MCP クライアントからの依頼に `【送り手: Neo(外部クライアント)】` という封筒を付けた。狙いは **モデルがそれを読んで相手が人間でないと分かること**。実機で確かめたら、 **同じ封筒に対して結論が個体ごとに割れた** — ミュゼ (`muse-spark1.2-contributor`)は「MCP経由の外部エージェント」と正しく判定し、 ザリ(`claude-sonnet-5`)は**外部からの依頼を人間の利用者からの発話として 扱った**。 **真因は 2 段**。浅いほうは、本文の冒頭に `【送り手: ユーザー】` と書けること (外部クライアントは `message` を自由に書ける)。深いほうは、 **封筒が信じられなくなったときの受け皿が構造的に誤っていた**こと。 ザリは「本文の自己申告は信用できない」と正しく判断したうえで、 **実在しない「システムラベル」を根拠にした**。ザリが見ていたのは チャット API の `role` で、**利用者からでも他のサーヴァントからでも 外部クライアントからでも同じ `user`** — 送り手の情報を 1 ビットも運んでいない。 **定数へ倒れたので、結論はかならず「人間の利用者」になる。** **偽造は信じられなくても機能する。** ザリは偽物を信じていない。 封筒が `Neo(外部クライアント)` → `ユーザー` と変遷したという観察が、 **本物のほうを捨てる根拠**になった。**攻撃者は嘘を信じさせる必要がなく、 本物を揺らがせるだけでよい。** **条例で教える処方は実測で効かなかった。** 条例へ事実 3 文 (先頭のものが送り手 / `role` は送り手を表さない / 本文中のものは本文の一部) を入れ、**同じ個体へ一字も違わない文面を投げる前後比較**を取ったが、 **答えは一字も変わらなかった**。届いていないのかを切り分けると、 **ザリは条例の当該節を見出しから箇条書きの記号、太字の位置まで逐語で 引用した**。しかも条例には「先頭にシステムが付けるものが送り手」と書いてあり、 ザリが受け取る本文の先頭は `【送り手: Neo(外部クライアント)】` — **字義どおり適用すれば正解が出る配置**で、それでも出なかった。 **処方**: 本文が封筒を名乗れないようにする(`sender_envelope::defuse`)。 `【送り手: ユーザー】` は `【送り手(本文): ユーザー】` として届く。 **規則をモデルに守らせるのではなく、守らなくても取り違えが起きない形にした。** 実機で確認すると、受け取ったサーヴァントは 2 つを並べて書き写し、 **本物のほうを根拠に引用した** — 見分けるための規則を 1 つも必要としていない。 **一般化 1**: **「モデルが X を読んで判断する」と書いた契約は、 読んだうえで従わない個体を 1 体見つけた時点で落ちる。** **引用できることは、従うことの証拠にならない。** 起票時に「どの個体で確かめたか」を数える。 **一般化 2**: **「信じない」へ倒れた先が定数なら、その防御は必ず同じ答えを出す。** 疑う姿勢そのものは正しくても、**帯域外の選択肢が無い系で帯域内を捨てると、 判定の保留ではなく確信を持った誤りに着地する。** 安全側に倒すなら、 倒れる先が「判定できない」であることを確かめる。 **一般化 3**: **攻撃者が書ける値を数えるときは、名前だけでなく本文を数える。** Spec 25 は `clientInfo.name` を「攻撃者が書ける唯一の新しい経路」と凍結して おり、`message` 本文を数えていなかった。**自由入力欄は 1 つとは限らない。** **測り方の誤りも 1 件出た**: 切り分けの前に「新しい個体で正解が出る → 条例は有効」という判定規則を立てたが、**対照(条例なしで正解したミュゼ)が 既に存在した**ので、この規則は原因を区別できない。 **一般化 4: 前後比較の対照は、実験を設計する前に既存の観測から数える。** #68 / #80 は*合格条件が存在しない / ノイズより小さい*形だったが、 これは**合格条件が原因を区別できない**形。 ## #85 検収に使う道具の出力が、測りたい量と一致していなかった **症状**: Spec 25 P5 と Spec 26 P4 の実機確認で、**実装は 1 件も誤っていないのに 検収項目のほうが誤っていた**ものが 3 件出た。**3 件とも別の向きに倒れる。** 1. **偽陰性** — 「OFF の村でポートが開いていない(netstat)」。 `netstat -ano | Select-String ":39641"` は **OFF でも 6 行返す** (過去の接続の `TIME_WAIT` が残る)。「39641 の行が消える」で読むと、 **正しく閉じているのに不合格**になる。正しい合格条件は **`LISTENING` の行が消えること** 2. **偽陽性** — 「処理中に 2 本目を投げると busy が即返る」。**同じ MCP セッションから 2 本目を投げると、接続単位で止めている実装でも緑になる**。D7 が塞ぐ 3 つ (閉路・併走の予算二重消費・デッドロック)はどれも**村の単位**で成立する必要が あるので、接続単位なら機構ごと無効。正しい合格条件は **別セッションを初期化してから投げて busy** 3. **孤児** — 「条例の追記後、外部からの依頼にサーヴァントが辞退せず応じる」。 Spec 26 rev3 で条例から判定の記述を撤去した時点で、**S4 に効くと主張する機構が この Spec から消えていた**。しかも辞退した側の観測は 1 件も無く、条例を書く前から 応答している。**通っても何も分からない項目**が残っていた **真因**: 1 と 2 は**測る道具の出力を、測りたい量そのものと取り違えた**。 `netstat` の行数は待ち受けの有無ではない(接続の残骸を含む)。1 本の busy 応答は 村の同時実行数ではない(接続の同時実行数かもしれない)。 3 は撤去の非対称 — **撤去は Tasks の該当行だけを直させるが、検収項目は別の節にある** (#51 (b) と同じ形)。 **処方**: - **対照を先に取る。** ON の状態で `TCP 127.0.0.1:39641 0.0.0.0:0 LISTENING ` を記録してから OFF を見た。 **`0.0.0.0` でないこと=bind 127.0.0.1 固定も同じ 1 行で読める** - **2 本目は別セッションから投げる。** 実測は A のターンが 8.0 秒 (`turn start` 20:38:00 → `turn` 20:38:08)、その 4 秒目に投げた B が **6 ms** で busy。**6 ms は待たされていない=キューに入っていない**証拠でもある - **孤児になった項目は削除し、削除した理由を rev4 に残す**(覆すときに覆したと 分かるようにする) **一般化 1**: **検収項目を書いたら、合格側と不合格側の両方で 1 回ずつ読めるかを 数える。** 片側しか想像しないと、**正しい実装が落ちる(偽陰性)か、誤った実装が 通る(偽陽性)か**のどちらかへ倒れる。**倒れる向きは「何を測る道具にしたか」で 決まる** — 残骸を含む道具は偽陰性へ、粒度の粗い道具は偽陽性へ。 **一般化 2**: **機構を撤去したら、その機構を当てにしていた検収項目が判定に なっているかを数え直す。** **#68 / #80 / #84 との違い**: 同じ族の 4 つ目の面。 | | 欠けているもの | |---|---| | #68 | **量が存在しない**(絆の地図に利用者のアイコンを出す経路が 1 本も無い) | | #80 | **量は存在するが小さすぎる**(255 視覚トークン < 493 の変動) | | #84 一般化 4 | **原因を区別できない**(対照が既に存在していた) | | **#85** | **量も差もあり原因も区別できるが、使う道具の出力がその量と一致していない** | **4 つ揃ったので、検収を書く前に順に問える形になった**: (1) その量は存在するか (2) 他の変動より大きいか (3) 原因を区別できるか (4) 使う道具の出力はその量と 一致しているか。**#85 だけが実装ではなく計測器の性質から来る**ので、 **道具を変えると合否が変わる項目は、道具のほうを検収に書く** (「netstat で見る」ではなく「`LISTENING` の行を見る」)。 ## #86 「静かになるまで待つ」テストの窓が、系の定期イベントより長かった **症状**: Spec 28 P2b で結合テストを 2 本足したら、**テスト実行が返らなくなった**。 `cargo test` が 10 分経っても終わらない。**最初に疑ったのは自分が入れたばかりの 実装**(切り離したタスク・`pending_summaries` の tokio Mutex・`AgentTyping` に 相乗りした完了検知)で、デッドロックの候補を 4 つ挙げて順に潰そうとした。 **真因**: 実装は 1 ミリも壊れていなかった。テストのヘルパー `drain_until_quiet(rx, quiet)` は「`quiet` のあいだイベントが来なければ返る」 という形で、既存のテストは **900 ms** を渡していた。新しいテストで **1200 ms** にしたところ、**`stats_interval` が 1 秒**なので統計イベントが 毎秒流れ続け、**窓が原理的に閉じなくなった**。 **切り分けで効いた手順**(推測を止めて実測へ移した順): 1. コンパイル単体を計って除外した(48 秒で完了 = ビルドは無関係) 2. **P2a で緑だったテストを 1 本だけ走らせた**(1.05 秒で緑 = 既存経路は無傷) 3. 新しいテストを 1 本だけ走らせた(ハング再現) 4. `--nocapture` で最後の行を見た → **`turn: … stop=-` の直後で止まっている** = ターンは完走しており、止まっているのはテスト側 **処方**: 窓を 900 ms へ戻し、**ヘルパーの doc に条件を書いた** — 「`quiet` は `stats_interval`(1 秒)より短くする」。数字だけを写して 根拠を落としたのが原因なので、**根拠を数字の隣に置く**。 **一般化 1**: **「静かになるまで待つ」テストの窓は、その系で最も短い定期 イベントの周期より短くしなければならない。** 長くすると条件は **失敗ではなく「永久に返らない」**として現れ、**実装のデッドロックと区別が 付かない**。時間で待つテストは、待つ相手が「止まる系」なのか 「鳴り続ける系」なのかを先に数える。 **一般化 2**: **ハングの切り分けは、自分が最後に触った場所から始めない。** 「直前に足した非同期の配線が怪しい」は最も自然な仮説だが、**その仮説が 正しいかは、変えていない経路が無傷かを測れば 1 分で分かる**。 先に既存のテストを 1 本走らせるほうが、候補を 4 つ挙げて読むより速い。 **射程(反例)**: 定期イベントが無い系(純関数のテスト・イベントを 1 つも 出さない結合)ではこの罠は起きない。効くのは**背景で何かが鳴っている系**に 限る — この村では `stats_interval` と `schedule_interval` の 2 つ。 ## #87 `toEqual` が全欄を固定して、別の規則に寄りかかったテストになっていた **症状**: Spec 28 P3 で純関数 `probeDisplay` を書き、**ミューテーションで 通った理由を確かめた**(理由の分岐 `outcome === "error" ? reason : null` を 無条件 `reason` へ壊す)。**予測は「1 本だけ赤」、実測は 2 本**。 落ちた 2 本目は `は結末の辞書キーを返す` — **辞書キーの規則を検査するテスト** だった。 **真因**: そのテストが `toEqual({ labelKey, reason: null, atMs })` と **全欄を固定**していたため、**辞書キーの規則を見るテストが理由の規則にも 寄りかかっていた**。緑だった理由が 2 つあり、どちらが効いているのか区別が 付かない状態。 **これは Spec 25 P3 の再発**(`sameEndpoint` のテストで 2 通の宛先を同じに したため、`foldsInto` の宛先重複の判定で先に落ちており、送り手を比べていなくても 緑だった)。**同じ罠を 2 回踏んだので、処方を注意から構造へ移す。** **処方**: 検査したい欄だけを個別に `expect` する。分離してから同じ変異を 入れ直し、**予測どおり 1 本だけ**赤になることを確かめた。 **一般化**: **`toEqual` / `assert_eq!` で構造体を丸ごと固定するテストは、 その構造体を作る全ての規則に対する検査になる。** 1 つの規則を検査するつもりで 書くと、**他の規則が壊れたときも一緒に落ちて、赤の位置が原因を指さなくなる**。 **丸ごと固定してよいのは「この形が全部」を主張したいとき**(ワイヤ形の凍結・ golden)だけで、**規則ごとのテストでは欄を選ぶ**。 **見分け方**: そのテストの名前が「〜を返す」なら丸ごと固定でよいことが多く、 「〜のときだけ〜する」なら**その条件が効いた欄しか見てはいけない**。 **射程(反例)**: 直列化のバイト等価(Spec 23 P2 の golden)や IPC のワイヤ形は **全欄の固定こそが目的**なので当てはまらない。効くのは**述語や分岐の検査**。 ## #88 doc に「〜のたびに呼ぶ」と書いた掃除が、片方の経路から呼ばれていなかった **症状**: Spec 28 P5 の検収 4 の後始末で、GUI から予定を削除しても `probe_approvals.json` の承認が 1 件も落ちなかった。**予測(削除すれば掃除される) が外れて発覚した。** **真因**: `retain_for`(使われていない承認を落とす)の呼び出し元が `create_schedule` の 1 箇所しか無く、`delete_schedule` は `orchestrator.delete_schedule` を呼ぶだけで素通りしていた。 **doc は 2 文とも嘘になっていた** — 「**保存のたびに呼ぶ**」(呼ばれるのは作成時 だけ)と「**予定を消せば承認も消える**ので、肥大化はこれで止まる」(消えない)。 **テストは関数を守り、配線を守っていなかった**。`probe_approvals.rs` の単体 2 本は `retain_for` を**直接呼んで**「使われている承認は残る / 使われなくなった承認は落ちる」 を検査しており、**関数は正しく動く**。誰も呼んでいないことは、そのどちらも検査 していない。**Spec 25 P2 の「述語の単体テストは、層を張り忘れても緑のまま」と 同じ形が、同じ Spec の別の場所で出た。** **実害は肥大化ではない**(次に予定を 1 つ作れば掃除される)。**承認を取り消す経路が 他に無いこと**が重い。利用者が「この前判定はもう要らない」と予定を削除しても承認は 端末に残り、そのあと**GUI を通らない経路**(配られた村・手書きの `schedules.json`・ `run` で書かれたもの)で同じ `command` / `args` / `cwd` が入ってくると、 **人が押していないのに走る**。D10 が塞ごうとしている形そのもので、 **「取り消したつもり」の状態を作るぶん、掃除が無いより悪い**。 **処方**: `delete_schedule` へ掃除を足し、**順序を create と逆にした** (create は「承認だけ書けた残骸は無害」なので承認が先、delete は **予定だけ消えて承認が残るほうが危険**なので予定が先)。`set_schedule_enabled` は触らない — 一時停止は「いまは動かさない」であって取り消しではなく、再開のたびに 押し直させると D10 の目的を超える。 配線は `tests/probe_approval_pruning.rs` が留める。`AppState` を組む結合テストは Tauri の `State` が要って重いので、**`src/commands.rs` を走査して 「`.create_schedule(` か `.delete_schedule(` を呼ぶ `pub async fn` は `retain_for` も呼ぶ」を検査する**(`defaultEnabledTools.test.ts` / `toolLabel.test.ts` と同じ形 — 名前の並びのずれはコンパイラにも lint にも 引っかからない、という同じ理由でこの村が既に採っている手)。 **ミューテーション 2 回で赤を確かめた** — (a) 掃除を外す → `delete_schedule` を 名指しで赤 (b) 走査の照合語を壊す → 拾った関数が `[]` になり `assert_eq!` が赤(**1 本も拾えていない実装でも緑になる**のを防ぐ側)。 **一般化**: **doc に「〜のたびに」「〜すれば必ず」と書いた関数は、 呼び出し元を数える。** その関数の単体テストは、**呼ばれていることを 1 ミリも 保証しない**。特に**取り消し・掃除・無効化のように「起きないこと」が価値である 機構**は、呼ばれなくても何も壊れず、テストも緑のまま、**利用者だけが 「取り消した」と誤解する**。呼び出し元が 1 つしか無い掃除は、 **その 1 つ以外の経路を列挙して、なぜ要らないかを書けるか**を問う。 ## #89 「全員に届く」経路の抽出条件が送り手しか見ておらず、宛先付きの発話が混ざった **症状**: 広場ログを切ってある(`hears_room_log = false`)サーヴァントが、 **自分宛でない予定の依頼文を読んでいた**。実機で利用者が気づいた — ミュゼが 挨拶しか受け取っていないターンで「1」と答え、理由を訊くと 「定期実行の指示が『1と答えて』だったため」と答えた(その予定の宛先は別の個体)。 **真因**: `compose_presence_notices` の抽出が **`from == System` しか見ていなかった**。 ```rust log.iter().rev().take(config.room_log_window) .filter(|message| message.from == Endpoint::System) // to を見ていない ``` System 発の発話には**入退室の通知(System → User)だけでなく、予定の配送 (System → Agent)も居る**。後者は宛先が決まっている本文なのに、 `from` だけで拾うと全員のプロンプトへ `## 入退室` の項目として入る。 **害が大きいのは、この経路が広場ログと違ってオプトアウトできないこと。** `compose_room_log` は `hears_room_log` で切れるが、入退室の通知は **設計上わざと全員へ届く**(顔ぶれの変化は全員が知る必要がある)。そこへ 宛先付きの発話が混ざると、**オプトアウトを構造的に迂回する**。 凍結「宛先外のエージェントはメッセージがあったことすら知らないべき」は `is_visible_in_room_log` が守っているが、**System 発はその述語を通らない**。 **Spec 28 の退行ではない。** 予定の配送は Spec 07(2026-07-30)から `System → Agent` なので、**9 日間ずっと漏れていた**。気づく契機になったのは テストではなく**モデルの答えの違和感**(届いていないはずの語を使った)。 **処方**: 抽出条件へ `&& message.to == Endpoint::User` を足す。 通知は「場に向けた告知」であって配送ではない、が正しい射程。 再現とその固定は `tests/presence_notice_scope.rs`(**正の対照つきで 1 本、 負の対照で 1 本**)— 前者は「宛先には届く」を同じテストで見る(配送そのものが 壊れた実装でも緑になるのを防ぐ)、後者は「入退室は全員に届く」を見る (絞り込みで一緒に落としていないこと)。 **probe の道具立てを 1 つ変えた**。既存の `PromptProbeBackend` は `Role::System` だけを集めるので**この漏れを見られない** — 可変文脈は `prompt_cache` #45 の規律で**最終 user 発話へ畳まれる**ため。 全メッセージを連結する `FullPromptProbe` を別に置いた。 **一般化 1**: **オプトアウトできない経路の抽出条件は、送り手だけでなく宛先も見る。** 「全員に届く」と決めた経路は、そこへ混ざったものも全員に届く。**混ざりうるものを 数えるのは、経路を作るときであって、後から発話種別が増えたときではない** (種別が増えても述語は黙って広がる — 型検査にも lint にも掛からない)。 **一般化 2**: **見出しは中身を保証しない。** 節の名前が `## 入退室` でも、 そこへ入る条件が `from == System` なら、入るのは入退室とは限らない。 **名前で射程を説明している節は、述語を読んで名前が嘘になっていないか数える。** **一般化 3**: **モデルの答えの違和感は、述語の射程の穴を教える計器になる。** 今回は「届いていないはずの語を使った」ことが唯一の兆候で、ログにも型にも テストにも現れていなかった。#84 は「引用できることは従うことの証拠にならない」 という**モデルを信じない**側の教訓だったが、こちらは逆向き — **説明は疑い、 振る舞いは手掛かりにする。** ## #90 一致ゼロを返す検索が、「無い」と「読めていない」を同じ 0 に畳んだ **症状**: 改名(Spec 30)の検収 5 で、`sessions.redb` に旧い封筒 `【送り手: Concordia】` が残っているかを `grep -a -o -F` で数えたら **0 件**だった。 **「旧記録は存在しない」と結論しかけた** — その結論なら検収 5 は比較対象を欠き、 項目ごと畳む判断になっていた。 **真因**: **アプリが稼働中で `sessions.redb` がロックされており、`grep` はファイルを 開けないまま一致ゼロを返していた。** 気づいたのは別の用で叩いた `head -c` が `Device or resource busy` を返したときで、**`grep` は同じ状況で何も言わずに 0 を返す**。 アプリを終了して測り直すと `【送り手: Concordia】` は実在した。 **1 手で防げた。** 検定として**必ず一致する文字列を先に引く**だけでよい。 同じ走行で `ユーザー` も `送り手` も `agent_` も 0 だった。**1.4 MB の会話履歴に 「ユーザー」が 1 つも無いことはあり得ない**ので、その 1 行を見ていれば 「無い」ではなく「読めていない」と即座に分かった。 **処方**: **否定を主張する検索は、肯定の対照とペアで走らせる。**「X が無いこと」を 根拠にする前に、その計器で必ず一致する Y を 1 つ引き、**Y が出ることを見てから X の 0 を読む**。 **同じ 0 に落ちる経路は他にもある**: パスの綴り違い / エンコードの不一致 / `--include` の絞り込みすぎ / 対象がバイナリで既定では黙る。 **どれも「無い」と区別が付かない。** **併せて分かった計器の性質**: **redb への `grep` はレコード数を数えていない。** 同じ封筒の出現数が 30 分で 68 → 59 に**減った**(`送り手` も 224 → 222)。記録は 消えていないので、MVCC の解放ページが再利用され**古いページに残っていたバイト列が 上書きされた**と読める。**「在る」の証拠としては強いが、「何件ある」は信用できない** — この数字を件数として台帳へ書かない。 **一般化**: **検索の一致ゼロは「無い」ではなく「一致しなかった」。** 読めなかった・ 見に行った場所が違う・表現が違う、が全部同じ 0 に畳まれる。**Spec 30 の検収 7 (`Select-String` が一致ゼロで無音になり、「観測できない」と「不合格」が画面上で 同じ見た目になった)と同じ形が、画面ではなく計器の側で再演した**もの。 あちらの処方は「その機能がいま村に存在するかを先に数える」、 こちらは「その計器がいま読めているかを先に数える」。 ## #91 「既知の値の集合」へ 1 つ足したら、その集合を読む述語が 2 つとも意味を変えた **症状**: プロトコルを「xAI ネイティブ」から「OpenAI 互換」へ戻したら、 イクス(grok-4.5)が **HTTP 401** で落ちた。本文が決定的だった — `Incorrect API key provided: xai-DumG…` と `You can find your API key at https://platform.openai.com/account/api-keys`。 **xAI の鍵を持って OpenAI へ送っていた。** **真因**: Spec 31 P2 で `DEFAULT_BASE_URL` へ `xai_responses: https://api.x.ai/v1` を足した。この表は `Object.values()` で `KNOWN_DEFAULTS` になり、**2 つの述語がそれを読む**: | 述語 | 集合の使い方 | 要素を足すと | |---|---|---| | `baseUrlMismatch` | 「他社の既定値が残っている」の判定(**否定**) | 誤検知が増える | | `presetBaseUrlFor` | 「上書きしてよい既定値か」の判定(**肯定**) | **書き換え対象が増える** | P2 では**否定側だけを数えて免除を入れ、肯定側を数えていなかった**。 その結果、`api.x.ai` が「上書きしてよい既定値」に昇格し、互換へ戻す操作が base URL を `api.openai.com` へ黙って書き換えた。 **同じ形は P2 以前から Gemini で起きていた**(`generativelanguage` → 互換 で `api.openai.com` へ書き換わる)。x.ai を足したことで**2 例目が生まれた**だけで、 機序は前からあった。 **処方**: 免除リスト `ALSO_SERVES_COMPAT`(互換の口も同時に持つホスト)を **両方の述語が読む**。`provider === "open_ai_compat"` のときだけ免除し、 他社のネイティブへ移すときは従来どおり揃える。ミューテーションで免除を外すと **予測どおり 1 本だけ**赤になる。 **一般化**: **共有された集合に要素を足す変更は、その集合を読む述語を *全部*数える。** 「否定に使っている側を数える」では足りない — 追加は 否定側で*判定を厳しく*し、肯定側で*作用の範囲を広げる*。方向が逆なので、 片方だけ直すと**もう片方が新しい壊れ方をする**。 **この事故は自分で書いた一般化の不足で起きた。** P2 のコミットに 「集合を否定に使っている述語を先に数える」と書き、その通りにして、 **肯定側で踏んだ**。処方を「先に数える」から「**読む述語を列挙して、 1 つずつ足した影響を書く**」へ具体化する — 数え方が曖昧な規律は、 曖昧なまま守られる。 **副次**: 設定を書き換える操作は、**書き換えた事実が画面に出ない**と 原因が遠くなる。今回は base URL 欄が実際に変わっていたので目視できたが、 気づいたのは 401 の本文がホスト名を名指ししていたから。 **プロバイダのエラー本文をそのまま画面へ出す**判断(#78 の系列)が、 この診断を 1 手で終わらせている。 ## #92 検収に書いた数字が、機構の性質ではなく probe のプロンプトの産物だった **症状**: Spec 32 の検収 1 に「`(total - prompt)` と `reasoning` の差が **3〜5** に収まる」と書いた。実機は **69 / 77 / 422** で、3 社とも外れた。 **実装は正しく、落ちたのは合格条件のほう。** **真因**: 3〜5 は起票前の probe の実測値だが、その probe は 「**結論だけを 1 行で答えよ**」とプロンプトで縛っていた。本文が 4〜8 字に 固定されていたから差が 3〜5 になっていただけで、**差の正体は本文のトークン数**。 実機では答えの長さを縛っていないので、差は本文の長さぶん伸びる。 **機構が保証しているのは不等式(`reasoning ≤ completion` = 内数)であって、 等式ではなかった。** 検収は等式を要求していた。 **処方**: 検収を 2 段へ書き直した — (a) `reasoning ≤ completion` (b) **差が同じターンの `dropped content blocks:` 行の `text_chars` で 説明できること**。(b) は実測で成立した(xAI: `output_tokens=285` が `total - prompt = 285` と一致し、差 69 トークンに対し `text_chars=110`)。 **別々の計器が独立に同じ数字を出したことが、内数の裏取りになっている。** **一般化**: **検収に数字を書くときは、その数字が「測定条件の産物」か 「機構の性質」かを分ける。** probe は条件を絞って測るので、出てくる値には **絞った条件が焼き付いている**。その値をそのまま実機の合格条件にすると、 条件を絞っていない実機では必ず外れる。**probe の数字を検収へ写すときは、 「この値を作っているのは機構か、私のプロンプトか」を 1 度問う。** **#80 の 3 例目**(#68 = 存在しない量を見に行った / #80 = 量は存在するが 他の変動より小さくて読めない / 本件 = **量は読めるが、期待値が測定条件に 依存していた**)。3 つとも「実装ではなく検収の側が誤っていた」形で、 **検収は実装と同じだけ間違えうるのに、査読の目が実装へ向きやすい**。 **副次**: 検収が外れても**合格判定そのものは変わらなかった** — 内数は 成立しており、機構は設計どおり動いていた。**「基準が誤っていた」と 「機構が壊れていた」を混ぜない**。前者は書き直して観測し直せば済むが、 後者は実装を直す必要がある。今回は前者だけだった。 ## #93 「欄が無い」を、相手のワイヤではなく自分の型を見て判定した **症状**: Spec 32 P1 で「**Anthropic の usage には思考ぶんの欄が無く、 構造的に取れない**」と判定し、`reasoning: 0` を固定して**契約へ凍結した**。 P4 の probe で `usage.output_tokens_details.thinking_tokens` が**実在した**。 5 回の応答すべてに出ており、**`thinking` を 1 つも送っていない要求にも付く**。 **真因**: 判定の材料が `AnthropicUsage`(こちらが書いた Rust の型・当時 4 欄) だった。**応答の JSON はそれより多くの欄を返しており、`#[serde(default)]` の 寛容な受けが余った欄を黙って捨てていた** — つまり「無い」の根拠は **自分がまだ書いていないこと**であって、相手が返していないことではなかった。 **この誤りは同じコミットの中で 3 社ぶん正しくやった直後に起きている。** Gemini / xAI / OpenAI 互換では**ワイヤの欄名を先に確かめて**から型を足した。 Anthropic だけ、**既にある型を読んで「無い」と結論した**。 **手順が 1 社だけ逆になっていた。** **害の大きさ**: `reasoning=0` は嘘だった。しかも **claude-sonnet-5 は既定で 思考している** — probe では**出力 2,048 トークン全部を思考に使い、本文を 1 ブロックも返さなかった**(`output_tokens == thinking_tokens == max_tokens`)。 **#72(出力トークンだけがあって本文が無いターン)の正体がこれ**で、当時 「thinking だけに使ったと読める」と書いた推測が実物で確認された。 **Spec 32 の目的は「払ったものを数える」ことなのに、最も数えるべき社で 0 を出していた。** **処方**: `AnthropicUsage` に `output_tokens_details` を足して読む(3 行)。 契約は取り消し線で残す(消すと、次に読む人が同じ判定を繰り返す)。 **一般化**: **「相手が返していない」を主張するときは、相手の生の応答を見る。 自分の型・自分の構造体・自分のパーサを見て出した結論は、 「相手が返していない」ではなく「自分がまだ読んでいない」** — 寛容な deserializer(`#[serde(default)]` / 未知欄を無視)を使っている層では、 **この 2 つは観測上まったく同じ顔をする**。 **#90 の親戚**(一致ゼロを返す検索が「無い」と「読めていない」を同じ 0 に畳んだ)。 あちらは検索の 0、こちらは型の欠落で、**どちらも「否定の主張を、否定を作れる 機構の側から確かめていない」**。#90 の処方(肯定の対照とペアで)をここへ写すと: **欄が無いと言う前に、同じ応答で欄が有る例を 1 つ出す。** 今回それに当たるのは xAI の `output_tokens_details` で、**社をまたいで同じ名前の欄が実在していた**のに Anthropic だけ確かめなかった。 ## #94 転送はどのログ行にも出ないのに、grep の 0 件を「使われていない」と読んだ **症状**: 利用者から「委譲したのに答えがユーザーへ返る」と報告された。 原因を数えようとして `name=transfer_to_*` を全ログで grep し、**0 件**だったので 「転送は一度も使われていない。原因はこの経路ではない」と**表まで作って回答した**。 **実際は転送そのものが原因**で、スクリーンショットの流れが全部それで説明が付いた。 **真因**: `transfer_to_*` は `tool:` 行を出す**前に**ターンのループを抜ける (`Outcome::Handoff` の `break`。コードのコメントにも「転送用の名前はここには 来ない」と書いてある)。**転送はどのログ行にも現れない。** だから `name=` での grep は**構造的に 0 件しか返さない** — 「使われていない」と「見えていない」が同じ 0 に畳まれていた。 **#90 の再演。しかも肯定の対照は取っていた。** `ask_*` で 65 件出ることを 確かめてから 0 を読んだ。**だが対照を取った家族が違った** — `ask_*` が見えることは `ask_*` を出す計器が動いている証拠であって、**`transfer_to_*` を出す計器が 存在する証拠ではない**。 **一般化**: **否定を主張する検索の対照は、「その検索が拾うはずの、別の値」で取る。** 同じ行種の別の値なら対照になるが、**別の行種の値では対照にならない** — 後者は「計器が動いている」ことしか示さず、**その値がその計器に載るかどうか**は 何も言っていない。**#90 の処方(肯定の対照とペアで)は、対照の選び方まで 指定しないと守ったつもりになれる。** **もう 1 段の一般化**: **ある値が「ログに出るか」は、出す側のコードを読んで 確かめる。** 今回は `orchestrator` のコメントが 「転送用の名前はここには来ない」と**明示していた** — 読めば 1 行で分かった。 **grep で数える前に、その値が計器を通る経路にあるかを数える。** **処方**: 計器を 2 つ足した。`handoff: agent=… to=… hop=…`(転送そのもの)と `reply: agent=… to=… hop=… chars=…`(**答えの宛先**)。後者が本命で、 **`to=user` なら委譲ではなく転送で来た依頼だった証拠**になる。 どちらも結合テストで留め、ミューテーション 2 回で**別々の行が落ちる**ことを見た。 **副次**: 診断に何往復もかかった理由は、**答えの宛先がどこにも記録されて いなかった**こと。`turn:` 行は誰から来たか(`from=`)は書くが、 **どこへ返したかは書いていなかった** — 往路だけの計器で復路を追おうとしていた。 ## #96 依頼主が「答えが無かった」と報告した直後に、その答えが届いた **症状**(2026-08-11 実機・利用者報告): 進行役のザリが「ジェミーへの依頼は 2 回とも会話が渡らずスルーだった」と報告し、**その直後に 1 回目と 2 回目の 返答らしきものを続けて出力した**。利用者の読みは 「会話の繋がりがおかしくなっていそうだ」。 **真因**: ワーカーが `ask` / `plan` に答えず、**依頼主自身へ転送していた**。 ```text 04:55:14 handoff: agent=agent_3 to=agent hop=2 ← plan の 1 本目 04:57:00 handoff: agent=agent_3 to=agent hop=2 ← ask_agent_3 04:57:00 tool: agent=agent name=ask_agent_3 ok=true body_chars=71 ``` **`body_chars=71` は「相手はこの依頼に自分で答えず、… へ会話を渡しました。 答えはこちらへ戻りません。…」の定型文の長さ**(1 文字も違わない)。 比較用に、同じ日の正常な回は `reply: agent=agent_3 to=agent chars=1243`。 **1 つの依頼が 2 本に分裂していた**: 1. `ask` の戻り口には定型文が返る → 依頼主は「答えが無かった」と報告する 2. 中身は**別の因果**として依頼主の受信箱へ積まれ、**飛行中のターンが 終わるまで待つ**(実測 2 分 57 秒)→ 依頼主はそれを新しい依頼として読み、 答えを**利用者へ**返す だから画面では「スルーされた」の直後に返答が並ぶ。**時系列は正しく、 1 件が 2 件になっていた。** 余分に 2 ターン走っている(28,084 + 29,453)。 **モデルの意図は正しく、道具だけが違った。** 転送先は依頼主自身で、 「依頼主へ答えを返す」つもりだった。**説明文では防げない**(#84)— `ask_*` の説明には「答えを受け取るなら `ask_*`」と書いてあり、 `protocol_note` にも書いてある。読んだうえで転送を選んでいる。 **処方**: **委譲(`ask` / `plan`)で呼ばれたターンでは `transfer_to_*` を 提示しない**(`offer_transfer` に `reply_to.is_none()` を追加)。 個体の設定(`allow_handoff`)とは独立の、**構造で決まる規則**。 **委譲の連鎖は残る** — `ask` と `plan` は落ちないので、ワーカーはさらに 別の相手へ訊いて、その答えを自分の答えとして返せる。 **一般化**: **答えを待っている口があるターンで、その口を使わない出口を 提示してはいけない。** 提示した出口はいつか選ばれる(説明文では止まらない)。 しかも選ばれたことは**依頼主側では「答えが無かった」として、宛先側では 「新しい依頼」として**現れるので、**2 つの画面を突き合わせるまで同じ 1 件だと 分からない**。出口の数は、状況によって減らす。 **もう 1 段**: **出口が 2 つあってどちらを選ぶかをモデルに委ねる設計は、 片方だけが依頼主へ戻るなら分裂する。** 選択を正しくさせるのではなく、 選択そのものを消す。ミューテーション(提示を戻す)では `max_hops` まで 輪が回り、**利用者宛の答えが 4 通**になった — 実機で見えた 2 通は たまたま早く止まっただけで、**症状の上限は 2 ではない**。 **計器が効いた実例**: #94 で足した `handoff:` と `reply:` の 2 行が、 今回は**ログを開いた最初の画面で原因を示した**。前回(計器の無い状態)は 同じ症状の診断に何往復もかかっている。**「診断できなかった」を直した処方は、 次に同じ family の症状が出たときに測れる。** **副次: 落ちたテスト 2 本が、変更の射程を教えた。** どちらも「委譲で呼ばれた 個体が転送する」ことを前提にしており、**その状況ごと消えた**ので赤くなった。 うち 1 本(`PlanTaskState::HandedOff` の凍結)は**その variant が死んだのか**を 数え直す契機になり、**ツール非対応モデルの旧経路では生きている**ことが分かった (`use_tools = false` へ付け替えて凍結を維持。バックエンドは 1 行も変えていない — 転送ツールが出ないので同じコードが別の腕へ落ちる)。 **この旧経路はそれまでテストで 1 本も覆われていなかった。** **一般化: 変更で赤くなった既存のテストは、まず「何を凍結していたか」を読む。** 期待値を直す前に、**その凍結の対象が到達不能になったのか、経路が 1 本減った だけなのかを数える** — 前者なら撤去、後者なら付け替えで、処方が逆になる。 ## #97 「無い」と言った走査が、2 箇所しか見ていなかった **症状**(2026-08-11): 検証役のミュゼが「`blackboard/イクス.md` は MatrAIx」と 報告したのに対し、**「そのファイルは存在しません」と断定した**。利用者の訂正 — 「MatrAIx という語はイクス作業メモに存在する。昨日の 17:25 のファイルだ」。 実物は `D:\Outcasts.jp\blackboard\イクス.md`(4,469 B・2026-08-10 17:25)。 **真因は 2 段**: 1. **画面のカードの `RAG` 欄を `work_dir` と読んだ。** カードは モデル / 稼働時間 / トークン / RAG / 接続 / 直近ツールを出しており、 **work_dir は 1 文字も出ていない**。見に行った `D:\Outcasts.jp\draft` は ジェミーの `rag_sources` で、誰の work_dir でもなかった 2. **見た場所が 2 つだけだった。** 実際の work_dir は `world.json` に全部あり、 **10 体中 9 体が `D:\Outcasts.jp`、ジェミーだけ `D:\ManualeRAG`** の 2 種類。 本命の `D:\Outcasts.jp\blackboard\`(8 ファイル)を一度も見ていない **肯定の対照は取れていた。** `D:\ManualeRAG\blackboard\ジェミー.md` が 見つかったので走査は空振りしていない — **#90 の処方は守っていた**。 だが**対照が示すのは「そのフォルダに走査が届いた」ことだけ**で、 「その値が住みうる全部のフォルダに届いた」ことは何も言っていない。 **#94 の「対照を取った家族が違った」の変奏で、同型は 3 回目**(#90 / #94 / 本件)。 **副次: 「計器から追えない」も誤りだった。** 同じ報告で「ミュゼが実際に 何を読んだかは `tool:` 行が `args_chars` しか書かないので追えない」と書いたが、 **追えた**: ```text file ok=true args_chars=62 body_chars=2450 blackboard/イクス.md 4,469 B file ok=true args_chars=64 body_chars=1363 blackboard/ジェミー.md 2,094 B file ok=true args_chars=70 body_chars=8597 blackboard/ザリ・ロブステル.md 15,473 B ``` `args_chars` はファイル名の長さ順、`body_chars` は日英混在の実サイズと整合する。 **#94 で「計器が持っていない情報を推測しない」と書いた直後に、 逆側 —「計器が持っている情報を持っていないと言う」— を踏んだ。** **一般化**: **「無い」を主張する走査は、走査した範囲そのものを列挙して出す。** 範囲を書かない「無い」は、**範囲の広さについて何も言っていない**のに、 読み手には「全部見た」として届く。**「追えない」も「無い」の一種**なので 同じ規律が掛かる。 **処方(注意から構造へ。3 回目なので #67 の合図)**: **その値が住みうる場所の 一覧を、走査の前に機械から取る。** 今回なら `world.json` の `workDir` を 全個体ぶん出す 1 コマンドで「2 種類ある」と分かった(実際、それを打った瞬間に 自分の誤りが出た)。**画面から読んだ値を根拠にしない** — カードやダイアログは 欄を選んで出しており、**そこに無い欄は「無い」ではなく「出していない」**。 --- ## #98 「殺せる形」に作ってあったのに、殺す呼び出しが 1 つも無かった **症状**(2026-08-12): サーヴァントに `ssh -v outcasts` を実行させ、返らないので 利用者が打ち切った。画面もログも**正常に打ち切られた**ように見える。 ```text 02:24:50.266 interrupt requested: agent=agent seq=1 02:24:50.266 tool: agent=agent round=1 name=run ok=true body_chars=25 02:24:50.273 turn interrupted: agent=agent seq=1 rounds=1 elapsed_ms=0 ``` **5 分後に `ssh.exe` が 2 本、`49.212.209.180:22` へ `Established` のまま生きていた。** **真因**: `process::spawn_and_wait` に **`kill` の呼び出しが 1 つも無かった**。 ```rust Waited::Finished(Err(_elapsed)) => Ran::TimedOut, // kill していない Waited::Cancelled => Ran::Cancelled, // kill していない ``` `tokio::select!` が待ちの future を捨てて戻るだけで、子は走り続ける (`tokio::process::Child` の `kill_on_drop` は既定で偽)。 **タイムアウトの経路も同じ穴**で、こちらは 1 度も踏まれていなかった。 **「そう作ってある」と「そう動く」は別。** Spec 15 は `command-group` を **依存として足す判断まで**して(`unsafe_code = "forbid"` なので Job Object を 自前で書けない、という理由つきで)、`group_spawn` で**プロセス木を殺せる形**に 作ってあった。**足りなかったのは殺す 1 行**で、依存も型も揃っていた。 **機序**: `wait_with_output` は `self` を取る。`child` が future へ移るので、 **打ち切りのときに `child.kill()` を呼ぶ手が残らない**。書いた側は 「待って結果を取る」1 本の呼び出しで済ませたくなるが、**その形を選んだ時点で kill は書けなくなっていた**。処方はパイプを別タスクで並行に汲み、 `child.wait()`(借用)で待って、走り切らなかったときに kill すること。 **テストは緑だった。しかも「打ち切りが速いこと」を測っていた。** `a_cancelled_run_stops_without_waiting_for_the_timeout` は経過時間 < 10 秒を assert しており、**バグのある実装で 0.2 秒で通っていた** — 汲み取りを待たずに 返っていたので、**殺さないほうが速い**。修正で汲み取りを待つようにした結果、 **同じ assertion が kill の有無を判定するようになった**(ミューテーションで 0.2 秒 → 30.36 秒)。 **一般化 1: 「速く返った」は「止まった」ではない。** 返り値と外の世界の状態は 別で、**関数が返っても子プロセスは走っている**。時間だけを測る assertion は、 **返り値の速さ**を測っているのか**副作用の完了**を測っているのかを書き分ける。 **一般化 2: 依存を足した判断が実装されたことを、依存の存在で確かめない。** `command-group` は `Cargo.toml` に在り、`group_spawn` も呼ばれており、 doc も「プロセス木ごと kill する」と書いていた。**揃っていなかったのは 呼び出しだけ**で、grep で `command-group` を引くと全部ヒットする。 **#94 の「肯定の対照を取った家族が違った」と同型で、ここでは 「機構が在ること」の対照が「機構が効くこと」の対照に見えていた。** **一般化 3: 実機確認が未観測のまま残った項目は、未確認ではなく未実装かもしれない。** Spec 15 の実機確認 (8)(9)「タイムアウトと打ち切りで孫まで kill」は **2026-08-04 から 8 日間「観測待ち」として台帳に載っていた**。 観測していれば初回で落ちていた。**「使いながら出る種類」と書いた項目ほど、 出るまでのあいだ実装の不在と区別が付かない。** --- ## #99 計器は在ったのに、書いた側も読む側も「無い」と言った **症状**(2026-08-12): サーヴァントの `run` から `ssh` が exit 255 で失敗した。 真因(環境変数 `PROGRAMDATA` の欠落)に辿り着くまで**利用者と 5 往復**した。 **ログには最初から答えが書かれていた**: ```text run: agent=agent command=ssh resolved=C:\Windows\System32\OpenSSH\ssh.exe args=2 ``` **同じ値がモデルへ返る本文にも入っていた**(`実行: <フルパス>`)。 つまり **Spec 15 の宿題 (a)「解決したフルパスを毎回結果とログに出す」は 両方とも実装済み**だった。にもかかわらず: - **サーヴァント側**は解決先を受け取ったうえで「私の実行環境(サンドボックス/ コンテナ)から到達できていない」と推測した。**`run` に隔離層は無い** - **私(実装した側)**は「その処方はまだ実装されていない」と**断定した**。 実装を読まずに言った **同じ形をその日のうちに 2 回踏んだ。** 許可パターンを `allow` へ足した直後、 サーヴァントは **`allow=10` と自分のログに出ているのに**「許可パターンが 固定のままなので渡す手段がない」と報告した。**追加後の一覧を見ていない。** **#90 との違いが要点。** #90 は「**計器が空振りしていないこと**を先に確かめる」 (走査の検定)。こちらは**計器が存在するかどうかを確かめていない**。 同じ「読む前に確かめる」でも、**検定の対象が 1 段手前**にある。 **一般化 1: 「その計器は無い」と言う前に、1 回 grep する。** 計器を足す判断は台帳に残るが、**足したという事実は「まだ無い」と書いた 古い文と共存する**。私は Spec 15 の宿題の一覧(「処方は〜出すこと」)を読んで、 **処方が書かれていること**を**実装されていないこと**と読み違えた。 **台帳が処方を書いている箇所は、実装済みでも文面が変わらない。** **一般化 2: 診断の 1 手目はログの grep。** 実装を読むより、推測を述べるより、 **その現象がログに何を残したか**を先に見る。今回は `grep "run: " | grep ssh` の 1 回で `resolved=` が出て、**再現も 1 発**(同じバイナリを同じ環境で叩く)だった。 **一般化 3: モデルへ情報を届けても、読ませたことにはならない。** #84 は「規則を読んだうえで従わない個体が居る」だったが、こちらは **事実を受け取ったうえで別の仮説を立てる**形。**処方は本文を増やすことではなく、 人間が先にログを見ること** — 増やしても同じ扱いを受ける。 --- ## #100 検査の根拠が `Path` だったので、開発機の OS でだけ緑だった **症状**(2026-08-12): `v0.1.3` のタグを push したら、**macOS と Ubuntu が同じ テスト 1 本で落ちた**(Windows は緑)。 ```text thread 'blackboard::tests::only_flat_note_names_are_accepted' panicked at crates/fuseforks-core/src/blackboard.rs:211:13: 通してはいけない名前が通った: sub\note.md test result: FAILED. 527 passed; 1 failed; ``` **真因**: `is_safe_note_name` が区切り文字の判定を `Path::file_name()` に 委ねていた。**`\` を区切りとして扱うのは Windows の `Path` だけ**で、 Unix では `sub\note.md` が合法な平置きのファイル名になり、 `file_name() == Some(name)` が成立して素通りする。 ```rust Path::new(name).file_name().and_then(|n| n.to_str()) == Some(name) ``` 処方は**区切りを自分で数えること**(`!name.contains(['/', '\\'])`)。 `file_name()` の比較はドライブ接頭辞のような Windows 固有の形を拾う保険として 残すが、**区切りの保証はその上の行が持つ**。 **テストは正しかった。実装だけが OS に依存していた。** 検収の側は `r"sub\note.md"` を raw 文字列で書き、コメントに 「普通の文字列だと `\n` が改行になり Windows の区切りとして検査されないまま 通ってしまう(実際に踏んだ)」とまで書いてあった。**意図は文字で正しく 書かれており、それを実装が満たしているかだけが確かめられていなかった。** **実害は脆弱性ではない。** Unix で `sub\note.md` が通っても、それは `blackboard/` 直下に `sub\note.md` という名前のファイルが 1 つ出来るだけで、 **黒板の外は指さない**(区切りではないので join しても降りない)。 壊れたのは **CI が止まったこと**と、その帰結で出た下の二次被害。 **「検査が抜けた」と「境界が破れた」は別で、混ぜて書くと台帳が嘘になる。** **二次被害: `v0.1.3` の Release が Windows のアセットだけで public になった。** `build.yml` は `fail-fast: false` の matrix で、**各ジョブが独立に `tauri-action` を `releaseDraft: false` で呼ぶ**。だから **3 OS のうち 1 つでも通れば Release が公開される** — Windows が 24 分かけて 完走し、`.exe` と `.msi` だけが付いた `v0.1.3` が出た。 `v0.0.5` の GitHub 障害では 3 OS とも落ちたので、**片肺で公開される経路は これが初回**。~~**処方は未決**~~ → **2026-08-13 に `releaseDraft: true` で決着** (`v0.1.5` で 2 回目を踏んだ。**同じ罠を 2 回踏んだら、処方を注意から構造へ移す**)。 **採らなかった案はテストを別ジョブへ切り出して `needs:` で束ねる形** — そちらは 「テストが通れば公開してよい」という前提を残すが、**2 回とも壊れたのは テストの結果ではなくアセットの数**だった。**publish を人の手に戻すほうが、 何を見て出すかを人が決められる。** **一般化 1: 標準ライブラリの「その OS での正しさ」を、仕様の判定に使わない。** `Path` / `MAIN_SEPARATOR` / `to_lowercase` / 行末 / ファイル名の大小同一視は **プラットフォームごとに正しく違う**。仕様が「両方の区切りを拒む」なら、 判定は**文字を直接数える**形で書く。**`contains` は定義上どこでも同じ答えを返すが、 `Path` は環境の質問に答えている。** **一般化 2: 開発機 1 台の緑は、3 OS で配る製品の緑ではない。** この村は 3 OS のインストーラを配っているのに、**日常の検査は Windows でしか走らない**。 `cargo test` が緑でも、**OS を跨ぐ挙動を持つ述語は未検査のまま**タグまで届く。 タグを打つ前に踏める網は今のところ無く、**CI が最初の他 OS 実行**になる。 **一般化 3: 部分的に成功した CI は、成功した部分だけを世に出す。** matrix の各ジョブが独立に配布物を公開する構成では、**「全部緑なら出す」は どこにも書かれていない**。`fail-fast: false` は診断のために正しい設定だが、 **診断のための設定が公開の条件まで緩めている**ことは別に数える。 --- ## #101 テストの入力に、ホストの OS ロケールが混ざっていた **症状**(2026-08-12): `v0.1.4` のタグで **3 OS 全部が同じ 6 テストで落ちた** (`external_ask.rs`。日本語の封筒を期待したら `【Sender: Neo (external client)】` と `## Arrivals and departures` が返った)。 **開発機では全 Phase を通して一度も落ちていない。** **真因**: Spec 35 で**言語がコアの挙動の入力になった**が、統合テストはその入力を **明示せず、`bootstrap` の `sys_locale::get_locale()` からホスト任せで受けていた**。 開発機は日本語 OS なので Ja、CI のランナーは 3 OS とも en なので En — **日本語を assert するテストが、日本語ホストでしか成立しない**形になっていた。 **処方**: テスト内の全 `Orchestrator::bootstrap`(21 箇所)の直後で `set_language(Ja)` を明示。**時計を引数で受けるのと同じ規律** (`schedule.rs` の「内部で `Local::now()` を呼ばない」のロケール版)。 OS 判定そのものを検査する 1 本(`the_language_is_determined_once_and_persisted`) だけはピンを通らない素の boot を使う — あのテストは ja|en のどちらでも通る形で 最初から書かれていた。 **検証はミューテーションで CI を手元に再現した** — `from_os_locale` を 「常に En」へ変異させて全スイートを回し、**落ちたのは変異を検出した 単体テスト 1 本だけ**(統合 533 本は緑)。en ホストの挙動を、en ホスト無しで 確かめた形。 **一般化 1: 機能がコアに新しい入力を足したら、既存のテストがその入力を どこから得るかを数える。** golden は `Language` を引数で明示していたので 無事だった。統合テストは**環境から暗黙に**受けていて落ちた — 同じ日に 書いた検査でも、入力の受け方で耐性が分かれる。 **一般化 2: #100 の「開発機 1 台の緑は 3 OS の緑ではない」は、OS の種類だけの 話ではない。** 区切り文字(#100)の次はロケール。パス・改行・ロケール・ タイムゾーン — **ホストから暗黙に流れ込む入力は、どれも同じ形で割れる**。 **一般化 3: 今回 3 OS が全部落ちたのは偶然の防御だった。** ランナーが全部 en だったから片肺公開(#100 の二次被害)にならなかっただけで、 もし 1 OS だけ ja ホストなら Windows 単独公開が再演していた。 ~~`releaseDraft: false` の未決はまだ生きている。~~ **2026-08-13 に決着(`releaseDraft: true`)** — その日 `v0.1.5` で 2 回目を踏んだ。 **今度は落ちたのがランナーの DNS**(`index.crates.io` を引けず、テストは 1 本も 走らずに依存解決の前で終了)で、**原因はコードでも OS でもロケールでもなかった**。 **3 回とも別の原因で、二次被害だけが同じ形。** ここが構造で塞ぐ判断の根拠 — **原因を 1 つずつ潰しても、公開の経路が開いたままなら同じ被害が出る。** ## #102 描画ライブラリを差し替えたら、型で守られていない結合が 4 つ一斉に露出した **症状**: 絆の地図の描画を Vue Flow から `v-network-graph` へ差し替えた (2026-08-13)。**型検査も lint もテストも全部緑のまま**、実機で 4 つ壊れた。 1. **カードの drop が効かない** — `AgentList.vue` が `closest(".vue-flow__node")` でノードを探していた。ライブラリのクラス名を 自分のコードが直接名指ししていたので、差し替えた瞬間に何にも当たらなくなった 2. **ノードを掴めない**(見た目は完全に正しい) — ライブラリの既定 CSS は `.v-ng-node .draggable / .selectable` にだけ `pointer-events: all` を与える。 自前ノードはそのクラスを持たないので当たり判定が無かった。 `draggable: true` も `node:dragend` も正しく繋がっているのに動かない 3. **見出しの文言が鍵の文字列のまま出る** — `map.title` / `map.counts` / `map.fit` を使ったが、実在するのは `map.heading` / `map.summary` で `fit` は無かった。**vue-i18n は鍵が無くても落ちず、鍵をそのまま表示する** 4. **背景の点だけがパンで取り残される** — `v-network-graph` は `grid` より下の層(`background` / `root`)を**画面に固定**し、視点の変換を 掛けない。層の名前からも型からも読めない差で、ドキュメントの一節にだけある **真因**: どれも「**ライブラリの識別子を、こちらのコードが直接名指ししている**」 箇所だった。クラス名(1・2)・辞書の鍵(3)・レイヤー名(4)。名指しは文字列で 行われるので、**参照先が消えてもコンパイラは何も言わない**。 **処方**: - 1 は選択子を**自前の属性**(`[data-kizuna-node]`)へ変えた。 `TopologyMap` が自分で付けるので、次にライブラリを替えても意味が変わらない - 2 は当たり判定を**自分の CSS** で与えた。スロットが受け取る `class` を使う手も あったが、**その `class` は型宣言(`NodeSlotProps`)に載っていない** (実行時には来る)。宣言に無いものへ寄りかかると版が上がって黙って壊れる - 3 は辞書を開いて実物と突き合わせた。**気づけたのは別件で辞書を開いたから**で、 それが無ければ実機の見出しがおかしいまま残っていた - 4 は SVG の層をやめ、**容器の CSS 背景 + `view:pan` / `view:zoom` を自分で写す** 形にした。層を `base`(視点に追従する側)へ移す案は、**`fitToContents` が 内容の外接矩形にその矩形を含めてしまい Fit が壊れた**ので採れない。 「追従する」かつ「外接矩形に入らない」層がこのライブラリに無い **一般化 1**: **ライブラリの識別子を文字列で名指ししている箇所は、差し替えの前に 数える。** クラス名・スロット props の名前・レイヤー名・辞書の鍵。どれも型検査の 外にあり、**参照先が消えたことを機械が教えない**。数え方は「ライブラリ名で `grep` して、自分のコードに出てくる箇所を全部見る」。 **一般化 2**: **「渡した」と「効いている」は別。** 稼働中の辺を動く破線にする 設定は `animate` を渡してあったが、`dasharray` が無いと実線を流すだけで見た目が 変わらない。**configs に値があるのを見て「入れた」と思っていた**。渡した値が 効いているかは、値の存在ではなく実機の見え方で確かめる。 **一般化 3(これが一番重い)**: **差し替えは、無かった機能を可視化する。** 同じ作業で「**地図のハンドル同士をドラッグして絆を張る**」が**最初から存在しない** ことが分かった(`#node-default` の上書きでハンドルごと消えており、`@connect` は 到達不能、`.vue-flow__handle` は死んだ CSS)。それを**画面の案内文・DETAIL 日英・ CLAUDE.md・Spec 21 の 4 箇所が書いており、互いを裏付けていた**ので、誰も実装を 数えなかった。**#53 の 2 例目**(契約に「できる」と書いた操作は入口があるか 数える)だが、今回は**入口どころか描画要素ごと無かった**。 **台帳が複数あることは、裏取りにならない。** 写した先が増えただけで、出所は 1 つ。 **数えるべきは台帳の一致ではなく、実装にその要素があるか。** ## #103 失敗したターンのトークンが、村のどの数字にも計上されない **症状**: 出力上限で本文が空になったターン(`LLM_OUTPUT_TRUNCATED`)は、 **課金は起きているのに村の中のどの計器にも出ない**。実測(2026-08-13、 claude-sonnet-5・`maxOutputTokens` 64): ```text 22:25:17 turn start: agent=agent hop=0 from=user chars=17 22:25:20 dropped content blocks: kinds=thinking count=1 output_tokens=64 text_chars=0 tool_calls=0 22:25:20 turn failed: agent=agent code=LLM_OUTPUT_TRUNCATED fatal=false: 出力上限に達し…(64 トークン) ``` **`turn:` 行が無い。** 直前の成功したターンには `turn: … total=49069` があるので、 **同じログの中で対になっている**。Anthropic の残高は `$2.16 → $2.15` へ実際に減った。 | | 成功 | 失敗 | |---|---|---| | `turn:` 行 | ある | **無い** | | カードの累計トークン | 増える | **±0** | | 予算(Spec 11 の天井)の消費 | 減る | **±0** | | プロバイダの課金 | 減る | **減る** | **真因**: `turn.rs` の ```rust let mut response = backend.chat(request).await?; // ← ここで即脱出 … tokens += response.usage.total(); // 到達しない pool.debit(&response.usage); // 到達しない ``` `reject_empty_reasoning`(`client.rs` が 6 ワイヤ全部で呼ぶ)が `ChatResponse` を `Err` へ変える。**`usage` を持った値が `Err` に化けた時点で、 中身ごと捨てられている。** `?` 1 文字が受け皿になっていた。 **帰結**: **上限を小さくした個体が失敗し続けると、予算が減らないまま課金だけが 増える。** 予算(Spec 11)は「実効トークンの天井で自動的に止める」機構なので、 **止めるべき状況でだけ数えない**という向きに壊れている。 **処方(入れたのは計器だけ)**: `reject_empty_reasoning` の中で `truncated:` を 1 行出す(`limit` / `prompt` / `cached` / `completion` / `reasoning`)。 **`usage` を持ったまま `Err` へ変わる地点はここだけ**なので、計器もここにしか 置けない。`tests/truncated_note.rs` が**負の対照つきで**その行が出ることを留める (本文のある応答では出ない。#90 の処方)。ミューテーションで赤を確認済み。 ~~**未着手(判断が要る)**: 予算とカードへ計上するか。~~ **→ 2026-08-16 に着地 (飛行中台帳の形)。** 判断は「エラーごとに『払ったか』が違う」をそのまま型へ写した — `LlmError::usage() -> Option<&Usage>` を足し、**`Some` を返すのはプロバイダが 200 を 返した後にこちらが `Err` へ変えた variant だけ**(今は `OutputTruncated { limit, usage }`)。 400 / DNS / タイムアウトは `None` のまま = 「払いが分からないものに見積もりを 捏造しない」(払っていない 400 の連発が予算を食う側に倒れる)。 **処方の骨格は otari(mozilla-ai の LLM ゲートウェイ)の `inflight` 登録簿と同じ** — 「使用量の記録は過去しか語れない」ので、要求が始まった時点で登録し、 **終わり方に関わらず精算する**。実装は 3 点: 1. `turn.rs` の `TurnSpend` を**ターンの飛行中台帳**にした(`tokens / cached / prompt / reasoning / rounds / hop`)。`run_turn` を外側(台帳の所有者)と `run_turn_inner`(実行ループ)に割り、内側は `&mut` で加算するだけ。 **`?` で内側から抜けても台帳は外側に残る**ので、`Err` の出口でも `settle_failed_turn` がカードの累計と `turn:` 行を書ける。**4 つの出口 (完走 / 失敗 / 割り込み / 予算)に使用量の行が 1 本ずつ**揃った 2. LLM 呼び出し地点の `?` を `match` に変え、`err.usage()` が `Some` なら **成功と同じ入口**(`spend.absorb` + `guard.commit`)を通してから `Err` を 返す。まとめ呼び出しの `Err` 腕も同じ(同じ穴が 2 箇所目にあった) 3. `turn:` 行は失敗でも出る — `stop=failed:{CODE}` で**欄の並びは成功と同じ**。 `turn failed:` は理由の文面を運ぶ行として残す(数えるのは `turn:` だけでよい) **結合テスト `tests/failed_turn_settlement.rs` は予算まで数字で見る** — 天井 3,000 / 進行役 1 周目 5 / ワーカーが切れた応答で 2,500 払う → `remaining=495` で進行役の 2 周目が `reserve_short`。**旧実装なら Drop の全額返金で 2,995 になり 2 周目が通る**ので、進行役がユーザーへ返さないことが負の対照になる。 **観測の前に書いた予測(残 495)が 1 桁も違わなかった。** ミューテーション 2 回 (`absorb` を外す → 累計の assert で赤 / `commit` だけ外す → 予算の assert で赤)で **3 つの assert が別々の仕事をしている**ことを確かめた。 **残余**: usage 自体が届かない失敗(切断・abort・タイムアウト)は今も Drop の全額返金へ落ちる。見積もれないので、これは計器の穴ではなく事実の穴。 `LlmError::usage` に腕を足せば台帳側は 1 行も変わらない形にしてある。 **一般化**: **`?` は受け皿である。** `Result` の `Err` へ値を畳んだ瞬間、 その値が持っていた情報は呼び出し側から消える。**エラーに変換する地点で、 捨てている中身に「後で数えたいもの」が入っていないかを問う。** #72(未知の入力を握り潰す)の**変換版**で、あちらは `_ => {}`、こちらは `?`。 **併せて分かったこと**: **Anthropic は思考段階(`effort`)を 1 つも送っていない。** `anthropic.rs` の `encode` は `req.effort` を読まず、`thinking: {adaptive, summarized}` だけを送る。画面で「最大」を選んでもワイヤは 1 バイトも変わらない。 `adaptive` は**枠に合わせて思考を縮める**ので、枠を 500 にしても本文 460 字が返り、 **64 まで落として初めて空になった**。`data_contract` の `Effort` の節に adapter ごとの写像を列挙したが、**Anthropic の行が抜けている**。 ## #104 gemini-3.7-flash は 19,000 トークン未満のプロンプトでキャッシュを 1 度も返さない **症状**(2026-08-14 の利用者報告): 「gemini-3.7-flash でキャッシュがまったく 効かない。gemini-3.6-flash も効かなくなった。値下げの回収でプロバイダが渋くしたのか、 3.7 で壊れたのか」。`turn:` 行の `cached=0` が並ぶ。 **実測(同一 agent・同一会話・4 分間の A/B。動かした変数は機種だけ)**: ```text 18:00:02 cache: model=gemini-3.7-flash r=2 prompt=10175 cached=0 ← +3 秒後・同一 prefix 18:03:42 cache: model=gemini-3.6-flash r=2 prompt=14435 cached=8063 18:05:12 cache: model=gemini-3.6-flash r=1 prompt=11836 cached=8041 ← +74 秒・ターンをまたぐ 18:28:34 cache: model=gemini-3.7-flash r=2 prompt=18305 cached=0 18:28:38 cache: model=gemini-3.7-flash r=3 prompt=19035 cached=15998 ← 4 秒後・跨いだのはサイズだけ ``` `system_digest` は各機種で全区間同一(こちら側の prompt は 1 バイトも揺れていない)。 | prompt 帯 | 3.7 のヒット | |---|---| | 8,495 〜 **18,305** | **23 本中 0**(例外なし) | | **19,035** 〜 43,503 | 11 本中 9 | **機序の見当**: `cached` の実測値が**約 8,000 の塊に量子化**されている — 3.6 は 8,041 / 8,063 / 8,089(**1 ブロック**)、3.7 は 15,998 / 16,009 / 16,015 (**2 ブロック**)→ 24,124 → 32,298。**3.7 は 34 本中 1 度も 1 ブロックを返さない。** 最小が 2 ブロック(約 16,000)なので、それを載せるのに prompt が約 19,000 要る。 **公称の最小 4,096 はどちらの機種にも当たっていない**(文書と実装の乖離の 5 例目。 WebP / legacy 410 / xAI PDF / Meta の `effort` に続く)。例外が 1 本(20,113 = 2.5 ブロック相当)あるので、**量子化は傾向であって法則ではない**。閾値より上でも 11 本中 2 本は外す(前後のラウンドは当たっているので一過性)。 **外部の一致**: googleapis/python-genai #2064(Open・P2)が `gemini-3-flash-preview` で **9K〜17K の同型の帯**を報告している。**世代を跨いで再発している。** **帰結(金)**: 3.6 と 3.7 は同額(入力 $0.75 / キャッシュ $0.075 = ちょうど 1/10)。 この村の実測で **3.6 が $0.4593/1M、3.7 が $0.7500/1M = 1.63 倍**。 **19,000 を超えない仕事に 3.7 を当てると、同じ答えに 6 割余計に払う。** 村の会話ターンは 9〜15K が定位置で、**死に帯のど真ん中**。 **処方**: コードは 1 行も変えない(プロバイダ側の欠陥)。**機種の選び分けが処方** — 会話は 3.6、19K を超え続ける仕事(RAG・添付・長いツールループ)だけ 3.7。 **誤診しかけた 2 点。どちらも「見えている粒度」が原因**: - **`turn:` 行では帯も機種も見えない。** `prompt` は**ラウンドの合計** (`turn.rs` の `prompt += response.usage.prompt`)なので、9 ラウンドのターンは 「prompt=142,337」と出る。リクエスト単位の実寸は 8k〜25k で、**帯をまたいでいる**。 しかも `turn:` は `backend=gemini` までしか書かず**モデル名が無い**ので、 同じ個体のテンプレートを差し替えると 3.6 と 3.7 が 1 本の系列に混ざる。 分けられたのは `cache:` 行が `model=` と `round=` を持っていたからで、 **計器の粒度が、答えられる問いの粒度を決めていた** - **私は一度「3.6 も cross-turn が壊れた」と結論しかけた。** 根拠は 8/12〜8/14 の `round=1` が 1/12 だったこと。**反証は 18:05:12 の 1 行** (74 秒あけた別ターンで `cached=8041`)。1/12 の内訳は、gap が数時間で TTL を 越えた 3 本と、**stable prefix が 4,096 に届かない個体**(`stable_chars=2938`)の 15 本だった。**「効かなかった」の中に、効かないのが正しい条件が混ざっていた。** **一般化**: **部分集合でしか観測していない性質を、集合全体の性質として書かない。** 3.7 の最初の 20 本は全部 8,495〜15,654 = 帯の内側にしか無く、そこから 「3.7 はキャッシュが効かない」と書けば**正しい観測から誤った射程**が出る。 **閾値を疑ったら、閾値の反対側を 1 本踏むまで結論を書かない** — 今回それを踏んだのは利用者で、1 往復で決着した。#91 と同族。 ## #105 「オーバーシュートは 1 呼び出し分」が、因果 1 本の数えを fanout へ写した嘘だった **症状**: 症状はまだ実機に出ていない。**出たとしても見えない** — 超過分は 残高が 0 に飽和して消え、`turn:` 行にも予算の数字にも「天井を超えて払った」 とは書かれない(#103 の隣。どちらも払いが数字に出ない形)。 見つけた経路は実機ではなく **TLC**(`specs/tla/BudgetOvershootBound`・2026-08-14。 MASC の bug-model の型を移入した初日に、既存の主張が 1 つ落ちた)。 **主張と実装**: 「飛行中 1 呼び出し分のオーバーシュートを許容して数える」が **4 箇所**に書かれていた(`budget.rs` doc / `turn.rs` `run_turn` の注釈 / `data_contract` `token_budget` / CLAUDE.md の Spec 11 節)。実装は: ```rust pub fn try_reserve(&self) -> bool { self.remaining_milli.load(Ordering::Acquire) > 0 // load のみ。予約しない } ``` `delegation.rs` の波は JoinSet で N 体を spawn し**全員が同一の Arc** を指すので、 **残額 1 milli でも N 体が同時に try_reserve を通る**。TLC の反証経路は `ReserveByLoad ×3 → DebitByLoad ×3 = spent 3(天井 1)`。 **正しい上限は「天井 + 同時に飛ぶ本数 × その時いちばん大きい 1 呼び出しの実費」** で、「1 呼び出し分」は因果が 1 本のときだけ成立する。 **皮肉な構図**: `run_plan`(delegation.rs)は**同じ fanout のもう片方の危険** (「タスクごとに新しいプールを作ると天井が人数倍に化ける」)を注釈で数えて Arc を共有させていた。**共有しても、検査が原子でなければ超過は人数倍**の側は 誰も数えていない — 片方の race を塞いだ注釈が、もう片方が塞がっている印象を作る。 **処方**: 台帳 4 箇所を同日訂正。**その後 Spec 38 として実装まで着地** (2026-08-14〜15・ブランチ `20260814_reserve`)— `try_reserve(estimate)` を CAS 化し、 `ReservationGuard` の move セマンティクス(`commit(self, actual)` で清算 / commit されなかったパスは Drop が全額返金)で**二重返金を型で消した**。 `JoinSet::abort` で Drop が走るかは査読 2 系統で結論が割れたので**実測で決めた** (走る・全額返金される)。会計の正しさは `specs/tla/BudgetReservationSettlement` が留める — **`committed` フラグ設計(double-refund)は depth 3 で違反**し、 型で消した判断が機械で裏づけられた。 **一般化 2 つ**: - **並行機構の上限の主張は、因果 1 本で数えた値が fanout で人数倍になっていないか 数える。** 「1 呼び出し分」「高々 1 回」の形の文は、その数えがどの並行度で 取られたかを書かないと、fanout を足した日に黙って嘘になる - **atomic という語が並んでいても、atomic なのは各操作であって列ではない。** load も CAS も個々は atomic で、間に飛行時間が挟まれば列は TOCTOU。 doc に「TOCTOU は仕様」と正しく書いたうえで、**その幅の上限だけを 数え違えていた** — 検査済みの語彙が並ぶ場所ほど、数字の主張は検査されない ## #106 相互委譲の輪は、輪が回る前に受信箱で止まる(そして 180 秒ごとに課金される) **症状**(2026-08-15・実機。利用者の報告「両方待ち状態になった」): 進行役 ザリ (`agent`)がルナ(`agent_8`)へ委譲し、**ルナの波の宛先にザリが入っていた**。 画面では両者が待ちに見える。 ```text 02:18:00.565 turn start: agent=agent_8 hop=1 from=agent ← ザリ → ルナ 02:18:21.376 plan wave: agent=agent_8 tasks=3 to=[agent,agent_10,agent_3] 02:18:21.380 turn start: agent=agent_3 hop=2 from=agent_8 ← ジェミーは走る 02:18:21.388 turn start: agent=agent_10 hop=2 from=agent_8 ← イクスも走る (agent = ザリ の hop=2 ターンは 1 本も無い) 02:21:00.575 tool: agent=agent round=1 name=ask_agent_8 body_chars=21 ← 02:18:00 + 180 秒 02:21:21.394 plan bundle: agent=agent_8 tasks=3 elapsed_ms=180017 ← ちょうど ask_timeout ``` **真因**: `agent_loop` は **1 ターンずつしか処理しない**。ザリは自分のターンの中で ルナの答えを待って**ブロックしている**ので、ルナの波がザリ宛に投げた封筒は **受信箱に積まれたまま**になる。**輪は回らない — 回る前に詰まる。** `turn start: agent=agent hop=2` が 1 本も出ていないことがその証拠。 **これは予告されていた**(`OrchestratorConfig::ask_timeout` の doc): > 委譲は相手の応答を**待ってブロックする**。相互に委譲し合う配置では > 待ち合わせが起きうるので、必ず戻る上限が要る。`max_hops` は深さしか > 縛らず、待ちの時間は縛らない。 **台帳は正しかった。これが初めての実機観測**で、`elapsed_ms=180017` が 「必ず戻る上限」の実測値。**`max_hops`(既定 8)も予算も塞いでいない** — 深さ 2 で止まっており、予算はこの走行では尽きていない。 **払い**: 1 周 **180 秒**。ザリはその後 `ask_agent_8` を計 3 回呼び、 **3 回とも空振り**(`body_chars=21 / 21 / 31`)。ツール上限(既定 12)まで 繰り返せるので**最悪 36 分**、その間ずっと課金される。 **`RepeatGuard` は効かない** — 判定は「ツール名 + 引数 + 結果本文」の完全一致だが、 実測の引数は `args_chars=154 / 96 / 72` で**毎回違う**。モデルは同じ意図を 違う文面で書き直すので、**同じ空振りを繰り返しても「同じ呼び出し」にならない**。 #41 の処方 1 が想定した「同じ引数で叩き続ける」形とは別の空転。 **後始末は設計どおり動いた**: 詰まりが解けた時点で `turn folded: agent=agent hop=2 from=agent_8` が **2 本**出ている (Spec 10 の出口 2b — 依頼元のターンが既に切れていれば **LLM を 1 回も呼ばずに**畳む)。 積まれた封筒 2 通を、追加の課金なしで捨てている。 **Spec 38(予算の予約)とは独立**。周回は `try_reserve` → `chat` → `commit` → **その後にツール実行**の順なので、`ask` の 180 秒の待ちの間に予約は握られていない。 旧実装(load 観測)でも同じ輪が同じように起きる。 **処方は入れていない**(利用者が形を検討中)。候補が 3 つあり、**類型が違う**: | 案 | 類型 | 評価 | |---|---|---| | (a) 条例に「依頼者にタスクを振るな」と書く | **文言** | **弱い。** #84 が落とした形そのもの — ザリは Spec 26 で規則を逐語で引用したうえで従わなかった。転送の件(2026-08-11)でも「`ask_*` の説明に書いてあり、モデルはそれを読んだうえで転送を選んでいる」 | | (b) 絆を片方向にする | **構造・利用者の操作** | **今日できる。** コード変更ゼロ。ただし他の文脈での ルナ→ザリ の委譲も同時に消える。「線は人が引く」の範囲内なので利用者の裁定 | | (c) 委譲で呼ばれたターンでは、**依頼主を `ask`/`plan` の宛先集合から外す** | **構造・コード** | **前例がある** — 2026-08-11 の「委譲で呼ばれたターンは転送を提示しない。判定は `reply_to` の有無ただ 1 つ」と同型。ただし**直接の 2 者の輪しか切れない**(A→B→C→A は封筒の祖先を辿らないと分からない)。Spec が要る | **一般化**: **「待ちのある機構」の危険は、待ちが**起きること**ではなく、 待っている当人が**次の仕事を受け取れなくなる**ことにある。** ここでは ザリがブロックしている間、ザリ宛の封筒は 1 通も処理されず、輪は 「相互に呼び合う」形にすらならずに受信箱で止まった。**輪の検知(hop・祖先の 追跡)を設計するときは、輪が回る前に詰まる経路のほうが先に来ると数える。** ## #107 `display: none` の親の中に置いた全画面の層は、`fixed` でも描画されない **症状**(2026-08-16 実機・利用者報告): 統計画面(Spec 39)を開いている間に タイトルバーの「システム設定」や「条例」を押すと、**その場では何も起きず、 村の画面へ戻った瞬間にダイアログが現れる**。押しても反応が無いので**二度押しできる**。 **真因**: `App.vue` のテンプレートで、`ToastHost` / `ConfirmHost` / ダイアログ 6 つ / 初期化の覆いが、**3 ペインのグリッド `
` の内側**にあった。Spec 39 P3 で そのグリッドへ `v-show="view === 'village'"` を足したので、統計画面では `display: none` になる。**`position: fixed` は親の `display: none` に勝てない** — 祖先が描画されないなら、子孫は座標の基準ごと存在しない。 **押下自体は効いている**(`settingsOpen = true` は立つ)。効いていないのは描画だけで、 村へ戻ると `display` が復活して「今開いた」ように見える。**状態と描画が別々に 壊れると、利用者からは「効かないボタン」に見える。** **実害はダイアログの見え方に留まらない。** 同じ位置に居た `ConfirmHost` は 「閉じる前の確認」(`askConfirm`)の描画先で、**統計画面では確認が出ず、 `askConfirm` の Promise が解決しないので窓が閉じられない**。CLAUDE.md の 「閉じられなくなるほうが害が大きい」に真っ向から反する状態を、 1 つの `v-show` が作っていた。`ToastHost` も同様に沈黙する。 **処方**: 全画面の層(トースト・確認・各ダイアログ・初期化の覆い)を **グリッドの外**へ出す。あわせて利用者裁定で、統計画面の間は タイトルバーのダイアログ入口 6 つを `disabled` にした(**窓操作は塞がない**)。 **入口を塞ぐだけでは足りない** — `ToastHost` / `ConfirmHost` の入口は タイトルバーではないので、構造を直さないと沈黙したままになる。 **回帰**: `statsView.test.ts` が `v-show` のグリッドの範囲を `
` の深さで求め、 その中に 9 つの層が**入っていない**ことを見る(インデントで判定すると整形で壊れる)。 ミューテーション(`ConfirmHost` をグリッドの中へ戻す)で 1 本だけ赤になることを確認済み。 **一般化 1**: **`v-show` を足した瞬間、その要素の子孫すべてが新しい条件に従属する。** 新しい表示モードを足すときに数えるのは「隠したいもの」ではなく、 **「隠す範囲の中に何が入っているか」** — とくに `fixed` / `absolute` で 「画面全体を覆うつもりの層」は、書いた場所と描かれる場所が違うので目視で漏れる。 **一般化 2**: **押下が状態にだけ効いて描画に効かないと、「効かないボタン」として 現れ、利用者は二度押しする。** 状態と描画のどちらが壊れたかは画面から区別できない。 **処方が実機で効いたことの対照**(2026-08-16 06:02。Spec 39 の検収 1 の走行): 統計画面を開いたまま個体を失敗させたら、**失敗のトーストが統計画面の上に出た** — 修正前は `ToastHost` がグリッドの中に居たので、この吹き出しは**原理的に見えなかった**。 **別の検収を踏む走行が、そのまま処方の実証になった。** **一般化 3(この修正の作業中に踏んだ)**: **`vue-tsc` と vitest はテンプレートの タグの対応を検証しない。** ブロックを移す編集で閉じタグを 1 つ落としたとき、 型検査もテスト 391 本も緑のまま通り、**`bun run build` だけが落ちた** (`Element is missing end tag`)。**テンプレートを構造ごと動かす編集は、 型検査とテストではなくビルドで確かめる。** ## #108 「秘密が揃っているか」を見る門が、必要な 5 つのうち 2 つしか数えていなかった **症状**: 発火する前に見つけたので実害は出ていない。**`APPLE_ID` の secret を 登録した時点で、`APPLE_PASSWORD` が空のまま macOS の署名・公証経路が有効になる 状態**になっていた。そのままタグを打てば、署名は通って公証が 401 で落ち、 macOS のジョブだけが失敗する。 **真因**: `build.yml` の門をこう書いていた。 ```yaml HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERTIFICATE != '' && secrets.APPLE_ID != '' }} ``` **この門は、空文字で落ちる形を防ぐために自分で作ったものだった。** Tauri の `macos/sign.rs` は `var_os("APPLE_CERTIFICATE")` で見ており**空文字も `Some` を 返す**ので、`env:` へ直に書くと未設定の secret(= 空文字)を「証明書が指定された」と 読んで取り込みに行き、macOS だけが落ちる。だから「揃っているときだけ発火する」門を 置いた。**その門が、揃っているかを部分的にしか数えていなかった。** 必要なのは 5 つで、依存は片方向ではない。Tauri の `ENVIRONMENT_VARIABLES.md` は **「`APPLE_ID` を指定するなら `APPLE_PASSWORD` と `APPLE_TEAM_ID` も設定しなければ ならない」**と明記している — **`APPLE_ID` を数えたことが、数えていない 2 つを 必須にしていた。** **部分的な有効化に価値が無いことも、後から数えたら出た。** 署名だけ通って公証が 無い `.app` は Gatekeeper が結局拒むので、**得るものはゼロで、リリースが割れる 危険だけが増える。** **処方**: 5 つ全部を数える。 ```yaml HAS_APPLE_SIGNING: >- ${{ secrets.APPLE_CERTIFICATE != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_ID != '' && secrets.APPLE_PASSWORD != '' && secrets.APPLE_TEAM_ID != '' }} ``` **見つけた経路はテストでも CI でもない** — secret を 4 つ登録した時点で 「いま門はどちらに倒れているか」を数え直したこと。**門の条件と、実際に登録されて いるものの一覧を並べて見るまで、両者が食い違っていることは分からなかった。** **一般化 1**: **「必要な入力が揃っているか」を検査する門を書いたら、その入力を 実際に数える。** 標本では足りない。書いた本人は「代表的な 2 つを見れば十分」と 思っており、**その 2 つを選んだ根拠は無かった**。 **一般化 2**: **依存は片方向とは限らない。** `APPLE_ID` は「あれば使う値」ではなく 「あると別の 2 つを必須にする値」だった。**門に足す 1 つが、門に足すべきものを 増やすことがある。** **一般化 3**: **部分的に有効化して得るものがあるかを数えると、門の粒度が決まる。** 今回は「署名だけ」に価値がゼロだったので、5 つを AND で束ねるのが正しい粒度だった。 価値があるなら段階的な門にすべきで、そこは自明ではない。 ## #109 App 固有パスワードとアカウントのパスワードは、入れた後 20 分経つまで見分けがつかなかった **症状**(2026-08-16): `v0.1.9` のタグで macOS のジョブが落ちた。証明書の取り込みも 署名も完走したうえで、最後の公証だけが ```text failed to notarize app: HTTP status code: 401. Invalid credentials. Username or password is incorrect. Use the app-specific password generated at appleid.apple.com. ``` **真因**: `APPLE_PASSWORD` に**Apple ID のアカウントのパスワード**を入れていた。 公証が要求するのは appleid.apple.com で別途発行する**App 固有パスワード** (`xxxx-xxxx-xxxx-xxxx` の形)。**どちらも「Apple ID のパスワード」と呼べてしまう。** **重いのは取り違えたことではなく、判定に 20 分かかったこと。** 資格情報の正誤を 確かめる唯一の経路が「タグを打って 3 OS のビルドを回し、macOS の Tauri ビルドが 終わってから公証の段に到達する」だった。**外すたびに 20 分**で、しかもタグを 消費する。 **処方**: 資格情報だけを検証する手動 workflow (`.github/workflows/verify-notary.yml`)。`xcrun notarytool history` は **ビルドを 1 行も走らせずに認証だけを判定する**ので 1 分で真偽が出る。 認証が通れば履歴(空でもよい)が返り、誤っていれば 401。 **そのうえで、値を出さずに形を検査する段を前に置いた。** ```text APPLE_ID : present (19 chars) APPLE_PASSWORD : present (15 chars) length = 15 -> WARNING: App 固有パスワードの想定 (19) と違う ``` **App 固有パスワードはハイフン込みで 19 文字**(ハイフンを外すと 16)。**15 は どちらでもない**ので、`notarytool` を呼ぶ前に誤りが確定する。空白の混入も同じ形で 見る。**秘密を 1 文字も出さずに判定できている。** **初回の実行で元を取った。** 20 分のビルドを回さずに原因が確定し、直した後の 再実行は `length = 19` → `No submission history.`(= 認証成功)で 1 分だった。 **一般化 1**: **秘密は中身を見なくても、形(長さ・区切り文字・空白の有無)で 誤りの大半が捕まる。** 発行元が固定書式を定めている資格情報ほど効く。 **#71 の対になる形** — あちらは*計器がモデルの出力経由で秘密をログへ再放流した* 事故で、こちらは*秘密を出さずに検査する計器*。**計器に秘密を通すかどうかは 設計で選べる。** **一般化 2**: **判定に 20 分かかる検査は、20 分かけて 1 つずつしか誤りを潰せない。** 資格情報のように「合っているか間違っているか」しかない入力は、**本番の経路から 切り離して単独で叩ける口を作る**と桁が変わる。切り離せるかどうかは、 その検査が副作用を持つかで決まる(`notarytool history` は読むだけ)。 **一般化 3**: **同じ語で呼ばれる 2 つの秘密は取り違える。** 「Apple ID の パスワード」はアカウントのものと App 固有のものの両方を指せた。**入力欄の名前 (`APPLE_PASSWORD`)がどちらとも読めるとき、書式で区別できるなら書式で検査する。** ## #110 文字数を返す関数の戻り値を、バイト長で割った(他プロジェクトで独立に再発) **症状**: この村では**まだ本番で踏んでいない**。Spec 16 と Spec 17 の査読で 添えられた参考実装に 2 回続けて現れ、どちらも実装前に潰した。 **3 例目を別のコードベースで観測した**ので、ここに残す。 **観測した実物**(`iOfficeAI/aionrs` の `crates/aion-compact/src/fold.rs`。 Apache-2.0。2026-08-17 時点): ```rust fn common_prefix_len(a: &str, b: &str) -> usize { a.chars().zip(b.chars()).take_while(|(ca, cb)| ca == cb).count() // 文字数 } fn lines_are_similar(a: &str, b: &str) -> bool { let prefix = common_prefix_len(a, b); // 文字数 let min_len = a.len().min(b.len()); // バイト長 prefix as f64 / min_len as f64 >= MIN_PREFIX_RATIO // 0.5 } ``` **真因**: 分子が文字数、分母がバイト長。UTF-8 の ASCII では両者が一致するので **英語のテストでは一生出ない**。日本語は 1 文字 3 バイトなので比が約 1/3 になり、 **閾値 0.5 が実質 1.5 になって、どんなに似た行でも畳まれない。** この関数はツール出力の圧縮(似た行を `[... N identical lines]` へ畳む)に使われる。 **日本語の出力だけが圧縮されずに素通りする**が、**エラーにはならない**ので 気づく契機が無い。 **この村での 2 例**(どちらも査読の参考実装。実装前に潰した): - **Spec 16**(`grep` の `include` / `context`)— 文字数の枠を `len()` で数えていた - **Spec 17**(`sd` の batch preview)— 同じ形 **当時の一般化**: **`MAX_*_CHARS` という名前の定数を `len()` と突き合わせる差分は、 日本語では枠の 1/3 で発火する。** テストが ASCII だけだと通ってしまう。 **処方**: 比を取る前に**分子と分母の単位を揃える**。文字数で比べるなら `a.chars().count().min(b.chars().count())`、バイトで比べるなら分子も `a.as_bytes().iter().zip(...)`。**どちらでもよいが、混ぜてはいけない。** 検査は**非 ASCII を含む入力を 1 本入れるだけで足りる** — ASCII だけのテストは この誤りに対して**構造的に無力**で、何本足しても検出しない。 **一般化 1**: **`.len()` は「長さ」ではなく「バイト長」だ。** Rust の `str::len` が バイトを返すことは知られているが、**比や割合を組み立てるときに分子だけが `chars()` になっている形**は目視で残る。**同じ式の中で単位が変わる。** **一般化 2**: **同じ罠が別のコードベースで独立に踏まれていたら、その記録は このプロジェクト固有ではない。** #33 の「単位を間違えた閾値は、境界付近ではなく 全域で外れる」と同じ族で、**あちらは文字数 ÷ トークン数、こちらは文字数 ÷ バイト長**。 **単位の異なる 2 つの量で比を作る箇所を、実装の前に数える価値がある。** **一般化 3**: **英語圏で書かれたコードを日本語の入力で使うと、この族の誤りは 「壊れる」ではなく「効かない」として現れる。** 例外も警告も出ず、 最適化が黙って無効になる。**#33 も同じ形で数日気づけなかった**(キャッシュが 一度も効いていなかったのに、累積トークンの表示は同じ数字を出していた)。 ## #111 `type="number"` の `v-model` は `.number` 無しでも数値を渡す — 文字列前提の setter が黙って死んでいた **症状**: モデル登録ダイアログの単価欄(Spec 41)に**手で数字を入れても保存されない**。 「取得」ボタンで埋めた値は保存される。利用者の報告(2026-08-19。Perplexity は単価表に 載っていないので手で入れようとして発覚)。 **真因**: 単価欄は `computed` の get / set で `draft` と繋ぎ、setter は `raw.trim()` と**文字列前提**で書かれていた。しかし `` に `v-model` を 付けると、**`.number` 修飾子が無くても Vue が値を数値へ自動変換する** (`@vue/runtime-dom` の `vModelText`: `castToNumber = number || vnode.props.type === "number"`)。 数字を打った瞬間に setter へ `number` が届き、`(3).trim()` が `TypeError` で落ちて **`draft` へ 1 度も書かれない**。コンソールにだけ出て画面は何も言わない。 **空欄だけが通っていた** — `""` は `looseToNumber` で数値化されず文字列のまま届く。 だから「欄を空にする」と「取得で埋める」(`draft` を直接書く)は効き、**手入力だけが 効かない**という、原因を読みにくい形になった。 **処方**: 変換を純関数 `parsePriceInput(raw: string | number)` へ寄せ、`String(raw).trim()` で**型に関わらず同じ結果**にした。単体で「初版の setter に数値を渡すと `TypeError`」を **再現の赤**として残し(`priceInput.test.ts`)、数値でも文字列でも同じ値になることを留めた。 **私が見落とした理由**: Spec 41 に「**`v-model.number` を使わない**(空文字を `0` にするので 未設定が無料に化ける)」と自分で書いていたので、**修飾子を付けなければ文字列が来る**と 思い込んだ。Vue は `type` 属性を見て同じ変換を掛ける。**修飾子の有無は変換の有無ではない。** **一般化 1**: `computed` の型注釈は setter に届く値の型を保証しない。テンプレートの `v-model` が何を渡すかは**要素の `type` 属性とフレームワークの規則**で決まり、型検査の外。 `` / `type="range"` に繋ぐ setter は `string | number` で受ける。 **一般化 2**: 「A を使わないから B が来る」と書いた注記は、**A 以外の経路で B が来ない** ことを確かめていない。避けたものの代替経路を 1 度数える(ここでは `type` 属性)。 **一般化 3**: 同じ入力が「空欄では効き、値を入れると効かない」とき、疑うのは保存経路より **入力の型の分岐** — 空欄だけが別の型で届く境界を探す。 **同じ経路で 2 つ目の穴**: Spec 41 D1 は「`pricing_as_of` は取得なら JSON の日付、 **手入力ならその日**で常に埋まる」と凍結していたが、実装は取得のときしか書いておらず、 手入力した村の統計は「単価の時点は未記録」と出ていた(同じスクショで発覚)。 `todayIsoDate(now)` で手入力の setter がその日を書くようにした。**契約に「常に」と 書いた欄は、埋める経路を 1 つずつ数える**(取得 / 手入力 の 2 経路で片方だけ書いていた)。 ## #112 旧い版で村を開くと、`world.json` の未知の欄が黙って消える(`#[serde(default)]` は前方互換ではない) **症状**: 統計画面の `≈ $` が出なくなった。`world.json` を見ると**全テンプレートの単価 5 欄と `pricingAsOf` が `null`**。前夜 23:13 には `≈ $0.01` が実機で出ていた(2026-08-20)。 **真因**: `%LOCALAPPDATA%\fuseforks\fuseforks.exe`(**08-16 06:30** = 単価欄を知らない世代。 単価は Spec 41 で 8/18 の `v0.1.10` タグの**後**に入った)で村を開いた。`ModelTemplate` に `deny_unknown_fields` が無いので、旧い版は `inputPerMtok` 等を**未知フィールドとして黙って捨て、 書き戻しでファイルから消す**。警告は 1 行も出ない。 **コードの経路は全部無実だった**(先に疑って、実測で外した)— `draft.value` をそのまま IPC (`save` / `saveSecret` の 2 経路とも)→ `upsert_template` は `credential` だけ補正 → `to_persisted` は `self.templates.values().cloned()` → `from_persisted` は `insert(template)` → `reseedDraft` は `{ ...saved }`。**Spec 14 P1 型の「投影から組み直して欄が消える」穴は無い。** **契約が 2 つに割れていた。** `session_store` の凍結はこう書いてある —「旧い版で新しい村を 開くのは**非サポート**(未知の kind で `records()` が落ちる)。**未知 kind を WARN で飛ばす 読み取りは作らない** — 読めないレコードを黙って落とすと統計が『払っていない』側へ嘘をつく (#72 の形)」。**`world.json` はその逆で、黙って削る。** そして今回、**実際に統計が嘘をついた** (単価が消えて金額が出なくなった)。同じ危険に対して、同じリポジトリの 2 つの保存先が 反対の側へ倒れていた。 **構造的に再発する。** CLAUDE.md の winget の節に自分で書いてある —「MSI と NSIS は **同じ端末に並んで入り、identifier が同じなので村のデータは共有する**」。そこに手元の ビルドが加わって、**3 世代が 1 つの `world.json` を共有**している。 **処方(入れたのは 1 つ目だけ。2026-08-20)**: 1. **起動行に版と profile を出す**(`version: app=0.1.0 profile=release`)。**`CARGO_PKG_VERSION` は使えない** — CI がタグから書き換えるのは `tauri.conf.json` だけで、workspace の version は どのビルドでも `0.1.0`。**配布物と手元のビルドは分かれるが、手元のビルド同士は区別できない** (`profile` がその半分を埋める) 2. **着地(2026-09-14。Spec 不要と判断)**: `PersistedWorld` / `AgentSpec` / `ModelTemplate` / `AgentRole` / `AgentRoleDefaults` / `AgentGroup` が `#[serde(flatten)] unknown: UnknownFields` で 未知の欄を保持して書き戻す。**効くのは今後足す欄だけ** — 既に配布済みの旧版(v0.2.3 以前)は直せない。 **読みで残すだけでは足りなかった** — 画面は自分の版が知っている欄だけで組み直して送る (`snapshotToSpec`)ので、`update_agent` / `upsert_template` / `upsert_role` / `upsert_group` が **既存の側から未知の欄を引き継がないと、最初の保存操作で消える**。赤は 2 本に割って 2 段とも確認した(`unknown_fields_survive_load_and_save` / `..._updates_from_the_ui`)。 **持ち込んだ穴を 1 つ同日に塞いだ** — 未知の欄を全部残すと、**この版が廃止した欄** (`apiKeyEnv` = #1 で実キーが入った欄 / `defaults.ragSources`)まで書き戻され続ける。 既存の `apiKeyEnv` のテスト 2 本は「旧形式が読める」だけを見て書き戻しを見ていなかったので 緑のままだった。処方は廃止した欄を**型に名前を残して読み捨てる**(`retired_*: ()` + `skip_serializing` + `discard_retired`)。履歴の走査で、`model.rs` / `world.rs` から消えた `pub` 欄は `api_key_env` の 1 つだけだった(`ragSources` は `AgentSpec` に残っているので 差分に出ない — 走査の外にあった 2 つ目) 3. **採らない**: 旧版で起動を止める。「起動が止まる経路は作らない」(`from_persisted` の 既存の判断)と衝突する **一般化 1**: **`#[serde(default)]` は前方互換ではない。** 保証するのは「欄が欠けても読める」 ことだけで、「書き戻しで消さない」は保証しない。**読みと書きの両方を通る設定ファイル**では、 `default` を付けた時点で「古い版が新しい欄を削る」経路が同時に生まれる。 **一般化 2**: **互換の向きの扱いが、保存先ごとに割れていないか数える。** 片方に「黙って 落とさない」と凍結を書いたら、もう片方の保存先も同じ問いに答えているかを見る。片方だけ 厳しくしても、事故は緩いほうから入る。 **一般化 3**: **画面に出ている値が、ログにも要るかを画面を作ったときに問う。** 版番号は ステータスバーに出ていたのにログには無く、**事後に「どの世代が触ったか」を読む手段が 無かった**。診断の識別子は、事故が起きてから足しても**その事故自体は読めない**。 ## #113 有界ループの上限を「回数」で切ったら、速さに依存するテストになった(2026-08-25) **症状**: `v0.1.11` のリリースビルドで **macOS と Windows だけが赤**(Ubuntu は緑)。 落ちたのは `budget::tests::join_set_abort_runs_drop_and_refunds` で、**返金の検査 (テストの主題)ではなく、その手前の「予約が観測できたか」の assert**: ```text panicked at crates/fuseforks-core/src/budget.rs:621: 予約が残額から引かれる実装であること ``` **真因**: 予約の観測を待つループの上限が**回数**だった。 ```rust for _ in 0..10_000 { if pool.spent_effective() == 400 { reserved_seen = true; break; } tokio::task::yield_now().await; // ← 実時間で数ミリ秒しかない } ``` `yield_now()` 10,000 回は**実時間で数ミリ秒**で、`spawn` したタスクが別ワーカーで 走り出すより短い。混んだ CI ランナーでは予約の前にループが尽きる。**Spec 38 が `v0.1.8` で入って以来在り、`v0.1.10` の CI では通っていた** — 新しい退行ではなく **フレーク**で、速さの違いが OS 差として現れた。 **この有界ループは #86 の処方だった。** 無限 `while` にすると、予約しない実装 (ミューテーション = load 観測化)で**ハングになり赤と区別が付かない**ので上限を 付けた。ハングは防げたが、**上限を回数で切ったことで今度は速さに依存した。** **処方**: 上限の単位を「回数」から「秒」へ移す(`sleep(5ms) × 1000` = 5 秒)。 **`sleep` にするのは待つためだけではない** — `yield_now` の密なループはワーカーを 回し続けるので、**譲ったつもりで譲れていない**。 **ミューテーションで両方を確認した** — 予約しない実装で **15.66 秒で赤**(ハングせず、 assert が落ちる)。#86 の処方は保ったまま単位だけが変わった。 **一般化 1**: **「永久に返らない形にしない」ための上限は、実時間で切る。** 回数で切ると、待っている対象(別スレッドのスケジューリング・I/O・プロセス起動)が 回数と無関係な単位で動くので、**速い機械でだけ通るテスト**になる。 上限の単位は、**待っている対象の単位に合わせる**。 **一般化 2**: **同じテストが「主題の検査」と「前提の観測」を持つとき、落ちるのは たいてい前提の側。** ここでも主題(abort で Drop が走り返金される)は無傷で、 その手前の観測が届かなかっただけだった。**assert のメッセージが主題と前提で 別々に書かれていたから 1 行で切り分けられた** — 前提の assert に主題の文言を 書いていたら、実装を疑って時間を溶かしていた。 **一般化 3**: **CI の OS 差は、OS の差とは限らない。** 今回 Ubuntu だけ通ったのは Linux が特別なのではなく、その回のランナーが速かったから。**「macOS で落ちる」と 読むと OS 依存の実装を探しに行く** — 先に「どの assert か」を見る。 ## #114 進行役のための設定をワーカーが持つと、委譲の戻り口が「提案した」の一言で消費される(2026-09-02) **症状**: ザリが `plan` でジェミーとルナへ撒く → ルナは自分の `plan` でミュゼと イクスへ撒く → **ザリへは「提案した」の 105 字だけが戻り**、ザリはジェミーの 調査だけで束ねる → 3 分後にルナがミュゼとイクスの結果を束ねて**利用者へ**報告する。 利用者の問い —「ルナは報告が返ってからザリに報告すべきでは。そうしないのはなぜか」。 ```text 07:40:29 plan pending: agent=agent_8 to=[agent_3,agent_10] ← 窓が開く 07:40:32 reply: agent=agent_8 to=agent chars=105 ← 戻り口が消費される 07:40:39 plan dispatch: agent=agent_8 ← 人の承認 07:40:43 plan bundle: agent=agent ← ザリの束ねが閉じる 07:43:33 turn start: agent=agent_8 hop=0 from=system ← 新しい根(戻り口なし) 07:44:41 reply: agent=agent_8 to=user ← 利用者へ流れる ``` **真因**: ルナも `planReview` = true だった。Spec 43 の編集窓は「提示でターンを 正常に終え、承認が新しい因果の根になる」二相(凍結 5)で、**委譲されたターンの 戻り口(`reply_to`)を持ち越さない**。窓を開けた時点で、依頼主へ束ねが戻る経路は 構造的に消える。`turn.rs` は `spec.plan_review` を個体の設定のまま `run_plan` へ 渡しており、**転送に対しては #96 で入れた `!awaiting_reply` の門が、窓には 無かった**。Spec 43 の Design は「窓が要るのは進行役だけ。ワーカーに plan は 生えないことが多い」と書いていたが、`plan` は接続 2 体以上なら誰にでも生え、 検収の対照はワーカーに窓を付ける形を 1 度も踏んでいない。 **処方**: `plan_review && !awaiting_reply`(`CallRunner::awaiting_reply` を足した)。 委譲されたターンでは従来どおりターンの中で撒いて束ね、依頼主へ戻す。 結合テスト 1 本を赤 → 緑で留め、赤のログが実機と同じ形(`plan pending` → 依頼主へ 7 字の返信)であることを確かめた。Spec 43 凍結 10・`data_contract` の `plan_edit_window` へ追従。 **一般化**: 1. **「誰かのため」の設定は、その誰かでない個体にも付けられる。** 設定の意味が 役割(進行役)に依存するなら、役割は設定ではなく構造(このターンに戻り口が あるか)で判定する。転送の門(#96)と同じ形で、**2 例目** 2. **二相の機構は、1 相目でターンを終える瞬間に「そのターンが誰かに答えを 負っているか」を数える。** 戻り口はターンの寿命と一致しており、ターンを終える 機構はすべてそれを消費する — 提示・要約・打ち切りの各出口で同じ問いが要る 3. **検収の対照を「設定 ON の個体 / OFF の個体」で取ると、ON の個体が別の役割で 呼ばれる形が網の外に落ちる。** 検収は設定の値だけでなく、その個体が呼ばれる 経路(人から / 委譲から / 予定から)でも数える ## #115 外部裏付け: 3 つの公開ハーネスで再現された欠陥が、この村が構造で塞いだ穴と同じ継ぎ目に落ちている(arXiv:2608.23953・2026-09-02 実読) **この項は村の失敗ではない。** 「The Empire, Long Divided, Must Unite」(Jiahong Dai・NTU・ 単著・2026-08-25・N=3)が deepagents(LangChain)/ pi(Earendil)/ dsh(DeepSeek)を commit 固定でソースレベルに読み、**再現した欠陥を 4 つの継ぎ目に分類した**。そのうち 3 つが この台帳の既存項と同じ形なので、外部の実物として引けるように残す。 | 論文の継ぎ目 | 論文の実物(file:line は論文の記載) | この村の対応 | |---|---|---| | **S2 正規化の信頼の隙間** | deepagents `utils.py:691` — `os.path.normpath` が先頭の `//` を残すので `//secrets` が `/secrets` とパス要素では不一致、ファイルシステムでは同一。削除の deny 規則・承認の割り込み・バックエンドの振り分けの 3 検査が**素通り**(隣の wcmatch 系の検査は正規化しているので無傷) | `resolve_in_work_dir`(`tools/fs.rs`)が文字列検査ではなく **canonicalize 後の前方一致**を採った理由そのもの。Spec 15 が「パースで守る境界は必ず抜けられる」と 3 rev かけて退けた線 | | **S3 文字列一致で意味を決める** | pi `overflow.ts:60–142` — 約 25 本の正規表現でプロバイダのエラーを分類。Bedrock のスロットリングが素のオブジェクトで再送出され(`bedrock-converse-stream.ts:306`)JSON 化されて `{` で始まるため、それを弾くはずの先頭固定の否定正規表現(`:75`)を抜け、**一時的なレート制限が文脈溢れと誤読されて有料の破壊的圧縮へ**(`agent-session.ts:2079`。しかも retryable と印されない `retry.ts:26–44`) | Spec 08 の凍結「**分類は文言 parse でなく型で運ぶ**」。Spec 27 で却下した「`Error:` を含むなら ok を倒す」も同族。`refusal=` は文字列照合だが**観測だけ**で配送に触らない(#84 の規律) | | **S4 黙って続く対 大声で止まる** | 上の全欠陥に共通 — 未知の形が既定値へ落ちて走り続ける(deepagents の設定層だけは「除外規則が何にも当たらなければ raise」する対照) | #72(握り潰した事実を数えない `_ => {}` は沈黙になる)/ 閉じた許容 / eigent の採点器 fail-open の反面教師。論文の規則「**境界では未知の形は既定値ではなく誤り**」は同文 | | S1 sync/async の乖離 | deepagents `async_subagents.py:445/:625` — 手書きの同期版と非同期版で guard の位置が違い、片方だけ未処理の KeyError | Rust の単一ループなので該当なし(論文自身が言語固有と断る) | **論文が「収束していない唯一の次元」と呼ぶもの = 外部検証可能性。** ランタイムを信用せずに 第三者が改竄の有無を確かめられる記録は 3 つとも無い(dsh の「可視 ⇒ 記録済み」の実行時 アサーションが最上位だが、運用上の再構成であって外部検証ではない)。**この村も同じ段に居る** — `fuseforks.log` と `sessions.redb` は村自身が書く記録で、改竄検知を持たない。論文が 6 段目に 置く問い「**費用の天井が守られたことを証明できるか**」は、予算の CAS 予約と `turn:` 行が 運用上は答えているが、外から確かめられる形では答えていない。評価基盤の「完遂」の軸の隣に 置く候補として名前が付いた(起票はしない — 論文自身も「隙間を特定して止まる」)。 **一般化**: 1. **自分が構造で塞いだ穴は、他所では同じ継ぎ目で開いている。** 「正規化は 1 箇所で必ず canonical へ、下流は canonical しか受けない」「エラーは型で運び、正規表現は安全側へ倒す 最後の手段」「未知の形は誤り」の 3 規則は、この台帳の #72 / Spec 08 / Spec 15 と論文が 独立に同文へ着地した。**規律の外部裏付けは、失敗の項の隣に置くと引ける** 2. **取り返しのつかない継ぎ目に損害が集中する。** 論文の 4 標本のうち 3 つが不可逆 (deny 規則をすり抜けた再帰削除 / 圧縮による履歴の上書き)。自動の破壊的操作(圧縮・退避・ 削除)は記録し、できれば可逆にする — この村が削除をごみ箱に限り、要約を手動にし、 黒板を append で育てる判断と同じ向き 3. **「コーチングは薄く、耐久性は厚く」。** 極小主義の pi が積んだのはセッション記録・ クラッシュ復旧・モデル癖のカタログで、モデルへの指示ではない。「ループの所有が 構造的な差」(2026-08-17 の位置取り)の外部裏付け ## #116 URL の取得に失敗した後、モデルは 10 周・200K トークンを回して存在しないスレッドの内容を組み立てた(2026-09-04) **症状**: Gemini の URL context(Spec 48)を ON にしたジェミーへ、2 つのスレッドの ID を 途中で合成した**存在しない** outcasts.jp の URL を貼って「内容を読んで」と頼んだ。 取得は正しく失敗し(`gemini tools: url_context=0/1 statuses=URL_RETRIEVAL_STATUS_ERROR`)、 ターンも落ちなかった。**そのあとモデルは `grep` / `rag` / `fd` / `file` ×5 / `manuale__search` / `memoria__recall_memory` と検索 16 語で 10 周を回し、 `stop=tool_limit`(12/12)・`turn: prompt=201603` を払って、1,224 字の筋の通った 答えを返した** — 利用者の判定は「ハルシネーション」。前の周に読んだ**実在する** スレッドの本文が履歴に 2 通残っていた(`history_msgs=2/16`)ので、それと検索と手元の ファイルから組み立てた形。 **真因**: 取得の失敗は API からモデルへ返っており(`toolResponse` の part に状態が 載る)、モデルはそれを読んだうえで**「読めなかった」ではなく「他の手段で答える」を 選んだ**。村の機構はどれも止めない — RepeatGuard は同じ呼び出しの 3 回目しか止めず (毎周違う道具を呼んでいる)、周回上限は 12 で止めたが**それは払い終わった後**、 予算は 200K でまだ余裕がある。**#82 の「答えられないことに気づけない」の URL 版**で、 違いは「気づく材料(ERROR)が渡っているのに使わなかった」こと。 **もう 1 つの副産物**: 取得に失敗しても `tool_use_prompt=10296` が課金側に載った (404 のページ = SPA の殻を取ってきて ERROR の印を付けた、と読むのが自然だが 1 対の 観測)。**失敗は無料ではない。** **処方(2026-09-04 利用者裁定 —「ハルシネーションをしないように覚えさせれば解決できる。 運用だね」)**: 表示も機構も足さない。条例 / `Memory.md` の文言で、その個体に 「取得に失敗した URL の内容は語らない」を持たせる。黒板・手順書と同じ道 — 条例で運用を 始め、機構は要るときに。**効いたかは観測でしか分からない**(#84 — 文言は保証ではない)。 次に同じ形が出たら `gemini tools: url_context=0/N` の周の後の `reply:` を読む。 Spec 48 D3 の「取得失敗は画面に出さず、頻度を見てから」は据え置き。 **一般化**: 1. **失敗を名指しで返しても、受け手が LLM なら「止まる」保証にはならない。** #44 の 「打ち切り文に次の手を書く」は、モデルが回復するための処方だった。**回復の道が開いて いると、答えられない問いにも回復してしまう** — 失敗の名指しは必要条件で、十分条件ではない 2. **費用の歯止めはどれも「答えの正しさ」を見ていない。** RepeatGuard は同一性、周回上限は 回数、予算は量。**違う道具を順に呼びながら間違った答えへ進む形**は 3 つ全部の外側 3. **履歴に残った「隣の実在」が捏造の材料になる。** 直前に実在するスレッドを読んだ 個体は、存在しない ID にもその文体と構造を当てはめる。**対照は「その会話で初めて URL を貼る」形**で、続きの会話で貼ると材料が増える(検収を組むときの順序) ## #117 「同じ世代の欄は同じ門」は成り立たなかった — 2.5-lite は `thinkingLevel` を拒み `urlContext` を受けた(Spec 48 P0・2026-09-03) **症状**: Spec 48 rev2 で「URL context にも 2.5 系の門が要るか」を P0 (e) に置き、 **予測は 400**(`thinkingLevel` が 2.5-flash-lite で 400 だったので、同じ 3 系世代の欄は 同じく拒む、と写した)。実測は **200**(`toolUsePromptTokenCount` 6,541)。門は要らなかった。 **真因**: `thinkingLevel` は Gemini 3 で入った欄、URL context は 2025 年の 2.5 世代の機能。 **「どの版で入った欄か」を確かめずに「3.8 の固有スキル」という括りで同じ門を予測した。** Spec 36 の carries 表を 20 マス全部撃ったのと同じ規律(欄ごと・モデルごとに撃つ)を、 2 欄目で省略した。 **処方**: 予測は外れたが P0 で撃っていたので実装には乗らなかった(門を書いていれば 2.5-lite の利用者から URL context を黙って奪っていた)。**Spec の P0 に「予測を先に 書いてから撃つ」を置く形がここで効いた** — 外れたことが記録に残る。 **一般化**: **機能の門はモデルの世代ではなく、その欄がいつ入ったかで決まる。** 同じプロバイダの同じ世代でも欄ごとに導入時期が違い、「3.x の機能」という括りは 門の単位として粗い。門を 1 つ書くごとに、その欄で 1 発撃つ。 ## #118 JSON の配列要素へ欄を「末尾に」足したら、diff が 1 要素あたり「1 行変更 + 1 行追加」になった(Spec 50 P0・2026-09-05) **症状**: `prices.json` の 1,493 要素へ `max_input_tokens` を足す生成スクリプトの 1 回目で、 `git diff --stat` が `2987 insertions / 1494 deletions`。削除の 1,493 本は `"output_per_mtok": 0.0` → `"output_per_mtok": 0.0,` — 値は 1 桁も動いていないのに、 **「単価が動いていない」を追加行だけで読む**という P0 の確認法が使えなくなった。 **真因**: JSON の要素で最後の欄にはカンマが付かない。**欄を末尾に足すと、それまで最後だった 欄の行にカンマが付く**ので、その行が「変更」として diff に出る。シリアライザは正しく、 diff の読み方の前提(追加だけのはず)が置き場に依存していた。 **処方**: 欄を **`"key"` の直後**へ置く(`{"key": ..., "max_input_tokens": N, ...単価}`)。 前の行は元からカンマ付きなので diff は純粋な 1 行追加になり、 `1494 insertions / 1 deletion`(窓 1,493 + `_notice` 1 対 `_notice` 1)で読めた。 Pages リポジトリの `tools/build_prices.py` はこの順で要素を組み直す。 **一般化**: **JSON に欄を加算するとき「追加行だけの diff」が成立するかは置き場で決まる。** 末尾は必ず前の行を巻き込む。加算の証明を diff で立てるなら、欄は先頭側へ置く。 ## #119 同じトグルを 2 通りに書いたら、ON でノブが消えた(Spec 51 P4・2026-09-05) **症状**: グループ見出しのスイッチ(全体 ▶ の門)を ON にすると、ノブ(黒い丸)が消えて 青い錠剤だけになる(利用者のスクリーンショット)。カードの一括起動トグルは同じ画面で正しく出ている。 **真因**: カードのトグル(`AgentCard.vue`)はノブを `left-0.5` / `left-4.5` で**置いて**いる。 見出しのスイッチは `translate-x-0.5` / `translate-x-3` で**動かして**いた。同じ部品を 2 通りに書き、片方だけが実機で壊れた。**Tailwind の class の組み合わせが効くかは、 書いた側からは見えない**(型検査にも vitest にも掛からない)。 **処方**: カードと同じ書き方(`left-*`)へ揃えた。以後トグルはカードの書き方を写す。 **一般化**: **同じ見た目の部品を 2 箇所に書くなら、書き方も写す。** 「同じに見える」実装を 別の CSS で組むと、片方だけが壊れたときに「なぜ片方だけか」の答えが書き方の差にしか 無く、しかもそれは画面でしか出ない。`roleColorTokens.test.ts` がビルド成果物を走査した のと同じ種類の穴で、今回は 2 例目に達していないので網は張らず、書き方を揃えるに留める。 ## #120 実装の数を `grep | head` で数えて 10 と書いたら 57 だった(Spec 52 D4・2026-09-07) **症状**: Spec 52 rev2 の D4 に「`LlmBackend::chat` の署名に cancel を足す。実装は 10 箇所 (`HttpLlmBackend` / `EchoBackend` / 結合テストの 8 バックエンド)。全部機械的変更」と書いた。 P2 で数え直したら `impl LlmBackend for` は **57 箇所**(結合テストだけで 55)。 **真因**: 起票時の grep を `| head` で切って読み、切れた先を「それで全部」と読んだ。 `head` は「最初の N 行」であって「全部で N 行」ではないが、出力が N 行ちょうどのとき 両者は画面上で区別が付かない。数を書く grep に `head` を付けていた。 **処方**: 数は `grep -c` / `wc -l` で取り、`head` は中身を見るときだけに使う。 Spec 側は署名を変える案を捨て、**加算的な既定メソッド**(`chat_cancellable`。既定は token を 読まず `chat` へ委ねる)にして上書きを `HttpLlmBackend` 1 つに閉じた — 57 箇所を機械的に 書き換えても何も守らない。守るもの(sleep だけ切る・HTTP は切らない)は変えていない。 **一般化**: **「N 箇所」と書く前に、その N を出したコマンドに `head` が付いていないかを見る。** 出力が丁度 N 行で切れていると、切れたことは出力のどこにも現れない。同じ形は #94 (`tool:` 行を `name=` で grep して 0 件と読んだ)と同族で、あちらは「出ない構造」を 数えた誤り、こちらは「切った出力」を数えた誤り。**数を主張する grep は切らない。** ## #121 打ち切りが待ちを 2 ms で切ったのに、ターンは「API エラー」で閉じた(Spec 52 P4・2026-09-08) **症状**: 60 秒の `Retry-After` 待ちに入っている個体で「■ 停止」を押すと、待ちは 2 ms で 切れた(`interrupt requested` 01:21:13.190 → `turn:` 01:21:13.192)が、ターンは `stop=failed:LLM_API` で閉じ、会話ペインに「API エラー (status=429)」が出た。人が止めたのに 失敗を名乗る。 **真因**: Spec 52 D4 は「待ちが切れたら**いま受けた失敗をそのまま返し**、ターンループが 周回境界で `is_cancelled()` を見て `interrupted` へ落とす」と書いた。周回境界の検査は `Ok` の経路にしか無く、`turn.rs` の LLM 呼び出しの `Err` の腕は払いを清算してすぐ `Err` で 抜ける。**cancel を先に読む順序(`token_budget.precedence`)は `Ok` の経路にしか敷かれて いなかった**。結合テスト(`tests/retry_hint_log.rs`)は `HttpLlmBackend` の `select!` が 切れることまでしか見ておらず、その戻り値をターンループがどう分類するかは網の外だった。 **処方**: `Err` の腕で払いの清算の直後に `turn.token.is_cancelled()` を見て `finish_interrupted` へ落とす(`finish_interrupted` は 1 実装のまま、呼ぶ場所が 2 つ)。 `tests/interrupt_during_retry_wait.rs` で赤 → 緑 → ミューテーションで赤を確認。 **一般化**: **「切れたら失敗をそのまま返す」と「失敗は即抜ける」を別々に書くと、両方が 正しくても組み合わせで打ち切りが失敗に化ける。** 早期 return する腕は周回境界を通らない ので、優先順位を周回境界にだけ敷いても、その手前で抜ける経路には効かない。処方は 「戻り値に新しい variant を足す」ではなく、**抜ける腕の側で同じ順序を読む**こと(#96 の 転送の門・#114 の編集窓と同じ「戻り口の手前に 1 行」の形)。**機構が効いたこと(2 ms)と 分類が正しいこと(interrupted)は別の観測**で、片方だけ見ると合格に見える。 ## #122 グループを隠して 2 体だけにすると、Fit が左上へ寄った(Spec 51 追補・2026-09-08) **症状**: 3 グループのうち 2 つを隠して絆の地図に 2 体だけ出すと、Fit(手動も、作り直し時の 自動も)が 2 体を**左上**へ寄せ、内容が小さく描かれる。ペインが**広いほど**ずれが大きく、 2 グループで 1 つ隠すだけなら目立たない(利用者の観察 3 つ: 「関係ない位置」「左上に来る」 「ペインの幅によって変わる」)。 **真因(ライブラリ単体の再現ページで数字を取った)**: v-network-graph 0.9.23 の `fitToContents` は、内容が小さくペインが広いとき**上限を超える zoom** を計算し (幅 1,200 px で 2 体なら 4.88。`lib/index.js` の `Is` / `ro`)、**zoom だけを `maxZoomLevel`(この村は 2)へ丸め、pan は丸める前の値のまま当てる**(`ao` の pos は 4.88 用。zoom の setter だけが clamp = `:5706`)。実測: `matrix(2,0,0,2,354,138)` で 内容の中心が (475, 170)、ペインの中心は (601, 201) — 左上へ寄る。幅 300 なら要求 zoom が 1.47 で上限の下に収まり、ずれない = 「幅で変わる」の正体。**隠した個体の数がそのまま 効くのではなく、残った個体の広がりが小さいほど要求 zoom が上がる**。 **外した処方が 2 つ先にあった**(同日。どちらも真因ではなかった): 1. 「Fit は `layouts` の全件で外接矩形を取るので、隠した個体の座標を `layouts` から落とす」 (`visibleLayouts`)— `layouts` の広がりを読むのは `scalingObjects: false` の枝(`$s`)で、 この村は `true`(`Is`)。**設定の枝を読まずに別の枝の処方を書いた**。`visibleLayouts` は 幽霊の座標を渡さない衛生として残す 2. 「ライブラリの内部の写しが `Object.assign` で足すだけなので `:key` で作り直す」— 写しが merge なのは事実だが、Fit は描画の `getBBox()` を読むので写しは効いていなかった。 `:key` は「隠した直後に残った個体へ Fit し直す」目的で残す どちらも**実機を踏んでもらって「まだ出る」で外れが分かった**。3 回目は再現ページを 作って数字で確かめてから直した。 **処方**: `lib/kizunaFit.ts` の `fitMargin` — 見えている座標の広がり(円の半径だけで **控えめに**見積もる)と、ペインの大きさ・上限 zoom から、**余るぶんを余白へ回す**。 `fitToContents({ margin })` は辺ごとの px を受けるので、ライブラリの計算する zoom が 上限ちょうどになり clamp が起きず、pan もライブラリ自身の正しい値になる(再現ページで `matrix(2,0,0,2,480,170)`・内容の中心 = ペインの中心)。読み込み時の Fit もライブラリの `"fit-content"` をやめて同じ `fit()`(`view:load`)へ。広がりを控えめに見積もる向きが 要点 — 大きく見積もると zoom が上限を超えて元の症状に戻る。単体 5 + 配線の走査。 **一般化 3 つ**: - **設定の分岐を読んでから処方を書く。** 同じ関数でも `scalingObjects` で通る枝が違い、 片方の枝の読みは片方にしか当たらない(#117「同じ世代の欄は同じ門」と同じ形 — 括りが粗い) - **「まだ出る」が 2 回続いたら、実機を頼む前に再現を自分で作る。** 3 回目は再現ページで 数字を取ってから直した。Browser ペインが `hidden` だと rAF が止まり svg-pan-zoom が transform を当てない — 隠れたペインでの測定は `requestAnimationFrame` をタイマーへ 差し替えてから(それまでの測定は無効だった) - **上限を持つ機構は、上限を超える要求のときに他の量が整合するかを見る。** zoom だけ丸めて pan は丸めない、が今回の形。Spec 52 の `plan_wait` が「天井を超えたら止める」を選んだのと 同じ場所で、ライブラリは「丸めて進む」を選び、進んだ先で pan がずれた ## #123 Vue の SFC を `indexOf("")` で切ったら、入力欄が検査の範囲から落ちた(初回の案内・2026-09-13) **症状**: 初回の案内の配線を留める走査テスト(`lib/chatZoomWiring.test.ts` の初版、 後に `tourTargets.test.ts` と同じ形)が、`ChatPanel.vue` のテンプレート部分を `panel.slice(panel.indexOf(""))` で切り出し、 「`chat-zoom` クラスが 2 要素に付いている」を検査したら **1 しか数えず赤になった**。 実装は正しく 2 箇所に付いていた。 **真因**: `ChatPanel.vue` には `` が **6 個**ある — root のほかに `