Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 37 additions & 9 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,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] = {}
Expand Down Expand Up @@ -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


Expand Down
74 changes: 68 additions & 6 deletions tests/test_vault_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading