From 41c51f8baed8f0c0f43b7487ed891026fc8d472c Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 17 Jun 2026 00:00:51 -0500 Subject: [PATCH 1/3] feat(audit): visibility-aware kb.audit queries --- CHANGELOG.md | 6 +- spec/2026-05-21/methods.md | 5 +- spec/methods.md | 5 +- src/vouch/audit.py | 31 +++- src/vouch/cli.py | 23 ++- src/vouch/jsonl_server.py | 16 ++- src/vouch/models.py | 1 + src/vouch/scoping.py | 102 ++++++++++++- src/vouch/server.py | 22 ++- src/vouch/web/server.py | 5 +- tests/test_audit.py | 15 ++ tests/test_audit_scoping.py | 278 ++++++++++++++++++++++++++++++++++++ tests/test_scoping.py | 28 ++++ 13 files changed, 509 insertions(+), 28 deletions(-) create mode 100644 tests/test_audit_scoping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4446c3b5..cde3726f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,11 @@ All notable changes to vouch are documented here. Format follows relation proposals automatically, tagged `proposed_by: vouch-extractor`. They land in `proposed/` like any hand-filed relation and need the usual review; `vouch reject-extracted [--page ]` mass-rejects them (#224). -### Added +- Visibility-aware `kb.audit` / `vouch audit`: audit reads accept optional + `project` / `agent` viewer context (or nested `viewer_scope` on JSONL). + Events whose `object_ids` reference scoped claims, sources, or claim + proposals outside the viewer context are filtered out; events with no + `object_ids` remain visible to everyone (#232). - `vouch sync --vault ` — bidirectional sync between the KB and an Obsidian/Logseq-style markdown vault. Forward (vault → KB): edits to `/vouch/pages/.md` become page-edit proposals citing a diff --git a/spec/2026-05-21/methods.md b/spec/2026-05-21/methods.md index 6e6daf4e..f9405250 100644 --- a/spec/2026-05-21/methods.md +++ b/spec/2026-05-21/methods.md @@ -227,8 +227,9 @@ flags promotable claims for review. ### `kb.audit` -**Params:** `{ "tail": int?, "filter": {"event": str?, "actor": str?}? }`. -**Result:** array of `AuditEvent`. +**Params:** `{ "tail": int?, "project": str?, "agent": str?, "viewer_scope": {"project": str?, "agent": str?}? }`. +**Result:** `{ "viewer": {"project": str?, "agent": str?}, "events": [AuditEvent] }`. +Events whose `object_ids` reference scoped artifacts outside the viewer context are omitted; events with no `object_ids` (e.g. `kb.init`) are always included. ### `kb.export` diff --git a/spec/methods.md b/spec/methods.md index 6e6daf4e..f9405250 100644 --- a/spec/methods.md +++ b/spec/methods.md @@ -227,8 +227,9 @@ flags promotable claims for review. ### `kb.audit` -**Params:** `{ "tail": int?, "filter": {"event": str?, "actor": str?}? }`. -**Result:** array of `AuditEvent`. +**Params:** `{ "tail": int?, "project": str?, "agent": str?, "viewer_scope": {"project": str?, "agent": str?}? }`. +**Result:** `{ "viewer": {"project": str?, "agent": str?}, "events": [AuditEvent] }`. +Events whose `object_ids` reference scoped artifacts outside the viewer context are omitted; events with no `object_ids` (e.g. `kb.init`) are always included. ### `kb.export` diff --git a/src/vouch/audit.py b/src/vouch/audit.py index 05f44888..f3a58df9 100644 --- a/src/vouch/audit.py +++ b/src/vouch/audit.py @@ -12,10 +12,14 @@ import uuid from collections.abc import Iterator from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from .models import AuditEvent +if TYPE_CHECKING: + from .scoping import ViewerContext + from .storage import KBStore + AUDIT_FILENAME = "audit.log.jsonl" @@ -59,20 +63,39 @@ def log_event( return ev -def read_events(kb_dir: Path) -> Iterator[AuditEvent]: - """Stream every event in order. Safely skips malformed lines.""" +def read_events( + kb_dir: Path, + *, + store: KBStore | None = None, + viewer: ViewerContext | None = None, +) -> Iterator[AuditEvent]: + """Stream events in order. Safely skips malformed lines. + + When *viewer* is set, *store* must also be provided so scoped + ``object_ids`` can be resolved. Events referencing artifacts outside + the viewer context are omitted. Events with empty ``object_ids`` are + always included. + """ + if viewer is not None and store is None: + raise ValueError("read_events with viewer requires store for scope resolution") path = _audit_path(kb_dir) if not path.exists(): return + scoped = viewer is not None and store is not None + if scoped: + from .scoping import event_visible_to_viewer with path.open(encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: - yield AuditEvent.model_validate(json.loads(line)) + event = AuditEvent.model_validate(json.loads(line)) except (json.JSONDecodeError, ValueError): continue + if scoped and not event_visible_to_viewer(store, event, viewer): # type: ignore[arg-type] + continue + yield event def count_events(kb_dir: Path) -> int: diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5fcf71b8..55bf6555 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1644,13 +1644,30 @@ def reindex(embeddings: bool, backfill: bool, force: bool, model: str | None) -> @cli.command() @click.option("--tail", default=20, show_default=True, type=int) @click.option("--json", "as_json", is_flag=True) -def audit(tail: int, as_json: bool) -> None: +@click.option("--project", default=None, help="Viewer project for audit scope filtering.") +@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.") +def audit(tail: int, as_json: bool, project: str | None, agent: str | None) -> None: """Read the audit log.""" + from .scoping import viewer_from + store = _load_store() - events = list(audit_mod.read_events(store.kb_dir))[-tail:] + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:] if as_json: - _emit_json([e.model_dump(mode="json") for e in events]) + _emit_json({ + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "events": [e.model_dump(mode="json") for e in events], + }) return + if viewer.project or viewer.agent: + click.echo( + f"viewer: project={viewer.project!r} agent={viewer.agent!r}", + err=True, + ) for e in events: click.echo( f"{e.created_at.isoformat()} {e.event:30s} by {e.actor} objects={e.object_ids}" diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index b4961bd1..e9d4bbe7 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -518,11 +518,17 @@ def _h_import_apply(p: dict) -> dict: return r -def _h_audit(p: dict) -> list[dict]: - return [ - e.model_dump(mode="json") - for e in list(audit.read_events(_store().kb_dir))[-int(p.get("tail", 50)):] - ] +def _h_audit(p: dict) -> dict: + from .scoping import viewer_from_params + + s = _store() + viewer = viewer_from_params(s, p) + tail = int(p.get("tail", 50)) + events = list(audit.read_events(s.kb_dir, store=s, viewer=viewer))[-tail:] + return { + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "events": [e.model_dump(mode="json") for e in events], + } def _h_reindex_embeddings(p: dict) -> dict: diff --git a/src/vouch/models.py b/src/vouch/models.py index c779e594..abac5b34 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -429,6 +429,7 @@ class Capabilities(BaseModel): default_factory=lambda: { "enabled": True, "viewer_params": ["project", "agent"], + "scoped_methods": ["kb.search", "kb.context_pack", "kb.audit"], "env_vars": ["VOUCH_PROJECT", "VOUCH_AGENT"], "config_path": "retrieval.scope", } diff --git a/src/vouch/scoping.py b/src/vouch/scoping.py index e4c50994..c7aa9cf7 100644 --- a/src/vouch/scoping.py +++ b/src/vouch/scoping.py @@ -1,7 +1,9 @@ -"""Viewer-context scoping for retrieval (VEP-0005). +"""Viewer-context scoping for retrieval and audit reads (VEP-0005). -Scope is a retrieval/relevance filter, not an access-control boundary. -Artifacts remain readable as plaintext YAML on disk regardless of scope. +Retrieval surfaces treat scope as a relevance filter. The audit read path +also applies scope so multi-project KBs do not leak events across project +boundaries in ``kb.audit`` / ``vouch audit``. Artifacts remain readable as +plaintext YAML on disk regardless of scope. """ from __future__ import annotations @@ -9,13 +11,14 @@ import os from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import yaml -from .models import ArtifactScope, Visibility +from .models import ArtifactScope, ProposalKind, Visibility if TYPE_CHECKING: + from .models import AuditEvent from .storage import KBStore _SCOPED_KINDS = frozenset({"claim", "source"}) @@ -23,12 +26,16 @@ @dataclass(frozen=True) class ViewerContext: - """Who is asking — used to filter retrieval hits.""" + """Who is asking — used to filter retrieval hits and audit timelines.""" project: str | None = None agent: str | None = None +# Transport auth layers may pass a structured viewer scope (issue #232). +ScopeSpec = ViewerContext + + def _norm(value: str | None) -> str | None: if value is None: return None @@ -77,6 +84,22 @@ def viewer_from( return ViewerContext(project=resolved_project, agent=resolved_agent) +def viewer_from_params(store: KBStore, params: dict[str, Any]) -> ViewerContext: + """Resolve viewer context from kb.* method params (flat or nested).""" + nested = params.get("viewer_scope") + if isinstance(nested, dict): + return viewer_from( + config_path=store.config_path, + project=nested.get("project"), + agent=nested.get("agent"), + ) + return viewer_from( + config_path=store.config_path, + project=params.get("project"), + agent=params.get("agent"), + ) + + def is_visible(scope: ArtifactScope, viewer: ViewerContext) -> bool: """Return whether *scope* is visible to *viewer* in retrieval surfaces.""" vis = scope.visibility @@ -134,3 +157,70 @@ def filter_hits( if limit is not None and len(kept) >= limit: break return kept + + +def _is_sha256_hex(value: str) -> bool: + return len(value) == 64 and all(c in "0123456789abcdef" for c in value) + + +def _parse_scope(raw: object) -> ArtifactScope: + if raw is None: + return ArtifactScope() + if isinstance(raw, str): + return ArtifactScope(visibility=Visibility(raw)) + if isinstance(raw, dict): + return ArtifactScope.model_validate(raw) + return ArtifactScope() + + +def artifact_scope_for_object_id(store: KBStore, object_id: str) -> ArtifactScope | None: + """Return scope for an audit ``object_id``; ``None`` if unscoped or unknown.""" + if _is_sha256_hex(object_id): + try: + return store.get_source(object_id).scope + except Exception: + pass + + try: + return store.get_claim(object_id).scope + except Exception: + pass + + try: + proposal = store.get_proposal(object_id) + except Exception: + return None + + if proposal.kind != ProposalKind.CLAIM: + return None + + raw = proposal.payload.get("scope") + return _parse_scope(raw) + + +def event_visible_to_viewer( + store: KBStore, + event: AuditEvent, + viewer: ViewerContext, +) -> bool: + """Return whether *event* may be shown to *viewer*. + + Events with no ``object_ids`` (e.g. ``kb.init``) are visible to everyone. + Otherwise every referenced scoped artifact must pass ``is_visible``. + """ + if not event.object_ids: + return True + for oid in event.object_ids: + scope = artifact_scope_for_object_id(store, oid) + if scope is not None and not is_visible(scope, viewer): + return False + return True + + +def filter_audit_events( + store: KBStore, + events: list[AuditEvent], + viewer: ViewerContext, +) -> list[AuditEvent]: + """Drop audit events whose ``object_ids`` reference artifacts outside *viewer*.""" + return [e for e in events if event_visible_to_viewer(store, e, viewer)] diff --git a/src/vouch/server.py b/src/vouch/server.py index 4a630fe4..679d6901 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -708,10 +708,24 @@ def kb_import_apply(bundle_path: str, on_conflict: str = "skip") -> dict[str, An @mcp.tool() -def kb_audit(tail: int = 50) -> list[dict[str, Any]]: - """Return the last N audit events.""" - events = list(audit.read_events(_store().kb_dir))[-tail:] - return [e.model_dump(mode="json") for e in events] +def kb_audit( + tail: int = 50, + *, + project: str | None = None, + agent: str | None = None, +) -> dict[str, Any]: + """Return the last N audit events, filtered to the viewer scope.""" + store = _store() + viewer = viewer_from( + config_path=store.config_path, + project=project, + agent=agent, + ) + events = list(audit.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:] + return { + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "events": [e.model_dump(mode="json") for e in events], + } @mcp.tool() diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py index 9cb902da..f05a9692 100644 --- a/src/vouch/web/server.py +++ b/src/vouch/web/server.py @@ -527,7 +527,10 @@ async def contradict(claim_id: str, against: str = Form(...)) -> Any: @app.get("/audit", response_class=HTMLResponse, dependencies=guarded) def audit(request: Request, limit: int = 100) -> Any: - events = list(audit_mod.read_events(store.kb_dir)) + from ..scoping import viewer_from + + viewer = viewer_from(config_path=store.config_path) + events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer)) events.reverse() # newest first filtered = [e for e in events if _is_review_event(e.event)][:limit] rows = [ diff --git a/tests/test_audit.py b/tests/test_audit.py index 4d3f516d..3257041a 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -28,3 +28,18 @@ def test_audit_log_survives_malformed_lines(store: KBStore) -> None: (store.kb_dir / "audit.log.jsonl").open("a").write("garbage\n") events = list(audit.read_events(store.kb_dir)) assert len(events) == 1 + + +def test_read_events_unfiltered_by_default(store: KBStore) -> None: + from vouch.models import ArtifactScope, Claim, Visibility + + src = store.put_source(b"e") + store.put_claim(Claim( + id="secret", + text="x", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + audit.log_event(store.kb_dir, event="claim.create", actor="u", object_ids=["secret"]) + events = list(audit.read_events(store.kb_dir)) + assert any("secret" in e.object_ids for e in events) diff --git a/tests/test_audit_scoping.py b/tests/test_audit_scoping.py new file mode 100644 index 00000000..076bde4a --- /dev/null +++ b/tests/test_audit_scoping.py @@ -0,0 +1,278 @@ +"""Visibility-aware audit reads — issue #232.""" + +from __future__ import annotations + +import random +from pathlib import Path + +import pytest + +from vouch import audit +from vouch.models import ArtifactScope, Claim, Visibility +from vouch.proposals import approve, propose_claim +from vouch.scoping import ( + ViewerContext, + artifact_scope_for_object_id, + event_visible_to_viewer, + filter_audit_events, + viewer_from_params, +) +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _claim_event(store: KBStore, claim_id: str, *, actor: str = "agent") -> None: + audit.log_event( + store.kb_dir, + event="claim.create", + actor=actor, + object_ids=[claim_id], + ) + + +def test_audit_events_without_object_ids_always_visible(store: KBStore) -> None: + audit.log_event(store.kb_dir, event="kb.init", actor="setup") + audit.log_event(store.kb_dir, event="index.rebuild", actor="setup", object_ids=[]) + + events = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="billing"), + )) + assert {e.event for e in events} == {"kb.init", "index.rebuild"} + + +def test_two_project_kb_reviewer_sees_only_own_events(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="shared-design", + text="shared auth", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT), + )) + store.put_claim(Claim( + id="billing-secret", + text="billing auth", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + store.put_claim(Claim( + id="platform-secret", + text="platform auth", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="platform"), + )) + + _claim_event(store, "shared-design") + _claim_event(store, "billing-secret") + _claim_event(store, "platform-secret") + + billing_events = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="billing"), + )) + billing_ids = {oid for e in billing_events for oid in e.object_ids} + assert "billing-secret" in billing_ids + assert "shared-design" in billing_ids + assert "platform-secret" not in billing_ids + + platform_events = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="platform"), + )) + platform_ids = {oid for e in platform_events for oid in e.object_ids} + assert "platform-secret" in platform_ids + assert "billing-secret" not in platform_ids + + +def test_proposal_events_respect_payload_scope(store: KBStore) -> None: + src = store.put_source(b"evidence") + pr = propose_claim( + store, + text="billing-only proposal", + evidence=[src.id], + proposed_by="agent", + slug_hint="billing-proposal-claim", + dry_run=True, + ) + pr.proposal.payload["scope"] = { + "visibility": "project", + "project": "billing", + } + store.put_proposal(pr.proposal) + audit.log_event( + store.kb_dir, + event="proposal.claim.create", + actor="agent", + object_ids=[pr.proposal.id], + ) + + billing = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="billing"), + )) + assert any(pr.proposal.id in e.object_ids for e in billing) + + platform = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="platform"), + )) + assert not any(pr.proposal.id in e.object_ids for e in platform) + + +def test_private_claim_hidden_without_agent(store: KBStore) -> None: + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="alice-note", + text="private scratch", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PRIVATE, agent="alice"), + )) + _claim_event(store, "alice-note") + + default_events = list(audit.read_events(store.kb_dir, store=store, viewer=ViewerContext())) + assert not any("alice-note" in e.object_ids for e in default_events) + + alice_events = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(agent="alice"), + )) + assert any("alice-note" in e.object_ids for e in alice_events) + + +def test_approve_event_hidden_when_result_claim_is_foreign(store: KBStore) -> None: + src = store.put_source(b"evidence") + pr = propose_claim( + store, + text="billing scoped claim", + evidence=[src.id], + proposed_by="agent", + slug_hint="billing-approved", + dry_run=True, + ) + pr.proposal.payload["scope"] = {"visibility": "project", "project": "billing"} + store.put_proposal(pr.proposal) + claim = approve(store, pr.proposal.id, approved_by="reviewer") + assert claim.id == "billing-approved" + + platform = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="platform"), + )) + platform_object_ids = {oid for e in platform for oid in e.object_ids} + assert claim.id not in platform_object_ids + assert pr.proposal.id not in platform_object_ids + + +def test_unscoped_page_events_remain_visible(store: KBStore) -> None: + audit.log_event(store.kb_dir, event="page.create", actor="r", object_ids=["auth-design"]) + events = list(audit.read_events( + store.kb_dir, + store=store, + viewer=ViewerContext(project="billing"), + )) + assert any("auth-design" in e.object_ids for e in events) + + +def test_read_events_requires_store_when_viewer_set(store: KBStore) -> None: + with pytest.raises(ValueError, match="requires store"): + list(audit.read_events(store.kb_dir, viewer=ViewerContext(project="a"))) + + +def test_viewer_from_params_accepts_nested_viewer_scope(store: KBStore) -> None: + viewer = viewer_from_params(store, {"viewer_scope": {"project": "billing", "agent": "bot"}}) + assert viewer == ViewerContext(project="billing", agent="bot") + + +def test_fuzz_no_project_leak_across_viewers(store: KBStore) -> None: + """Mirrors gbrain zero-leaks: no viewer sees foreign project-bound events.""" + rng = random.Random(232) + projects = ["alpha", "beta", "gamma", "delta"] + src = store.put_source(b"shared evidence for fuzz audit scoping") + claim_scopes: dict[str, ArtifactScope] = {} + + for i in range(40): + vis_roll = rng.random() + if vis_roll < 0.25: + scope = ArtifactScope(visibility=Visibility.PROJECT) + elif vis_roll < 0.75: + scope = ArtifactScope( + visibility=Visibility.PROJECT, + project=rng.choice(projects), + ) + else: + scope = ArtifactScope( + visibility=Visibility.PRIVATE, + agent=rng.choice(["alice", "bob", "carol"]), + ) + cid = f"claim-{i}" + store.put_claim(Claim(id=cid, text=f"claim {i}", evidence=[src.id], scope=scope)) + claim_scopes[cid] = scope + _claim_event(store, cid) + + audit.log_event(store.kb_dir, event="kb.init", actor="fuzz") + + viewers = [ViewerContext()] + [ + ViewerContext(project=p) for p in projects + ] + [ + ViewerContext(agent=a) for a in ("alice", "bob", "carol") + ] + + for viewer in viewers: + visible = list(audit.read_events(store.kb_dir, store=store, viewer=viewer)) + for event in visible: + for oid in event.object_ids: + scope = artifact_scope_for_object_id(store, oid) + if scope is None: + continue + assert event_visible_to_viewer(store, event, viewer) + assert filter_audit_events(store, [event], viewer) == [event] + if ( + scope.visibility == Visibility.PROJECT + and scope.project is not None + and viewer.project is not None + ): + assert scope.project == viewer.project + if ( + scope.visibility == Visibility.PRIVATE + and scope.agent is not None + and viewer.agent is not None + ): + assert scope.agent == viewer.agent + + +def test_jsonl_audit_returns_scoped_envelope( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + from vouch.jsonl_server import handle_request + + src = store.put_source(b"e") + store.put_claim(Claim( + id="billing-only", + text="billing", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + _claim_event(store, "billing-only") + monkeypatch.chdir(store.root) + + resp = handle_request({ + "id": "audit-1", + "method": "kb.audit", + "params": {"tail": 50, "viewer_scope": {"project": "platform"}}, + }) + assert resp["ok"] + result = resp["result"] + assert result["viewer"]["project"] == "platform" + object_ids = {oid for e in result["events"] for oid in e["object_ids"]} + assert "billing-only" not in object_ids diff --git a/tests/test_scoping.py b/tests/test_scoping.py index c7110575..22478ede 100644 --- a/tests/test_scoping.py +++ b/tests/test_scoping.py @@ -164,6 +164,34 @@ def test_context_pack_respects_private_scope(store: KBStore) -> None: assert "alice-scratch" not in ids_other +def test_artifact_scope_for_object_id_resolves_claim_and_proposal(store: KBStore) -> None: + from vouch.proposals import propose_claim + from vouch.scoping import artifact_scope_for_object_id + + src = store.put_source(b"e") + store.put_claim(Claim( + id="scoped-claim", + text="x", + evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"), + )) + assert artifact_scope_for_object_id(store, "scoped-claim") == ArtifactScope( + visibility=Visibility.PROJECT, project="billing", + ) + + pr = propose_claim( + store, text="pending", evidence=[src.id], proposed_by="a", + slug_hint="pending-claim", dry_run=True, + ) + pr.proposal.payload["scope"] = {"visibility": "project", "project": "platform"} + store.put_proposal(pr.proposal) + assert artifact_scope_for_object_id(store, pr.proposal.id) == ArtifactScope( + visibility=Visibility.PROJECT, project="platform", + ) + + assert artifact_scope_for_object_id(store, "unknown-page-id") is None + + def test_default_kb_behavior_unchanged(store: KBStore) -> None: """Claims with legacy default scope stay visible to the default viewer.""" src = store.put_source(b"e") From 085f5f965be664f387201f9f90328e5bf01755a2 Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 17 Jun 2026 00:04:45 -0500 Subject: [PATCH 2/3] fix: update --- src/vouch/sessions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index 0762e6aa..0b1b80e1 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -12,7 +12,8 @@ import uuid from datetime import UTC, datetime -from . import audit, index_db, salience, volunteer_context +from . import audit, index_db, volunteer_context +from . import salience as salience_mod from .models import Page, PageType, ProposalStatus, Session from .proposals import approve from .storage import KBStore @@ -39,7 +40,7 @@ def session_start(store: KBStore, *, agent: str, task: str | None = None, def session_end(store: KBStore, session_id: str, *, note: str | None = None) -> Session: sess = store.get_session(session_id) - salience.reset_session(session_id) + salience_mod.reset_session(session_id) if sess.ended_at is not None: return sess # idempotent sess.ended_at = datetime.now(UTC) From 30d694c0510f5bbdcdef1153514a16811bc29d0a Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 17 Jun 2026 00:26:36 -0500 Subject: [PATCH 3/3] fix: update --- schemas/relation.schema.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schemas/relation.schema.json b/schemas/relation.schema.json index 561a5be9..0185d7f0 100644 --- a/schemas/relation.schema.json +++ b/schemas/relation.schema.json @@ -13,7 +13,9 @@ "similar_to", "blocks", "implements", - "references" + "references", + "mentions", + "relates_to" ], "title": "RelationType", "type": "string"