Case study — Docs-as-code Knowledge Base + Context Graph for a 15-project portfolio
This case study intentionally omits the client's name and every internal identifier. What remains is the system design, the measurement method and the results — the part that is safe to share publicly.
Role: Technical Lead, designed and built the whole system myself (2026-08 → 2026-09, roughly 7 weeks alongside project management work). Personal repo:
tmt-projects-kb. Stack: Markdown + YAML frontmatter, Python (tree-sitter, networkx, SQLite), MCP server, d3-force viewer.
1. Context
I am the Technical Lead on several web projects for a Japanese enterprise client. Each project has 3–4 microservice-style repos: web frontend, contact API, Terraform infra, CMS deployment. The tech stack and deployment environment differ per project and per env: Cloud Run, Google App Engine, Firebase Hosting/Functions, Cloud Functions gen1/gen2, with some still on CircleCI or AWS S3 + CloudFront.
Management documentation was scattered across Confluence, Google Sheets/Slides/Docs. After years of hand-offs through several generations of developers, most of it had drifted from the running code.
2. Measured pain points (before the system)
| Pain | What it looked like in practice |
|---|---|
| Updating a feature | Every time, hunting and reassembling information from Confluence, Sheets and 3–4 repos, with no way to tell which source was still correct. |
| Simple client questions | "Which projects run on Cloud Run in prod?", "How many projects still use Nuxt?" meant opening each repo to check — tens of minutes each time. |
| AI coding agents | Every session needed context fed in by hand. The agent read dozens of files to reconstruct relationships between repos, burned tokens, and still missed cross-repo hops or confused components with the same name in different projects. |
| Technical decisions | Root causes and trade-offs behind past fixes were never recorded; months later the same question came back — "why did we do it this way?". |
3. Goals and self-imposed constraints
Goal: every project-knowledge question (infrastructure, architecture, tech stack, deployment, env, convention, decisions) answerable straight from the KB, without spending time or tokens scanning source repos. People and agents share one source. Each class of question has one cheapest layer that answers it, and the agent has to pick the right layer.
Self-imposed constraints:
- Local-first, docs-as-code: Markdown + YAML frontmatter in git, no SaaS dependency. Reviewable
as a diff, committed with the
kb:prefix. - The running code is the referee: imported documentation is only a hint until it has been checked against code and pinned to a commit hash.
- No vector DB or embeddings at a scale of tens of projects × tens of files; structured progressive disclosure instead of fuzzy search.
- Never store a secret: only the name, the location (Secret Manager @ project / name), a hint and a SHA-256 prefix for comparison.
- Build cost = 0 LLM tokens: every derived artifact (index, graph) is generated deterministically from existing sources, and never edited by hand.
4. Solution — four layers, each for a class of question
4.1 Knowledge layer: docs-as-code KB by project × component × env
Answers: single facts about one project (GCP project, URL, runtime, deploy flow, secret location, convention) and the internal architecture of one component.
- One folder per project, each component mapping 1:1 to a code repo. Every doc carries controlled
frontmatter: runtime, GCP project, access control, deploy flow, integrations, secrets (pointers
only), tags,
sourceandverification. - Auto-generated index
index/projects.yaml(project × component × env × attribute): a single-fact question is answered by one file read, with no MCP involved. - Controlled vocabulary (
shared/vocabulary.yaml): the validator fails the build if frontmatter uses an unknown value (for examplecloudruninstead ofcloud-run). This is the precondition for the index and the graph to be generated deterministically. - ADRs in MADR 4.0.0 for every change with a decision behind it:
status(proposed / accepted / superseded…), and a 1–2 sentencesummaryindexed verbatim intoindex/decisions.mdso an agent can answer without opening the body. The validator warns on ADRs leftproposedfor over 30 days. - Clear scope rules: the KB holds architecture, tech stack, feature specs, env/infra, conventions and decisions. Effort, schedule, assignments and meeting notes never go in.
4.2 Import and verification layer
Answers: "is this fact still true, and true as of which commit?"
- A pipeline exports Confluence (by hub page, following links) and Google Drive into
_imports/raw/<slug>/, keeping originals intact and recording the source of each file. - A four-step process per new project: excavate the code repo (produce
verifieddocs from code) → import existing documentation → distil into the right component (allunverified) → a verification session against the code, recording every discrepancy. - Each doc carries
verification.statusandverified_against: <commit hash>. Theverification-report.mddashboard is generated automatically: verified coverage per project, files stale by source date, docs verified more than 90 days ago. - Verification surfaced real doc-vs-code drift: Confluence said "purge by URL" while the code purged everything by CP code (Akamai CCU v3); Confluence held a plaintext credential, which the KB refused to copy.
4.3 Graph layer: a two-layer context graph, partitioned per project
Answers: relationship questions — where does form X call into across repos, which files does rotating secret Y affect, which projects use package / middleware / host Z, "does X call Y".
- The knowledge layer is generated from validated frontmatter: Project, Component, EnvInstance, GcpProject, Runtime, Integration, Secret, ADR, SourceDoc.
- The code layer is generated from local repos with tree-sitter (10 extractors: TypeScript/JS, Next.js, Vue SFC, Express, Firebase Functions, Terraform HCL, Python, Contentful, env scan, HTTP calls): CodeFile, Symbol, Route, Middleware, EnvVar, HttpCall, TfResource.
- Bridge edges join the two layers: HAS_CODE, RESOLVES_TO (env var ↔ secret), CALLS (a frontend HTTP call resolved through an env var URL to another API's route), PROVISIONS (Terraform ↔ GCP project).
- Partitioned per project from the start:
_global.jsonholds only the knowledge layer plus a catalog layer (package + version, middleware, outbound hosts, Terraform resource types, env var names per component) so cross-project questions can be answered without ever exposing another project's code internals. An edge joining two different projects is a build error. - Token budget is first-class: each tool response stays under ~2,000 tokens; over budget, edges are grouped by type, files collapsed by directory, and Route / Symbol / EnvVar in the trace chain are kept before distant nodes are cut.
- A single
kb_refresh.pycommand rebuilds exactly the stale part (components with new commits, the index when docs change): 1–2 seconds when nothing is stale, tens of seconds when forcing a full rebuild of all 46 components. A git hook runs it after commit.
4.4 Access layer: a multi-client MCP server and a visual viewer
- MCP server
kb-graph, 8 tools, one per shape of question:kb_trace(form → route → controller → service across repos),kb_impact(what does changing or rotating X affect),kb_neighbors(one node's neighbours, for checking negatives),kb_find/kb_compare(cross-project over the catalog: "how many projects use Nuxt", "compare Express versions"),kb_sql(read-only SQL over the partition's SQLite for questions no tool covers),kb_entity,kb_status. The server reloads on mtime and reportscode_graph_stalewhen the local repo HEAD differs from the commit the graph was built from. - Registered for Claude Code, Gemini CLI, GitHub Copilot CLI and VS Code Copilot Chat from the
same binary. The agent's layer-selection rules (single fact → index; relationship → graph; never
conclude "no edge means no relation") are written once in
AGENTS.md;CLAUDE.md,GEMINI.mdandcopilot-instructions.mdare just pointers. - A force-directed viewer (canvas + d3-force, reading partition JSON directly): pick a partition, find a node, highlight 1- and 2-hop neighbours, an evidence panel with edges grouped by type, a legend to show and hide types, and state kept in the URL so a view can be shared. Used to present architecture to clients and to debug extractors.
4.5 Layer selection: which question goes to which layer
| Class of question | Answering layer | Why (per the benchmark in §5) |
|---|---|---|
| A single fact about one project: GCP project, URL, runtime, deploy, secret location | index/projects.yaml, no MCP | One file read, cheapest, verified against a commit |
| Internal architecture of one component: route → handler → middleware | KB docs (architecture.md), opening code when a specific line is needed | KB docs suffice (completeness 0.72); the graph currently loses here (0.64) because it has no route → handler function name edge and no per-route middleware |
| Cross-repo trace, impact of rotating a secret, isolation between projects | kb_trace, kb_impact | The graph recovers the completeness KB docs lose (0.44 → 0.72), gives file:line, and is cheaper than reading source |
| Cross-project: "how many projects use Nuxt / package X / middleware Y", version comparison | kb_find, kb_compare with project="*" | Cheapest and fastest of all three modes ($0.05 / 11 s), no grepping 35 repos, no exposure of other projects' code |
| Negatives: "does component X call Y" | kb_neighbors plus the phrase "per the static graph" | Without the graph, the agent confused same-named components across projects (2/3 and 1/3 runs); with it, 3/3 picked the right component |
| Decisions / ADRs | index/decisions.md | Summaries are indexed verbatim, so the body never has to be opened |
| Anything no tool covers yet | kb_sql | Read-only SQL over the partition's SQLite copy |
5. Results
Current scale (2026-09-25)
| Metric | Value |
|---|---|
| Projects / component repos | 15 / 51 (50 components have docs) |
| Distilled knowledge docs | 153 files, 135 verified against a commit hash (75% of the 180 files with a status) |
| Indexed ADRs | 27 project ADRs + 4 about the KB itself |
| Source documents imported | 184 files from Confluence / Drive |
| Code graph | 46 components, 10 tree-sitter extractors, 0 LLM tokens |
| Graph | ~25,000 nodes / ~45,500 edges across 15 partitions; _global 1,026 nodes / 2,679 edges |
| MCP | 8 tools, 4 clients, budget ≤ 2,000 tokens per response |
| Tests | 33 pytest (mini KB fixture + mini repo) |
Benchmark v2 — measured with real agent sessions, blind-graded
Method (2026-09-24): 9 questions representing 5 use-case groups, each run in 3 modes × 3 cold
runs (81 sessions), plus 3 long sessions of 5 consecutive questions × 3 modes (45 sessions). Model
claude-opus-5-5, Claude Code 2.1.281, 35 repos pinned to a HEAD. Ground truth was frozen before the
first run; completeness (0–1) was graded blind by a separate agent session that saw only the truth
and the anonymised answers; hallucinations were counted. Total cost of measuring and grading: $40.74.
Three modes: A Bare (reading source repos only), B KB docs (KB, no MCP), C KB + Graph (MCP). B − A is what the KB docs are worth on their own; C − B is what the graph adds.
| Question group | Metric | A Bare | B KB docs | C KB + Graph | How to read it |
|---|---|---|---|---|---|
| Inside one component (3 questions) | cost / time / compl | $0.19 / 48 s / 0.93 | $0.14 / 25 s / 0.72 | $0.13 / 25 s / 0.64 | KB docs suffice; the graph does not help and in fact loses |
| Trace / impact / isolation (3 questions) | cost / time / compl | $0.19 / 51 s / 0.72 | $0.12 / 23 s / 0.44 | $0.15 / 28 s / 0.72 | The graph recovers the completeness of reading source, cheaper and faster |
| Honest negatives (1 question) | compl | 0.11 | 0.11 | 0.33 | Without the graph: same-named components confused in 2/3 and 1/3 runs |
| Cross-project (2 questions) | cost / time / payload | $0.20 / 61 s / 5,604 tok | $0.14 / 23 s / 5,698 tok | $0.11 / 19 s / 3,372 tok | The catalog replaces grepping 35 repos, payload −2,327 tok |
| All 9 questions (median) | cost / time / turns / compl / hallu | $0.17 / 49 s / 10 / 0.70 / 2/27 | $0.13 / 24 s / 4 / 0.55 / 3/27 | $0.14 / 25 s / 5 / 0.65 / 2/27 | |
| Long 5-question session | session cost / per question from Q2 / compl / hallu | $3.65 / $0.84 / 0.92 / 3 | $2.73 / $0.64 / 0.72 / 2 | $2.06 / $0.48 / 0.79 / 1 | The graph is 44% cheaper than reading source, and it compounds with question count |
One concrete cross-repo trace question (contact form → route → controller → service across 2 repos):
KB docs scored 0.25 in completeness because they had the architecture but no file:line; the graph
scored 1.00 thanks to the CALLS edge from the frontend call site to the API's route.
ContactForm (contact-form.tsx:105)
--CALLS_HTTP--> POST {NEXT_PUBLIC_CONTACT_API_URL}jp/contact
--CALLS (envvar-resolved, dev+prod)--> route POST /api/jp/contact
--ROUTES_TO--> contactController.sendContactEmailJp
--IMPORTS--> zendeskService.ts
caveats: static trace — runtime middleware/DI and dynamic dispatch do not appear here
Honest conclusions from the numbers:
- The KB docs buy all of the savings: time −50%, turns 10 → 4, cost −25% compared with reading source — paid for with −0.15 completeness, because the agent stops at the documentation and never opens the code.
- The graph does not change total cost or time but recovers +0.10 completeness, concentrated in exactly the group it was designed for: trace / impact / isolation (+0.28, matching source reading) and honest negatives (+0.22). On cross-project questions the graph is both cheapest and fastest without losing quality.
- The graph genuinely loses on internal component architecture: route → handler edges stop at the file or module, with no function name and no per-route validator middleware; the agent trusts the graph and therefore does not open the code. This is recorded backlog, not a selling point.
- Long sessions are clearly cheaper with the graph: 5 consecutive questions at $2.06 versus $3.65 reading source, 1 hallucination versus 3 — and asking about one project right after another did not bleed facts from one into the other.
- Sources of error worth stating: n = 3 runs per cell, with a wide min–max; one entry in the ground
truth for the summary question disagreed with the verified KB, so most of that question's
hallucination flags were truth errors rather than the model inventing things; and the results hold
only for the pairing of Claude Code 2.1.281 with
claude-opus-5-5.
Operational impact
- Every agent session, opened in any repo, gets the same context through a
CLAUDE.local.mdpointer and MCP; nothing is fed in by hand any more. - Client questions like "which projects run Cloud Run in prod" are answered from the index in one read; "how many projects use Nuxt / still use Akamai" is answered by one tool call over the catalog, without opening a repo.
- 27 ADRs record the root causes and trade-offs of security audits, CVE patches and CI migrations —
including findings that reversed the original design (for example,
npm audit = 0does not mean clean because it misses advisories that exist only on GitHub;min-release-agedoes not apply tonpm ci). - The verification report names exactly which project's documentation is still stale, so verification can be prioritised instead of suspecting everything.
6. Design decisions worth stating, and why
| Decision | Rejected alternative | Reason |
|---|---|---|
| Frontmatter with a controlled vocabulary + auto-generated index | Full-text search / vector DB | Small scale, and most questions are structured facts; deterministic, no embeddings needed, reviewable as a diff. |
| Split layers by class of question, agent must choose the layer | A single access point (MCP only, or docs only) | The benchmark shows each layer wins on a different group; collapsing to one either loses completeness or costs turns. |
| Derive-only graph, 0 LLM tokens | Using an LLM to extract relations from code | Build cost is zero and reproducible; when it is wrong you fix the extractor, not the graph by hand. |
| Per-project partitions + a separate catalog layer | One global graph | Data isolation between clients, while cross-project questions are still answerable through the catalog without exposing code. |
| A hard token budget on every tool response | Returning the full subgraph | The agent gets just enough, without blowing up its context; collapsing happens in priority order rather than by arbitrary truncation. |
| Negative conclusions must say "per the static graph" | Treating a missing edge as a missing relation | Static analysis does not see dynamic dispatch or concatenated URLs; the build report lists unmatched and dynamic calls to check. |
Agent rules written once in AGENTS.md | One rulebook per agent | Multiple clients (Claude / Gemini / Copilot) share one set of rules, so they cannot drift apart. |
Dropped the commits field in ADRs, kept prs | Recording commit hashes | Hashes on a feature branch change after squash or rebase, producing "fill in after" notes that hang around for months. |
7. Lessons
- Distilling and verifying buys the savings; the graph buys the correctness. KB docs halve time and turns for every question; the graph is only worth it for relationship and cross-project questions. Do not build a graph before the docs are clean, and do not force the graph to answer what the docs already cover.
- Measure with ablation and blind grading before claiming anything. The first measurement (4 questions, not blind-graded) gave a rosier conclusion than reality; v2 with frozen ground truth exposed where the graph genuinely loses, on internal component architecture. Unfavourable results went into the benchmark verbatim and became backlog.
- Total session usage reflects the harness's turn count, not how much information the tools returned. The right quantities to compare are cost, turns, retrieved payload and blind-graded completeness.
- The source of hallucination was not "the model made it up" but same names across projects and a wrong truth: without the graph, the agent answered about a same-named component in a different project; and one batch of hallucination flags turned out to be ground truth pinned to the code while the rate limit actually lived in Cloud Armor.
- A controlled vocabulary was the cheapest investment with the largest return: it is what makes the index, the graph, the validator and the dashboard all generate deterministically.
- Staleness has to be visible:
code_graph_stale,kb_index_stale, ADRsproposedfor over 30 days, docs verified more than 90 days ago. A knowledge system that cannot report its own age will drift exactly like Confluence did.
8. Tech stack
Markdown + YAML frontmatter · Python 3.13 · tree-sitter (language pack: TypeScript/TSX/JS, Python, HCL, Vue, YAML, Dockerfile) · networkx · SQLite · MCP Python SDK (stdio) · pytest · HTML canvas + d3-force · GitHub / GHES · GCP (Cloud Run, GAE, Firebase, Cloud Functions, Secret Manager) · Terraform · Contentful · Akamai.
