You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
the relation graph is directed: a Relation is source --relation--> target (src/vouch/models.py, with RelationType values uses, depends_on, blocks, owned_by, similar_to, relates_to, contradicts, …). many of those edges have a natural counterpart. a symmetric type like similar_to / relates_to / contradicts reads the same in both directions, and a directed type has an expected mirror in navigation: if a blocks b, a reader standing at b wants to find a. today the mirror only exists if someone proposed it explicitly, so kb.neighbors on the far endpoint silently misses edges that logically point at it. as auto-extracted typed edges (#224) land more relations per approved page, the asymmetry compounds and retrieval walks come back thin.
backlink reconciliation is a read-then-propose pass that scans the existing graph, finds edges whose expected reverse or bidirectional counterpart is absent, and files a propose_relation proposal for each gap. it never writes an edge itself — it queues the reverse for human review like any other write.
it reads the current graph through existing surfaces — kb.graph_export (provenance.graph_export, exposed via src/vouch/server.py) for the full edge set, and kb.neighbors (find_neighbors in src/vouch/graph.py) to confirm the reverse edge is genuinely missing at the target before proposing. for each gap it emits one proposals.propose_relation(store, src=<orig target>, relation=<mirror>, target=<orig src>, proposed_by="reconcile", rationale="backlink for <orig relation id>", dry_run=…).
which mirror to emit per RelationType is configuration, not hardcoded, so a kb can declare its own inverse map (e.g. depends_on ↔ blocks, an owned_by reverse, and the symmetric set {similar_to, relates_to, contradicts} that mirrors in place). the pass ships with a sane default map; unmapped types are skipped rather than guessed. the map is read from .vouch/config.yaml — depends on typed config validation (#243) landing, or falls back to plain parsing until then. --dry-run returns the would-propose set with no writes.
since this adds a new kb.* method it touches the four registration sites — @mcp.tool() in src/vouch/server.py, _h_reconcile_backlinks + HANDLERS["kb.reconcile_backlinks"] in src/vouch/jsonl_server.py, METHODS in src/vouch/capabilities.py, and the cli command in src/vouch/cli.py — plus tests/test_reconcile_backlinks.py. the pass itself is orchestration and belongs alongside the other graph-lifecycle helpers in src/vouch/lifecycle.py; storage.py stays pure i/o and gains nothing here.
review gate & scope
every proposed backlink is a pending Proposal of kind relation and lands durably only through proposals.approve() after a human kb.approve. the reconciliation pass is automated work by definition, so it proposes and never approves or writes an approved edge — no auto-approve, no direct put_relation. it reuses the endpoint checks in propose_relation (both endpoints must be existing nodes) so it cannot manufacture dangling edges. no network, no new storage format — it reads local yaml through existing methods and writes pending proposals into .vouch/, staying local-first.
builds on #224 (auto-extracted typed edges): #224 creates forward edges from approved pages; this issue proposes the missing reverses across whatever edges already exist, regardless of how they got there. distinct from #184 (graph-aware retrieval / --expand-graph), which changes how a query walks the current graph at read time — this issue changes the graph's contents by proposing edges for review and does not touch the retrieval path. related config lives in #243 (typed config model).
acceptance criteria
kb.reconcile_backlinks registered at all four sites (server, jsonl, capabilities, cli) with matching signatures
default inverse/symmetric map defined, overridable via .vouch/config.yaml; unmapped RelationType values are skipped, not guessed
pass reads the graph via kb.graph_export / kb.neighbors and confirms the reverse edge is absent before proposing
each gap yields exactly one pending Proposal (kind relation) via proposals.propose_relation, with a rationale referencing the originating edge
the pass never approves or writes an approved relation; approval requires a human kb.approve
symmetric types (similar_to, relates_to, contradicts) reconcile without proposing a duplicate when the mirror already exists
--dry-run reports the would-propose set and writes nothing
--limit bounds the number of proposals per run
tests/test_reconcile_backlinks.py covers: a directed gap proposed, an already-mirrored edge skipped, a symmetric edge, an unmapped type skipped, and dry-run emitting no proposals
the relation graph is directed: a
Relationissource --relation--> target(src/vouch/models.py, withRelationTypevaluesuses,depends_on,blocks,owned_by,similar_to,relates_to,contradicts, …). many of those edges have a natural counterpart. a symmetric type likesimilar_to/relates_to/contradictsreads the same in both directions, and a directed type has an expected mirror in navigation: ifa blocks b, a reader standing atbwants to finda. today the mirror only exists if someone proposed it explicitly, sokb.neighborson the far endpoint silently misses edges that logically point at it. as auto-extracted typed edges (#224) land more relations per approved page, the asymmetry compounds and retrieval walks come back thin.backlink reconciliation is a read-then-propose pass that scans the existing graph, finds edges whose expected reverse or bidirectional counterpart is absent, and files a
propose_relationproposal for each gap. it never writes an edge itself — it queues the reverse for human review like any other write.proposed surface
a new read-then-propose command:
vouch reconcile-backlinks [--rel-types uses,blocks,…] [--limit N] [--dry-run]kb.reconcile_backlinkswithrel_types: list[str] | None,limit: int,dry_run: bool = Falseit reads the current graph through existing surfaces —
kb.graph_export(provenance.graph_export, exposed viasrc/vouch/server.py) for the full edge set, andkb.neighbors(find_neighborsinsrc/vouch/graph.py) to confirm the reverse edge is genuinely missing at the target before proposing. for each gap it emits oneproposals.propose_relation(store, src=<orig target>, relation=<mirror>, target=<orig src>, proposed_by="reconcile", rationale="backlink for <orig relation id>", dry_run=…).which mirror to emit per
RelationTypeis configuration, not hardcoded, so a kb can declare its own inverse map (e.g.depends_on↔blocks, anowned_byreverse, and the symmetric set{similar_to, relates_to, contradicts}that mirrors in place). the pass ships with a sane default map; unmapped types are skipped rather than guessed. the map is read from.vouch/config.yaml— depends on typed config validation (#243) landing, or falls back to plain parsing until then.--dry-runreturns the would-propose set with no writes.since this adds a new
kb.*method it touches the four registration sites —@mcp.tool()insrc/vouch/server.py,_h_reconcile_backlinks+HANDLERS["kb.reconcile_backlinks"]insrc/vouch/jsonl_server.py,METHODSinsrc/vouch/capabilities.py, and the cli command insrc/vouch/cli.py— plustests/test_reconcile_backlinks.py. the pass itself is orchestration and belongs alongside the other graph-lifecycle helpers insrc/vouch/lifecycle.py;storage.pystays pure i/o and gains nothing here.review gate & scope
every proposed backlink is a pending
Proposalof kindrelationand lands durably only throughproposals.approve()after a humankb.approve. the reconciliation pass is automated work by definition, so it proposes and never approves or writes an approved edge — no auto-approve, no directput_relation. it reuses the endpoint checks inpropose_relation(both endpoints must be existing nodes) so it cannot manufacture dangling edges. no network, no new storage format — it reads local yaml through existing methods and writes pending proposals into.vouch/, staying local-first.builds on #224 (auto-extracted typed edges): #224 creates forward edges from approved pages; this issue proposes the missing reverses across whatever edges already exist, regardless of how they got there. distinct from #184 (graph-aware retrieval /
--expand-graph), which changes how a query walks the current graph at read time — this issue changes the graph's contents by proposing edges for review and does not touch the retrieval path. related config lives in #243 (typed config model).acceptance criteria
kb.reconcile_backlinksregistered at all four sites (server, jsonl, capabilities, cli) with matching signatures.vouch/config.yaml; unmappedRelationTypevalues are skipped, not guessedkb.graph_export/kb.neighborsand confirms the reverse edge is absent before proposingProposal(kindrelation) viaproposals.propose_relation, with a rationale referencing the originating edgekb.approvesimilar_to,relates_to,contradicts) reconcile without proposing a duplicate when the mirror already exists--dry-runreports the would-propose set and writes nothing--limitbounds the number of proposals per runtests/test_reconcile_backlinks.pycovers: a directed gap proposed, an already-mirrored edge skipped, a symmetric edge, an unmapped type skipped, and dry-run emitting no proposals