markharness

0034: knowledge remove for physical deletion of Knowledge elements

Status

Accepted (decided 2026-09-27; implemented 2026-09-27). The knowledge remove subcommand itself, identity::recovery/feature_ops::roll_forward’s crash-recoverable delete extension, a regression test proving generate already handles it with no extra code, and regression tests covering path-traversal and symlink-ancestor attempts are all done. See checklist-knowledge-remove.md for details.

Context

0027 §4 deliberately deferred deletion: knowledge reconcile supports only non-deleting merge, and an exact mode or --delete/--allow-retire flags were left unimplemented because “a mistaken scope or AI omission becomes destructive” and “the initial version has no concrete deletion requirement that justifies that risk.”

That concrete requirement now exists: authors need to remove a mistaken or obsolete Requirement, Feature, Behavior, or Scenario (and, since 0017 §3 makes one Scenario equal to one TestCase, this is also how a TestCase is removed). 0021 already settled the underlying semantics — “When a Feature or TestCase is removed from knowledge/, it is simply gone from the Knowledge tree. No UID-reuse prohibition or dedicated retired state transition is tracked” — so this ADR does not need to invent a retire/tombstone model; it only needs to decide how deletion is triggered, scoped, and made crash-safe.

The Knowledge tree has two distinct reference shapes that behave differently under deletion:

Requirement and Feature id values are unique across the whole project (.markharness/knowledge/requirements/<id>/, .markharness/knowledge/features/<id>/), but Behavior and Scenario id values are unique only within their parent (features/<feature_id>/<behavior_id>/, features/<feature_id>/<behavior_id>/<scenario_id>/), so the same slug can legitimately exist under two different parents.

Existing multi-file Knowledge mutations (0027) are not written directly to the working tree. They go through identity::recovery’s staging protocol (begin_batch_with_payload → commit_batch → feature_ops::roll_forward → finish), so a crash between writing one file and another always converges to either the pre-mutation or the post-mutation state, never a mix. identity::recovery::PendingKnowledgeFile currently represents only “write this file’s full contents”; it has no way to represent “this file no longer exists.”

Decision

1. Add markharness knowledge remove <type> <key> as its own subcommand

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

This is a new KnowledgeCommand::Remove variant alongside Reconcile (src/cli.rs), not an exact/--delete mode of reconcile. A single-item, explicitly ID-targeted removal does not carry the “mistaken scope or AI omission” risk ADR 0027 §4 flagged against implicit, omission-based retirement of a whole plan; it is a separate, narrower operation and gets a separate Interface. reconcile’s merge-only, non-deleting contract is unchanged.

No confirmation prompt is added, matching the CLI’s existing non-interactive convention (0028 §3) and axes prune’s explicit-flag-required (not prompted) precedent. Because the operation is always a single explicitly named element, it executes immediately.

2. Resolve <key> as a slug first, falling back to disambiguation

Resolution reuses identity::knowledge_walk::find_by_id/find_by_uid (src/identity/knowledge_walk.rs) rather than a new directory walker.

3. Cascade-delete mandatory-parent children; detach optional back-references

4. Scope this operation to canonical Knowledge files only

knowledge remove deletes only files under .markharness/knowledge/. It does not touch:

5. generate needs no new pruning logic

generate::load_knowledge_snapshot walks .markharness/knowledge/ fresh on every call and builds one TestCase per Scenario file it finds; application::generate_testcases fully replaces the generated/ directory from that snapshot on every run (0017 §3: one Scenario equals one TestCase). Because of this, a Scenario deleted by knowledge remove — directly or by cascade — is already absent from the next generate run’s output with no additional pruning code. This ADR adds only a regression test proving that existing behavior, not new production code, honoring CLAUDE.md’s YAGNI rule.

6. Extend identity::recovery’s staging protocol to represent deletion

Add a PendingKnowledgeDelete { relative_path: String } alongside PendingKnowledgeFile, and a new IntentPayload::KnowledgeRemove { deletes: Vec<PendingKnowledgeDelete>, files: Vec<PendingKnowledgeFile> } variant (identity::recovery) — deletes for cascade-removed element files/directories, files for back-reference rewrites, mirroring IntentPayload::KnowledgeReconcile { files, moves }’s existing shape.

Per 0021 §4, deletion issues no identity event (only UID issuance and rename are tracked). knowledge remove therefore calls begin_batch_with_payload(root, Vec::new(), Some(payload)) with an empty batch_events, exactly like a content-only reconcile patch; its logical commit point is commit_batch’s commit_marker_path write, not an event file.

Extend identity::feature_ops::roll_forward — the single dispatcher every caller of run_startup_recovery already uses — with a match arm for IntentPayload::KnowledgeRemove that idempotently removes each deletes path (already-removed is not an error, matching the existing move-replay precedent for the same reason) and replace_files each files entry. Because this is the same dispatcher every existing call site already passes to run_startup_recovery, no call site other than the new knowledge_remove module itself needs to change.

This makes knowledge remove’s cascade delete plus back-reference rewrite crash-recoverable exactly like knowledge reconcile’s multi-file writes: a crash between removing one file and rewriting another converges to the fully-applied state on the next startup recovery scan, never a partially-applied one.

Invariants

Impact

Options considered and not taken