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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/vouch/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading