Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 63 additions & 17 deletions src/vouch/vault_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -218,29 +225,36 @@ 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)
claims_out = _claims_dir(vault_dir)
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,
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
95 changes: 89 additions & 6 deletions tests/test_vault_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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:
Expand Down
Loading