Architecture
whodar turns scattered work data into a queryable map of who knows what and which channel to ask in. It is built in layers, each with one job, so a new data source or a new way to ask is a small, isolated addition.
Data flow
A connector reads a source and emits records. The index merges records into a graph of people, teams, orgs, topics, and channels, and builds keyword postings and optional embeddings. A resolver answers a query against the index. A frontend calls a resolver and presents the answer.
source -> connector -> records -> index -> resolver -> frontend
Layers
Connectors implement one method, Fetch, returning normalized records. Ten exist today: org-CSV, Slack, GitHub, Jira, Confluence, PagerDuty, git history, CODEOWNERS, Microsoft Graph, and a JSON import. Each new source is one connector and changes nothing else.
The model is the normalized graph: people, teams, orgs, topics, and channels, with weighted edges. People merge across sources by email, so one human is one entry.
The index holds the graph plus a keyword posting list and, when built with embeddings, a vector per person and channel. It ranks people and channels for a query and explains why each matched.
Resolvers answer a query and share one Answer shape. The keyword resolver needs no model. The semantic resolver ranks by embedding similarity. The LLM resolver retrieves candidates, ranks and summarizes with a local model, and stays grounded in the real candidates.
Policy governs model egress. The default is strict: answers never leave the machine. Redacted admits only known providers and only anonymized numbered candidates. An organization can pin the policy from a locked system file that user flags and environment variables cannot loosen.
Frontends are thin and share the engine: a CLI, a localhost web UI, a Slack bot over Socket Mode or the Events API, and an MCP server over stdio for agent clients such as Claude Code and Claude Desktop.
Adding a source
Implement the connector Source interface, returning records for people or channels, and add a case to the index command. The index, resolvers, web UI, and bot then work with the new data without change. Every source after the first was added this way.
Measured scale
The index and the episode store are each one JSON file, read whole by every
command and written whole by every index run. Measured on an M-series laptop
with the opt-in scale suite
(WHODAR_SCALE=1 go test ./internal/simorg/ -run TestScale):
| Size | People | Conversations | Ingest | Index file | Episode file | Save | Cold start | Ask | Heap |
|---|---|---|---|---|---|---|---|---|---|
| Team | 50 | 520 | 0.3s | 146KB | 784KB | 86ms | 8ms | 1ms | 4MB |
| Department | 250 | 3,660 | 1.7s | 892KB | 4MB | 283ms | 41ms | 1ms | 24MB |
| Company | 1,000 | 15,150 | 5.9s | 4MB | 13MB | 1.0s | 166ms | 3ms | 95MB |
| Enterprise | 5,000 | 48,400 | 20s | 17MB | 40MB | 3.7s | 580ms | 12ms | 318MB |
| Huge | 10,000 | 151,000 | 57s | 44MB | 119MB | 9.4s | 1.7s | 29ms | 902MB |
Answering stays in tens of milliseconds throughout, so the query path is not what limits size. Three other costs are.
Writing is the worst of them, and it is not I/O. Saving ten thousand
people takes 9.4 seconds for a 44MB file, while the larger 119MB episode file
writes in under a second. The difference is that Save rebuilds the entire
posting index from scratch every time, re-encoding every term for every person.
Save runs about six times the cold start at every size measured.
That has a consequence worth stating plainly, because the incremental refresh section below is easy to over-read. Fetching is bounded by the delta: Jira, Confluence, GitHub, and Slack query for what changed since the stored watermark, and git resumes from the last commit read per repository. The write is not. A refresh that reads three new commits still re-encodes and rewrites the whole index, and pays the full save cost in the table above. Reading a delta and writing a corpus is the current shape.
For the work whodar is pointed at today this is affordable. An assessment is a
batch run where the save is ten seconds of a minute-long job, and an
organization of a few hundred people saves in under a third of a second. It
becomes worth fixing for a long-running serve deployment at a large
organization doing scheduled refreshes, and the fix is to reuse the encoding of
terms that did not change rather than to change the storage format.
Holding the graph in memory is the second cost: 902MB at ten thousand people, and every process holds its whole graph, so several open at once multiply it.
Changing the storage format is not the cheap answer, despite the file being the obvious suspect. The at-rest encryption works precisely because the file is one opaque blob: the vault seals plaintext bytes with AES-256-GCM and writes the result. An embedded database would mean either giving up whole-database encryption or taking a cgo dependency, and cgo would break the cross-compiled release that ships six architecture and platform pairs. Both of those are load-bearing, so the format stays until something forces the trade.