From 781817b8e9edd949c0a613a05152ffacb6fb0485 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 31 Jul 2026 02:21:10 -0700 Subject: [PATCH 1/3] fix(capture): apply coerce_numeric to the numeric config fields #686 added coerce_numeric and routed capture.py's two boolean fields through coerce_bool, but left min_observations and dedup_window_seconds on bare int()/float(). a typo'd value raised out of load_config instead of falling back to the default, which is the exact case the helper's own docstring cites (`min_observations: "three"`), and the resulting unused import tripped ruff F401. surfaced by merging test into this branch: the branch-push workflows on test don't run pytest/mypy/ruff, so the gate never ran on the merge that landed it. Co-authored-by: Cursor --- src/vouch/capture.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 9aeb98b8..ec99b654 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -75,9 +75,15 @@ def load_config(store: KBStore) -> CaptureConfig: return CaptureConfig( enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED), realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME), - min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)), - dedup_window_seconds=float( - raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) + min_observations=coerce_numeric( + raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS), + DEFAULT_MIN_OBSERVATIONS, + int, + ), + dedup_window_seconds=coerce_numeric( + raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS), + DEFAULT_DEDUP_WINDOW_SECONDS, + float, ), answer_mode=answer_mode, ) From f82bc3c410da145968de200dd887259b528e9552 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 31 Jul 2026 03:10:11 -0700 Subject: [PATCH 2/3] feat(delete): cascade option for propose_delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #600. `referenced_by()` refuses a delete while anything still points at the target. that block is correct, but in a compiled kb it leaves most claims permanently undeletable — pages cite claims in bulk — and a supersede pair is mutually locked at both ends, so no delete ordering can ever remove either half of a chain. the gate is unchanged. what changes is what the reviewer is asked to approve: with cascade=true the required referrer edits are recorded in the proposal payload as a plan, and _approve_delete re-derives that plan at approve time — the same posture as the existing ref re-check — applies it, and only then deletes. the approve-time referenced_by gate still has to come back empty, so the gate is satisfied rather than bypassed. pages and claims lose their pointer, frontmatter and the inline [claim: …] body markers both. relations are deleted outright: an edge whose endpoint is gone has no meaning, and relations carry no inbound refs of their own, so the walk is one level deep by construction and there is no transitive cascade to bound. additive and default-off — omitting cascade reproduces today's behaviour exactly, and the refusal message now names the flag so the dead end is discoverable. Co-authored-by: Cursor --- CHANGELOG.md | 22 ++ src/vouch/cli.py | 8 +- src/vouch/jsonl_server.py | 1 + src/vouch/proposals.py | 217 +++++++++++++++- src/vouch/server.py | 8 +- tests/test_cascade_delete.py | 475 +++++++++++++++++++++++++++++++++++ 6 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 tests/test_cascade_delete.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed8832f..ba91ca9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- **cascade delete — the referrers ride along in the proposal** (#600): + `kb.propose_delete(..., cascade=true)` and `vouch propose-delete --cascade`. + `referenced_by()` refuses a delete while anything still points at the target, + which is correct but leaves most of a compiled kb undeletable — pages cite + claims in bulk, and a supersede pair is *mutually* locked (`b` lists `a` in + `supersedes`, `a`'s `superseded_by` points back at `b`), so neither end of a + chain could ever be removed by any delete ordering. The gate is unchanged; + what changes is what the reviewer is asked to approve. With `cascade`, the + required referrer edits are recorded in the payload as a plan, and + `_approve_delete` **re-derives** that plan at approve time — the same posture + as the existing ref re-check — applies it, and only then deletes, so the + approve-time `referenced_by` gate still has to come back empty. Pages and + claims lose their pointer (frontmatter *and* the inline `[claim: …]` body + markers, via the same `strip_claim_markers` helper `wipe_dead_refs` uses); + relations are deleted outright, because an edge whose endpoint is gone has no + meaning, and relations carry no inbound refs of their own, so the walk is one + level deep by construction with no transitive cascade to bound. Every edit + lands its own irreversible audit event (`page.cascade_unlink`, + `claim.cascade_unlink`, `relation.delete`) and the `{kind}.delete` event names + what it touched. Additive and default-off: `cascade` omitted reproduces + today's behaviour exactly, and the refusal message now names the flag so the + dead end is discoverable. - **correction capture — the pushback becomes a proposal** (#430): the adapter captured tool *outcomes* passively but never the single highest-signal event in a session, the user correcting the agent ("no, we deploy from `main` not diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 9dba52bd..8fb21faa 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2376,8 +2376,13 @@ def propose_relation_cmd(src: str, relation: str, target: str, confidence: float @click.argument("target_id") @click.option("--rationale", default=None) @click.option("--dry-run", is_flag=True, help="Validate without filing the proposal.") +@click.option( + "--cascade", is_flag=True, + help="Include the referrer edits in the proposal instead of being refused.", +) def propose_delete_cmd( - target_kind: str, target_id: str, rationale: str | None, dry_run: bool + target_kind: str, target_id: str, rationale: str | None, dry_run: bool, + cascade: bool, ) -> None: """File a review-gated request to hard-delete an artifact.""" store = _load_store() @@ -2388,6 +2393,7 @@ def propose_delete_cmd( target_id=target_id, rationale=rationale, dry_run=dry_run, + cascade=cascade, proposed_by=_whoami(), ) click.echo(pr.id) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 38a34cb8..bdd4fd9f 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -553,6 +553,7 @@ def _h_propose_delete(p: dict) -> dict: rationale=p.get("rationale"), session_id=p.get("session_id"), dry_run=bool(p.get("dry_run", False)), + cascade=bool(p.get("cascade", False)), proposed_by=_agent(), ) return { diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 6ba3f75e..a15abcc7 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -578,6 +578,7 @@ def propose_delete( rationale: str | None = None, session_id: str | None = None, dry_run: bool = False, + cascade: bool = False, ) -> Proposal: """File a review-gated request to hard-delete a durable artifact. @@ -585,6 +586,12 @@ def propose_delete( referenced by another artifact — the maintainer must supersede or remove the referrers first. The full artifact is snapshotted into the payload so the decided proposal and audit event record exactly what was removed. + + `cascade=True` lifts that block by making the referrers part of the + proposal instead of a prerequisite for it: the required referrer edits + are recorded in the payload, the reviewer approves the whole set as one + decision, and `_approve_delete` re-derives and applies them before the + delete. Default off, so every existing caller keeps today's behaviour. """ if target_kind not in _DELETE_KINDS: raise ProposalError( @@ -597,18 +604,22 @@ def propose_delete( except ArtifactNotFoundError as e: raise ProposalError(f"unknown {target_kind} id: {target_id}") from e refs = referenced_by(store, target_kind, target_id) - if refs: + if refs and not cascade: hint = " (supersede it instead?)" if target_kind == "claim" else "" raise ProposalError( f"cannot delete {target_kind} {target_id}: referenced by " + ", ".join(refs) + hint + + " — or re-file with cascade to include the referrer edits " + "in this proposal (CLI: --cascade)" ) - payload = { + payload: dict[str, Any] = { "target_kind": target_kind, "id": target_id, "snapshot": artifact.model_dump(mode="json"), } + if cascade: + payload["cascade"] = cascade_plan(store, target_kind, target_id) return _file_proposal( store, kind=ProposalKind.DELETE, payload=payload, proposed_by=proposed_by, session_id=session_id, @@ -923,11 +934,15 @@ def _payload_block_reason( except ArtifactNotFoundError: return None # already gone → idempotent approve is fine refs = referenced_by(store, target_kind, target_id) - if refs: + if refs and "cascade" not in payload: return ( f"cannot delete {target_kind} {target_id}: referenced by " + ", ".join(refs) ) + # A cascade proposal is *expected* to have referrers — unlinking them + # is what the reviewer approved. `_approve_delete` re-derives and + # applies the plan, then the approve-time `referenced_by` re-check + # still has to come back empty before anything is deleted. return None @@ -1333,6 +1348,187 @@ def referenced_by(store: KBStore, target_kind: str, target_id: str) -> list[str] return refs +def _relation_cascade_steps(store: KBStore, target_id: str) -> list[dict[str, Any]]: + return [ + {"kind": "relation", "id": rel.id, "action": "delete"} + for rel in store.list_relations() + if target_id in (rel.source, rel.target) + ] + + +def cascade_plan( + store: KBStore, target_kind: str, target_id: str +) -> list[dict[str, Any]]: + """The referrer edits that would let `target_id` be deleted. + + Structured mirror of `referenced_by`, walked in the same order, so the + plan a reviewer approves lines up with the refusal that sent them here. + One step per referring artifact: a page citing the target in both its + frontmatter and its body is one decision, not two. + + Pages and claims lose their pointer; relations are deleted outright, + because an edge whose endpoint is gone has no meaning. Relations carry + no inbound refs of their own (`referenced_by` returns [] for them), so + the walk is one level deep by construction — there is no transitive + cascade to bound. + """ + if target_kind not in _DELETE_KINDS: + raise ProposalError( + f"unknown target_kind {target_kind!r}; expected one of " + f"{sorted(_DELETE_KINDS)}" + ) + steps: list[dict[str, Any]] = [] + if target_kind == "claim": + for page in store.list_pages(): + if target_id in page.claims: + steps.append( + {"kind": "page", "id": page.id, "unlink_claims": [target_id]} + ) + steps.extend(_relation_cascade_steps(store, target_id)) + for claim in store.list_claims(): + if claim.id == target_id: + continue + step: dict[str, Any] = {"kind": "claim", "id": claim.id} + if target_id in claim.supersedes: + step["unlink_supersedes"] = [target_id] + if claim.superseded_by == target_id: + step["clear_superseded_by"] = True + if target_id in claim.contradicts: + step["unlink_contradicts"] = [target_id] + if len(step) > 2: + steps.append(step) + elif target_kind == "page": + steps.extend(_relation_cascade_steps(store, target_id)) + elif target_kind == "entity": + for claim in store.list_claims(): + if target_id in claim.entities: + steps.append( + {"kind": "claim", "id": claim.id, "unlink_entities": [target_id]} + ) + for page in store.list_pages(): + if target_id in page.entities: + steps.append( + {"kind": "page", "id": page.id, "unlink_entities": [target_id]} + ) + steps.extend(_relation_cascade_steps(store, target_id)) + return steps + + +def _apply_cascade_page( + store: KBStore, step: dict[str, Any], step_id: str, *, actor: str +) -> bool: + try: + page = store.get_page(step_id) + except ArtifactNotFoundError: + return False + claims = [c for c in step.get("unlink_claims") or [] if c in page.claims] + entities = [e for e in step.get("unlink_entities") or [] if e in page.entities] + if not claims and not entities: + return False + page.claims = [c for c in page.claims if c not in claims] + page.entities = [e for e in page.entities if e not in entities] + if claims: + # frontmatter and the inline [claim: …] markers both, or the body + # keeps rendering a citation whose claim no longer exists. + page.body = strip_claim_markers(page.body, claims) + page.updated_at = datetime.now(UTC) + store.update_page(page) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_page( + conn, id=page.id, title=page.title, body=page.body, + type=page.type, tags=page.tags, + ) + audit.log_event( + store.kb_dir, event="page.cascade_unlink", actor=actor, + object_ids=[page.id], data={"claims": claims, "entities": entities}, + reversible=False, + ) + return True + + +def _apply_cascade_claim( + store: KBStore, step: dict[str, Any], step_id: str, *, actor: str +) -> bool: + try: + claim = store.get_claim(step_id) + except ArtifactNotFoundError: + return False + supersedes = [c for c in step.get("unlink_supersedes") or [] if c in claim.supersedes] + contradicts = [ + c for c in step.get("unlink_contradicts") or [] if c in claim.contradicts + ] + entities = [e for e in step.get("unlink_entities") or [] if e in claim.entities] + clear_superseded_by = ( + bool(step.get("clear_superseded_by")) and claim.superseded_by is not None + ) + if not (supersedes or contradicts or entities or clear_superseded_by): + return False + claim.supersedes = [c for c in claim.supersedes if c not in supersedes] + claim.contradicts = [c for c in claim.contradicts if c not in contradicts] + claim.entities = [e for e in claim.entities if e not in entities] + if clear_superseded_by: + claim.superseded_by = None + claim.updated_at = datetime.now(UTC) + store.update_claim(claim) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_claim( + conn, id=claim.id, text=claim.text, + type=claim.type.value, status=claim.status.value, tags=claim.tags, + ) + audit.log_event( + store.kb_dir, event="claim.cascade_unlink", actor=actor, + object_ids=[claim.id], + data={ + "supersedes": supersedes, "contradicts": contradicts, + "entities": entities, "superseded_by_cleared": clear_superseded_by, + }, + reversible=False, + ) + return True + + +def _apply_cascade( + store: KBStore, steps: list[dict[str, Any]], *, actor: str +) -> list[str]: + """Apply an approved cascade's referrer edits. Returns the ids changed. + + Runs *before* the target is deleted, so the approve-time `referenced_by` + gate below finds nothing and the delete proceeds — the gate is satisfied, + never bypassed. Every step is idempotent: a referrer that was already + edited or removed between propose and approve is skipped rather than + fatal, which is what makes a crash-retry of approve() safe. + """ + changed: list[str] = [] + for step in steps: + if not isinstance(step, dict): + continue + kind = str(step.get("kind", "")) + step_id = str(step.get("id", "")) + if not step_id: + continue + if kind == "relation": + try: + store.delete_relation(step_id) + except ArtifactNotFoundError: + continue + with index_db.open_db(store.kb_dir) as conn: + index_db.deindex(conn, kind="relation", id=step_id) + audit.log_event( + store.kb_dir, event="relation.delete", actor=actor, + object_ids=[step_id], data={"cascade": True}, reversible=False, + ) + elif kind == "page": + if not _apply_cascade_page(store, step, step_id, actor=actor): + continue + elif kind == "claim": + if not _apply_cascade_claim(store, step, step_id, actor=actor): + continue + else: + continue + changed.append(step_id) + return changed + + def _reconstruct_deleted( target_kind: str, snapshot: dict[str, Any] ) -> Claim | Page | Entity | Relation | Goal: @@ -1380,6 +1576,16 @@ def _approve_delete( with index_db.open_db(store.kb_dir) as conn: index_db.deindex(conn, kind=target_kind, id=target_id) return _reconstruct_deleted(target_kind, snapshot) + cascaded: list[str] = [] + if "cascade" in payload: + # Re-derived here rather than replayed from the payload, for the same + # reason refs are re-checked: the KB may have moved since the proposal + # was filed. A referrer added after propose time is still unlinked; one + # removed since is simply absent from the new plan. The payload's copy + # stays as the reviewer-visible record of what they approved. + cascaded = _apply_cascade( + store, cascade_plan(store, target_kind, target_id), actor=approved_by + ) refs = referenced_by(store, target_kind, target_id) if refs: raise ProposalError( @@ -1390,9 +1596,12 @@ def _approve_delete( deleter(target_id) with index_db.open_db(store.kb_dir) as conn: index_db.deindex(conn, kind=target_kind, id=target_id) + data: dict[str, Any] = {"snapshot": snapshot} + if cascaded: + data["cascaded"] = cascaded audit.log_event( store.kb_dir, event=f"{target_kind}.delete", actor=approved_by, - object_ids=[target_id], data={"snapshot": snapshot}, reversible=False, + object_ids=[target_id], data=data, reversible=False, ) return artifact diff --git a/src/vouch/server.py b/src/vouch/server.py index 5bcf4932..60528857 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -804,17 +804,21 @@ def kb_propose_relation( def kb_propose_delete( target_kind: str, target_id: str, rationale: str | None = None, session_id: str | None = None, dry_run: bool = False, + cascade: bool = False, ) -> dict[str, Any]: """Propose hard-deleting a durable artifact (claim/page/entity/relation). Files a PENDING delete request that a *different* reviewer approves via - kb.approve. Refused if the target is still referenced by another artifact. + kb.approve. Refused if the target is still referenced by another artifact, + unless cascade=True, which records the referrer edits (pages and claims + lose their pointer, relations are deleted) in the same proposal so the + reviewer approves the whole set as one decision. """ try: pr = propose_delete( _store(), target_kind=target_kind, target_id=target_id, proposed_by=_agent(), rationale=rationale, - session_id=session_id, dry_run=dry_run, + session_id=session_id, dry_run=dry_run, cascade=cascade, ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e diff --git a/tests/test_cascade_delete.py b/tests/test_cascade_delete.py new file mode 100644 index 00000000..aeea5f05 --- /dev/null +++ b/tests/test_cascade_delete.py @@ -0,0 +1,475 @@ +"""Cascade delete: referrer edits ride along in the delete proposal (#600). + +The "block if referenced" gate stays exactly where it was. `cascade=True` +changes what the reviewer is asked to approve — the target *and* the edits +that free it — rather than lowering the bar for approving a delete. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from vouch import audit +from vouch.cli import cli +from vouch.jsonl_server import handle_request +from vouch.models import ( + Claim, + Entity, + EntityType, + Page, + ProposalStatus, + Relation, + RelationType, +) +from vouch.proposals import ( + ProposalError, + approve, + cascade_plan, + check_approvable, + propose_delete, + referenced_by, +) +from vouch.server import kb_propose_delete +from vouch.storage import ArtifactNotFoundError, KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _claim(store: KBStore, cid: str = "c1", text: str = "a claim", **kw) -> Claim: + src = store.put_source(b"src-bytes-" + cid.encode()) + return store.put_claim(Claim(id=cid, text=text, evidence=[src.id], **kw)) + + +def _decide(store: KBStore, proposal_id: str) -> None: + approve(store, proposal_id, approved_by="reviewer") + + +def _events(store: KBStore) -> list[str]: + return [e.event for e in audit.read_events(store.kb_dir)] + + +# --- today's behaviour is untouched --------------------------------------- + + +def test_referenced_delete_is_still_refused_without_cascade(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + with pytest.raises(ProposalError) as e: + propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent" + ) + assert "referenced by" in str(e.value) + + +def test_refusal_names_the_cascade_flag(store: KBStore) -> None: + """The dead end has to be discoverable, or the flag may as well not exist.""" + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + with pytest.raises(ProposalError) as e: + propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent" + ) + assert "cascade" in str(e.value) + assert "--cascade" in str(e.value) + + +def test_unreferenced_delete_files_no_cascade_key(store: KBStore) -> None: + _claim(store, "c1") + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent" + ) + assert "cascade" not in pr.payload + + +def test_cascade_on_an_unreferenced_target_is_an_empty_plan(store: KBStore) -> None: + _claim(store, "c1") + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + assert pr.payload["cascade"] == [] + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_claim("c1") + + +# --- the plan -------------------------------------------------------------- + + +def test_plan_mirrors_referenced_by_for_a_page_cited_claim(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + assert referenced_by(store, "claim", "c1") == ["page 'p1'"] + assert cascade_plan(store, "claim", "c1") == [ + {"kind": "page", "id": "p1", "unlink_claims": ["c1"]} + ] + + +def test_plan_deletes_relations_and_unlinks_pages(store: KBStore) -> None: + _claim(store, "c1") + _claim(store, "c2") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + store.put_relation(Relation( + id="c1--supports--c2", source="c1", + relation=RelationType.SUPPORTS, target="c2", + )) + plan = cascade_plan(store, "claim", "c1") + assert {"kind": "page", "id": "p1", "unlink_claims": ["c1"]} in plan + assert { + "kind": "relation", "id": "c1--supports--c2", "action": "delete" + } in plan + + +def test_plan_skips_claims_that_do_not_reference_the_target(store: KBStore) -> None: + _claim(store, "c1") + _claim(store, "c2") + assert cascade_plan(store, "claim", "c1") == [] + + +def test_plan_rejects_an_unknown_kind(store: KBStore) -> None: + with pytest.raises(ProposalError): + cascade_plan(store, "sandwich", "c1") + + +# --- approving a cascade --------------------------------------------------- + + +def test_page_cited_claim_is_deletable_with_cascade(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="body [claim: c1] tail", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + assert pr.status is ProposalStatus.PENDING + # nothing has moved yet — filing a proposal is not a write + assert store.get_page("p1").claims == ["c1"] + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_claim("c1") + assert store.get_page("p1").claims == [] + + +def test_cascade_strips_the_inline_body_marker_too(store: KBStore) -> None: + """Frontmatter alone would leave the body rendering a dead citation.""" + _claim(store, "c1") + store.put_page(Page( + id="p1", title="P", body="before [claim: c1] after", claims=["c1"], + )) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + assert "[claim: c1]" not in store.get_page("p1").body + assert "before" in store.get_page("p1").body + + +def test_supersede_pair_is_no_longer_mutually_locked(store: KBStore) -> None: + """Neither end of a supersede chain could be removed before this.""" + _claim(store, "old") + _claim(store, "new", supersedes=["old"]) + store.update_claim( + store.get_claim("old").model_copy(update={"superseded_by": "new"}) + ) + assert referenced_by(store, "claim", "old") + assert referenced_by(store, "claim", "new") + pr = propose_delete( + store, target_kind="claim", target_id="old", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_claim("old") + assert store.get_claim("new").supersedes == [] + + +def test_cascade_clears_superseded_by_on_the_surviving_claim(store: KBStore) -> None: + _claim(store, "old") + _claim(store, "new", supersedes=["old"]) + store.update_claim( + store.get_claim("old").model_copy(update={"superseded_by": "new"}) + ) + pr = propose_delete( + store, target_kind="claim", target_id="new", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + assert store.get_claim("old").superseded_by is None + + +def test_cascade_unlinks_contradicts(store: KBStore) -> None: + _claim(store, "c1") + _claim(store, "c2", contradicts=["c1"]) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + assert store.get_claim("c2").contradicts == [] + + +def test_cascade_deletes_the_relations_that_pointed_at_the_target( + store: KBStore, +) -> None: + _claim(store, "c1") + _claim(store, "c2") + store.put_relation(Relation( + id="c1--supports--c2", source="c1", + relation=RelationType.SUPPORTS, target="c2", + )) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_relation("c1--supports--c2") + # the far endpoint is untouched — only edges are collateral, not nodes + assert store.get_claim("c2").id == "c2" + + +def test_entity_cascade_unlinks_claims_and_pages(store: KBStore) -> None: + store.put_entity(Entity(id="e1", name="E", type=EntityType.CONCEPT)) + _claim(store, "c1", entities=["e1"]) + store.put_page(Page(id="p1", title="P", body="x", entities=["e1"])) + pr = propose_delete( + store, target_kind="entity", target_id="e1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_entity("e1") + assert store.get_claim("c1").entities == [] + assert store.get_page("p1").entities == [] + + +def test_page_cascade_deletes_relations_pointing_at_it(store: KBStore) -> None: + store.put_page(Page(id="p1", title="P", body="x")) + _claim(store, "c1") + store.put_relation(Relation( + id="c1--supports--p1", source="c1", + relation=RelationType.SUPPORTS, target="p1", + )) + pr = propose_delete( + store, target_kind="page", target_id="p1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_page("p1") + with pytest.raises(ArtifactNotFoundError): + store.get_relation("c1--supports--p1") + + +# --- re-derivation at approve time ----------------------------------------- + + +def test_a_referrer_added_after_propose_is_still_unlinked(store: KBStore) -> None: + """The plan is re-derived at approve, exactly as refs are re-checked.""" + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + store.put_page(Page(id="p2", title="P2", body="y", claims=["c1"])) + _decide(store, pr.id) + assert store.get_page("p2").claims == [] + with pytest.raises(ArtifactNotFoundError): + store.get_claim("c1") + + +def test_a_referrer_removed_before_approve_is_not_fatal(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + store.delete_page("p1") + _decide(store, pr.id) + with pytest.raises(ArtifactNotFoundError): + store.get_claim("c1") + + +def test_payload_keeps_the_plan_the_reviewer_saw(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + store.put_page(Page(id="p2", title="P2", body="y", claims=["c1"])) + stored = store.get_proposal(pr.id) + assert stored.payload["cascade"] == [ + {"kind": "page", "id": "p1", "unlink_claims": ["c1"]} + ] + + +# --- the gate -------------------------------------------------------------- + + +def test_check_approvable_allows_a_cascade_proposal(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + assert check_approvable(store, pr.id, approved_by="reviewer") is None + + +def test_check_approvable_still_blocks_a_plain_referenced_delete( + store: KBStore, +) -> None: + """A pre-cascade proposal whose target gained a referrer stays blocked.""" + _claim(store, "c1") + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent" + ) + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + reason = check_approvable(store, pr.id, approved_by="reviewer") + assert reason is not None and "referenced by" in reason + + +def test_cascade_does_not_bypass_self_approval(store: KBStore) -> None: + """On a human-reviewed KB the second pair of eyes is still required.""" + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + cfg["review"]["approver_role"] = "human" + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + with pytest.raises(ProposalError, match="forbidden_self_approval"): + approve(store, pr.id, approved_by="agent") + # refused → nothing was unlinked + assert store.get_page("p1").claims == ["c1"] + assert store.get_claim("c1").id == "c1" + + +def test_proposing_a_cascade_writes_nothing(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="b [claim: c1]", claims=["c1"])) + before = _events(store) + propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + assert store.get_page("p1").claims == ["c1"] + assert "[claim: c1]" in store.get_page("p1").body + assert store.get_claim("c1").id == "c1" + assert [e for e in _events(store) if e.endswith("cascade_unlink")] == [] + assert len(_events(store)) == len(before) + 1 # the proposal event only + + +# --- audit ----------------------------------------------------------------- + + +def test_every_cascade_edit_lands_an_audit_event(store: KBStore) -> None: + _claim(store, "c1") + _claim(store, "c2") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + store.put_relation(Relation( + id="c1--supports--c2", source="c1", + relation=RelationType.SUPPORTS, target="c2", + )) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + events = _events(store) + assert "page.cascade_unlink" in events + assert "relation.delete" in events + assert "claim.delete" in events + + +def test_the_delete_event_names_what_the_cascade_touched(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + deletes = [ + e for e in audit.read_events(store.kb_dir) if e.event == "claim.delete" + ] + assert deletes[-1].data["cascaded"] == ["p1"] + + +def test_cascade_edits_are_irreversible_in_the_log(store: KBStore) -> None: + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + pr = propose_delete( + store, target_kind="claim", target_id="c1", proposed_by="agent", cascade=True + ) + _decide(store, pr.id) + unlinks = [ + e for e in audit.read_events(store.kb_dir) + if e.event == "page.cascade_unlink" + ] + assert unlinks and unlinks[-1].reversible is False + + +# --- surfaces -------------------------------------------------------------- + + +def test_mcp_surface_accepts_cascade(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + out = kb_propose_delete( + target_kind="claim", target_id="c1", cascade=True + ) + assert out["status"] == ProposalStatus.PENDING.value + assert store.get_proposal(out["proposal_id"]).payload["cascade"] + + +def test_mcp_surface_without_cascade_still_refuses(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + with pytest.raises(ValueError, match="referenced by"): + kb_propose_delete(target_kind="claim", target_id="c1") + + +def test_jsonl_surface_accepts_cascade(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + resp = handle_request({ + "id": "1", "method": "kb.propose_delete", + "params": {"target_kind": "claim", "target_id": "c1", "cascade": True}, + }) + assert resp["ok"] is True + assert store.get_proposal(resp["result"]["proposal_id"]).payload["cascade"] + + +def test_jsonl_surface_without_cascade_returns_the_error_envelope( + store: KBStore, monkeypatch +) -> None: + monkeypatch.chdir(store.kb_dir.parent) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + resp = handle_request({ + "id": "1", "method": "kb.propose_delete", + "params": {"target_kind": "claim", "target_id": "c1"}, + }) + assert resp["ok"] is False + assert "referenced by" in resp["error"]["message"] + + +def test_cli_cascade_flag(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + runner = CliRunner() + res = runner.invoke(cli, ["propose-delete", "claim", "c1", "--cascade"]) + assert res.exit_code == 0, res.output + assert store.get_proposal(res.output.strip()).payload["cascade"] + + +def test_cli_without_cascade_is_a_clean_error(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + store.put_page(Page(id="p1", title="P", body="x", claims=["c1"])) + runner = CliRunner() + res = runner.invoke(cli, ["propose-delete", "claim", "c1"]) + assert res.exit_code != 0 + assert "Traceback" not in res.output + assert "--cascade" in res.output From 3c949fc4fcb9eb39f5a0e2b7ef5f6e7f584a590b Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 31 Jul 2026 03:49:26 -0700 Subject: [PATCH 3/3] test(delete): cover the cascade applier's stale-plan branches approve() re-derives the plan, so the applier's artifact-missing and already-unlinked paths cannot be reached through the public flow. they exist for the narrow race where a concurrent writer changes a referrer between derivation and application, and for a crash-retry of approve(). exercised directly against _apply_cascade, which is the only honest way to reach them, and what the 100% diff-coverage gate asks for. Co-authored-by: Cursor --- tests/test_cascade_delete.py | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_cascade_delete.py b/tests/test_cascade_delete.py index aeea5f05..b24e1817 100644 --- a/tests/test_cascade_delete.py +++ b/tests/test_cascade_delete.py @@ -27,6 +27,7 @@ ) from vouch.proposals import ( ProposalError, + _apply_cascade, approve, cascade_plan, check_approvable, @@ -302,6 +303,68 @@ def test_payload_keeps_the_plan_the_reviewer_saw(store: KBStore) -> None: ] +# --- the applier is idempotent under a stale plan --------------------------- +# +# approve() re-derives the plan, so these branches are not reachable through +# the public path — they exist for the narrow race where a concurrent writer +# changes a referrer between derivation and application, and for a crash-retry +# of approve(). Exercised directly, because that is the only honest way to +# reach them. + + +def test_applier_skips_a_page_that_vanished(store: KBStore) -> None: + assert _apply_cascade( + store, [{"kind": "page", "id": "gone", "unlink_claims": ["c1"]}], + actor="reviewer", + ) == [] + + +def test_applier_skips_a_page_already_unlinked(store: KBStore) -> None: + store.put_page(Page(id="p1", title="P", body="x")) + assert _apply_cascade( + store, [{"kind": "page", "id": "p1", "unlink_claims": ["c1"]}], + actor="reviewer", + ) == [] + assert "page.cascade_unlink" not in _events(store) + + +def test_applier_skips_a_claim_that_vanished(store: KBStore) -> None: + assert _apply_cascade( + store, [{"kind": "claim", "id": "gone", "unlink_supersedes": ["c1"]}], + actor="reviewer", + ) == [] + + +def test_applier_skips_a_claim_already_unlinked(store: KBStore) -> None: + _claim(store, "c2") + assert _apply_cascade( + store, [{"kind": "claim", "id": "c2", "unlink_supersedes": ["c1"]}], + actor="reviewer", + ) == [] + assert "claim.cascade_unlink" not in _events(store) + + +def test_applier_skips_a_relation_that_vanished(store: KBStore) -> None: + assert _apply_cascade( + store, [{"kind": "relation", "id": "gone", "action": "delete"}], + actor="reviewer", + ) == [] + + +def test_applier_ignores_a_malformed_step(store: KBStore) -> None: + assert _apply_cascade(store, ["not-a-dict"], actor="reviewer") == [] # type: ignore[list-item] + + +def test_applier_ignores_a_step_without_an_id(store: KBStore) -> None: + assert _apply_cascade(store, [{"kind": "page"}], actor="reviewer") == [] + + +def test_applier_ignores_an_unknown_step_kind(store: KBStore) -> None: + assert _apply_cascade( + store, [{"kind": "source", "id": "s1"}], actor="reviewer" + ) == [] + + # --- the gate --------------------------------------------------------------