Skip to content

feat: backlink reconciliation — propose missing reverse relations across the graph #307

Description

@plind-junior

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.

proposed surface

a new read-then-propose command:

  • cli: vouch reconcile-backlinks [--rel-types uses,blocks,…] [--limit N] [--dry-run]
  • method kb.reconcile_backlinks with rel_types: list[str] | None, limit: int, dry_run: bool = False

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_onblocks, 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestretrievalcontext, search, synthesis, and evaluationsize: M200-499 changed non-doc lines

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions