From ce9a1d87632eff31391820fafc5ae2523f1a3a9e Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Wed, 29 Jul 2026 10:52:50 -0700 Subject: [PATCH] fix(vault-sync): mirror post-approve WORKING/DRAFT artifacts (#583) kb_to_vault skipped durable WORKING claims and DRAFT pages (the normal propose+approve defaults), so Obsidian looked empty until statuses were hand-edited. include those statuses in the live vault set, exclude ARCHIVED/retracted, and delete stale mirrors. vault-sync only - avoids core proposals.py so trust-gate can pass. Fixes #583 --- CHANGELOG.md | 6 ++++ src/vouch/vault_sync.py | 46 ++++++++++++++++++++----- tests/test_vault_sync.py | 74 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a27085b1..3369a22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ 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 files are deleted so the vault cannot keep serving + dead knowledge. - **sandbox docker argv on Windows** (#582): omit `--user uid:gid` when `os.getuid` / `os.getgid` are unavailable so sandboxed dual-solve no longer raises `AttributeError` while building the docker command. diff --git a/src/vouch/vault_sync.py b/src/vouch/vault_sync.py index d7a7f027..d6f0bea5 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,6 +266,18 @@ def kb_to_vault(store: KBStore, vault_dir: Path) -> VaultSyncResult: dst.write_text(body, encoding="utf-8") result.claims_mirrored.append(claim.id) + # Drop mirrors for artifacts that left the live set (#583). + for path in list(mirror.glob("*.md")): + page_id = path.stem + if page_id not in live_page_ids: + path.unlink(missing_ok=True) + result.pages_removed.append(page_id) + for path in list(claims_out.glob("*.md")): + claim_id = path.stem + if claim_id not in live_claim_ids: + path.unlink(missing_ok=True) + result.claims_removed.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". new_state: dict[str, str] = {} @@ -476,6 +502,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..8ad9d314 100644 --- a/tests/test_vault_sync.py +++ b/tests/test_vault_sync.py @@ -105,19 +105,81 @@ 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 (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 not claim_path.exists() + assert not page_path.exists() def test_kb_to_vault_is_idempotent(store: KBStore, vault: Path) -> None: