DOCS · REFERENCE
Architecture
What runs where, why Postgres is the only runtime dependency, and how an index run stays atomic.
On this page
Boring on purpose. Postgres is the only runtime service, and the decision that keeps it that way is written down rather than assumed.
The pieces
| Path | What it is |
|---|---|
apps/api | NestJS API — auth, projects, pipeline, agents, the knowledge engine. |
apps/web | Next.js — landing, wizard, dashboard, board, spec review, knowledge, runs. |
apps/runner | The self-hosted daemon that claims and executes jobs. |
cli | specd — Go, a single static binary. |
packages/shared | Spec lifecycle, EARS rendering, the model rate card, cost metering. |
packages/db | Drizzle schema plus plain-SQL migrations (Postgres + pgvector). |
packages/templates | AGENTS.md, CLAUDE.md and the knowledge/ scaffold. |
evals | Quality grading against independent oracles — see Evals. |
The data flow
your repository (git = source of truth)
knowledge/**.md src/**
│
│ merge webhook → queued row + NOTIFY
▼
indexer chunk · embed · links · symbols · coupling
one transaction, shrink-guarded
▼
Postgres docs · chunks · links · code nodes · coupling
▼
retrieval RRF → doc-graph hop → code snippets
▼
SpecAgent drafts, cites, four verdicts
▼
[ named human approves ]
▼
build on spec/<id> branch → PR / MR — you merge
└──────────────────────────────────────────► back to the webhookWhy Postgres is the only runtime dependency
Index runs are queued rows woken by Postgres `LISTEN/NOTIFY` — there is no broker. A webhook returns in the time it takes to write one row; a worker in the API process is woken by the notification and does the work. The poll interval exists only as a backstop for a dropped listen connection.
- Jobs are claimed with
FOR UPDATE SKIP LOCKED, so two workers polling the same queue cannot take the same row. - Jobs abandoned by a dead worker are reclaimed by lease, not by a heuristic about how long is too long.
- Run logs stream live over SSE, across API instances.
The decision to remove the queue that used to sit here is recorded at knowledge/decisions/0008-remove-unused-queue.md. One fewer service is one fewer thing to operate, monitor and explain.
Indexing is deterministic and atomic
Every write of an index run lands in one transaction, guarded two ways:
- A shrink guard
- A run that would gut the index is refused — an empty listing against a non-empty index is rejected at any size. A scanner that silently returned nothing must not be able to erase a knowledge base.
- A provenance check
- A run that drops edges belonging to documents it never touched is rolled back. A run may only affect what it actually read.
What to re-index is decided by a per-document content sha plus an extractor fingerprint. Change the chunker or the embedder and unchanged documents re-embed — because two vector spaces in one index is incoherence, not staleness.
The code index
specd indexes the repository's file tree and its declarations for TypeScript, Go and Python — a line-based tier, graded against real compilers (see Evals). That is what makes three things possible:
- A document citing
RunnerJobsService.claim()resolves to the real symbol. - Retrieval can serve the function's actual source as a citable excerpt, fenced in the prompt and cited as
path#Class.method. - When the code moves on without the document, both the doc's health and the spec's citation can say so.
Drift is measured against the code
Doc↔code coupling is mined from a bounded window of git history. The signal reads "6 commits touched `apps/api/src/runners/` since this doc last moved with it" — which names the code to go read. A 90-day timer only measures time passing.
Stack
Next.js · NestJS · Postgres + pgvector · Anthropic SDK · Go CLI. specd's own design notes live in knowledge/architecture.md, and every decision behind this page is an ADR under knowledge/decisions/ — the product eats its own food.