Skip to content

How it works

The app reads Markdown, cuts it into anchored passages, extracts decisions from those passages, and saves the results as YAML records beside your documents. Everything you see in the UI, in search answers, and in MCP results is computed from those files.

Two kinds of data come out of the pipeline. Your Markdown and the .decision/ YAML records are canonical: you commit them, and they are readable without the app. The SQLite full-text index, the vector index, and compiled scenarios are disposable: the backend rebuilds or recomputes them from the canonical files.

How repository Markdown becomes decisions, a graph, and answersPipeline in ten steps. 1: Markdown files, canonical and never written. 2: discovery skips generated, metadata, and configured paths. 3: passages become evidence anchors with path, heading path, passage hash, and context. 4: extraction by mock rules or a LiteLLM model, with one schema-feedback retry for invalid deltas. 5: validation against the record-v1 schema on save and references on read. 6: canonical .decision/ YAML, one file per record. 7: human review reads records and appends judgments, including undo. 8: the compiler applies selected option deltas to the base graph per request and reports conflicts and invalid assumptions. 9: disposable SQLite FTS5 and vector indexes, rebuilt from the YAML. 10: consumers through FastAPI: the map and What-if, why-search, scenario diffs, and five MCP tools.MARKDOWN TO DECISIONSINFERRED RECORDSREADAPPENDREBUILDCANONICAL1Markdown filesADRs, RFCs, design docs,READMEs, any headings;read, never written2Discoverydiscover_markdown()skips generated, metadata,and configured paths3Passages, anchorspassages()path, headings, hash,context; relocatable4ExtractionLiteLLM, one fix-up retrymock rulesmodel5Validationrecord-v1 schema andrecord rules on save;references on readCANONICAL6.decision/ YAMLone YAML file per record,inferred and human,atomic replace per file7Human reviewconfirm, reject, correct;each judgment appends;undo appends tooPER REQUEST8Compilercompile_scenario()graph + option deltasconflicts, assumptionsDISPOSABLE9IndexesSQLite FTS5 passages,Qdrant or local vectors;rebuilt after each scan10ConsumersFastAPIMap, What-if, diffsWhy-search, groundedMCP: 5 read-only toolsCanonical: kept in GitDisposable: rebuildableProcess stepSWITCHPLINTHPIPELINEHow repository Markdown becomes decisions, a graph, and answersPipeline in ten steps. 1: Markdown files, canonical and never written. 2: discovery skips generated, metadata, and configured paths. 3: passages become evidence anchors with path, heading path, passage hash, and context. 4: extraction by mock rules or a LiteLLM model, with one schema-feedback retry for invalid deltas. 5: validation against the record-v1 schema on save and references on read. 6: canonical .decision/ YAML, one file per record. 7: human review reads records and appends judgments, including undo. 8: the compiler applies selected option deltas to the base graph per request and reports conflicts and invalid assumptions. 9: disposable SQLite FTS5 and vector indexes, rebuilt from the YAML. 10: consumers through FastAPI: the map and What-if, why-search, scenario diffs, and five MCP tools.MARKDOWN TO DECISIONSINFERRED RECORDSREBUILDCANONICAL1Markdown filesADRs, RFCs, design docs, READMEs;any headings, no template;read, never written2Discoverydiscover_markdown()skips generated and metadata foldersplus configured ignore paths3Passages and evidence anchorspassages(), evidence_anchor()path, heading path, passage hash,context; relocated on rescans4Extractioninvalid deltas get one schema-feedback retrymock rulesLiteLLM model5Validationrecord-v1 schema and record ruleson every save; cross-recordreferences on every readCANONICAL6.decision/ YAMLfile per record,inferred and human,atomic per file7Human reviewconfirm, reject,correct, undo;always appendsPER REQUEST8Compilerbase graph + deltas;conflicts, invalidassumptionsDISPOSABLE9IndexesSQLite FTS5,Qdrant or localvectors; rebuilt10ConsumersFastAPI and 5 MCP toolsMapWhat-ifDiffsWhy-searchCanonical: kept in GitDisposable: rebuildableProcess step
Full size: SVG, PNG.

File names refer to modules in the backend Python package under backend/src/.

# Stage Takes in Produces Code Kept as
1 Markdown files Your repository Source text none Canonical; never written
2 Discovery Repository root, .decision/config.yaml Sorted list of .md files discover_markdown() in repository.py Not stored
3 Passages and evidence anchors Markdown files, earlier evidence records One evidence record per passage passages(), relocate_evidence() in repository.py; evidence_anchor() in domain.py Canonical evidence/
4 Extraction Current passages Inferred entities, aliases, claims, decisions with option deltas scan(), _mock_decisions(), extract_with_provider() in service.py; extract_with_litellm() in providers.py Saved only through stage 5
5 Validation One candidate record The record on disk, or an error PortableRepository.save(), validate_candidate(), validate_references() in repository.py; validate_record() in domain.py Not stored
6 .decision/ YAML Validated records One YAML file per record PortableRepository.save() Canonical
7 Human review A claim, alias, or identity pair An appended review or undo record review_claim(), correct_claim(), review_alias(), correct_alias(), review_exact_identity_candidate(), undo_review() in service.py Canonical
8 Compiler Records and option selections Scenario graph, changed IDs, conflicts, invalid assumptions, identity groups graph(), _project_scenario() in service.py; effective_claims(), compile_scenario(), identity_projection() in domain.py Computed per request
9 Indexes Current evidence, base graph SQLite FTS5 table, passage vectors _rebuild_indexes() in service.py; SearchIndex in store.py; QdrantVectorIndex in service.py; LocalVectorIndex in local_vectors.py Disposable
10 Consumers API and MCP requests Map data, What-if results, scenario diffs with downstream impact, search answers, tool results, each with an input receipt api.py, search() in service.py, scenario_analysis.py, receipt.py, mcp_tools.py Not stored

Any Markdown counts: ADRs, RFCs, design notes, READMEs. The app needs no template or special syntax, and it never writes to these files.

discover_markdown() finds every *.md file under the repository root and returns them in sorted order. It skips .git, .decision, .tasks, virtual environments, node_modules, dist, build, coverage output, and tool caches. Any folder or file name listed under extraction.ignore in .decision/config.yaml is skipped too. extraction.include: [.tasks] opts the .tasks folder back in; the other default exclusions stay.

passages() splits each file at headings and blank lines. A chunk of at least 20 characters becomes a passage with its heading path, a display line, and up to 160 characters of neighboring text on each side.

evidence_anchor() derives the evidence ID from the source path, the heading path, and a SHA-256 hash of the normalized passage (lowercased, whitespace collapsed). The scan saves each passage as an evidence record together with the file’s Git blob hash and content hash.

On a rescan, relocate_evidence() keeps an earlier evidence ID when exactly one passage in the same file has the same hash. Failing that, it accepts the passage whose heading path and surrounding text still match, provided no other passage matches as well. Anything else marks the old anchor unresolved for review. Line numbers are display hints, never identity.

Two extractors write the same record shapes. Every record they create has epistemic_state: inferred and cites evidence IDs.

The mock extractor runs on POST /api/v1/discovery/scan. It matches a fixed catalog of technology and service names (Kafka, PostgreSQL, SQS, Redis, Kubernetes, and the sample’s services) and sentences of the form “we chose X because”. Sections in the same file headed “Alternative” or “Considered”, or containing phrases such as “would reduce”, supply the other options. _mock_decisions() writes a typed delta for each option. These rules are tuned to the bundled sample.

Model extraction runs on POST /api/v1/extraction/run. extract_with_provider() refreshes the evidence records first, then sends every current passage with its ID, plus the known entity IDs, to extract_with_litellm(), which asks the model for JSON. When the reply contains malformed delta operations, more than one selected option, or entity references that do not resolve, the backend sends one retry that lists the problems and the valid entity IDs. The model must quote its source: an entity, decision, or option is kept only when its evidence_quote matches exactly one passage.

Every write goes through PortableRepository.save(). validate_record() checks the decision/v1 schema version, record type, ID format, epistemic state, confidence range, unique option IDs, a declared selected_option, and supported delta operations. The record is then validated against .decision/schema/record-v1.schema.json. Inferred claims and aliases must cite evidence, and a same_entity_as claim must link two existing entities of the same kind.

Cross-record checks in validate_references() run whenever records are read. Each evidence record must point to a file inside the repository and match its stored passage hash, and every reference to evidence, entities, and options must resolve. Model-proposed decisions also pass validate_candidate(), which runs the cross-record checks before the write. When only a decision’s generated deltas fail, the decision is saved with empty deltas and an extraction_review block that records the rejected operations and the validator errors.

Each record is one YAML file under a folder for its type: entities/, evidence/, aliases/, claims/, relationships/, decisions/, scenarios/, and connections/. save() writes a temporary file in the same folder, flushes and fsyncs it, and atomically replaces the target, so an interrupted write leaves either the complete old record or the complete new one. A scan makes many such writes. If the scan raises an exception, _run_scan() restores .decision/ from a snapshot taken at the start. The portable format reference describes each record.

You confirm, reject, assert, or correct a claim or alias on the Facts page or through the API. Each judgment is a new record marked reviewer: human. It names the original record (reviewed_claim or reviewed_alias) and lists the earlier judgments it supersedes. The machine inference stays on disk unchanged. undo_review() appends a record whose undoes field cancels the current judgment at the head of a review chain, which brings back the previous judgment or the original inference. Exact-name identity candidates go through review_exact_identity_candidate(), which appends a same_entity_as claim. Re-extraction leaves human-reviewed records untouched.

The compiler runs on every graph, scenario, entity, and search request; nothing it produces is written to disk. graph() loads the records, drops undone judgments, and keeps the effective claims: an active human judgment replaces the inference about the same subject, predicate, and object, and a rejection removes it. Entities, structural claims (uses, publishes_to, calls, depends_on, stores_in, communicates_with, implements), and relationship records form the base graph.

_project_scenario() starts from each decision’s selected_option, overlays any What-if selections, and calls compile_scenario(). That function applies each selected option’s operations in decision-ID order and records which IDs were added, removed, or changed. It reports a conflict when two decisions write the same entity or relationship, when an expect precondition fails, or when an operation adds an element that exists or removes one that does not. It reports an invalid assumption when a relationship’s endpoint is missing from the scenario, or when an unselected option refers to an element the scenario removed. identity_projection() groups entities only through active human-confirmed or asserted same_entity_as claims; exact-name matches and inferred links stay suggestions.

After a scan or a model extraction, _rebuild_indexes() rebuilds both indexes from the current evidence and the base graph. SearchIndex keeps an SQLite FTS5 table of passages, each tagged with the IDs of entities whose names it contains, in the backend’s cache folder. The vector index embeds every passage. With exactly one connection that sets embedding_model and capabilities.embeddings: true, it uses embed_with_litellm(). Otherwise it falls back to hashed_embedding(), a deterministic token hash that is not semantic retrieval; the API reports which mode is active.

Under Docker Compose the vectors live in Qdrant, one collection per repository path (QdrantVectorIndex). The stdio MCP server keeps them in process (LocalVectorIndex) and calls rebuild_indexes() at startup, which rebuilds both indexes from the evidence records without rescanning Markdown.

FastAPI (api.py) serves everything under /api/v1. The React UI loads the architecture map from GET /api/v1/graph and sends What-if selections to POST /api/v1/scenarios/resolve, which returns the compiled graph with its changed IDs, conflicts, and invalid assumptions.

search() combines full-text hits, vector hits, entity and alias names, and the compiled scenario. For a question that starts with “why”, it returns grounded: true only when effective claims cite the returned passages, or when the returned passages are the cited option’s own evidence. Otherwise the answer states that it is not fully grounded.

scenario_analysis.py compares two compiled scenario graphs. POST /api/v1/scenarios/diff and the diff_scenarios tool return what changed between two selection maps, plus downstream impact: the entities that depend, over relationships, on each element the switch removes or replaces.

receipt.py stamps graph, scenario, search, and diff results with a digest of the canonical inputs: the .decision/ records, the current human review heads, the evidence anchors, and the selections. It never reads SQLite, Qdrant, or the clock, so rebuilding the indexes leaves it unchanged.

The MCP server (mcp_tools.py) exposes list_decisions, search_decisions, inspect_architecture, inspect_entity, and diff_scenarios over the same service. See MCP tools.

This trace follows the food-delivery sample’s fixtures/food-delivery/docs/decisions/event-transport.md through a mock scan. The demo copies the sample’s docs/ folder to the root of a fresh repository, so the stored source path is decisions/event-transport.md. The IDs below come from running the sample’s bootstrap script.

Discovery returns the file, and passages() produces four passages, one per section: Context, Decision, Alternative: managed queue, and Consequences. The Decision section becomes evidence evidence-8b8d19d6a067b605, with heading path Event delivery for food orders / Decision and display line 9:

We chose Apache Kafka because replay and per-order ordering matter more than reducing broker operations. The Order Service publishes the event stream to Kafka, and the Fulfillment Service consumes it with a durable offset.

The mock rule matches “We chose Apache Kafka because” and takes the rationale from the words after “because”. The section headed “Alternative: managed queue” names Amazon SQS, which becomes the second option. The scan saves .decision/decisions/decision-9069314c598bbe6a.yaml, titled after the document’s top heading, with Apache Kafka as the selected option. Each option is a delta. Here is the Kafka option, shortened:

- id: option-0e42fd4e38cd9281
title: Apache Kafka
rationale: replay and per-order ordering matter more than reducing broker operations
evidence: [evidence-8b8d19d6a067b605, evidence-07e01f1152ce3632, evidence-a34142e15ea57f9a]
delta:
base: architecture
operations:
- op: add_entity
entity: {id: resource-bc094960cda1ecbb, kind: resource, name: Order event stream (Apache Kafka)}
expect: {exists: false}
- op: add_relationship
relationship: {id: relationship-dcc3e8c1188a2d38, from: entity-225ac6a9466f917c,
to: resource-bc094960cda1ecbb, kind: publishes_to}
expect: {exists: false}
- op: add_relationship
relationship: {id: relationship-88195dc3f65129be, from: resource-bc094960cda1ecbb,
to: entity-8bcf11933046c744, kind: implemented_by}
expect: {exists: false}

entity-225ac6a9466f917c is the Order Service and entity-8bcf11933046c744 is Apache Kafka. The option also cites the Context and Consequences passages, because they name the Order Service that the publishes_to edge starts from.

With the repository’s selections, the compiled graph contains Order event stream (Apache Kafka). A What-if request with {"decision-9069314c598bbe6a": "option-2d494dede037e255"} compiles the Amazon SQS option instead: the Kafka stream drops out, and Order event stream (Amazon SQS) takes its place, with the Order Service publishing to it and an implemented_by edge to Amazon SQS. Both scenarios have 18 entities and 13 relationships, with no conflicts and no invalid assumptions. Nothing in .decision/ changes until you save a named scenario.