From 873d7c60a928bf5b91d35ab3fb504ecbf8a047ee Mon Sep 17 00:00:00 2001 From: tryeverything24 Date: Tue, 21 Jul 2026 15:21:59 -0700 Subject: [PATCH] fix(sync): register inbound sources before evidence in gated apply the as-proposals pass registered substrate in one sorted path walk, and evidence/... sorts ahead of sources/..., so put_evidence raised "cites unknown source" for any inbound evidence whose source arrived in the same sync -- the standard shape for a peer sharing receipt-backed claims. the whole gated apply crashed partway, after sync check had called the same input ok, and with no audit event for anything already registered. import_as_proposals (bundle.py) already registers sources before evidence for exactly this reason; the sync path now uses the same two-sub-pass order. regression test: a peer kb with source + evidence + claim-citing-the- evidence, all new to the receiving kb, fails on the previous code at storage.put_evidence and passes now. --- CHANGELOG.md | 11 +++++++++++ src/vouch/sync.py | 10 +++++++++- tests/test_sync.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0704c6a4..5e28028d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,17 @@ All notable changes to vouch are documented here. Format follows per-prompt block, the session banner, `vouch status` and the opt-in question all say so rather than calling it "this repo's" knowledge. +### Fixed +- `vouch sync --as-proposals` no longer crashes when the inbound KB + carries a receipt-backed claim whose evidence AND source are both new + here. the gated apply registered substrate in one sorted path walk, and + `evidence/...` sorts ahead of `sources/...`, so `put_evidence` raised + "cites unknown source" for evidence arriving alongside its own source — + aborting the whole sync partway with no audit event, on input + `sync check` had just called ok (and that `vouch import-proposals` + accepts for the identical bundle). sources are now registered before + evidence, the same pass-1 order `import_as_proposals` already uses. + ## [1.5.0] — 2026-07-20 ### Added diff --git a/src/vouch/sync.py b/src/vouch/sync.py index 5c570967..4515c452 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -329,12 +329,20 @@ def _sync_apply_as_proposals( } # Pass 1: substrate registered directly so claim proposals can cite it. + # Sources before evidence, in two sub-passes: put_evidence rejects an + # evidence record whose cited source is not yet on disk, and the sorted + # path walk visits "evidence/..." ahead of "sources/..." — so a single + # interleaved loop crashes on any inbound evidence whose source arrives + # in the same sync. Mirrors the pass-1 order import_as_proposals + # (bundle.py) already uses for the same reason. for path in new: kind, _ = _artifact_kind(path) if kind == "source" and path.endswith("/content"): store.put_source(_read_source_file(src, path)) sources_registered += 1 - elif kind == "evidence": + for path in new: + kind, _ = _artifact_kind(path) + if kind == "evidence": ev = Evidence.model_validate(yaml.safe_load(_read_source_file(src, path))) store.put_evidence(ev) evidence_registered += 1 diff --git a/tests/test_sync.py b/tests/test_sync.py index 071cbb6e..fb02a2f6 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -96,6 +96,48 @@ def test_sync_apply_as_proposals_approves_into_a_claim_with_origin(tmp_path: Pat assert "origin:kb-remote" in dest.get_claim(approved.id).tags +def test_sync_apply_as_proposals_registers_sources_before_evidence(tmp_path: Path) -> None: + """An inbound receipt-backed claim (source + evidence + claim, all new) syncs. + + Regression: pass 1 walked a single sorted(new_files) list, and + "evidence/..." sorts ahead of "sources/..." -- put_evidence then raised + "cites unknown source" for any evidence whose source arrived in the same + sync, crashing the whole gated apply before anything was audited. + import_as_proposals (bundle.py) already registered sources before + evidence; the sync path must keep the same order. + """ + from vouch.models import Evidence + + incoming = _store(tmp_path / "incoming") + content = b"the deploy runs at 03:00 utc" + src = incoming.put_source(content, title="runbook") + ev = Evidence( + id="ev-deploy-span", + source_id=src.id, + locator="bytes=0-28", + quote="the deploy runs at 03:00 utc", + byte_start=0, + byte_end=28, + ) + incoming.put_evidence(ev) + incoming.put_claim( + Claim(id="deploy-time", text="deploys run at 03:00 utc", evidence=[ev.id]) + ) + dest = _store(tmp_path / "dest") + + result = sync.sync_apply( + dest.kb_dir, incoming.root, as_proposals=True, origin_kb="kb-remote" + ) + + assert result["sources_registered"] == 1 + assert result["evidence_registered"] == 1 + assert result["failed"] == [] + assert len(result["proposed"]) == 1 + # The substrate landed, so the pending claim proposal can cite it. + assert dest.get_evidence("ev-deploy-span").source_id == src.id + assert dest.get_source(src.id).id == src.id + + def test_cli_sync_apply_as_proposals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: incoming = _store(tmp_path / "incoming") _claim(incoming, "c1", "alpha from the other kb")