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

PathWhat it is
apps/apiNestJS API — auth, projects, pipeline, agents, the knowledge engine.
apps/webNext.js — landing, wizard, dashboard, board, spec review, knowledge, runs.
apps/runnerThe self-hosted daemon that claims and executes jobs.
clispecd — Go, a single static binary.
packages/sharedSpec lifecycle, EARS rendering, the model rate card, cost metering.
packages/dbDrizzle schema plus plain-SQL migrations (Postgres + pgvector).
packages/templatesAGENTS.md, CLAUDE.md and the knowledge/ scaffold.
evalsQuality grading against independent oracles — see Evals.

The data flow

merge → index → retrieve → draft → gate → build → merge
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 webhook

Why 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:

  1. A document citing RunnerJobsService.claim() resolves to the real symbol.
  2. Retrieval can serve the function's actual source as a citable excerpt, fenced in the prompt and cited as path#Class.method.
  3. 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.