diff --git a/CHANGELOG.md b/CHANGELOG.md index 582e79fa..43b0c529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Fixed +- **vault sync mirrors post-approve WORKING/DRAFT artifacts** (#583): + `kb_to_vault` now includes durable `WORKING` claims and `DRAFT` pages + (the propose+approve defaults), so Obsidian mirrors fill without + hand-editing status. `ARCHIVED` pages and retracted claims stay out, + and stale *mirror-owned* files are deleted (untracked user markdown + under the vault house is preserved) so the vault cannot keep serving + dead knowledge. - **hot-memory sidebar still fills after exclude_ids** (#597): compute_hot_memory used to truncate to limit then drop excluded ids, so search/context sidebars under-filled whenever hit ids overlapped the hot set. exclusions are applied while ranking so the sidebar still returns up to limit other recent claims. - **sandbox docker argv on Windows** (#582): omit `--user uid:gid` when `os.getuid` / `os.getgid` are unavailable so sandboxed dual-solve no diff --git a/src/vouch/vault_sync.py b/src/vouch/vault_sync.py index d7a7f027..49c525c2 100644 --- a/src/vouch/vault_sync.py +++ b/src/vouch/vault_sync.py @@ -81,6 +81,8 @@ class VaultSyncResult: """ pages_mirrored: list[str] = field(default_factory=list) claims_mirrored: list[str] = field(default_factory=list) + pages_removed: list[str] = field(default_factory=list) + claims_removed: list[str] = field(default_factory=list) pages_proposed: list[str] = field(default_factory=list) pages_skipped_unchanged: list[str] = field(default_factory=list) pages_skipped_unknown_id: list[str] = field(default_factory=list) @@ -153,16 +155,21 @@ def _claims_dir(vault_dir: Path) -> Path: def _approved_pages(store: KBStore) -> Iterable: for page in store.list_pages(): - if page.status != PageStatus.DRAFT: - yield page + # durable pages are written by the review gate (propose defaults to + # DRAFT). ARCHIVED is retracted — kb_to_vault deletes stale mirrors. + if page.status is PageStatus.ARCHIVED: + continue + yield page def _approved_claims(store: KBStore) -> Iterable: for claim in store.list_claims(): - # Working claims have not been through the review gate; archived / - # superseded / redacted claims are intentionally not surfaced into the - # vault (Obsidian backlinks would otherwise resurrect dead knowledge). + # durable claims are written only after the review gate. WORKING is the + # normal post-approve default (#583); ACTIONABLE / STABLE / CONTESTED + # are later live states. retracted statuses stay out; kb_to_vault also + # deletes leftover mirror files for those ids. if claim.status in { + ClaimStatus.WORKING, ClaimStatus.ACTIONABLE, ClaimStatus.STABLE, ClaimStatus.CONTESTED, @@ -218,7 +225,9 @@ def kb_to_vault(store: KBStore, vault_dir: Path) -> VaultSyncResult: Overwrites the mirror each call: the vault subdirectory is vouch's house, and only the KB writes there. User edits to mirrored files are picked up by :func:`vault_to_kb` on the next forward pass *before* this function - overwrites them. + overwrites them. Mirror files for artifacts that left the live set + (retracted claims, archived pages) are deleted so the vault cannot keep + serving dead knowledge. """ result = VaultSyncResult() mirror = _mirror_dir(vault_dir) @@ -226,21 +235,26 @@ def kb_to_vault(store: KBStore, vault_dir: Path) -> VaultSyncResult: mirror.mkdir(parents=True, exist_ok=True) claims_out.mkdir(parents=True, exist_ok=True) + live_pages = list(_approved_pages(store)) + live_claims = list(_approved_claims(store)) + live_page_ids = {p.id for p in live_pages} + live_claim_ids = {c.id for c in live_claims} + # Build a citing-pages index up front so claim stubs can backlink in O(1). citers: dict[str, list[str]] = {} - for page in _approved_pages(store): + for page in live_pages: for cid in page.claims: citers.setdefault(cid, []).append(page.id) # Pages - for page in _approved_pages(store): + for page in live_pages: text = _serialize_page(page) dst = mirror / f"{page.id}.md" dst.write_text(text, encoding="utf-8") result.pages_mirrored.append(page.id) # Claim stubs - for claim in _approved_claims(store): + for claim in live_claims: body = _render_claim_stub( claim_id=claim.id, claim_text=claim.text, @@ -252,15 +266,45 @@ def kb_to_vault(store: KBStore, vault_dir: Path) -> VaultSyncResult: dst.write_text(body, encoding="utf-8") result.claims_mirrored.append(claim.id) - # Refresh state file: record the hash of every mirrored file so the next - # forward pass can detect user edits as "current content != recorded hash". + # Drop previously mirrored files for artifacts that left the live set + # (#583). only touch paths recorded in sync state — untracked user files + # under vouch/pages or vouch/claims are left alone (vault_to_kb already + # skips them as non-edits). + prev_state = _load_state(vault_dir) + for rel in prev_state: + if rel.startswith("pages/"): + page_id = Path(rel).stem + if page_id in live_page_ids: + continue + path = mirror / f"{page_id}.md" + if path.is_file(): + path.unlink() + result.pages_removed.append(page_id) + elif rel.startswith("claims/"): + claim_id = Path(rel).stem + if claim_id in live_claim_ids: + continue + path = claims_out / f"{claim_id}.md" + if path.is_file(): + path.unlink() + result.claims_removed.append(claim_id) + + # Refresh state for live mirrors only — do not absorb untracked user + # files into sync state (that would make the next forward pass treat + # them as editable KB pages). new_state: dict[str, str] = {} - for f in mirror.glob("*.md"): - rel = f"pages/{f.name}" - new_state[rel] = _sha256_text(f.read_text(encoding="utf-8")) - for f in claims_out.glob("*.md"): - rel = f"claims/{f.name}" - new_state[rel] = _sha256_text(f.read_text(encoding="utf-8")) + for page_id in live_page_ids: + f = mirror / f"{page_id}.md" + if f.is_file(): + new_state[f"pages/{page_id}.md"] = _sha256_text( + f.read_text(encoding="utf-8") + ) + for claim_id in live_claim_ids: + f = claims_out / f"{claim_id}.md" + if f.is_file(): + new_state[f"claims/{claim_id}.md"] = _sha256_text( + f.read_text(encoding="utf-8") + ) _save_state(vault_dir, new_state) return result @@ -476,6 +520,8 @@ def sync_vault( r = kb_to_vault(store, vault_dir) combined.pages_mirrored.extend(r.pages_mirrored) combined.claims_mirrored.extend(r.claims_mirrored) + combined.pages_removed.extend(r.pages_removed) + combined.claims_removed.extend(r.claims_removed) return combined diff --git a/tests/test_vault_sync.py b/tests/test_vault_sync.py index b765b08c..fc72540f 100644 --- a/tests/test_vault_sync.py +++ b/tests/test_vault_sync.py @@ -105,19 +105,102 @@ def test_kb_to_vault_creates_claim_stubs_with_backlinks( assert "alpha-page" in text -def test_kb_to_vault_skips_draft_pages(tmp_path: Path, vault: Path) -> None: - """Mirror is for *approved* artifacts only -- a draft has not been - through the review gate and must not leak into the vault.""" +def test_kb_to_vault_mirrors_draft_pages(tmp_path: Path, vault: Path) -> None: + """Durable pages land as DRAFT after propose+approve (#583); the vault + must mirror them. ARCHIVED stays out (see removal regression below).""" s = KBStore.init(tmp_path / "kb") src = s.put_source(b"x", title="x") s.put_page(Page( id="draft-page", title="Draft", - body="not yet approved", type=PageType.CONCEPT, + body="gate-approved default status", type=PageType.CONCEPT, status=PageStatus.DRAFT, sources=[src.id], )) result = kb_to_vault(s, vault) - assert not (vault / VAULT_DIR / "pages" / "draft-page.md").exists() - assert "draft-page" not in result.pages_mirrored + assert (vault / VAULT_DIR / "pages" / "draft-page.md").is_file() + assert "draft-page" in result.pages_mirrored + + +def test_approve_then_kb_to_vault_mirrors_without_status_handedit( + tmp_path: Path, vault: Path, +) -> None: + """Regression for #583: propose+approve defaults (WORKING / DRAFT) must + mirror without forcing ACTIONABLE / ACTIVE by hand.""" + from vouch.proposals import approve, propose_claim, propose_page + + s = KBStore.init(tmp_path / "kb") + src = s.put_source(b"seed", title="seed") + cpr = propose_claim( + s, + text="Obsidian mirrors need live post-approve statuses.", + evidence=[src.id], + proposed_by="alice-example", + slug_hint="obsidian-status", + ) + claim = approve(s, cpr.proposal.id, approved_by="blake-example") + assert claim.status is ClaimStatus.WORKING + + ppr = propose_page( + s, + title="Vault status page", + body="body cites the claim", + proposed_by="alice-example", + claim_ids=[claim.id], + source_ids=[src.id], + slug_hint="vault-status-page", + ) + page = approve(s, ppr.id, approved_by="blake-example") + assert page.status is PageStatus.DRAFT + + result = kb_to_vault(s, vault) + assert "vault-status-page" in result.pages_mirrored + assert "obsidian-status" in result.claims_mirrored + assert (vault / VAULT_DIR / "pages" / "vault-status-page.md").is_file() + assert (vault / VAULT_DIR / "claims" / "obsidian-status.md").is_file() + + +def test_kb_to_vault_removes_mirrors_after_retraction( + store: KBStore, vault: Path, +) -> None: + """Retracting a claim / archiving a page must delete leftover vault + markdown so dead knowledge cannot linger.""" + from vouch import lifecycle + + kb_to_vault(store, vault) + claim_path = vault / VAULT_DIR / "claims" / "alpha-claim.md" + page_path = vault / VAULT_DIR / "pages" / "alpha-page.md" + assert claim_path.is_file() + assert page_path.is_file() + + lifecycle.archive(store, claim_id="alpha-claim", actor="reviewer") + page = store.get_page("alpha-page") + page.status = PageStatus.ARCHIVED + store.update_page(page) + + result = kb_to_vault(store, vault) + assert "alpha-claim" in result.claims_removed + assert "alpha-page" in result.pages_removed + assert "alpha-claim" not in result.claims_mirrored + assert "alpha-page" not in result.pages_mirrored + assert not claim_path.exists() + assert not page_path.exists() + + +def test_kb_to_vault_preserves_untracked_user_files( + store: KBStore, vault: Path, +) -> None: + """Cleanup must not delete markdown the user dropped into the mirror + dirs — those are skipped by vault_to_kb and are not vouch-owned.""" + kb_to_vault(store, vault) + user_page = vault / VAULT_DIR / "pages" / "my-note.md" + user_claim = vault / VAULT_DIR / "claims" / "scratch.md" + user_page.write_text("# my note\n", encoding="utf-8") + user_claim.write_text("# scratch\n", encoding="utf-8") + + result = kb_to_vault(store, vault) + assert user_page.is_file() + assert user_claim.is_file() + assert "my-note" not in result.pages_removed + assert "scratch" not in result.claims_removed def test_kb_to_vault_is_idempotent(store: KBStore, vault: Path) -> None: