Skip to content

Architecture

Spec Engine is a derived-index pipeline. Truth lives in git as spec-engine/<KEY>/SPEC.json files plus @spec tags in code. Everything else is computed from those two inputs and can be thrown away.

CLI (citty) HTTP (Hono) MCP (stdio)
engine/src/cli.ts engine/src/server/ engine/src/server/mcp.ts
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Surfaces: parse input, call an operation, render │
│ engine/src/commands/*.ts server/api.ts server/mcp.ts │
└───────────────────────────┬──────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Operations: one function per verb, typed input, typed │
│ result, no process exit, no console │
│ engine/src/operations/*.ts │
└───────────────┬──────────────────────────┬───────────────┘
│ │
write path│ read path│
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ authoring/ parser/ │ │ indexer/pipeline.ts runIndex │
│ one seam: │ │ discover → parse → scan → │
│ validateAndWrite │ │ validate → one write tx │
│ (no bun:sqlite) │ │ then check/ guard/ map/ … │
└───────────┬────────────┘ └──────────────┬───────────────┘
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ CANONICAL TRUTH (git) │──▶│ DERIVED INDEX (disposable) │
│ spec-engine/*/SPEC.json│ │ .spec-engine/index.sqlite │
│ + @spec tags in code │ │ via storage/sqlite.ts only │
└────────────────────────┘ └──────────────────────────────┘
ComponentResponsibilityPath
CLI rootRegister subcommands with lazy imports so the compiled binary starts fastpackages/engine/src/cli.ts
Command layerOne file per subcommand: flags, platform-dir resolution, exit codes, text or --json renderingpackages/engine/src/commands/
OperationsOne function per verb. The CLI, the API, and MCP all call thesepackages/engine/src/operations/
DiscoveryTake membership and shape from platform-map, read each member’s pin, derive the platform version. See Platform Mappingpackages/engine/src/indexer/discover.ts
ParserRead and validate SPEC.json envelopes into typed recordspackages/engine/src/parser/
ScannerWalk member repos, extract @spec tags, decide implements vs verifies from the pathpackages/engine/src/scanner/
Index pipelinerunIndex: the one composition of discover, parse, scan, validate, writepackages/engine/src/indexer/pipeline.ts
StorageThe only bun:sqlite importer. Open, cold reset, upserts, queries, build_idpackages/engine/src/storage/sqlite.ts
SchemaTables, the FTS5 table, and the coverage, drift, term_drift, provenance_matrix viewspackages/shared/src/schema.ts
Storage contractThe Storage interface and row types every surface reads throughpackages/shared/src/storage.ts
ChecksDiagnostic producers, SQL and non-SQL: grammar, file refs, removal, status flips, proofs, CODEOWNERSpackages/engine/src/check/
Loss guardDiff the requirement derivation at a git ref against the worktreepackages/engine/src/guard/
AuthoringDomain scaffolds, next-id allocation, @ file refs, statement grammar at write timepackages/engine/src/authoring/
ProjectionsOne directory per computed answer, each with a format.ts for text modemap/, query/, resolve/, relations/, propagation/, provenance/, gate/, results/
HTTP APIHono routes over Storage, read routes always on, write routes flag-gatedpackages/engine/src/server/api.ts
MCP serverEleven tools and one prompt, same JSON as the CLI’s --jsonpackages/engine/src/server/mcp.ts
WebappServer-rendered pages. No filesystem, no sqlite, no engine importpackages/webapp/src/
TrackerOptional read-only Linear adapter with a cache sidecarpackages/tracker/src/
Docs siteThis site, served offline by spec docspackages/site/

An operation takes typed input and returns typed data or a typed refusal. It never exits the process and never prints. Each surface maps a refusal to its own vocabulary: the CLI to exit 2, the API to a status, MCP to an error result.

OperationInputResultCLIHTTPMCP
querystorage, text, limitranked hits, or usage on an FTS5 grammar errorspec queryGET /api/queryspec_query
resolveFilesstorage, platform-relative filesthe requirements tagged in themspec resolveGET /api/resolve?files=spec_resolve
reqTagsstorage, requirement idevery tag site, and whether the id is knownspec resolve --reqGET /api/resolve?req=spec_req_tags
propagationstorage, requirement idper-member migration statespec propagationGET /api/propagation/:idspec_propagation
coverageReportstorageper-domain rollupGET /api/reportspec_coverage_report
checkstorage, platform dir, results file, base ref, flagsdiagnostics, build_id, red or greenspec checkspec_check
nextIdplatform dir, domain key or prefixthe resolved key and next unused id, or usagespec reqspec_next_id
mintplatform dir, key, statement, why, lives-in, issuethe new id and file, or not_found / usage / invalid_domain_filespec req --textPOST /api/requirements
amendplatform dir, id, fields, a fresh-tags callbackthe changed fields, or not_found / conflict / invalid_domain_filespec amendPUT /api/requirements/:id
supersedeplatform dir, id, successor statement and fields, a fresh-tags callbackthe successor id, the domain version, the retag worklist, or not_found / conflict / usage / invalid_domain_filespec supersedePOST /api/requirements/:id/supersedespec_supersede
moveplatform dir, id, target domain key, optional successor fields, a fresh-tags callbackboth files, both versions, the retag worklist, or usage / not_found / conflict / invalid_domain_filespec move
deprecateplatform dir, id, reason, a fresh-tags callbackthe file and the tags still bound, or not_found / conflict / invalid_domain_filespec deprecatePOST /api/requirements/:id/deprecatespec_deprecate
mintTerm, listTerms, reviseTerm, confirmTermplatform dir, term fields or idsthe term id, the store rows, the bumped version, or the re-pinned citation; not_found / conflict / invalid_domain_filespec term
coverageMatrixstoragerequirement × repo rowsspec mapGET /api/coverage
getRecordstorage, requirement idone full Requirement row, or nullspec getGET /api/requirements/:idspec_get
listRecordsstorage, optional key and statusevery Requirement row in (key, seq) orderspec listGET /api/requirementsspec_list
relationsstoragerelates linksspec relationsGET /api/relations
provenancestorage, optional issue idprovenance matrix rowsspec provenanceGET /api/provenance
guardstorage, platform dir, git refthe losses a change is about to causespec guard
gatestorage, platform dir, repo, requirement idthe gate outcome and build_id, or usage for an unknown repospec gate
resolveMemberPin, writeMemberConfigrepo dir, pin override, forcethe pin and its source; wrote or already configured, or usage / conflictspec init
newDomain, listDomainsplatform dir, keythe scaffolded file, or usage / conflict / invalid_domain_file; the { key, scope } rowsspec domain
writeGlossary, migrateGlossary, checkGlossaryplatform dirthe generated count, the migrated count or a skip, clean or driftspec glossary

Reads take an open Storage handle because the surface owns the handle’s lifetime: the CLI and MCP open one per call through withIndex, the API keeps one for the life of the server. Writes take the platform directory because they edit SPEC.json files. A lifecycle write takes a fresh-tags callback for its worklist, so a one-shot surface cold-rebuilds a throwaway index while the API re-indexes into the handle it keeps. Every guard runs before the first byte is written (REQ-038 ), and operations-parity.test.ts proves each surface writes the same envelope.

Each of these exists exactly once, and a grep fence in scripts/arch-fences.sh fails CI if a second copy appears. Every fence label starts with the id of the requirement the fence enforces, and the fence function carries that id as its @spec tag.

SeamWhat goes through itFence
runIndexEvery index build. One transaction, one build_idreviewed, not fenced
validateAndWriteEvery write of a SPEC.json file, a test’s fixture included; a planted defect under src/testing/fixtures/ is the only file written by handSCHM-026 every SPEC.json write goes through validateAndWrite
bun:sqlite importOnly storage/sqlite.ts may import itSCHM-025 bun:sqlite outside storage/sqlite.ts, SCHM-025 bun:sqlite outside the engine
Storage interfaceEvery read the CLI, API, and MCP makethe webapp import fence in packages/webapp/test/import-fence.test.ts
OperationsThe write seam and the id allocator are reached only through operations/; no command, route, or tool imports them. A test’s fixture builder (src/testing/platform.ts) is one more caller of the same operationsSCHM-027 write seam under operations
No model callsThe engine never runs an LLM. The MCP authoring prompt is a text templateAUTHOR-008 llm-free engine
Derived versionsNo authored specVersion on requirement domainsSCHM-020 no authored specVersion
Generated docsGLOSSARY.md and the TAXONOMY charters are regenerated, never hand-editedCHCK-020 glossary round-trip, CHRT-007 charters generated from the envelopes

spec map, spec check, spec query, and the other read commands:

  1. Resolve platformDir and assert it contains spec-engine/. A non-platform directory exits 2 and leaves no .spec-engine/ behind.
  2. Open the index. --fresh, check --ci, and gate cold-reset it first.
  3. If the index is missing, run runIndex. Otherwise trust it and warn on stderr when a SPEC.json is newer than the index.
  4. Query the views.
  5. Render text to stdout, or the sorted --json array. Warnings and guidance go to stderr.

spec req, spec amend, spec supersede, spec move, spec deprecate, spec term:

  1. Resolve the target domain and run every guard before writing a byte.
  2. Allocate the next id from the filesystem, never from the index.
  3. Judge new statement text against the EARS grammar when the domain declares one.
  4. Write the whole envelope through validateAndWrite.
  5. Lifecycle commands then reindex fresh and print the retag worklist.
  1. spec serve composes the engine’s API routes and the webapp’s pages onto one Hono app, bound to 127.0.0.1.
  2. Pages call /api/* in-process. There is no loopback fetch.
  3. A feature whose flag is off answers 404 on its endpoints and renders a placeholder page.

The six headline invariants (cold-rebuild identity, check --ci builds fresh, one shared schema, spec defects become diagnostics, one model one seam, drift is a view) are catalogued in spec-engine/TAXONOMY.md with the requirement id that records each.

Don’tWhyDo instead
Write a value into the index that cannot be re-derivedA cold rebuild must give an identical resultPut truth in SPEC.json or a tag; express the report as a view
Import bun:sqlite in a second fileForks the storage contract and reds the SCHM-025 fenceExtend Storage and implement it in storage/sqlite.ts
Bun.write a spec file directlySkips schema validationMutate the envelope and call validateAndWrite
Use a ticket number as a requirement id or in a tagTickets are temporary; requirements outlive themMint with spec req, record the ticket with --issue
”Fix” a defect under fixtures/Those defects are what spec check exists to catchLeave them. Tags under fixtures/ never index
Trust a warm index after editing specs or tagsRead commands only build when the index is missingPass --fresh, or use check --ci / gate
  • Exit codes are the contract: 0 success (including empty results), 1 data failure, 2 usage or environment error.
  • Storage failures map to a named hint. A sandboxed process without file locks gets SQLITE_IOERR_VNODE; the CLI says so and /api/* returns a structured 503.
  • Every guard runs before the first write, so a rejected move or supersede never half-applies.
  • Outside git, check deletion detection and spec guard warn and exit 0.