diff --git a/Makefile b/Makefile index 38fa02d..3d8bce5 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ SEARCH_Q ?= computer ingest-grok ingest-grok-media \ ingest-self ingest-git ingest-hg \ verify search stats test docs chain-check chain-check-shards \ - clean clean-db clean-data help + falsify burn clean clean-db clean-data help all: bootstrap fetch-cur ingest-cur verify stats ## bootstrap → fetch cur → ingest cur → verify → stats @@ -123,6 +123,10 @@ falsify: bootstrap ## mark a cached answer wrong: make falsify KEY=hex REASON='w @if [ -z "$(KEY)" ]; then echo "usage: make falsify KEY= REASON='why'" >&2; exit 2; fi $(ABORIST) --shards-dir $(SHARDS_DIR) providence --falsify $(KEY) --reason "$(REASON)" +burn: bootstrap ## delete a leaf with no children: make burn KEY=hex REASON='why' [FORCE=1] + @if [ -z "$(KEY)" ]; then echo "usage: make burn KEY= REASON='why' [FORCE=1]" >&2; exit 2; fi + $(ABORIST) --shards-dir $(SHARDS_DIR) burn --cache-key $(KEY) --reason "$(REASON)" $(if $(FORCE),--force,) + # Multi-source RAG query against the shard cluster. # Usage: make query Q="What is anarcho-capitalism?" QUERY_TOP_K ?= 8 diff --git a/aborist/cli.py b/aborist/cli.py index 4307adb..81d87c6 100644 --- a/aborist/cli.py +++ b/aborist/cli.py @@ -445,6 +445,108 @@ def _falsify_cache_key( return {"status": "not_found", "cache_key": cache_key_value} +def _burn_cache_key( + cache_key_value: str, + *, + reason: str, + by_actor: str, + shards_dir: Path | None, + db_path: Path | None, + force: bool = False, +) -> dict: + """Delete a providence_cache leaf, but only if it has no children. + + "Kindergarten of a tree's genesis" — early/scratch use. Falsify keeps + history; burn removes the row. Children today = falsifications + referencing this cache_key. If any exist, refuse without ``--force``. + + Always writes a 'providence_burn' audit event so the chain records + that a leaf was removed and why. Use ``aborist providence --falsify`` + instead when downstream consumers may have built on this answer. + """ + import time as _time + + from aborist.store import append_audit, discover_shards, transaction + + paths: list[Path] = ( + discover_shards(shards_dir) if shards_dir else [Path(db_path)] + ) + + for sp in paths: + c = connect(sp) + try: + row = c.execute( + "SELECT cache_key, audit_mode, n_verified, falsification_state, " + " question_text FROM providence_cache WHERE cache_key = ?", + (cache_key_value,), + ).fetchone() + if row is None: + continue + child_falsifications = c.execute( + "SELECT COUNT(*) FROM falsifications WHERE cache_key = ?", + (cache_key_value,), + ).fetchone()[0] + if child_falsifications > 0 and not force: + return { + "status": "refused_has_children", + "cache_key": cache_key_value, + "shard": sp.name, + "child_falsifications": int(child_falsifications), + "hint": "use --force to burn anyway, or 'providence --falsify' to keep history", + } + now = int(_time.time()) + with transaction(c): + c.execute( + "DELETE FROM providence_cache WHERE cache_key = ?", + (cache_key_value,), + ) + event_hash = append_audit( + c, + event_type="providence_burn", + subject_root=cache_key_value, + body={ + "cache_key": cache_key_value, + "burned_audit_mode": row["audit_mode"], + "burned_n_verified": int(row["n_verified"]), + "burned_state": row["falsification_state"], + "question_text": row["question_text"], + "reason": reason, + "by_actor": by_actor, + "child_falsifications_at_burn": int(child_falsifications), + "forced": bool(child_falsifications > 0 and force), + }, + ts=now, + ) + return { + "status": "burned", + "cache_key": cache_key_value, + "shard": sp.name, + "burned_audit_mode": row["audit_mode"], + "reason": reason, + "by_actor": by_actor, + "audit_event_hash": event_hash, + "ts": now, + } + finally: + c.close() + + return {"status": "not_found", "cache_key": cache_key_value} + + +def _cmd_burn(args: argparse.Namespace) -> int: + """CLI: burn a providence_cache leaf with no children.""" + result = _burn_cache_key( + args.cache_key, + reason=args.reason or "", + by_actor=args.by_actor or os.environ.get("USER", "unknown"), + shards_dir=Path(args.global_shards_dir) if args.global_shards_dir else None, + db_path=Path(args.db) if args.db else None, + force=bool(args.force), + ) + print(json.dumps(result, indent=2)) + return 0 if result.get("status") == "burned" else 1 + + def _cmd_providence(args: argparse.Namespace) -> int: """List providence_cache records or falsify one by cache_key.""" if getattr(args, "falsify", None): @@ -1767,6 +1869,37 @@ def build_parser() -> argparse.ArgumentParser: ) prov_cmd.set_defaults(func=_cmd_providence) + burn_cmd = sub.add_parser( + "burn", + help=( + "delete a providence_cache leaf with no children " + "(kindergarten use; falsify is the audit-preserving alternative)" + ), + ) + burn_cmd.add_argument( + "--cache-key", + dest="cache_key", + required=True, + help="cache_key (hex) of the providence record to burn", + ) + burn_cmd.add_argument( + "--reason", + default=None, + help="reason text recorded in the providence_burn audit event", + ) + burn_cmd.add_argument( + "--by-actor", + dest="by_actor", + default=None, + help="who is burning (default: $USER)", + ) + burn_cmd.add_argument( + "--force", + action="store_true", + help="burn even if children (falsifications) exist; not recommended", + ) + burn_cmd.set_defaults(func=_cmd_burn) + reclassify_cmd = sub.add_parser( "reclassify", help="re-run the verifier against existing live providence records " diff --git a/tests/test_burn.py b/tests/test_burn.py new file mode 100644 index 0000000..0383c50 --- /dev/null +++ b/tests/test_burn.py @@ -0,0 +1,296 @@ +"""Burn — delete a providence_cache leaf with no children. + +Burn vs falsify: + falsify keeps the row, flips falsification_state away from 'live'. + Audit event = 'falsify'. History is preserved. + burn deletes the row entirely. Audit event = 'providence_burn'. + Refuses if any falsifications reference this cache_key. + +Burn is the kindergarten-stage operator: scratch work that built up +during a tree's genesis, before downstream consumers ingested it. Once +falsifications point at a record, burning would orphan them — the +default is to refuse, --force overrides for cleanup edge cases. +""" + +from __future__ import annotations + +import json +import time + +import pytest + +from aborist.cli import _burn_cache_key +from aborist.store import append_audit, connect, transaction + + +def _seed_providence_record(db_path, *, cache_key: str, question: str = "q?", audit_mode: str = "HYBRID") -> None: + """Insert one minimal providence_cache row + a 'providence_query' audit + event so the chain has prior state. Mirrors what runner.ask() persists.""" + conn = connect(db_path) + try: + with transaction(conn): + event_hash = append_audit( + conn, + event_type="providence_query", + subject_root=cache_key, + body={"cache_key": cache_key, "question": question}, + ) + conn.execute( + "INSERT INTO providence_cache " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, hit_count, audit_mode, n_quotes, n_verified, " + " unverified_quotes, verifier_method) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', 'private', ?, ?, 0, ?, 1, 1, NULL, 'quote')", + ( + cache_key, + "00" * 32, + "https://example.com/doc", + "qh", + question, + "answer", + json.dumps({}), + "mp", + "ch", + "gh", + "v9.8.0", + "norm-v1", + "tok-512-v1", + event_hash, + int(time.time()), + audit_mode, + ), + ) + finally: + conn.close() + + +def _seed_falsification(db_path, *, cache_key: str) -> None: + """Mark cache_key as falsified, leaving a falsifications row that makes + the leaf "have children" for burn's purposes.""" + conn = connect(db_path) + try: + with transaction(conn): + event_hash = append_audit( + conn, + event_type="falsify", + subject_root=cache_key, + body={"cache_key": cache_key, "to_state": "failed", "reason": "test"}, + ) + conn.execute( + "INSERT INTO falsifications " + "(cache_key, state, reason, by_actor, at, audit_event_hash) " + "VALUES (?, 'failed', 'test', 'tester', ?, ?)", + (cache_key, int(time.time()), event_hash), + ) + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_burn_removes_row_and_writes_audit_event(tmp_path): + db = tmp_path / "qa.db" + KEY = "ab" * 32 + _seed_providence_record(db, cache_key=KEY, audit_mode="UNGROUNDED") + + result = _burn_cache_key( + KEY, reason="kindergarten cleanup", by_actor="alice", + shards_dir=None, db_path=db, + ) + assert result["status"] == "burned" + assert result["cache_key"] == KEY + assert result["burned_audit_mode"] == "UNGROUNDED" + assert result["reason"] == "kindergarten cleanup" + assert result["by_actor"] == "alice" + assert isinstance(result["audit_event_hash"], str) and len(result["audit_event_hash"]) == 64 + + conn = connect(db) + try: + gone = conn.execute( + "SELECT 1 FROM providence_cache WHERE cache_key = ?", (KEY,) + ).fetchone() + last = conn.execute( + "SELECT event_type, body, subject_root FROM audit_events " + "ORDER BY seq DESC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert gone is None + assert last["event_type"] == "providence_burn" + assert last["subject_root"] == KEY + body = json.loads(last["body"]) + assert body["cache_key"] == KEY + assert body["burned_audit_mode"] == "UNGROUNDED" + assert body["reason"] == "kindergarten cleanup" + assert body["by_actor"] == "alice" + assert body["forced"] is False + + +# --------------------------------------------------------------------------- +# Children gate +# --------------------------------------------------------------------------- + + +def test_burn_refuses_when_falsifications_exist(tmp_path): + """A leaf with children must be falsified, not burned. Refusal preserves + the row AND skips the audit event so callers can retry cleanly.""" + db = tmp_path / "qa.db" + KEY = "cd" * 32 + _seed_providence_record(db, cache_key=KEY) + _seed_falsification(db, cache_key=KEY) + + conn = connect(db) + try: + events_before = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0] + finally: + conn.close() + + result = _burn_cache_key( + KEY, reason="oops", by_actor="alice", shards_dir=None, db_path=db, + ) + assert result["status"] == "refused_has_children" + assert result["child_falsifications"] == 1 + assert "hint" in result + + conn = connect(db) + try: + still_there = conn.execute( + "SELECT cache_key FROM providence_cache WHERE cache_key = ?", (KEY,) + ).fetchone() + events_after = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0] + finally: + conn.close() + assert still_there is not None + # Refusal must NOT write a providence_burn event — the chain only records + # actions actually taken. + assert events_after == events_before + + +def test_burn_force_overrides_children_gate(tmp_path): + """--force burns even with falsifications; audit event marks forced=True.""" + db = tmp_path / "qa.db" + KEY = "ef" * 32 + _seed_providence_record(db, cache_key=KEY) + _seed_falsification(db, cache_key=KEY) + + result = _burn_cache_key( + KEY, reason="forced cleanup", by_actor="alice", + shards_dir=None, db_path=db, force=True, + ) + assert result["status"] == "burned" + + conn = connect(db) + try: + gone = conn.execute( + "SELECT 1 FROM providence_cache WHERE cache_key = ?", (KEY,) + ).fetchone() + last = conn.execute( + "SELECT body FROM audit_events ORDER BY seq DESC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert gone is None + body = json.loads(last["body"]) + assert body["forced"] is True + assert body["child_falsifications_at_burn"] == 1 + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +def test_burn_unknown_key_returns_not_found(tmp_path): + db = tmp_path / "qa.db" + # Empty DB; no records. + result = _burn_cache_key( + "00" * 32, reason="", by_actor="alice", shards_dir=None, db_path=db, + ) + assert result["status"] == "not_found" + assert result["cache_key"] == "00" * 32 + + +# --------------------------------------------------------------------------- +# Audit chain integrity +# --------------------------------------------------------------------------- + + +def test_burn_preserves_audit_chain(tmp_path): + """After burn, the chain has no dangling prev_event_hash references — + same property `make chain-check` enforces.""" + db = tmp_path / "qa.db" + KEY = "12" * 32 + _seed_providence_record(db, cache_key=KEY) + + _burn_cache_key(KEY, reason="t", by_actor="a", shards_dir=None, db_path=db) + + conn = connect(db) + try: + breaks = conn.execute( + """ + SELECT COUNT(*) FROM audit_events a1 + LEFT JOIN audit_events a2 ON a2.event_hash = a1.prev_event_hash + WHERE a1.prev_event_hash IS NOT NULL AND a2.event_hash IS NULL + """ + ).fetchone()[0] + finally: + conn.close() + assert breaks == 0 + + +# --------------------------------------------------------------------------- +# CLI integration +# --------------------------------------------------------------------------- + + +def test_burn_cli_invocation(tmp_path, capsys): + """`aborist burn` end-to-end through build_parser, including JSON output.""" + from aborist.cli import build_parser + + db = tmp_path / "qa.db" + KEY = "fa" * 32 + _seed_providence_record(db, cache_key=KEY) + + parser = build_parser() + args = parser.parse_args([ + "--db", str(db), + "burn", + "--cache-key", KEY, + "--reason", "via cli", + "--by-actor", "alice", + ]) + rc = args.func(args) + assert rc == 0 + + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert payload["status"] == "burned" + assert payload["reason"] == "via cli" + + +def test_burn_cli_returns_non_zero_on_refused(tmp_path, capsys): + """Non-zero exit when refused so scripts can detect and react.""" + from aborist.cli import build_parser + + db = tmp_path / "qa.db" + KEY = "fb" * 32 + _seed_providence_record(db, cache_key=KEY) + _seed_falsification(db, cache_key=KEY) + + parser = build_parser() + args = parser.parse_args([ + "--db", str(db), + "burn", + "--cache-key", KEY, + "--reason", "wont land", + ]) + rc = args.func(args) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "refused_has_children"