Skip to content

D226 — Typed read projections and a read-only UI API

Decision. The data a browser needs to explore a KB comes from two typed Go projections — kb.GraphSnapshot for the link graph and mcpserver.queryConcepts for the concept inventory — served over /api/ui/v1, a purpose-built read-only JSON surface inside the existing HTTP auth chain. Neither projection holds authorization logic: the caller passes its own visibility predicate, so the MCP handlers and the HTTP adapter run the same walk under their own principal. No new tool is added to the agent surface.

Why. The facts were already derivable from the read tools, but only as prose and Markdown shaped for a bounded agent call. A browser scraping concept_list's text, or calling graph_neighbors once per node, would have been slow and would have re-implemented the semantics on the far side of the wire — where a permission rule cannot be enforced. Extracting the projections costs one more layer between the walk and the tool response, and it means two consumers now depend on shapes that used to be private to one handler.

Alternatives rejected. A graph_snapshot MCP tool: it would spend agent context on a payload no agent asked for, and the browser is not an MCP client. Letting the UI read KB files directly: it would need a second copy of the domain model and could not be permission-filtered. Reusing kb_status' aggregate counts for the UI overview: kb_status walks the whole KB, so a narrowed token could have inferred how many concepts it is not allowed to see — the overview derives every count from the filtered walk instead, and takes from kb_status' territory only the replication facts, which it serves only to a caller that can already see the whole KB.

Consequences. Three invariants now have to hold for every response on this surface, and each one has a test. No returned edge may have an endpoint absent from the node list. A concept hidden from the caller is absent as a node, as an edge endpoint and from every count — and, because it exists, it is not reported as a broken target either, which would disclose its id. A hidden KB or concept answers 404, never 403: distinguishing them would make the API an existence oracle. Sorting happens before truncation everywhere, so identical KB state yields an identical response and a graph does not reshuffle between two identical requests. concept_list's response bytes, accounting and filter semantics are unchanged, and the existing tests are the proof.