Tran Manh Tuan

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)

PainWhat it looked like in practice
Updating a featureEvery 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 agentsEvery 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 decisionsRoot 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, source and verification.
  • 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 example cloudrun instead of cloud-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 sentence summary indexed verbatim into index/decisions.md so an agent can answer without opening the body. The validator warns on ADRs left proposed for 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 verified docs from code) → import existing documentation → distil into the right component (all unverified) → a verification session against the code, recording every discrepancy.
  • Each doc carries verification.status and verified_against: <commit hash>. The verification-report.md dashboard 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.json holds 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.py command 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 reports code_graph_stale when 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.md and copilot-instructions.md are 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 questionAnswering layerWhy (per the benchmark in §5)
A single fact about one project: GCP project, URL, runtime, deploy, secret locationindex/projects.yaml, no MCPOne file read, cheapest, verified against a commit
Internal architecture of one component: route → handler → middlewareKB docs (architecture.md), opening code when a specific line is neededKB 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 projectskb_trace, kb_impactThe 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 comparisonkb_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 / ADRsindex/decisions.mdSummaries are indexed verbatim, so the body never has to be opened
Anything no tool covers yetkb_sqlRead-only SQL over the partition's SQLite copy

5. Results

Current scale (2026-09-25)

MetricValue
Projects / component repos15 / 51 (50 components have docs)
Distilled knowledge docs153 files, 135 verified against a commit hash (75% of the 180 files with a status)
Indexed ADRs27 project ADRs + 4 about the KB itself
Source documents imported184 files from Confluence / Drive
Code graph46 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
MCP8 tools, 4 clients, budget ≤ 2,000 tokens per response
Tests33 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 groupMetricA BareB KB docsC KB + GraphHow 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.64KB 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.72The graph recovers the completeness of reading source, cheaper and faster
Honest negatives (1 question)compl0.110.110.33Without 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 tokThe 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 sessionsession 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 / 1The 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.md pointer 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 = 0 does not mean clean because it misses advisories that exist only on GitHub; min-release-age does not apply to npm 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

DecisionRejected alternativeReason
Frontmatter with a controlled vocabulary + auto-generated indexFull-text search / vector DBSmall 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 layerA 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 tokensUsing an LLM to extract relations from codeBuild cost is zero and reproducible; when it is wrong you fix the extractor, not the graph by hand.
Per-project partitions + a separate catalog layerOne global graphData isolation between clients, while cross-project questions are still answerable through the catalog without exposing code.
A hard token budget on every tool responseReturning the full subgraphThe 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 relationStatic 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.mdOne rulebook per agentMultiple clients (Claude / Gemini / Copilot) share one set of rules, so they cannot drift apart.
Dropped the commits field in ADRs, kept prsRecording commit hashesHashes 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, ADRs proposed for 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.