markharness

0034: Knowledge要素を物理削除するknowledge remove

ステータス

Accepted(2026-09-27決定、2026-09-27実装完了)。knowledge removeサブコマンド本体、identity::recovery/feature_ops::roll_forwardのクラッシュ耐性のある削除拡張、generateが追加実装なしで既に対応することを示す回帰テスト、およびパストラバーサル/symlink祖先に対する回帰テストをすべて満たした。詳細はchecklist-knowledge-remove.mdを参照。

背景

0027§4は削除を意図的に先送りした。knowledge reconcileは削除を伴わないmergeのみをサポートし、exactモードや--delete/--allow-retireフラグは「誤ったスコープやAIの見落としが破壊的な結果を招く」ことと「初期版には、そのリスクを正当化する具体的な削除要件がない」ことを理由に未実装のまま残された。

その具体的要件が今、存在する。作者は誤って作成した、または不要になったRequirement・Feature・Behavior・Scenarioを削除する必要がある(そして0017§3が1 Scenario = 1 TestCaseと定めているため、これはTestCaseを削除する手段でもある)。基盤となる意味論は0021がすでに決着させている——「FeatureまたはTestCaseがknowledge/から削除されると、それは単にKnowledgeツリーから消える。UID再利用の禁止や専用のretired状態遷移は追跡しない」——ため、本ADRはretire/tombstoneモデルを新たに考案する必要はなく、削除をどう起動し、どの範囲に及ぼし、どうクラッシュ安全にするかだけを決める。

Knowledgeツリーには削除時の振る舞いが異なる2種類の参照形状がある。

RequirementとFeatureのidはプロジェクト全体で一意(.markharness/knowledge/requirements/<id>/、.markharness/knowledge/features/<id>/)だが、BehaviorとScenarioのidは親の配下でのみ一意(features/<feature_id>/<behavior_id>/、features/<feature_id>/<behavior_id>/<scenario_id>/)であり、異なる親の下に同じslugが正当に存在し得る。

既存の複数ファイルにわたるKnowledge変更(0027)は作業ツリーへ直接書き込まれない。identity::recoveryのステージングプロトコル(begin_batch_with_payload → commit_batch → feature_ops::roll_forward → finish)を経由するため、1つのファイルを書いてから別のファイルを書くまでの間でクラッシュしても、変更前または変更後の状態のどちらかに必ず収束し、混在状態にはならない。identity::recovery::PendingKnowledgeFileは現在「このファイルの全内容を書く」ことしか表現できず、「このファイルはもう存在しない」ことを表現する手段がない。

決定

1. markharness knowledge remove <type> <key>を独立したサブコマンドとして追加する

markharness knowledge remove <requirement|feature|behavior|scenario> <key>
    [--feature <id>] [--behavior <id>] [--dir <path>] [--json]

これはReconcileと並ぶ新規KnowledgeCommand::Removeバリアント(src/cli.rs)であり、reconcileのexact/--deleteモードではない。単一要素をID指定で明示的に削除する操作は、ADR 0027§4が懸念した「大きな宣言的Intentのうち名前を挙げなかった要素すべてを暗黙に、省略ベースで退役させる」というリスクを共有しない。したがって別個の、より狭い操作として別のInterfaceを持つ。reconcileの「mergeのみ・削除なし」という契約は変更しない。

CLIの既存の非対話的な運用方針(0028§3)と、axes pruneの「明示フラグが必要(プロンプトなし)」という先例に合わせ、確認プロンプトは追加しない。操作は常に明示的に指定された単一要素に対するものなので、即時実行する。

2. <key>はまずslugとして解決し、必要な場合のみ曖昧性解消を行う

解決には新規のディレクトリ走査を書かず、identity::knowledge_walk::find_by_id/find_by_uid(src/identity/knowledge_walk.rs)を再利用する。

3. 必須単一親参照を持つ子は連鎖削除し、任意の逆参照はデタッチする

4. この操作は正本のKnowledgeファイルのみに範囲を限定する

knowledge removeは.markharness/knowledge/配下のファイルのみを削除する。以下には触れない。

5. generateには新たなプルーニングロジックは不要

generate::load_knowledge_snapshotは.markharness/knowledge/を呼び出しごとに新たに走査し、見つけたScenarioファイルごとに1つのTestCaseを構築する。application::generate_testcasesは、その都度そのスナップショットからgenerated/ディレクトリ全体を毎回置き換える(0017§3: 1 Scenario = 1 TestCase)。このため、knowledge removeによって(直接または連鎖により)削除されたScenarioは、追加のプルーニングコードなしに、次のgenerate実行の出力から既に欠落する。本ADRは、CLAUDE.mdのYAGNI原則に従い、その既存動作を証明する回帰テストのみを追加し、新規プロダクションコードは追加しない。

6. identity::recoveryのステージングプロトコルを削除を表現できるよう拡張する

PendingKnowledgeFileと並ぶPendingKnowledgeDelete { relative_path: String }を追加し、新規IntentPayload::KnowledgeRemove { deletes: Vec<PendingKnowledgeDelete>, files: Vec<PendingKnowledgeFile> }バリアント(identity::recovery)を追加する——deletesは連鎖削除される要素のファイル/ディレクトリ、filesは逆参照の書き換えを表し、既存のIntentPayload::KnowledgeReconcile { files, moves }の形に倣う。

0021§4により、削除はidentity eventを発行しない(UID発行とrenameのみが追跡対象)。したがってknowledge removeは、内容のみのreconcileパッチと同様に、batch_eventsを空にしてbegin_batch_with_payload(root, Vec::new(), Some(payload))を呼ぶ。その論理的なコミットポイントはeventファイルではなく、commit_batchが書くcommit_marker_pathである。

run_startup_recoveryのすべての呼び出し元が既に使っている単一のディスパッチャであるidentity::feature_ops::roll_forwardを、IntentPayload::KnowledgeRemove用のmatchアームで拡張する。このアームはdeletesの各パスを冪等に削除し(既に削除済みでもエラーとしない。既存のmove再生が同じ理由で持つ前例と同様)、filesの各エントリをreplace_fileする。これは既存のすべての呼び出し箇所がrun_startup_recoveryに渡している、まさに同じディスパッチャなので、新規knowledge_removeモジュール自身以外のどの呼び出し箇所も変更する必要はない。

これにより、knowledge removeの連鎖削除+逆参照書き換えは、knowledge reconcileの複数ファイル書き込みと全く同様にクラッシュ耐性を持つ: あるファイルを削除してから別のファイルを書き換えるまでの間にクラッシュしても、次回起動時のリカバリスキャンで必ず「完全に適用された状態」に収束し、部分的に適用された状態にはならない。

不変条件

影響

検討し、採用しなかった選択肢