Find every surface related to X¶
The rule came first, and the rule was the problem.
This project tells its agents, in writing and without exceptions, that a change to a named concept is a change to every surface that carries that concept. Rename a role, and the protocol doc, the management mockup, the review rules, and the stage component all move in the same turn. Touch an annotated code unit, and its annotation block moves with it. Change how the shell navigates, and both management docs change too. The rules are unambiguous about the obligation.
They are silent about the hard part, which is finding the surfaces.
An agent handed "update every doc that describes the role filter" has one honest move available to it: grep. Grep for the term, grep for the plausible synonyms, grep for the file paths, read the hits, guess at what it missed, and hope. That works until the vocabulary drifts. A doc that calls it "role-based view filtering" and a code unit tagged view-filter and a test that exercises the same seam under a third name are all the same surface, and a substring search connects none of them.
So the discovery step was the weakest link in a chain of rules that assumed discovery was free. The fix was to stop asking a text search to reconstruct a relationship the project could simply record.
One vocabulary, two storage sites¶
The mechanism is smaller than it sounds. Code and docs already had metadata. They just did not share any.
Source units carry an inline codemap: block: a comment that names the unit's capabilities, risk flags, maturity, affinity, and a hash: stamp proving the judgment fields still describe the hashed code. Those capability tokens are not free text. They come from a closed list in codemap/vocabulary.json -- engine, server, auth, ws, db, protocol, view-filter, hidden-info, and the rest. An agent that wants a token the list does not have must pick the closest legal one and say so, not invent a new one.
Docs now carry the same tokens in YAML frontmatter, under capability:. That is the entire join. A doc and a code unit are related when they claim the same capability, and both claim it from the same closed vocabulary, so the claim is comparable.
Alongside it sits a second, docs-only namespace, doc-kind:, drawn from its own closed set: adr, blog, devblog, mockup, reference, runbook, spec, tutorial. It answers a different question. capability says what a document is about; doc-kind says how to use it. "Everything about the role filter" and "the runbook for the role filter" are different queries, and separating the namespaces is what lets the second one exist.
What deliberately did not transfer is as interesting as what did. The auto:, ai:, and human: provenance tiers stay code-only, because they describe who may refresh a field in a source annotation. The hash: currency stamp stays code-only too. A hash over a code unit proves an annotation still describes that unit; a hash over prose proves nothing anyone wants to know. Copying the mechanism across would have produced a stamp that looked rigorous and meant nothing. The spec in workflow/docs/DOC_CODE_GRAPH.md says so out loud, and leaves room for a doc-freshness signal designed for documents rather than borrowed from source.
The vocabulary is a closed set for the same reason the join works at all. lint-doc-tags validates every doc's frontmatter against both lists at authoring time. An invented token that fits the content beautifully -- rag, retrieval, ai -- is a token nothing else in the repo will ever match.
The path is the key¶
Every node in the graph, code or doc, is keyed by its git-tracked repo path. src/server/room.py. workflow/docs/CODE_ANNOTATION.md. Nothing clever.
The discipline is in what is excluded. The docs site is built from a staging tree: scripts/mkdocs_stage_docs.py copies docs/<rel> and workflow/docs/<rel> into site-src/, rewriting links as it goes. That staged tree is a build artifact. Index it, and every doc gets a second identity -- one canonical, one generated, links rewritten between them -- and every join back to a review diff, a ticket's ## Touches list, or a commit silently splits in half. So the indexers read canonical source trees only, and site-src/ and site/ are never nodes.
The payoff is that a graph hit is immediately actionable. It is the same string you would put in a ticket, pass to git diff, or open in an editor. There is exactly one namespace for "where this thing lives," and the retrieval layer speaks it natively.
The hub page is the graph, rendered¶
Given the tags and the paths, a generator writes one page per capability into docs/overview/capability-hubs/. It reads git-tracked docs under docs/ and workflow/docs/, parses codemap: blocks out of tracked source and test files, walks codemap_edges for the neighbors of every annotated unit, and emits, for each capability: the tagged docs with their titles and kinds, the annotated code units, and the graph neighbors reachable from them.
The view-filter hub page is the answer to the question that started this post. Game design docs, the protocol reference, the logging guide, the review rules, the client stage components, the engines, the server view builders, the hidden-information Playwright specs. Not because someone maintained a list, but because each of those files, at some point, claimed the capability in its own frontmatter or its own annotation block, and the generator did the join.
The generator also refuses to be quiet about its own gaps. Every hub page and the hub index carry a Tag Health section: which capabilities have zero tagged docs, and which docs carry tokens outside the vocabulary. The failure mode of a metadata system is that it decays silently -- coverage stops growing, nobody notices, and the graph quietly becomes a graph of the subset somebody tagged in 2026. Printing the holes on the generated page makes the decay a reviewable fact instead of an assumption.
There is a doc-side edge artifact too. A regenerated doc reference index turns markdown links and inline path mentions between canonical docs into weighted edges -- a real link counts more than a passing mention -- and records, per doc, how much of the tree points at it. Derive It, Don't Stamp It argued for exactly that artifact while it was still hypothetical: a canonical page should not be important because it declares criticality: high in its frontmatter, but because the graph can show who consumes it. That index now generates.
Retrieval ranks metadata, not prose¶
The graph is not a search engine, and the honest version of this post says where each layer stops.
The project that remembers itself described the retrieval surfaces that already existed: history-search for BM25 over ticket and run-record prose, history-semantic for true vector similarity when the words diverge. The semantic layer's document set has since been extended, and the extension has a shape worth stating precisely, because it is easy to overclaim.
Canonical docs and codemap units are now embedded alongside tickets and run records, and history-semantic can target any of the four source families. But a doc is not embedded as its prose. Its embedding text is a metadata card: the path, the first heading, the capability tokens, the doc-kind tokens. A codemap unit's card is its path, area, component, maturity, affinity, capabilities, and risk flags. Nothing else.
That is a smaller claim than "the docs are in the vector index," and it is the right one. The card is what a routing question needs. "Which canonical surfaces are about hidden information?" is answered by tags, titles, and paths, and answering it from tags is cheap, stable, and does not drift every time a paragraph is rewritten. Full-prose retrieval is a different feature with a different cost, and pretending the metadata card delivers it would set up the next agent to trust a ranking it should not.
So the contract the spec writes down is layered rather than unified. Parse the explicit filters out of the query -- capability tokens, doc-kind values, path prefixes, node types. Use them to scope the text and vector candidates. Expand from the seed paths through codemap_edges to related code and docs. Rank direct text matches first, graph-near surfaces next, distant or low-confidence hits last. The graph does not replace text search. It tells text search where to look, and then it tells the agent what else it just touched.
What it unlocks next¶
Two things, and they were the reason to build it in this order.
The first is derived review routing. The code side already computes review risk instead of stamping it: changed paths, annotations, dependency edges, test marks, and policy weights go in; complexity, risk, and a review route come out. Canonical docs had no such substrate, which is how a documentation edit that rewrites a routing contract could travel through the system as an ordinary doc edit. A doc-side graph with real incoming reach is the missing input. A doc that half the orchestrator points at earns more review than one nothing links to -- and it earns it by measurement, not by a field somebody remembered to set.
The second is the retrieval an agent actually wants at the start of a turn: rank doc nodes, code nodes, ticket intent, and run-record hindsight in one ranked list, filtered by capability before a single similarity score is computed. "Load everything that shares this capability before you edit" instead of "grep, and hope."
The change worth noticing is not that the repo grew a graph. It is that a rule the project had already committed to -- edit every related surface, in the same turn -- stopped depending on the agent's ability to guess which surfaces those were. The obligation was always writable. What it needed was for the project to answer the question it kept asking.
Or: subscribe to the newsletter for more posts on building this stuff.