Mid- and long-term roadmap
Status: adopted 2026-08-20, with three amendments recorded at adoption (§6 principle 2, Phase F item ⑤, and the naming decision in §11).
Every claim about the current codebase in this document was re-verified
against commit f702736 on 2026-08-20. Counts are written as dated
measurements against that commit rather than as standing facts, because a count
rots on the next merge. Where a number came from a measurement recorded
elsewhere in this repository, the source is named; nothing here is a fresh
benchmark.
This document describes direction and design. It is not a description of
shipped capability. knowledge.trace and knowledge.impact do not exist.
Neither does an evaluation harness. Two items have left that list since it was
written, and each left by a different phase's work. The review ingestion
adapter was on that list and no longer belongs on it: ADR-0030 slice 1 landed
theurian review ingest, and the re-measured table below carries the flag flip
that goes with it. The write-intent MCP tools left it second —
knowledge.proposeChange and knowledge.generateMigrationDraft register as of
ADR-0032 (Phase B slice B4),
and the note below the re-measured table carries that flip. Neither departure is
Phase B finished: what each moved is stated where the flip is recorded, and
the phase's own Exit criteria row is the authority for the rest. What ships
today is what system.capabilities reports, and that
report is the authority every sentence below was checked against:
| Flag | Value at f702736 |
|---|---|
knowledgeSearch |
"hybrid" |
knowledgeGet |
true |
hybridRetrieval |
true |
raptor |
true |
reviewIngestion |
false |
traceability |
false |
writeTools |
false |
Measured: packages/theurian-core/src/theurian/mcp/tools.py, the
system.capabilities handler.
An eighth flag has joined them since that measurement, and is deliberately not
added to a table anchored to a commit: sensitivityEnforcement: true, from
#119. It reports that this
build enforces the disclosure axis, so an empty result may mean "withheld by the
deployment's declared ceiling" rather than "nothing matched" — and it reports
only that, never the ceiling itself.
A ninth joined on the same terms, and stays out of the same table:
reviewFindings: true, from
#368
(ADR-0029). It reports that
review.findings is callable — an offline read of the Review-Finding: trailers
theurian findings build landed in that project's own store, served under the
untrusted-content safety triple. It was not reviewIngestion moving. The two
are separate on purpose, because the change that reaches GitHub is the one that
owes SEC-10's repository allowlist, and an offline trailer read owes none.
A tenth joined with the flip below, on the same terms and out of the same table:
reviewIngestionScope: "public-allowlisted", published with reviewIngestion
and never one without the other
(ADR-0030 decisions 2 and 6).
Re-measured 2026-09-10 on the ADR-0030 slice-3 branch (#630), and one row moved. The table above is left as the dated reading it is, and so is this one — its right column is a second dated reading and not a standing answer, which is the correction ADR-0032 forced: it was written as "the current answer", and a later slice moved a second row without this sentence noticing. The reading was obtained by building the server and calling the tool rather than by reading the handler's literals:
| Flag | Value at f702736 |
Re-measured 2026-09-10 |
|---|---|---|
knowledgeSearch |
"hybrid" |
"hybrid" |
knowledgeGet |
true |
true |
hybridRetrieval |
true |
true |
raptor |
true |
true |
reviewIngestion |
false |
true |
traceability |
false |
false |
writeTools |
false |
false |
The three flags that joined after f702736 — sensitivityEnforcement,
reviewFindings and reviewIngestionScope — were read in the same call and hold
true, true and "public-allowlisted". They stay out of both columns for the
reason given above: a table anchored to a commit does not gain rows the commit
never had.
writeTools moved after that reading, on the Phase B slice-B4 branch
(#698), and reads true.
Recorded here rather than as a third column, for the same reason: a column is a
reading, and one reading per row is enough to see the direction.
ADR-0032 decision 5 ties the
flip to the first registration, in one commit and in both directions —
knowledge.proposeChange and knowledge.generateMigrationDraft register, and
the value and the registration are held to each other by
tests/unit/test_write_tools_flag_claims.py::test_writetools_reads_true_exactly_when_a_write_intent_tool_is_registered,
so neither a flag flipped ahead of its feature nor a tool registered while the
flag lags can land quietly. Read the value as narrowly as it is written: it
says a write-intent tool exists that a client may call, and not that a client may
write approved knowledge. No MCP tool reaches an approved-state write — the tools
are handed a draft-only facade whose reachable surface is the two draft entries
alone (ADR-0032 decision 8) — and approval is still a human merging a pull
request.
What moved the one row is
ADR-0030 slice 3, and the value
means less than it looks like it means, so read the whole arc: slice 1 shipped
the adapter that reaches GitHub — infrastructure/github/ spawns the operator's
gh — and with it SEC-10's repository allowlist, read and enforced before any
process is spawned; slice 2 added theurian review ingest, which lands evidence
files under .theurian/review/ screened by an ingestion-time secret gate; and
through both the flag read false, because it speaks about the
MCP-client-callable surface and nothing wider. Slice 3 registered
review.search over the store theurian review build projects from those files,
and that is the first thing here a client can call — so the flag moved, meaning
an ingestion call surface exists that a client may call and not that a client
may start an ingestion run. No MCP tool spawns gh; a fetch is still an
operator's act. The two-slice window in which the machine-readable answer read
false while a fetch path and then a landing path shipped is a recorded
residual, closed by slice 3. The raw-URL fetch controls — a
scheme allowlist and private-network rejection — stay owed against the OpenAPI
$ref fetcher (#429).
Contents
- Premise — the stated principles against the implementation
- Current state assessment
- Product boundary
- Target architecture
- Knowledge model
- Retrieval architecture
- Standing design principles
- Roadmap: Phase 0 and Phases A–F
- Prioritization
- ADR candidates
- Risks
- Final recommendation
Appendix: documentation contradictions to clear in Phase 0
0. Premise
The principles Theurian is commonly described by all exist in the repository. Most are half-implemented: the label is published and the enforcement is not, or the primitive exists and the query surface does not. The roadmap starts from that gap rather than from the label.
The State column below is the count. It is deliberately not restated as a
number in this sentence: a total sitting beside a table anyone may edit is a
defect waiting for the next edit, and this document has already produced that
defect twice. Derived from the table rather than asserted: at f702736 on
2026-08-20 it reads seven partial against three shipped, over ten rows.
| The principle, as usually stated | What the code actually holds | State |
|---|---|---|
| AI proposes; humans approve | More precisely: AI proposes. Git reviews. Humans approve. (ADR-0013). There is no approval command and no approver field in Theurian — a search for approver, approved_by and approvedBy across packages/theurian-core/src/ and schemas/ returns nothing at f702736, and the approval record lives in Git metadata. But the merge is the intended route, not an enforced one: nothing in the code checks that a migration was ever merged (T-15's recorded residual — see §1). |
partial |
| An agent cannot change approved knowledge directly | Stronger than usually stated. The write-intent MCP tools (writeTools: true since ADR-0032) emit a proposal a human reviews and merges and reach no approved-state write: they hold a draft-only facade whose reachable surface is the two draft entries alone (ADR-0032 decision 8), and a test walks the bytecode of every registered tool to hold that none reaches a canonical write (ADR-0013). |
shipped |
| Git-native | Canonical state rebuilds from Git-tracked YAML migrations and body files into an empty database (FR-K4, ADR-0004). SQLite is always derived. But the CI job that would prove it — rebuild from empty and compare — does not exist; rg empty-db-rebuild returns four documents saying so and no workflow (#64). Mechanism ships; the proof is owed. |
partial |
| Evidence-backed knowledge | INV-8: every revision carries at least one SourceAnchor or the authored-in-theurian label, enforced in the dataclass constructor. Every search result carries provenance. |
shipped |
| Local-first, reached over MCP | Loopback-only daemon (127.0.0.1:7419, bearer token), and an offline CI job proves "no API key needed" on every commit that touches Core, its schemas, tests, tools or the toolchain (ADR-0009). core.yml is path-filtered and docs/** is not among its paths, so a docs-only commit — this one included — does not run it. MCP is Streamable HTTP. Every tool it exposes was read-only until ADR-0032; the three write-intent tools it now exposes write a proposal directory and nothing else — test_proposal_service.py::test_generation_writes_only_under_the_proposal_directory diffs the whole tree for the service all three draft through — and no tool reaches an approved-state write. |
shipped |
| Provenance | A revision carries author, source_commit, source_anchors; a proposal carries evidence.json (agentId, model, reasoning). But evidence.json is an input a human reads during review — Core does not read it — and the record of who approved exists only outside Theurian, in Git. |
partial |
| Trust and validity | Validity windows (validFrom/validTo) and asOf search are implemented. trustLevel is published on every result and filtered on by no query; sensitivity was in that sentence until #119 and is now an enforced read control. The chunks table carries both columns; no retrieval query selects or filters on trust_level. What a caller sees is published from canonical, not read off the index row: sensitivity is threaded in as the item's current authority and trustLevel comes from revision.metadata, deliberately, because a changeSensitivity moves an item's classification without writing a new revision (mcp/results.py, SEC-14). SqliteIndexStore._scope now emits three predicates rather than two — chunks.project_id = ?, chunks.status = ? unless include_unapproved, and chunks.sensitivity IN (…) against the deployment's declared ceiling — and _node_scope emits the same three over nodes; a build writes no row above that ceiling, and a reclassification past it purges the published build (ADR-0025). Its docstring names trust_level and namespace as the columns "no query reads". "Theurian has a trust model" is a true sentence for one axis of three, which is what keeps this row partial: trustLevel is still a label, and tenant and ACL group are held by write-time refusal rather than by any predicate. |
partial |
| Knowledge lifecycle | Six statuses exist (draft, proposed, approved, deprecated, superseded, rejected). No transition graph is enforced anywhere — a case-insensitive search for transition across packages/theurian-core/src/ returns nothing, so a migration writing rejected → approved applies. Separately, SURFACEABLE_STATUSES is {APPROVED, DRAFT, PROPOSED}, so rejected, superseded and deprecated are unreachable under any flag. |
partial |
| Reproducibility | stateHash and snapshotId are published on every response, but passing a snapshotId back to re-query a past state is not implemented (the second half of FR-R7). knowledge.search takes projectId, query, limit, includeUnapproved, maxTokens, useDense, asOf — and no snapshot parameter. |
partial |
| Vendor neutrality | The wire surface (MCP tools, versioned schemas, protocolVersion) names no vendor and is neutral. The install surface is Claude Code only: McpClientConfig has exactly one adapter, infrastructure/claude/mcp_config.py. There is no Codex, Gemini, or generic .mcp.json adapter. |
partial |
A correction worth carrying. The vendor-coupling point is often stated as "setup can only write
~/.claude.json". That is not what the adapter does:infrastructure/claude/mcp_config.pyopens with "Theurian reads~/.claude.json. It never writes it." — every write to the config itself is delegated toclaude mcp add/claude mcp remove, so that the literal token never enters a config file (SEC-5) and Theurian never reformats Claude Code's live state. The coupling is real and it is the adapter count, not the file.
Four implementation facts that a first reading of the older planning material
tends to get wrong, all confirmed at f702736:
- RAPTOR, embeddings, typed relations, the
traceability_edgestable, andasOfare not prospective features. They are in the schema and in the implementation today. - The relation vocabulary most often proposed for traceability has nine terms.
Seven of them are already
RelationTypevalues (§4). - There is no standalone roadmap document before this one. The roadmap was the milestone table in the README.
- The milestone numbers are already unreliable. ADR-0013 records
theurian proposeas "Landed in Milestone 7" while the README lists Milestone 7 asplanned. This document uses phases for that reason.
1. Current state assessment
Where the project stands against the goal of being a foundation for specification-driven development with AI. Legend: shipped — complete and released; partial — the skeleton or the design exists, the capability does not; absent — effectively nothing.
Shipped
- A governed knowledge lifecycle — immutable revisions behind a mutable item
pointer (ADR-0006),
content addressing by SHA-256, six statuses, approved-only by default, and
rejectedunreachable under any flag as the place a rejection's reasoning can safely live. - "AI proposes" enforced structurally on the MCP surface — since
ADR-0032 and
ADR-0033 there are three write
tools there, and the guarantee is that none reaches an approved-state
write: they are handed a draft-only facade whose reachable surface is exactly
the two draft entries, so
acceptand_commitare unreachable from a tool (decision 8, held by a closure walk over the built server intests/integration/test_mcp_tools.py). The bytecode-walk test over every registered tool still holds that none reaches a canonical write, and is the narrower of the two controls: it sees names in the registered callable's own code chain and does not enter a collaborator's body. The rest of the chain — proposal directory → PR → human merge →migrate apply— is only partly enforced. Since ADR-0034's T-15 check (Phase B slice B3),migrate applyrefuses by default a migration that is not committed atHEAD(--allow-uncommittedrestores the old behaviour), so the commit is now a check the code makes. The merge is not: a local commit on a local branch satisfies the check, so the human's review of the pull request stays a workflow convention, and the actors table's untrusted same-UID process can still apply its own migration by committing it first. The threat model records the narrowed residual under T-15. - Typed relations, 14 of them —
implements/implemented_by,supersedes/superseded_by,depends_on,constrained_by,verified_by,reviewed_by,contradicts,related_to,derived_from,evidenced_by,rejects,exception_to. Direction-dependent ones are mirrored automatically, andknowledge.getputs every edge through the disclosure gate individually. Per-edge gating predates T-21 and was itself the leak path —_relation_is_visiblegated each endpoint through a read that resolved aliases, so an alias key equal to a withheld item's id evaluated the wrong item's authority. T-21 was closed by two fixes, on both sides. Read side: the non-resolving read — each endpoint is now read withget_item_exact, the row the id literally names, and the principle the split records is reachability may resolve an alias; authority — a visibility decision on a referenced id — must read the literally-named row. Write side: a whole-set refusal —AliasItemCollisionErrorrejects a migration set whose alias key also names a live, non-deprecateditem id, so the collision cannot be authored in the first place. Reading only the first half would leave a reader thinking the class was closed read-side alone. - Temporal primitives — validity windows,
asOfsearch,freshness(isWithinValidity,ageDays), and the migration operationdeprecateItem'ssupersededByfield recording asupersedesedge automatically. - Hybrid retrieval — FTS5 word index, a trigram index for CJK (ADR-0023), RRF rank fusion (ADR-0021), diversification, a token budget, and a substring fallback when the index cannot answer.
- Disclosure equality as a checkable safety property — "an index holding
withheld documents and an index that never held them return the same response
to the same query", pinned by hypothesis tests (SEC-13, T-15, T-17). Purging
the index on withdrawal is implemented (T-17a,
ADR-0024) for the status axis only, and
the threat model records two residuals with it: a request already in flight at
the pointer swap can still answer from the pre-purge build, and a purge that
fails now taints the active-index pointer so the stale build is no longer served
(GHSA-97q9-xxfg-33r6) — the failure is still reported (
indexPurgewithpublished: false,failed: true, and a remedy) rather than silent. The sensitivity axis is #119's work, and Phase 0 states its shape. - A RAPTOR forest — implemented and opt-in (
raptor.enableddefaults tofalseinschemas/config/project-config.schema.json). Summaries are routing-only: ADR-0008 decision 8 states that search may traverse a summary node and only leaf chunks are returned, so a summary node is never a result row. What holds that is the visibility gate, not the test named after it: a summary node has no (item, current-revision) pair forCanonicalVisibility._may_surfaceto clear, so it cannot reach a result row. Under a mutation that publishes node ids as item and revision ids,test_a_summary_node_is_never_itself_a_result_rowstill passed — two routing tests killed the mutation instead. The test-strength gap is tracked as #269; the property itself is not in doubt, only what enforces it. The default summarizer is extractive — every emitted sentence is a verbatim substring of its children — deterministic, and uses no LLM. A summary cannot span a scope boundary (project,tenant,sensitivity,acl_group,namespace,status) because a node whose children disagreed on any component would have no tree to belong to (ADR-0008). The governance problem people expect AI-generated summaries to pose is solved here structurally rather than by policy. - The skeleton of reproducibility — the state hash (ADR-0007, ADR-0016, ADR-0017), blue/green index files with a pointer swap (ADR-0022, ADR-0024), deterministic projection (ADR-0020).
- A versioned wire contract —
schemas/,protocolVersion, tests that check schemas against real output, and a published compatibility policy. - A vendor-independent core — ADR-0009 enforced in CI by an offline job and a
ban on vendor names in
domain/andapplication/.
Partial
- Traceability — relations, a
traceability_edgestable, aSpecificationentity, and a notably complete design indocs/architecture/traceability.md: non-foreign-keyTraceNodereferences, per-edge evidence and confidence, drift conditions D1–D7, five edge sources. What is missing is the collection path and the query surface (traceability: false). The table is declared and unpopulated —tests/integration/test_canonical_store_corruption.pynamestraceability_edgesin itsUNPOPULATED_TABLESset, and thesystem.capabilitiestest states thatCanonicalStore.list_traceability_edges"is declared on the port and called from nowhere insrc/". - Temporal validity — the primitives are there, but (a) status transitions
are unenforced, (b) there is no access path to
supersededordeprecatedcontent, so a question about how something changed cannot be answered in principle, and (c) snapshot re-query is unimplemented. - Governed metadata —
trustLevelandsensitivitycan be set at propose time (#249, shipped in0.1.0.dev7) and are published on every result. Nothing reads them (#119). - The agent write path — no longer the
theurian proposeCLI only.knowledge.proposeChangeandknowledge.generateMigrationDraftregister as of ADR-0032 (Phase B slice B4), drafting through the sameProposalServicethe CLI drafts through, so an agent from any vendor reaches the proposal path. It stays partial for three reasons, none of them the tools.review.generateKnowledgeCandidateis the third write-intent tool ADR-0013 designs and is ADR-0033's, registering at slice B5.generateMigrationDraftcarries ten of the fourteenOperationKindmembers, refusingcreateItem/upsertRevisionto the content tool andchangeSensitivity/restoreItemto the CLI (decision 3). And the phase's own Exit criteria row asks for a demonstration of propose → PR → merge → apply from a second client, which is F ① work and has not run. - Dense retrieval — the port and an exact cosine scan exist. The default
embedder is a hashed character n-gram vectoriser, and its own module says
"This is not a semantic model, and it does not pretend to be". It is not
weak; it is uninformative: the recorded measurement is that 91% of unrelated
natural-language questions clear the similarity floor (recorded in
README.md, ADR-0009, ADR-0021 andapplication/retrieval_service.py). Off by default is the right call. - Review ingestion — the domain model (
KnowledgeCandidate,domain/review.py) is built, and since ADR-0030 slice 1 so is the collection adapter:infrastructure/github/fetches pull requests, threads, comments and resolution state over the operator'sgh. Slice 2 lands that evidence on disk behindtheurian review ingest— durable files under.theurian/review/, each record screened by the ingestion-time secret gate and optionally redacted of participant display names before it becomes a file. Slice 3 added the serving half:theurian review buildprojects those files into a SQLite store and thereview.searchMCP tool reads it back, which is what movedreviewIngestiontotruebesidereviewIngestionScope: "public-allowlisted". Slice B5 added candidate generation (ADR-0033, out of ADR-0030's scope): the only construction site of aKnowledgeCandidateis that generator, which recomputes the promotion gate from the stored record, verifies the caller'sfixCommitagainst the local repository, and drafts an ordinary proposal — andreview.generateKnowledgeCandidateis a registered MCP tool since that slice, so a client reaches it over the wire. What is still absent after serving is classification. - Multi-vendor integration — neutral wire, Claude-only bootstrap (§0).
Absent
- Any evaluation baseline for retrieval quality. There are no golden
queries, no relevance judgements, and no measurement harness —
tools/holds only the mutation-testing scripts, and a search for "golden quer", "relevance judg", "recall@", "ndcg" or "mrr" across the repository returns one file, ADR-0021, where the phrase appears in a rejected alternative. Every quality number this project has is a one-off record in prose. Ranking changes ship against no baseline.
The verdict holds; the evidence sentence above does not, and is corrected here rather than left to rot. Re-measured 2026-09-20 on the branch of PR #776 — named by its pull request because the sha is not reachable from
origin/main.tools/no longer holds "only the mutation-testing scripts": it also holdsaudit/,corpus_drift.py, thepremise_*andsweep_*instruments, and — since Phase A slice S1 —eval/schemas/, the corpus interface contract. The same five-phrase key returns 7 files, not one: ADR-0021 and its two served-corpus twins, this roadmap, two of those three contract schemas, and ADR-0036, which governs what committed judgements may be used for. What is absent is unchanged: noqueries.yaml, nojudgements.yaml, no harness and no baseline report, so every quality number is still a one-off record in prose and a ranking change still ships against no baseline. Nothing recomputes this bullet. Unlike appendix rows 3, 6 and 10 it carries no pin, which is why it went stale silently; one is owed with the harness (Phase A slice S4). - Impact analysis. One mention repository-wide, as an endpoint in a diagram. No design. - Drift detection. The D1–D7 conditions are defined and nothing evaluates them.propose --scope-path(dev7) has only just started writing the data that a drift check would read. - History and evolution queries. Superseded knowledge survives only ingit log. - Security debt that gates widening the agent write path. These are partly shipped rather than absent, and the difference matters when scoping Phase 0:
| Requirement | What ships | What is owed |
|---|---|---|
| SEC-8 (resource bounds) | MAX_YAML_BYTES (4 MiB), the YAML loader's nesting-depth refusal, read_source_file's MAX_SOURCE_FILE_BYTES cap, MAX_BUDGET_TOKENS (32,000) and MAX_QUERY_CHARS (2,000), MAX_PROJECTION_CHARS (2 MiB) |
the wall-clock timeout and the archive expansion ratio (#215), plus the discrete defects #232, #245 and #26 |
| SEC-10 (SSRF) | external $ref targets are recorded, never fetched (parsers/openapi.py, cited to SEC-10 and T-7), and — since ADR-0030 — the repository allowlist, read and enforced before the gh review adapter spawns anything |
the scheme and private-network allowlists in the raw-URL context, owed against the $ref fetcher (#429; #129 closed on the wording, not the controls). On the gh path the scheme allowlist has no URL to read and private-network rejection is reduced rather than discharged, with ADR-0030's four-member divergence class recorded as the residual |
| SEC-11 (secret scanning) | the approval gate, over everything an acceptance lands: theurian propose accept scans every body it would land, the migration document's author-written field values (title, description, labels, scope paths, contentType, the date fields and — since #349 — the parsed contentFile), each operation's free text and chosen names, and every string of a source anchor (#336); and, with them, the artifacts the acceptance writes — the migration file's raw bytes (a YAML comment and every field as written), the migration filename, and each landed body path (#349); and the proposal's evidence.json, scanned whole-text under the same policy although the command lands it nowhere, because it tells the author to commit the directory the record sits in (#361) — block by default per security.secretScan (ADR-0027 decision 3), with an in-house best-effort detector whose finding locations are fixed literals that never reproduce the value. Refusals on that path scan every author-derived string they would print — both whole and as the cut that will print, since the detector is not monotone under truncation — and drop it whole if either scan reports (#360, #339) |
the gate's reach is the detector's reach, so a fragment a third party truncated before it can still be printed — measured through the real CLI on PyYAML's Mark.get_snippet; migration_loader.py still prefixes a landed migration's filename onto every MigrationError, which is a different producer's population — reached through resolve_context, so by every command that resolves a project context — and the remaining member of the echo family (#537); the accept --json migrationFile/bodyFiles success fields print landed paths full-length by recorded decision, since a redacted path reports nothing; a secret-shaped landed path reaches that field under warn, under off, or under block when the detector misses it, and under warn the same string is already published redacted beside it; theurian index build is SEC-11's second control since #329 — it scans every body it indexes, with the source anchors and relation notes served beside it, over every text channel of the approved, in-ceiling corpus this deployment serves by default on every rebuild, and reports rather than refusing because by then the content is already served; an unapproved body reachable through includeUnapproved and a superseded revision in the store are outside that population, recorded as residuals in the threat model and SECURITY.md — while theurian ingest runs no scan of its own and needs none, because it stores no content and draft-time advisory scanning remains owed (#330; #198 is closed, having shipped the propose accept half described in the left column) |
| SEC-12 (MCP input schema validation) | the control itself, since ADR-0031: one published input schema per registered tool under schemas/mcp/*-input.schema.json, validated by mcp/middleware.py's InputValidationMiddleware — wired into build_server, above the SDK's argument coercion, which is the only tier that sees the keys a caller sent — so an unknown key is refused rather than dropped, a registered tool that resolves to no loaded schema is refused at dispatch, a schema set that will not load whole stops the server being built, and a refusal names a key path and a constraint instead of echoing the caller's value |
the unit a published maxLength on a write-intent body counts — JSON Schema counts code points, while the byte cap it transcribes is in landed bytes, so a bound written as the one admits up to four times the other (#691); a decision on snapshotId, agentId and taskId, which the enforced contract now admits and no handler reads (#665); and, on the write surface itself, a published bound a caller cannot actually meet — the body maxLength is shadowed over the shipped transport by the tighter rendered-character bound and the transport cap, both of which fire first (#699), so it is driven against the loaded schema rather than over the wire. The value-domain constraints ADR-0032 decision 3 assigns have left this column: slice B4 published both input schemas with the body cap and uniqueItems on labels[], and their driving cases landed with the tools (tests/integration/test_write_intent_wire.py, tests/unit/test_input_schema_bounds.py) — what is left of that row is the unit question above, which is #691's and not decision 3's |
| SEC-16 (imperative text as data; a delimited untrusted region in summarization prompts) | the first half, by other means: SEC-15's safety triple rides every result, and the SummarizationProvider port docstring states the rule for summarizers |
the delimited untrusted region itself. There is no summarization prompt to delimit — the default summarizer is extractive and calls no LLM — so this falls due with the first abstractive adapter (Phase F ④). No open issue tracks it |
T-16 is graded Critical in the threat model —
"publication ships, install-time verification does not". The production half is
real and substantial: a clean-environment install check before publish, a
reproducible CycloneDX SBOM built from that verified install, SHA256SUMS over
every artifact, PyPI Trusted Publishing with PEP 740 attestations, and
tag-signature verification against a per-run trust root. Every one of those
acts on production; none acts on installation, and that unmet half is what
the Critical grade names. Tracked by
#80. The threat model's
summary row pointed at closed #39 until efd30fe repointed it; it now reads
"install-time verification unmet ([#80]; #39 is closed, on its documentation
half only)".
#80 carries post-1.0, so T-16's install-time residual is explicitly not a
0.1.0-stable gate today — which is precisely why Phase 0 asks for a recorded
decision on it rather than for an implementation.
The pre-1.0 label is what gates 0.1.0 stable, and the queue is the query
rather than a number kept here: gh issue list --label pre-1.0 --state open
returned 2 on 2026-09-05, against 21 on 2026-08-20. Both are snapshots;
the label is the authority.
2. Product boundary
The design principle is keep Theurian small, and the boundary needs one test rather than a list of preferences:
Theurian owns what humans approved, and the path to reach it. It does not own performing, sequencing, or enforcing an action.
| Class | Contents | Why |
|---|---|---|
| Must own | Governed canonical knowledge (lifecycle, immutable revisions, provenance) · the disclosure gate and disclosure equality · retrieval over that corpus · typed relations and the read surface for trace and impact · the semantics of temporal validity · the proposal format and its validation · a vendor-neutral MCP surface and versioned wire contract · the evaluation of its own retrieval quality | This is the product's definition, and nothing else can hold it. Git holds what is true; it does not structure which engineering judgement is currently in force. |
| Should own | Write-intent MCP tools, up to producing a proposal · review → KnowledgeCandidate collection · impact reports as a bounded traversal over recorded edges · the machine-decidable subset of drift detection · a golden-query benchmark |
Natural extensions of the above, already anticipated in existing ADRs and design documents. GOVERNANCE.md already commits review ingestion and traceability to Core permanently. |
| May support | A context-package export, explicitly labelled as derived · a real embedding adapter as an opt-in extra · install adapters for clients other than Claude Code · retrieval feedback signals | Valuable, and the product stands without them. Each is an additional implementation of a port that already exists (EmbeddingProvider, McpClientConfig). |
| Should not own | Agent orchestration, workflow state machines, task assignment · approval UI or approval by proxy (approval is the Git merge) · code and symbol search (delegated to Serena, stated in the issue template) · running CI, tests, or reviews · automatic promotion of AI output into knowledge · rule enforcement · a hosted multi-tenant service | Each either competes with a control point that already exists (Git branch protection, CI, the agent runtime) or contradicts "Theurian labels; it does not enforce" (README, T-3). |
The boundary with agent orchestration
Against a workflow of the shape human → requirement → spec agent → … → human approval, Theurian's responsibility is exactly three things:
- Read. Any agent can pull the currently valid specifications, decisions, rejected approaches and constraints, with evidence attached.
- Receive proposals. Any agent can produce a proposal (migration, body,
evidence). Its status stops at
proposed. - Distribute what approval recorded. A human's Git merge is the intended —
not the enforced — route to
approved, and then every agent sees the same truth. What is enforced is that no MCP tool writes approved knowledge — the phrasing matters since ADR-0032, because two MCP tools do now write, and what they write is a proposal directory.migrate applywill still apply an uncommitted migration a same-UID process put in.theurian/migrations/(T-15's recorded residual, and Phase B's Security row carries it).
Sequencing, assignment, and progress state do not live in Theurian. Starting
an implementation agent once a spec is approved is the caller's job — a human, an
agent runtime, or CI. The states such a workflow needs are already expressible:
"awaiting spec approval" is an item at status=proposed, "this implementation
satisfies that spec" is an implements edge, "we rejected this" is an approved
item of kind=rejected-approach. No workflow-specific schema is added.
3. Target architecture
Six changes from the current architecture: ① write-intent tools join the MCP
surface, ② trace and impact read tools join it, ③ the disclosure gate gains a
sensitivity axis (#119, and see the shape below), ④ CI joins as a reader, with
enforcement staying on the CI side, ⑤ Phase B adds an infrastructure/github/
adapter for review ingestion, and ⑥ Phase D changes the semantics of
SURFACEABLE_STATUSES and adds an includeSuperseded opt-in. The diagram below
draws the first four, because those are the ones its existing nodes already
carry; ⑤ is an internal adapter with no node of its own, and ⑥ lands with Phase D
and is drawn there in prose rather than here. ⑥ does change the published
surface — includeSuperseded is a new parameter on knowledge.get and
knowledge.trace — so it is named in the count, not filed under "internal".
Everything else — the canonical store, derived indexes, blue/green publication,
approval-as-merge — is unchanged.
③ is not a predicate-only change, and the diagram's single "sensitivity" line
understates it: see Phase 0's #119 rows for the four-part shape.
① and ③ have since shipped, and the * markers below are kept as the
diagram's own legend rather than re-keyed per change: ③ closed with #119 in
Phase 0, and ① with ADR-0032 in
Phase B slice B4 — the WR node's two tools are knowledge.proposeChange and
knowledge.generateMigrationDraft, and the edge it draws to Git is the whole of
what they reach.
flowchart TB
subgraph AG["AI agents (vendor-neutral, interchangeable)"]
CC["Claude Code"]
CX["Codex"]
GM["Gemini / any MCP client"]
end
subgraph IF["Agent interface — MCP daemon (loopback, bearer token, versioned wire contract)"]
RD["Read tools<br/>search · get · status · trace* · impact*"]
WR["Write-intent tools*<br/>proposeChange, producing a proposal file only"]
end
subgraph CORE["Theurian Core (local-first, offline, deterministic)"]
GATE["Disclosure gate<br/>status (today) + sensitivity (#119)<br/>disclosure equality: two-corpora equality"]
RET["Retrieval<br/>RRF hybrid (lexical + trigram)<br/>RAPTOR routing (opt-in) · dense (opt-in)"]
TR["Traceability<br/>typed relations (14) + external TraceNode edges<br/>(evidence + confidence + commit anchor)"]
TM["Temporal validity<br/>status · validity · asOf · supersedes chain"]
IDX[("Derived index<br/>rebuildable · blue/green · purged on withdrawal")]
ST[("Canonical store<br/>Git-tracked migrations and bodies · immutable revisions")]
end
GIT["Git / PR<br/>the approval point (merge = approve)"]
CI["CI / branch protection<br/>the enforcement point"]
AG --> RD
AG --> WR
RD --> GATE
GATE --> RET
GATE --> TR
GATE --> TM
RET --> IDX
TR --> IDX
IDX -. "derived: deleting it loses nothing" .-> ST
WR -- "proposal, with evidence" --> GIT
GIT -- "merge, then migrate apply" --> ST
CI -. "reads, e.g. a drift check" .-> RD
* = added by this roadmap (Phases B, C, E). The point of the diagram is that approval and enforcement sit outside Core: Theurian is the surface of fact that both of them share.
4. Knowledge model
Approach: keep the model, add vocabulary and enforcement
The current model — KnowledgeItem (mutable pointer) + KnowledgeRevision
(immutable, content-addressed) + KnowledgeKind (a closed enum of 11) +
KnowledgeRelation edges typed by RelationType (a closed enum of 14) — already
carries most of the
expressiveness a traceability product needs. No per-type schema is introduced
(one schema for ADRs, another for specs, and so on). A type is expressed as
kind plus structured (an optional dict) plus relations, and the model stays
uniform. That serves migration compatibility and "keep it small" at the same
time.
The commonly proposed relation vocabulary, mapped
| Proposed term | Existing RelationType |
Verdict |
|---|---|---|
| implements | implements / implemented_by |
exists |
| satisfies | expressible as implements — a spec also "implements" a requirement |
no addition. Add via ADR if a case appears where the conflation actually misleads |
| derived-from | derived_from |
exists |
| decided-by | expressible as derived_from pointing at an item of kind=decision |
no addition |
| supersedes | supersedes / superseded_by (declared acyclic) |
exists |
| depends-on | depends_on (declared acyclic) |
exists |
| contradicts | contradicts |
exists |
| verified-by | verified_by |
exists |
| evidence-for | evidenced_by |
exists |
Seven of the nine already exist, six of them verbatim — evidence-for maps onto
evidenced_by with the direction inverted. The conclusion is that the
relation vocabulary is not an open "fixed graph schema or not" question: the
answer — a closed enum extended by ADR — is already implemented, and this
roadmap keeps it. Free-string edge types are not introduced, because both search
and traversal depend on the type being closed.
Four additions
- Two new
kindvalues:requirementandspecification. This forces a decision about the existing separateSpecificationentity, which has its own table (CREATE TABLE specifications,infrastructure/sqlite/schema.py): fold it into the unified item, or keep it separate and connect it by relations. ADR candidate #6 — decide before Phase C builds on top of either. The recommendation is the unified form: spec-as-knowledge goes inkind, and the machine-readable payload goes instructured. - External nodes (code, PR, test, CI run) are
TraceNodes. Adopttraceability.md's existing design unchanged: a non-foreign-key(node_type, node_id)reference, per-edgeevidence(what asserts this edge) andconfidence(1.0 for an explicit declaration, below 1.0 for anything inferred), andsource_commitpinning when it was measured. The truth about a code entity is always Git's; Theurian holds only the edge, which is a claim. - Enforce INV-6 (acyclicity of
supersedesanddepends_on) at apply time.ACYCLIC_RELATIONSis declared indomain/enums.pyand exposed asKnowledgeRelation.must_be_acyclic, and atf702736that property has no caller insrc/— its only reader istests/unit/test_domain_invariants.py. Enforce it before Phases C and E build traversal on top of the graph. - A thin approval-provenance pointer. When
migrate applyruns, record inmigration_historythe SHA of the merge commit the applied migration arrived through, where that is obtainable. Approval remains Git's; this is only a pointer to which merge was the approval. No approver field is added.
Migration compatibility
- Relations are writable with the existing
addRelation/removeRelationoperations, so no new operation is needed. IfTraceNodeedges require one (an external node has noitemId), adding to the closed operation set is anapiVersionbump (ADR-0005's rule). Whether adding akindorRelationTypevalue is breaking or additive is not stated anywhere in the current policy — settle it in ADR candidate #3. Recommendation: specify the current behaviour (a migration containing a value an older Core cannot read is refused), treat the addition as minor, and makecompat checkdetect it. - On the SQLite side the discipline "schema version mismatch means rebuild, not migrate" is already established (ADR-0004, ADR-0017, ADR-0022), and table additions are absorbed by it. In-place migration is still never built.
5. Retrieval architecture
Requirements by query class
| Query class | Example | Mechanism needed | Current state |
|---|---|---|---|
| Exact decision lookup | "Why don't we use optimistic locking?" | lexical + trigram + RRF | Measured in the Phase A baseline (2026-09-24, exact-decision, n=8): Recall@1 0.625, Recall@5 0.75, Recall@10 0.875, MRR 0.671875. Sufficient is now a reading someone takes against those figures rather than an expectation; a later run is compared to them |
| Rejected alternative | "What did we reject in March?" | as above, plus kind=rejected-approach. A rejected approach is recorded as approved knowledge; status=rejected is the graveyard of proposals that may contain secrets, and is a different thing |
Mechanism sufficient; this is a corpus-discipline problem |
| Conceptual / broad | "What are our database design principles?" | RAPTOR routing as a path into cross-cutting document sets; a real embedding model later | RAPTOR implemented, and measured in Phase A slice S4c (2026-09-24): it raises this class's own top rank — broad-architectural Recall@1 0.111111 → 0.361111, MRR 0.583333 → 1.0 — and lowers it overall (Recall@1 -0.147727, MRR -0.097601), so it stays opt-in and default-on is declined, recorded in ADR-0008's decision 10 amendment. The mechanism test test_a_summary_match_routes_to_sibling_leaves_a_leaf_search_misses stands; what it did not quantify is now in the committed baseline's comparison block |
| Historical / evolution | "How has our consistency strategy evolved?" | traversal of the supersedes chain, plus semantics for historical disclosure | Impossible in principle today — nothing reaches superseded. Phase D |
| Traceability | "Why does this code exist?" | graph traversal, not search | Relations are visible through get; multi-hop traversal and external nodes are not. Phase C |
| Impact | "What breaks if SPEC-104 changes?" | bounded traversal plus an aggregated report | Absent. Phase E |
| Unknown / abstention | a question with no answer | refusing "withheld" and "absent" in the same words (implemented), plus measurement | Property is tested, and accuracy is measured: the golden set carries four unknown queries — two absent-topic, two withheld-topic — and the Phase A baseline reports abstentionAccuracy 0.75 over them (2026-09-24), the one miss being q-withheld-credential-cache, recorded on #787 as benign vocabulary overlap by visible rows. The golden-set instruction is discharged |
Query classification is not built — the tool split is the router
A query-classification layer inside Theurian is not recommended. Two
reasons: a deterministic classifier would be poor, and an LLM-backed one would
violate ADR-0009 (offline, deterministic, vendor-neutral); and the caller is
always an LLM agent, for whom choosing the tool is the query classification.
Routing is therefore achieved by publishing semantically distinct tools —
knowledge.search, knowledge.get, and later knowledge.trace and
knowledge.impact — each with a clear description. That is vendor-neutral,
deterministic, and costs almost no code.
Where each retriever stands
- RAPTOR — not the centrepiece. Keep the current design (opt-in, extractive,
routing-only). The instinct to avoid using a technique because it is
sophisticated is already satisfied by the implementation: a summary is never a
result row, derivation is deterministic, and a withdrawal purges it. The one
open question is whether it actually buys recall, and Phase A answers that.
Answered on 2026-09-24, in Phase A slice S4c: it buys top rank inside its
target class and costs it overall, so it stays opt-in. Broad-architectural
Recall@1 went 0.111111 → 0.361111 with MRR 0.583333 → 1.0, while overall
Recall@1 moved -0.147727 and MRR -0.097601 (over 26 queries, of which 22 carry
a
recallAtKat all), with the cost concentrated incross-adrandrejected-alternative. ΔRecall@10 is0.0in every class that carries a delta, so the same judged-relevant items stay within the first ten; the returned content's composition does move —evidencePrecisionshifted, and it is order-invariant — which is ADR-0008 decision 8's sibling-leaf routing doing its designed work. The decision, its reasoning, the movements in RAPTOR's favour, the relative-not-absolute caveat and the re-check command are recorded in ADR-0008's decision 10 amendment, and the numbers live in the committed baseline'scomparisonblock. Abstractive summarisation (LLM-generated) stays frozen until SEC-16 and an evaluation baseline both exist (Experimental) — untouched by that decision, which is about the extractive summariser alone. - Dense / semantic — the current hashed n-gram embedder is measured as
uninformative, so a real embedding adapter is considered only as an opt-in
extra, poured into the existing
EmbeddingProviderport with no change to retrieval itself. The default stays offline (ADR-0009). Adopt only if Phase A's harness shows an improvement on conceptual queries. - Reranker — held. The port exists, but RRF is built on the premise that scores are incomparable and only ranks fuse (ADR-0021); a score-emitting reranker breaks that premise. Nothing starts here until evaluation shows a need.
- Lexical + trigram — remains the workhorse. The known defect — a short term
dropping out of a mixed-length CJK query,
認証 トークン, recorded in the README — gets measured in Phase A before it gets fixed. - Graph traversal — built as a read surface separate from retrieval (Phases C and E). It does not fuse with ranking: trace and impact return recorded edges, not relevance.
6. Standing design principles
These are not phase deliverables. They constrain every phase, and a change that violates one is wrong regardless of which phase it belongs to.
1. Theurian labels; it does not enforce
Already load-bearing today: every result carries untrusted-knowledge,
mayContainInstructions and executable, and acting on those labels is the
calling agent's responsibility (README, and the threat model's T-3 residual). It
is not a slogan: CLAUDE.md's
review rubric anchors CRITICAL to disclosure, and cites T-3 being graded High
rather than Critical as the reason — so how a finding is graded in this project
depends on this line holding. Nothing in this roadmap moves enforcement into
Core.
2. Any impact-style answer declares the population it was computed over
Adopted 2026-08-20 as a standing principle, not deferred to Phase E's implementation.
Any knowledge.impact-style answer this project ever ships must state, as part
of the answer:
- the commit it was measured against,
- the number of recorded edges the traversal actually walked, and
- that coverage is not guaranteed.
The rationale is that false traceability is this roadmap's own largest product risk (§10). An assertive report computed over an incomplete graph does not merely mislead — it induces agent error with citations attached, which is harder to detect and harder to recover from than an obviously unsourced answer. A report that cannot state its own population is not permitted to imply completeness.
This is recorded here so that it constrains the design of Phase C's traversal — whose response shape Phase E's report is computed from — rather than being discovered when Phase E writes its schema. Phase E turns it into an ADR (ADR candidate #7) and a schema-required field.
3. A disclosure change is ADR-first
may_surface has six call sites, pinned by
tests/unit/test_gate_call_sites.py, and domain/enums.py's module docstring
says so. Four security advisories have been published against this project, and
all four are disclosure defects (GHSA-266v-fcj2-qggx, GHSA-7997-g35f-q59h,
GHSA-vx8x-rjfj-9x54, GHSA-w5cm-cqf9-vm7r; the threat model records each). Any
change that relaxes what may surface — most directly Phase D's history access —
is written as an ADR and passed through security review before implementation,
not after.
4. A claim about a measured property ships with the measurement or not at all
This document's own header follows it. Numbers quoted here are attributed to where they were recorded; nothing here is a fresh benchmark. The same applies to every phase: a phase that asserts an improvement without a measurement has not finished.
7. Roadmap
Phases rather than milestone numbers, because the milestone numbers already disagree with reality (§0); Phase 0 includes making the README's table true. Each phase ships independently, and only dependencies constrain the order. Anything independent may run in parallel.
Phase 0 — Stabilize: 0.1.0 stable, and making the record true
| Goal | Take the pre-1.0 label to zero, ship 0.1.0 stable, and make every statement in the repository agree with the implementation. |
| User value | The first release that can promise an upgrade path. Every later phase's credibility rests on it. |
| Architecture | #119 (enforcement of the sensitivity, tenant and ACL axes — four-part shape below), a decision on T-16 (implement install-time verification or record it as a non-goal — taken on 2026-09-05, and the Exit criteria row below carries the record), and #67 (required status checks). |
| #119 is mandatory before 0.1.0 stable | Recorded as a decision on 2026-08-20 in a comment on the issue itself. It was previously a recorded deferral; it is now a release gate. The reasoning: sensitivity and trustLevel are published on every retrieval result and filtered on by no query, which by this project's own severity rubric is the shape of a published claim that misleads a security decision. It must be closed before Phase B lets more agents write. |
| #119 is not a predicate change | A read-side predicate alone ships two defects, both verified in source and recorded on the issue. (1) The index side is half the control: IndexBuilder._build gates on status only and writes sensitivity into every chunk row (index_builder.py:146,209), so a query-time predicate leaves withheld-by-sensitivity chunk text in the FTS5 tables, where BM25 collection statistics still price the visible rows — T-17a's mechanism moved from the status axis to the sensitivity axis. (2) changeSensitivity must become a purge trigger: migration_engine.py:658-669 deliberately excludes it from the withdrawal-purge set, on the recorded ground that the stale sensitivity column "is read by no gate before #119". The moment a gate reads it, that exclusion inverts into a defect — a document reclassified internal → restricted keeps clearing the gate under its stale label until the next manual index build, and there is no canonical re-check for sensitivity of the kind CanonicalVisibility._may_surface performs for status. So the shape is at least four-part: build-time gating and derivation · a changeSensitivity-triggered purge extending ADR-0024 decision 5 · the read-side predicate · the two-corpora equality suite parametrized over the sensitivity axis. |
| Schema | No canonical change, and no new index columns — they already exist. But not index-side only: part 2 is an application-layer change, adding changeSensitivity to the migration engine's withdrawal set. |
| MCP / API | system.capabilities note updated. The response shape is not the only observable — withholding moves counts, budgets, rankings and collection statistics unless the equality property is held by construction, which is exactly what T-17 falsified about "more rows are withheld, that is all". |
| Migration | No canonical migration. What changes is what a changeSensitivity triggers: under ADR-0024 a purge copies the published build and deletes the withheld rows from the copy, so this is a copy-and-delete build rather than a full re-derive. |
| Security | SEC-8 resource bounds (#215, #232, #245, #26), T-16, and the audit of every threat-model Controls line against src (#199). |
| Tests | #119 extends the existing two-corpora equality tests by parameterising them over the sensitivity axis — over all four parts above, not the predicate alone. |
| Benchmark | None — Phase A owns that. |
| Exit criteria | 0.1.0 live on PyPI · pre-1.0 open = 0 · every appendix contradiction cleared · SECURITY.md and README pointing at the release authority — the core-v* tag list and PyPI — instead of asserting a version the next cut falsifies · the T-16 decision recorded — taken on 2026-09-05: install-time artifact verification is a non-goal for 0.1.0, and for 0.1.0 only — that comment is the decision record and carries the scope; the acceptance is re-taken before 0.2.0 — or discharged before then by building the control. The reasoning is in the threat model's T-16 entry, which is the record the Architecture row asks for; the gap keeps its owner, #80, while the control itself is still unowned — a successor issue is still owed. |
| Dependencies | None. Can start immediately. |
| Risks | Some of what the label holds needs a design decision (T-16 — taken 2026-09-05; #119's defaults — decided, recorded in the #119 closed (2026-08-24) row below) rather than execution. Estimating the label as a queue of chores is how it overruns; apply the class-budget discipline from CLAUDE.md. |
| Open design question | ~~Whether withheld-by-sensitivity rows are excluded from the index entirely or indexed-and-gated, and what entitlement decides "withheld" at all in a single-user loopback daemon~~ — both settled and both recorded as amendments to ADR-0025, which is where the reasoning lives. Exclusion at build time, with one build flavor per deployment; entitlement is a deployment serving profile, one operator-declared ceiling in the operator-owned data directory. This roadmap did not decide them and does not restate them. |
| #119 closed (2026-08-24) | The three rows above are the analysis that opened it and are kept as the record of what was believed on 2026-08-20; two of their present-tense source claims are now false and are corrected here rather than edited there. IndexBuilder._build no longer "gates on status only" — it consults may_disclose beside may_surface and writes no row above the deployment's ceiling. migration_engine no longer "deliberately excludes" changeSensitivity from the withdrawal-purge set — it admits it, and revisions_to_purge reduces the set against the build's recorded indexedSensitivities as a second flavor axis. The four-part shape those rows predicted is what shipped, and all four are discharged in ADR-0025's compliance section. sensitivity is no longer in the "filtered on by no query" sentence; trustLevel still is. What the Risks row calls "#119's defaults" was decided: a restrictive default, ceiling internal. |
Phase A — Retrieval evaluation baseline (golden queries)
| Goal | Build a harness that measures retrieval quality reproducibly, and make it the regression baseline for every later retrieval and graph change. |
| User value | The first time "what did this ranking change improve, and what did it break?" has a numeric answer. Whether RAPTOR goes default-on, whether the mixed-length CJK defect is worth fixing, and whether a real embedding model is adopted are all decided here. |
| Architecture | A development tool, not a runtime feature: tools/eval/ plus a pytest marker. A frozen fixture corpus (a snapshot of this project's own ADR corpus, synthetic withheld rows, a synthetic superseded chain, CJK documents) with committed golden queries and judgements. Fully offline and deterministic. |
| Schema | None. |
| MCP / API | None — evaluation runs against real MCP responses, the same discipline the wire-contract tests use. |
| Migration | None. |
| Security | Withheld rows in the fixture are synthetic only; no real secret goes into a fixture. The abstention class doubles as a disclosure-equality regression. |
| Tests | Determinism of the harness itself: the same corpus and the same queries produce a byte-identical report. |
| What the harness will measure | Query classes: exact decision · rejected alternative · cross-ADR · broad architectural · historical (enabled after Phase D) · spec → ADR → implementation (after Phase C) · code → decision (after Phase C) · impact (after Phase E) · conflicting · superseded · unknown, where no answer exists. Metrics: Recall@k · MRR · evidence precision (are the anchors right) · superseded-knowledge error rate · abstention accuracy · latency and index cost. These are the quantities the harness will produce. They are not current properties and no target value is set here — the first run defines the baseline, and the baseline is recorded as a dated measurement pinned to a commit SHA. |
| Exit criteria | A baseline report is committed and CI reports regressions against it. Whether it becomes a blocking gate is decided after #67. The RAPTOR, CJK and dense decisions each have a measurement behind them. All three met, 2026-09-24. ① The baseline is committed under tools/eval/baseline/ (673b12cf), with its README as the method record. ② CI reports against it: core.yml's retrieval-baseline job runs compare_baseline.py --advisory when retrieval-affecting paths change; compare_baseline.py's own module docstring is the single authority for what that advisory scope covers. ③ Each decision has its measurement. RAPTOR is decided — opt-in, default-on declined — on the slice-S4c comparison arm, recorded in ADR-0008's decision 10 amendment with the deltas, the isolation property and the re-check command. CJK is measured by the baseline's own two CJK members: q-cjk-mixed-length returns its target inside the first five but not at rank 1 (Recall@1 0.0, Recall@5 1.0, MRR 0.25), and q-cjk-unspaced does not return it within ten at all (0.0 throughout) — the mixed-length defect and the unspaced one (#284) quantified rather than described, and still unfixed, which is what this row asked for. Dense rests on the measurement ADR-0009's Milestone 5 amendment already records: the shipped hashed character-trigram embedder ran and lost — 91% of unrelated natural-language questions cleared its similarity floor while the lowest genuinely related query fell below the unrelated median, so the distributions overlap and no threshold separates them — which is what made useDense opt-in. That is a measured arm, not an unrunnable one: what waits on a real provider is the real-model arm, and adopting one stays conditional on an improvement this corpus can show. The blocking-gate question is untouched by all three and stays deferred to after #67, recorded as untaken in ADR-0036 decision 4. |
| Dependencies | None. Runs in parallel with Phase 0. |
| Risks | ADR-0021 rejects learning to rank on the grounds that it "needs labelled relevance judgements this project has no way to collect, and would make ranking un-reproducible across installs", which looks like a collision. Design it so it is not one: judgements are committed to the repository and thereby fixed, are never used to train ranking, and serve only regression detection and design decisions. LTR stays a non-goal. Record the reconciliation as ADR candidate #5. Recorded: ADR-0036, which confirms ADR-0021 rather than amending it — committing the judgements is what supplies the across-installs property that rejection named as absent. |
Phase B — Agent write path and review ingestion
| Goal | Let an agent from any vendor create a proposal over MCP, and collect KnowledgeCandidates from GitHub reviews. Promote the "proposes" in "AI proposes" from a CLI to a protocol. |
| User value | The spec-agent / code-agent / review-agent → proposal → human-approval shape works for agents other than Claude Code. A shared write path across agents opens for the first time. |
| Architecture | The write-intent tools ADR-0013 already specifies (knowledge.proposeChange, knowledge.generateMigrationDraft, review.generateKnowledgeCandidate). Every one of them outputs a proposal file and nothing else — and each is added to the existing bytecode-walk test that holds no registered tool reachable to a canonical write. The first two shipped in slice B4 (ADR-0032); the third is ADR-0033's and registered in slice B5. What slice B4 found is that the bytecode sweep is not, by itself, the control this row describes: it reaches one level and does not enter a collaborator's body, so a tool holding a ProposalService and calling accept would pass it. The tools are handed a draft-only facade instead, and a closure walk over the built server is what holds "reaches no approved-state write" (ADR-0032 decision 8). Review ingestion is an infrastructure/github/ adapter plus normalisation, designed in review-knowledge.md. |
| Schema | The proposal format is unchanged. KnowledgeCandidate uses the domain model that already exists. |
| MCP / API | New tools plus their wire schemas (additive; a protocol bump is not expected). writeTools: true, and the system.capabilities note updated with it. Done for the first two tools in slice B4: the registered set went from seven names to nine, two input schemas landed under schemas/mcp/, writeTools and the note moved in the same commit as the registration (ADR-0032 decision 5), and protocolVersion stayed theurian/v1 — the expectation this row recorded, now a measurement. Done for the third in slice B5: review.generateKnowledgeCandidate took the set from nine names to ten, its published input schema landed under schemas/mcp/ beside the others, and no flag moved — writeTools already read true, and it answers whether a write-intent tool exists rather than how many (ADR-0033). Appendix row 6 below carries the ten and recomputes them from the built server (test_documented_tool_set.py). |
| Migration | None. |
| Security | The preconditions are already recorded in the threat model. SEC-10's repository allowlist ships — read and enforced before the gh review adapter spawns anything (ADR-0030) — while the URL allowlists stay owed in the raw-URL context (#429; #129 closed on the wording, not the controls). SEC-11 (secret scanning) ships at the approval gate — theurian propose accept scans every body it would land and the migration document's author-written fields with it (#336), block by default per security.secretScan (ADR-0027 decision 3) — so the title and the published source anchors (provider, sourceUri, repository, commitSha, filePath), which appear verbatim on every result, are covered at the gate; index-time scanning shipped under #329 and theurian ingest runs no scan of its own — #198 is closed, having shipped the propose accept half above — and a proposal's evidence.json is scanned at accept since #361, leaving draft-time advisory scanning as the owed half of #330; the surfaces that describe what is and is not in force are regression-pinned by test_config_key_call_sites.py and test_examples.py. SEC-12 (JSON Schema validation of MCP input) becomes mandatory the moment a write-intent tool opens, and it ships ahead of that opening (ADR-0031): every tools/call is validated against that tool's published input schema in an SDK ServerMiddleware before dispatch, and a registered tool that resolves to no loaded schema is refused there rather than served — so a write-intent tool joins the control by being registered instead of by being remembered. The value-domain half ADR-0032 decision 3 assigns — the body cap and uniqueItems on labels[] — arrived with the tools in slice B4 and is published in schemas/mcp/knowledge-propose-change-input.schema.json. Two limits on it are recorded rather than closed, and the §1 SEC-12 row carries both: the maxLength counts code points where the constant it transcribes counts bytes (#691), and over the shipped transport it is shadowed by the tighter rendered-character bound and the transport cap (#699). T-15's merge residual was a Phase B precondition, and slice B3 satisfied its commit half: opening a protocol-level write path multiplies the callers who can put a file in .theurian/migrations/, so migrate apply now refuses by default a migration that is not committed at HEAD (ADR-0034; --allow-uncommitted restores the old behaviour). What stays owed is the merge itself — a local commit on a local branch still passes, because merged into a reviewed branch is a forge fact, not a working-tree one. evidence.json's agentId and model become the only record of origin in a multi-agent setting, so their being required is preserved. |
| Tests | The e2e ADR-0013 explicitly records as owed — after an agent session that calls every write-intent tool, approved state is unchanged — is discharged here. Landed in slice B4 as tests/e2e/test_write_intent_session.py, against a real daemon: the canonical store and the approved bodies are byte-identical afterwards, and the session is asserted to have landed a distinct proposal per tool, so a session that called none cannot pass it. Its coverage set is derived from the daemon rather than committed: the write-intent tools are the registered ones whose published input schema requires an evidence object (ADR-0032 decision 4), and that set is asserted equal to the arguments the session carries, so a newly registered write-intent tool with no entry reddens the test rather than going undriven. Slice B5 is where that derivation paid: review.generateKnowledgeCandidate joined by registering with a required evidence — nothing in the e2e named it — and the fixture grew the review evidence, the built review store and the anchored commit the tool needs in order to be driven at all. |
| Benchmark | No retrieval impact. Candidate quality is judged by the human reviewing; automatic quality scoring is a non-goal. |
| Exit criteria | A demonstration of propose → PR → merge → apply from both Claude Code and Codex (or any plain MCP client). Not reviewIngestion: true — ADR-0030 slice 3 moved that flag ahead of this phase, on the serving half alone, so it is already satisfied and no longer discriminates. What this phase owes is the half the flag does not report: a KnowledgeCandidate generated from an ingested review thread. Slice B5 landed that half and made it wire-reachable — review.generateKnowledgeCandidate is registered (ADR-0033), and test_candidate_generation_wire.py::test_a_thread_meeting_every_signal_lands_a_proposal_over_the_wire drives one such thread through the tool over the transport and finds the proposal under .theurian/proposals/. What is left of this row is the demonstration itself, which no test stands in for: propose → PR → merge → apply run from a second client. writeTools: true does not discriminate either, for the same reason one slice over: slice B4 moved it (ADR-0032 decision 5), and what this row asks for is that demonstration, which is F ①'s adapter and has not run. |
| Dependencies | Phase 0 — #119 and SEC-12 first. Both have landed: #119 in Phase 0, and SEC-12 in this phase's own slice B2 (ADR-0031), ahead of the tools it gates. The ordering is about writers increasing, not gates. |
| Risks | A new prompt-injection surface: review text is untrusted content, and turning it into a candidate is precisely the path by which an injected instruction becomes a knowledge candidate. The existing safety triple and never-auto-approve (FR-V4) absorb it, and the threat model's T-3 section now carries the candidate path (slice B5, ADR-0033): no comment body reaches the candidate — the proposal's title, body, kind, category and anchors are the caller's submission — thread text reaches an agent through review.search under the triple, and a candidate is a draft proposal a human merges or does not. T-3 keeps its grade and states why, and the residual it names is its own one actor later: an agent that writes a planted instruction into its own submission produces a candidate Theurian cannot distinguish from a fair generalization. |
Phase C — Traceability foundation
| Goal | Collect and query the graph exactly as traceability.md already designs it. Answer why is this code here? with citations. |
| User value | An agent can follow a chain of evidence — code → PR → spec → ADR → requirement — rather than a list of related documents. |
| Architecture | Three edge sources to start: ① migration declarations (addRelation, existing), ② commit trailers (Refs: ADR-0021) collected by ingest, ③ scope.paths, the data dev7 began writing. External nodes are TraceNode plus evidence, confidence and source_commit. Traversal is read-only with depth and node-count limits. |
| Schema | Promote the existing, unpopulated traceability_edges table to real use. Add apply-time enforcement of INV-6. |
| MCP / API | One new read tool, knowledge.trace: a bounded traversal from an item id or a TraceNode, with direction, edge type and depth. traceability: true. The nine tools traceability.md anticipates are not built — start with trace alone (plus Phase E's impact) and add only what use demonstrates. |
| Migration | If external-node edges need a migration operation, adding to the closed set is an apiVersion decision (ADR candidate #3). |
| Security | A graph response is a new disclosure family. Edge counts, reachability, and where a traversal truncates can each carry the existence of a withheld node. Apply the per-edge visibility gate at every hop — and apply it in T-21's corrected form, because per-edge gating on its own is what leaked: a traversal hop must not resolve an alias when deciding authority. Reachability may resolve; the visibility decision on each hop's endpoint reads the literally-named row (get_item_exact). Multi-hop traversal multiplies the number of places that distinction can be lost, which is why it is stated here as a requirement rather than left to the implementer. Write the two-corpora equality test for trace responses in the same change as the implementation — not afterwards. That is T-17's lesson. |
| Tests | Equality extension · depth limits · cycle tolerance · confidence and evidence passed through faithfully · a test pinning that every hop's visibility decision goes through the non-resolving read path, so a hop that reverts to a resolving read turns it red rather than reopening T-21 in graph form. |
| Benchmark | Enable the golden set's spec → ADR → implementation and code → decision classes. Add citation correctness as a metric the harness measures. |
| Exit criteria | A demonstration on this project's own corpus that "why is this module shaped this way" traces to an ADR. Equality tests green. |
| Dependencies | Phase A, for the measuring stick. Independent of Phase B — they may run in parallel. |
| Risks | Misleading a reader about how complete the edge collection is (§10, false traceability). Mitigation: the response says recorded edges in its schema field names and descriptions, and claims no coverage. §6 principle 2 governs the shape. |
Phase D — Temporal engineering truth
| Goal | Guarantee "what is currently in force" by enforcement, and make "how did it change" answerable for the first time. |
| User value | On top of an agent not mistaking a superseded ADR for current guidance — which already holds today, since superseded content is never returned — history becomes answerable when asked for. |
| Architecture | ① Apply-time enforcement of the status transition graph (today any transition applies). ② Separate the semantics of historical disclosure: rejected may contain secrets and stays permanently non-surfaceable, exactly as now, while superseded and deprecated are history and become readable through an explicit opt-in, labelled. The current implementation collapses both into the same unreachability, which is what makes historical queries impossible in principle. ③ FR-R7's second half: re-query a past state by snapshotId. |
| Schema | The transition graph is a domain constant checked by the migration engine. The disclosure change is a change to the semantics of SURFACEABLE_STATUSES and may_surface. |
| MCP / API | includeSuperseded on knowledge.get and knowledge.trace: explicit opt-in, reachable only along a supersedes chain, every result labelled. Not added to search — history is traversed, not searched. |
| Migration | None; semantics only. |
| Security | This is the phase's core risk. §6 principle 3 applies in full: item ② is written as an ADR (candidate #2) and passed through security review before implementation, and the change is write-side as well as read-side — two of may_surface's six call sites are writers (the index builder, which decides what is written, and the withdrawal purge, which decides what a published index must stop holding), so a semantics change moves what gets indexed and what gets purged, not just what a query returns. Design on the assumption that superseded bodies contain secrets. That is not a worst case but the recorded procedure: the threat model's T-15 names superseding the revision or retiring the item as the operation for removing a secret that got in, so superseded revisions are where removed secrets collect by design — and T-17 records that performing exactly that remediation is what re-opened a channel to read the secret back. Supersede-time disclosability metadata is therefore mandatory in this phase, not a contingency. |
| Tests | RED tests for transition enforcement · an equality test that history is reachable only through the opt-in and never leaks into search · determinism of snapshot re-query. |
| Benchmark | Enable the golden set's historical and superseded classes. The superseded-knowledge error rate becomes meaningful here. |
| Exit criteria | An evolution-shaped query is answerable from the supersedes chain with dates. An illegal transition is refused with an exit code. |
| Dependencies | Phase C (chain traversal sits on trace). ADR candidates #1 and #2 accepted. |
| Risks | A mistake on a disclosure surface is CRITICAL by this project's own definition. ADR-first and equality-test-first absorb it. Transition enforcement breaks apply if the existing corpus holds an illegal state — verify against the dogfood corpus beforehand. |
Phase E — Impact analysis and drift detection
| Goal | Answer what happens if SPEC-104 changes? within the range of recorded edges, and detect divergence between the graph and reality by machine. |
| User value | Before starting a specification change, a list of ADRs to reconsider, implementations to touch, and tests to run, each with a citation. |
| Architecture | knowledge.impact is a report layer that aggregates Phase C's traversal by node type. It performs no semantic inference — graph traversal only. The output is a set of counts by node type, each item's confidence, and the population declaration §6 principle 2 requires. Drift is the machine-decidable subset of D1–D7 (scope.paths against changed files, superseded dependencies, dangling TraceNodes), implemented as a CLI command CI can call. No policy engine is built — traceability.md itself warns that "a policy that blocks unrelated work gets disabled entirely, which is strictly worse than a partial one", so whether to block is CI's configuration to make. |
| Schema | None; it sits on Phase C. |
| MCP / API | knowledge.impact (read-only). Drift is a drift CLI subcommand — Phase E work, and not a registered command today. |
| Migration | None. |
| Security | The same disclosure family as trace — an aggregate count must not carry a withheld row, so the equality tests extend to impact responses. On resources, the traversal bound is documented as a SEC-8-class limit. |
| Tests | Equality · limits · "an item with zero edges returns an empty report plus its population declaration". |
| Benchmark | Add impact precision and recall to the golden set, with a ground-truth impact set built by hand once over the dogfood corpus. |
| Exit criteria | A demonstration on the dogfood corpus: a spec change producing an impact report. Drift detection finds at least one real drift in this repository — the appendix below is full of candidates. |
| Dependencies | Phase C (required). Phase D (improves precision; not required). |
| Risks | §10's false impact confidence is the largest. Making the population declaration a schema-required field is what turns "claims no completeness" from an intention into a structure. |
Phase F — Ecosystem (opportunistic, in any order)
| Goal / contents | ① A second client install adapter (a generic .mcp.json, or Codex CLI configuration) — the second implementation of McpClientConfig. Small, and the shortest route from asserting vendor neutrality to demonstrating it. ② Context package export — a context-package export command, not registered today: write a summary of approved knowledge to a static file of the AGENTS.md kind. Treated as an Index-class derivative — stamped with a generated-artifact label and the stateHash at generation, and never promoted to approved knowledge (ADR candidate #8). ③ A real embedding adapter (opt-in extra, only if Phase A justifies it). ④ Abstractive RAPTOR (only after SEC-16, and only if evaluation justifies it). ⑤ Discovered traceability — experimental research, see below. |
| Security | ① widens the config-writing surface (extends T-14's scope). ② carries the risk of a derivative being misread as truth, absorbed by the label and the stateHash. ④ is T-3 and SEC-16's main battleground. ⑤ is bounded by the fact that a discovered edge is never applied without human approval. |
| Exit criteria | Independent per item. ① is an e2e from Codex. ② is determinism across generate and regenerate. |
| Dependencies | ② none. ③ and ④ need Phase A. ⑤ needs Phase C. |
Phase F ⑤ — Discovered traceability (experimental)
Adopted 2026-08-20 as an extension slot, not a commitment.
External tools that already understand code — an LSP or Serena, Git, an OpenAPI document, a test framework — can discover relations that a human never declared: that this function implements that specification, that this test verifies that requirement. The proposal is that such a tool may submit what it found as an evidence-backed proposal, which goes to human review, and only a human's approval turns it into a traceability edge.
That is exactly ADR-0013's principle, applied to a discovered edge: AI proposes. Git reviews. Humans approve. A discovered edge enters as a proposal like any other, carries its evidence and a confidence below 1.0, and becomes an edge only through a merge.
Theurian itself never performs code analysis. Code and symbol search are delegated to Serena, and this item does not reverse that: the analysis happens in the external tool and Theurian receives only the claim. The scope of this item is therefore the submission surface — what an external discovery tool has to provide for its claim to be reviewable — and nothing else.
It is recorded here as an extension slot so that Phase C's edge model is designed with room for it, and so that a future proposal to add it has a starting point. It is not scheduled, and nothing depends on it.
Dependency summary
flowchart LR
P0["Phase 0<br/>Stabilize"] --> PB["Phase B<br/>Write path / review"]
PA["Phase A<br/>Eval baseline"] --> PC["Phase C<br/>Traceability"]
PA --> F34["Phase F ③④<br/>embeddings / abstractive"]
PC --> PD["Phase D<br/>Temporal"]
PC --> PE["Phase E<br/>Impact / drift"]
PC --> F5["Phase F ⑤<br/>Discovered traceability"]
PD -. "improves precision" .-> PE
P0 -. "may run in parallel" .- PA
8. Prioritization
| Priority | Item | Reason, in one line |
|---|---|---|
| P0 | Phase 0 — whatever the pre-1.0 label holds, #119 sensitivity enforcement, making the record true, the T-16 decision |
Everything else assumes it. #119 in particular is a precondition for going multi-agent |
| P0 | Phase A — golden query set and evaluation harness | Without it every retrieval decision stays a guess |
| P0 | Phase B's write-intent MCP tools, plus SEC-10, SEC-11, SEC-12 | The conditions under which "proposes" becomes vendor-interchangeable |
| P1 | Phase B's review ingestion · Phase C (trace) · INV-6 enforcement · the second client adapter (F ①) |
The core of the differentiation. F ① is small and turns an assertion into a demonstration |
| P1 | Phase D's status transition enforcement, and drafting ADR candidates #1 and #2 | The hole in the lifecycle. The disclosure change is ADR-first |
| P2 | Phase E (impact, drift) · Phase D's history disclosure and FR-R7's second half · context package (F ②) | The harvest that sits on top of C and D |
| Experimental | A real embedding adapter (F ③) · abstractive RAPTOR (F ④) · discovered traceability (F ⑤) · a reranker · submitFeedback |
Each only if evaluation, or a demonstrated need, justifies it |
| Not recommended | Agent orchestration and workflow state machines · a query classifier inside Theurian · ANN or a vector database by default · a graph database (SQLite is sufficient at this scale) · a policy enforcement engine · code and symbol indexing · automating or proxying approval · becoming a "control plane" (§11) | Outside the boundary, or in conflict with an existing ADR or the threat model |
9. ADR candidates
- Enforcing the status transition graph — define the legal transitions and check them in the migration engine (Phase D).
- Historical disclosure of
supersededanddeprecated— separating their semantics fromrejected(permanently non-surfaceable), the opt-in path, and non-exposure throughsearch. ChangesSURFACEABLE_STATUSES, so the ADR comes first without exception (Phase D). - The compatibility policy for extending a closed enum (
kind,RelationType, migration operations) — what is additive and what is anapiVersionorprotocolVersionbump (before Phase C). - Formalising the traceability representation — promoting
traceability.md'sTraceNode+ evidence + confidence + commit anchor design into an ADR (Phase C). - Governance of golden-query evaluation — the standing of committed judgements, and the reconciliation with ADR-0021's rejection of labelled relevance judgements (regression detection only; LTR permanently a non-goal) (Phase A) — accepted as ADR-0036.
- Whether the
Specificationentity folds into a knowledgekind— which of the two the traceability work builds on (before Phase C). - The semantics of impact analysis — recorded-edges-only, the population declaration as a required field (§6 principle 2), and extending disclosure equality to graph responses (Phase E).
- Governance of a derived context package — the proposal is that a generated artifact needs no new classification and is fixed as Index-class: losable without loss, and asserting no truth (Phase F ②).
- The default policy for a real embedding adapter — as an amendment to ADR-0009 and ADR-0021 (Phase F ③).
- The approval-provenance pointer — recording the merge commit at
migrate apply(small; runs alongside Phase B).
Existing ADRs needing reconciliation rather than replacement, both folded into Phase 0's truth-making:
- ADR-0003's "the port set is exactly these fourteen" is already broken, and
the closed-set test does not see the whole break. Measured at
f702736on 2026-08-20: 17Protocolclasses are declared underpackages/theurian-core/src/theurian/domain/ports/, whileALL_PORTSnames 15. The two outside it areCanonicalReadSession— a read sessionCanonicalStorereturns rather than a separately wired port — andMcpClientConfig, whose own module docstring calls itself a port ("McpClientConfig port … ADR-0012, §24.2"). It is not inALL_PORTSandports/__init__.pydoes not import it, sotest_port_set_is_closed, which compares names drawn fromALL_PORTS, cannot see it at all. So the ADR-less port additions past ADR-0003's fourteen are at least two:IndexStore, which at least reachedALL_PORTSand the closure test, andMcpClientConfig, which reached neither. ADR-0003 point 5 says adding a port requires an ADR, and none was written for either. An amendment is owed, and it should say which register —ALL_PORTS, or "aProtocolunderdomain/ports/" — the closed set is actually closed over.
Discharged by #140. ADR-0003 point 5 now carries a Milestone 7 amendment naming
ALL_PORTSas the register, with the three Protocols outside it and the reason each is outside. Two corrections to the paragraph above, recorded rather than applied silently:
- The figures moved, twice. Re-measured on 2026-09-04 on the branch of #534:
ALL_PORTSholds 17, against 20Protocolclasses underdomain/ports/. The 15-and-17 above is thef702736reading and stands as history; the 17-and-19 this row first carried was already stale when written, becauseIndexBuildSessionwas declared outside the register by #329 while the amendment was in flight. Three names joined point 5's fourteen and none left it:IndexStore,ReviewFindingSourceandReviewFindingStore. The last two arrived under ADR-0029 — the "adding a port requires an ADR" path — andIndexStoredid not.- "a read session
CanonicalStorereturns" was never true — and this bullet has now got the mechanism wrong three times. NoCanonicalStoremethod returns aCanonicalReadSession; that much was measured and has held throughout. The three attempts at who receives one: first "a read sessionCanonicalStorereturns" (wrong atf702736and never true); then a sharedstore_factory: Callable[[Path], CanonicalReadSession]across two builders (wrong — the annotations differ); thenRetrievalServiceas the consumer of that annotation (wrong — it takes nostore_factoryat all). Measured withinspect.signature, not read:ResultGate.__init__takesstore_factory: Callable[[Path], CanonicalReadSession](application/retrieval_service.py) andIndexBuilder.__init__takesCallable[[Path], IndexBuildSession](application/index_builder.py). Each correction was made by grepping for the annotation, which answers the file, and then naming the class from the file — the class was inferred every time. That is the defect, and it is recorded here rather than quietly fixed a third time, because the count of attempts is the only thing that stops a fourth. The conclusion is unchanged throughout: what an operator substitutes is still aCanonicalStoreadapter, so neither session opens a substitution point the register does not already cover.Not settled here: whether
McpClientConfigshould joinALL_PORTS. That is its own ADR-requiring decision. This block used to place it on #140, which closed with the amendment and therefore holds nothing; the decision is now owned by #553, and ADR-0003 point 5's amendment records it open in theMcpClientConfigrow of its Protocols outside the register table. - GOVERNANCE.md says an accepted ADR is never edited, only superseded; the practice is to append amendments. All 26 ADRs areaccepted, none issuperseded, and 10 of them carry at least oneAmended in Milestone Nblock (measured 2026-08-20; 24 atf702736, and 26 once ADR-0025 and ADR-0026 land — neither of the two new ones carries an amendment, so the 10 is unmoved). One of the two has to become the rule.
10. Risks
| Risk | Assessment | Mitigation |
|---|---|---|
| Complexity explosion | Real, and the largest. traceability.md already imagines nine tools |
Phases, and starting from two tools (trace, impact). Keep the "not recommended" table as the record of what was deliberately not built |
| The graph becomes the source of truth | Medium. Edges themselves arrive through migrations and are governed, but an external-node edge is only a claim | Extend ADR-0010's authority rule: the edge is canonical; the truth about the external entity it points at is always Git's. Surface confidence below 1.0 |
| Stale relationships | Certain to happen — code moves and the edge stays | Mandatory commit anchor, plus drift detection (Phase E), plus a staleness report of the same shape as indexStale. No magical automatic decay |
| Contamination by AI-generated knowledge | Low to medium. The structural defences are already strong: a candidate is never auto-approved, and a summary is routing-only | Phase B adds the candidate path to threat-model T-3. The context package is fixed as Index-class (ADR candidate #8) |
| Vendor coupling | Low on the wire, medium on bootstrap. Plus an external risk: the MCP specification itself changing | F ① early, for a second implementation. The SDK is already confined to mcp/ and daemon/ (ADR-0014) and the wire contract versions the protocol |
| Indexing cost | Low. The graph tables are small, RAPTOR is opt-in, and purge cost is measured: 51 ms against 2,614 ms on a 12.3 MB index and 579 ms against 37,684 ms on a 150.3 MB one, recorded in ADR-0024's table and quoted in ADR-0022 | Phase A includes index cost and update cost among the quantities the harness measures |
| Migration complexity | Low. "Schema mismatch means rebuild; no in-place migration" is already working | Keep the discipline. Every canonical addition is additive |
| False traceability / false impact confidence | The product's largest risk. An assertive report over an incomplete graph induces agent error with citations attached | §6 principle 2, as a schema-required field · confidence required · no wording that asserts completeness · impact precision and recall in the benchmark |
| A graph response as a new disclosure family | High. Edge counts, reachability and truncation each carry withheld existence — T-17's shape, in graph form | Extend two-corpora equality to trace and impact in the same change as the implementation (an exit criterion of Phases C and E) |
| Execution bandwidth | High. The pre-1.0 queue and six phases under a single maintainer, in a project where a warm-up slice has already consumed four review rounds |
Apply CLAUDE.md's class-budget rule at phase granularity too: three sibling findings, two follow-up PRs, or eight hours splits the box |
11. Final recommendation
"From knowledge retrieval tool to engineering truth" — sound, and already on the record
docs/index.md already states that Theurian is building toward a
chain from requirement → spec → ADR → PR → review → code → test → evidence, and
GOVERNANCE.md
already commits review ingestion and traceability to Core permanently. The README
declares traceability as Milestone 8. The evolution into a record of engineering
truth is therefore not a hypothesis — it is an established direction that is
unfinished, and Phases B through E are the plan that finishes it. Technically,
this review confirmed that most of the required foundation already exists: typed
relations, the TraceNode design, validity, disclosure equality.
"Control plane" — the name and the role are both rejected
Three reasons, each grounded in the codebase.
- It collides head-on with the project's own safety principle. "Theurian labels; it does not enforce. Acting on the label is the calling agent's responsibility" (README, T-3) is not decoration — it is the basis on which the threat model grades. A control plane includes enforcement by definition, and the moment that line is crossed, "the agent did not comply" becomes a Theurian vulnerability.
- The control points already exist, and competing with them is wrong. Approval's control point is Git (branch protection, ADR-0013's "Git reviews"); enforcement's control point is CI. Making Theurian a third control point is the entrance to the sprawling ALM product this project exists not to be.
- The physical design — local-first, loopback, single user — is incompatible with a control plane's availability and consistency requirements. Meeting them makes hosting inevitable, which conflicts with ADR-0009 and with GOVERNANCE's commitments.
The name that was adopted: Evidence Plane / System of Record
Settled 2026-08-20. The positioning is "Evidence Plane / System of Record", not "Control Plane". The surface of fact that agents, CI and humans all refer to. Recorded as ADR-0026, which carries the three reasons below as its rejected alternative and says which half of the boundary is held by a test and which is policy.
Theurian does not orchestrate, does not approve, does not enforce.
CI calling the Phase E drift subcommand and blocking a PR on the result is a
welcome configuration — and the thing that blocked was CI. That sentence is the
definition of the boundary.
Theurian in three years — the target, not the present
Everything in this paragraph is a description of where the phases above lead. None of it is a claim about what ships today; §0 and §1 are.
Theurian would be a local-first system of record for engineering truth, running alongside each repository. An AI agent from any vendor pulls, over MCP, the specifications, design decisions, constraints and rejected options that are currently in force, each with its evidence and its origin. Every proposed change lands in Git as a proposal, and a human's merge is what updates truth — by then enforced rather than merely intended, which is work T-15's residual still owes. It holds typed edges from requirement through to test, and answers "why does this code exist" and "what does changing this specification affect" — while stating the range of recorded edges it answered from. It does not orchestrate. It does not approve. It does not enforce — those are the jobs of the agent, of Git, and of CI. Theurian stays the small, checkable, offline-capable surface of fact that all of them can share while each remains replaceable.
Appendix: documentation contradictions to clear in Phase 0
Inconsistencies between documents, and between documents and the implementation. Each is the shape this project grades HIGH in its own review rubric: a published claim that is false.
Items are measured at the commit named in each cell; unless a cell says
otherwise, that commit is f702736, 2026-08-20. Several cells have been
re-measured since, because later changes in this repository kept moving the
numbers they cite — each of those names its own commit and says which change
moved it.
The table is a mixed list. Rows marked Discharged are closed and kept for the record rather than deleted; the rest are Phase 0's to clear. A discharge is not permanent: row 7's was withdrawn on 2026-09-05 when the drift it had predicted arrived, and the row says so rather than being quietly re-opened. A withdrawal states what the discharge claimed, what falsified it, and who owns what is left.
Every item was re-verified against f702736 on 2026-08-20. An item that had been
fixed since the original research pass would have been dropped; none had been.
Two further items were found during re-verification and added as items 11 and 12,
and four of the original ten had their counts or populations corrected — the
correction is recorded in the item rather than applied silently, because the
population key is the part a later reader has to be able to attack.
| # | The contradiction | Verified |
|---|---|---|
| 1 | SECURITY.md names 0.1.0.dev0 as the live release |
The string 0.1.0.dev0 appears 8 times; the status table row ("Released, at 0.1.0.dev0") and the release-line discussion are the load-bearing ones. Measured against the PyPI JSON API on 2026-08-20: of eight releases, dev0 through dev5 are yanked and only dev6 and dev7 are live. dev7 is the current version in packages/theurian-core/pyproject.toml |
| 2 | README says Milestone 7 is planned; ADR-0013 says "Landed in Milestone 7" |
The README half is discharged; the ADR half stands. Re-verified 2026-09-05 at f0b1743. The README's roadmap table no longer has a Milestone 7 row to read planned: it stops at "Milestones 0 through 6, which are history rather than a plan", and its What comes next section records the move to phases on 2026-08-20, names this very contradiction as the reason, and discharges its own half in its own words — "This section fixes the README's half; the ADR side is still open (roadmap appendix item 2)". That is a record this row can be checked against rather than a silent edit, which is why the split is written here instead of the row being closed. Still standing: docs/adr/0013-ai-writes-produce-proposals.md opens a section "Landed in Milestone 7, by the theurian propose CL:" and dates three amendments to "Milestone 7", a number the plan of record no longer uses — git grep -c "Milestone 7" -- docs/adr/0013-ai-writes-produce-proposals.md returns 4. It shipped in 0.1.0.dev5. Re-addressing those to a phase, or recording that milestone numbers stay as dated history inside ADRs, is what closes this row |
| 3 | Three places state that the RAPTOR forest is built but never read | False since retrieval began routing through summaries, and two of the three still say it — the "all still say it" this cell used to carry is itself now false. Population key: a sentence saying no query reads the forest, which is three phrases and not one, and one of them wraps a line — git grep -n "not yet retrieved from\|No traversal reads\|are Milestone 6" -- packages/ docs/architecture/ returns 4 lines in 4 files, measured 2026-09-05 at f0b1743. Discharged: docs/architecture/overview.md, which now reads "The RAPTOR forest is now read on the answer path, not only built" and names SqliteIndexStore.search_summaries as the mechanism — it has left the population entirely. Still standing: infrastructure/sqlite/index_schema.py ("a forest is written, purged, and not yet retrieved from") and infrastructure/raptor/__init__.py ("No traversal reads a node back at query time"), while IndexStore.search_summaries and raptor_path exist and system.capabilities reports raptor: true with a comment stating that this build reads the forest. The two further package docstrings also still stand — indexing/__init__.py ("RAPTOR construction and graph edges are Milestone 6") and retrieval/__init__.py ("RAPTOR search and parent/child expansion are Milestone 6") assign in the future tense work that has since landed |
| 4 | ~~"The port set is exactly these fourteen"~~ Discharged. | At f702736: ALL_PORTS held 15 against 17 Protocol classes under domain/ports/. Re-measured on 2026-09-04 on the branch of #534: 17 registered against 20 declared, and three Protocols outside the register — CanonicalReadSession, IndexBuildSession, McpClientConfig. Three names joined point 5's fourteen and none left it: IndexStore, ReviewFindingSource, ReviewFindingStore. Closed by #140: ADR-0003 point 5 carries a Milestone 7 amendment naming ALL_PORTS as the register, the count as a dated measurement, and each outside Protocol with the reason it is outside.The pin landed in the same branch, and it is not a spelling scan. test_every_protocol_under_ports_is_registered_or_recorded_as_outside_it walks the package at runtime — pkgutil.iter_modules + importlib, keyed on _is_protocol — and asserts the membership of declared-minus-registered against a committed EXPECTED_OUTSIDE_THE_REGISTER, not its size; test_adr_0003_names_the_register_and_every_protocol_outside_it holds this ADR's table to that same live difference. A source scan for class X(Protocol) was considered and rejected: it answers a question about spelling, and from typing import Protocol as P declares a Protocol no such scan sees. The prose pin went RED on its first run against real drift — IndexBuildSession, landed outside the register by #329 while the amendment was in flight.Population, re-derived on 2026-09-05 on the branch of #557, at ed7bba76: git grep -n -i "fourteen ports\|14 ports\|these fourteen" -- ':!packages/theurian-core/tests/' returns 11 lines across 7 files; the same key returned 9 across 6 at 7293ca9f, the merge of #534 that this cell last measured against, and the two that joined are the re-seeded twin body's, dispositioned below. The pathspec is part of the key, not a convenience: test_ports.py quotes these fourteen three times as pin machinery — it holds the claim rather than asserting it, which is the opposite of a surface carrying the claim — and without the exclusion the same command returns 14 lines across 8 files. Locations only — two matches are table rows whose pipes would break this cell: .theurian/knowledge/architecture/ports-and-adapters.01M0D5GTH18F2YV32VN4GZGYYA.md:35 and :101; .theurian/knowledge/architecture/ports-and-adapters.01M1QMNB8R2YG7RMS2JVYARM4C.md:35 and :47; CHANGELOG.md:36; docs/adr/0003-ports-and-adapters.md:35 and :47; docs/architecture/requirements-analysis.md:1228; docs/roadmap.md:805 and this row; and, in packages/theurian-core/CHANGELOG.md, the Milestone-0 entry's Fourteen ports bullet — named by its content, because a changelog grows above its old entries and so every line number in one rots: this cell cited :8330, and the same bullet is at :8481 at f0b1743. Dispositions: the two ADR-0003 lines are point 5's original sentence, preserved verbatim beneath its amendment (an accepted ADR is amended, never rewritten) and the amendment quoting it. The two 01M1QMNB8R2YG7RMS2JVYARM4C lines are that same pair in the governed twin at its current revision, re-seeded by #557 below. The 01M0D5GTH18F2YV32VN4GZGYYA pair is the superseded revision: kept in the population as immutable history and not served, since a default index purges non-current revisions along with what may_surface withholds (ADR-0024 Compliance, Decision 5 — the automatic withdrawal→purge trigger — is now wired). Its :101 gained no counterpart in the new body: the source sentence it mirrored — FakeMcpConfig naming three of the fourteen ports — was rewritten by #534 and now reads "four of" at docs/adr/0003-ports-and-adapters.md:216, carrying no literal number. That is why the twin contributes +2 to the population rather than moving 2 lines across. docs/roadmap.md:805 is this section's own bullet, discharged in place above, and the second docs/roadmap.md match is this row — named by position rather than by line number, because a line number here is falsified by any edit above it, and this row has already had to correct its own twice. CHANGELOG.md:36, requirements-analysis.md:1228 and Core's Fourteen ports bullet are all Milestone-0 filing-time records and all three now carry the same dated pointer — pointing the root CHANGELOG and not its two twins was itself the finding, and the asymmetry is what a later audit re-opens. docs/architecture/overview.md has left the population entirely: its diagram is re-keyed to the register and carries no literal number.The served-corpus twin was un-re-seeded; #557 re-seeded it on 2026-09-05. On #534's branch, .theurian/knowledge/architecture/ports-and-adapters mirrored ADR-0003 as it stood at 2a98d4c and so still read "fourteen" with no register beside it, while that branch's amendment moved the source — putting the item in drift (tools/corpus_drift.py there: 7 drifted, architecture.ports-and-adapters among them) and the face was recorded on #315 (comment dated 2026-09-04). Governance forbids editing a governed snapshot in place — it moves by a re-seed, never an edit — so #557 re-seeded all seven through real propose/accept rather than touching the bodies: migration 01M1QMNB8R2YG7RMS2JVYARM4B supersedes revision 01M0D5GTH18F2YV32VN4GZGYYA with 01M1QMNB8R2YG7RMS2JVYARM4C, anchored at fffa6b18, and the new body carries point 5's Milestone-7 amendment with the original "fourteen" sentence preserved verbatim beneath it. Measured on 2026-09-05 on the branch of #557, at ed7bba76, uv run --frozen python tools/corpus_drift.py exited 0 and printed Corpus drift: clean -- no drift -- compared 26 anchor(s) across 48 committed migration(s); 0 uncheckable; 22 superseded. Left open: whether McpClientConfig joins ALL_PORTS — its own ADR-requiring decision. This cell used to say the question was "still on #140"; #140 closed with the amendment above, so it holds nothing. The join decision is owned by #553 (open, filed 2026-09-05), and ADR-0003 point 5's amendment records it open — the McpClientConfig row of its Protocols outside the register table states it as an open question and says the amendment does not take it. This cell earlier read "no issue owns it": that was true at the audit and #553 filed it since |
| 5 | An active_indexes table is described as the publication mechanism |
No such table exists; the mechanism is .theurian/state/active-index.json. ADR-0008 already records this in an amendment and names the remaining population as four: docs/architecture/overview.md, docs/architecture/local-daemon.md, docs/architecture/requirements-analysis.md, and the indexing/__init__.py docstring. docs/architecture/raptor.md was discharged by #136. All four still stand |
| 6 | docs/protocol/mcp-tools.md documents tools in the present tense that do not exist |
Population key: a backticked namespace.member name on that page, less the four that are response-field paths rather than tools (freshness.isWithinValidity, retrieval.snapshotId, retrieval.stale, retrieval.indexed) — which is how f702736's 34 was reached. Re-measured on the branch of #630, 2026-09-10, and again on the branch of #744, 2026-09-19, each time because the branch registering a tool moved the registered half itself and item 11's precedent is that a line this document falsifies itself is corrected in the same change: the page still names 35 distinct tools — review.search was already documented as planned, so registering it moved no name onto the page; ADR-0032's knowledge.proposeChange and knowledge.generateMigrationDraft were already named as planned write-intent tools too, so registering them moved no name onto the page either; and ADR-0033's review.generateKnowledgeCandidate was on the page as a planned write-intent tool before slice B5 registered it, so it moved no name onto the page either — and 10 are registered (knowledge.search, knowledge.get, knowledge.status, knowledge.proposeChange, knowledge.generateMigrationDraft, project.list, review.findings, review.generateKnowledgeCandidate, review.search, system.capabilities). A single blanket disclaimer at the top of the page is the only qualification. The page also describes snapshotId as pinning a state hash so results stay reproducible, which is the unimplemented half of FR-R7. Both figures are pinned as of #504; before that neither was, which is why this row had already been re-measured by hand twice. test_documented_tool_set.py's reach now names this file as its third: test_the_roadmap_row_counts_the_tools_the_protocol_page_names recomputes the named figure from the page under the population key stated at the top of this cell, and test_the_roadmap_row_counts_and_names_the_tools_the_built_server_registers recomputes the registered figure and the ten names from the built server. The seventh tool falsified this row silently, which is how the 2026-09-10 re-measurement was forced rather than remembered; the tenth took it RED instead, which is what the pins bought, and an eleventh will do the same. Same class as #462, which records the ADR count this document and the README carry with no derivation behind it; that issue does not cover this row |
| 7 | The README asserts an ADR count that ADR authoring silently moves | Fixed twice, then falsified a third time by its own escape clause — so the discharge this row used to carry is withdrawn rather than quietly kept. The README said "23 ADRs" against 24 at f702736; the README-repositioning change (#282) corrected it to 24 but left this row asserting the old text, so the row was itself stale for one commit. The ADR-drafting change that added ADR-0025 and ADR-0026 set the README to 26 against 26, and the row was discharged on that reading — while saying, in the same sentence, "nothing pins the number, so the next ADR falsifies it again". Three ADRs later it had. Measured 2026-09-05 at f0b1743: git ls-files 'docs/adr/0*.md' \| grep -v 0000-adr-template \| wc -l returns 29, and docs/adr/README.md lists 29 rows, against a README link label still reading 26. The README's face is fixed here by taking the count out, which is one of the two durable fixes the withdrawn discharge named: the label is now ADRs, the register its siblings in that nav row already use (Roadmap, Threat model), and the authority for how many exist is the index at docs/adr/README.md — a table that cannot disagree with itself. The row stays open for what is left, owned by #462 (open; carries bug but no pre-1.0/post-1.0 gate label as of 2026-09-05, so it is outside the 0.1.0 exit criteria): no test recomputes an ADR count from the index, so the other assertions can still drift. Their population, under the key digits, optional bold markers, then ADR: git grep -nE '[0-9]+\*{0,2} ADRs?' -- ':!packages/theurian-core/tests/' ':!.theurian/' ':!docs/work-logs/' returns 3 lines in 1 file after this change — this row's own "23 ADRs" quotation, row 8's **26**, and the "Existing ADRs needing reconciliation" bullet above, which carries measured 2026-08-20 and so reads as a dated snapshot rather than a live claim. Row 8's number sits under this table's f702736 default frame and is not corrected here, because row 8's own contradiction is still live and unmeasured |
| 8 | GOVERNANCE.md forbids editing an accepted ADR; the practice is to amend | GOVERNANCE.md: "Decisions are changed by superseding an ADR, never by editing an accepted one", echoed by docs/adr/README.md ("Do not edit an accepted ADR beyond typo fixes"). In fact all 26 ADRs are accepted (24 at f702736, plus ADR-0025 and ADR-0026), none is superseded, and 10 carry at least one Amended in Milestone N block — which is what CLAUDE.md instructs. Two documents give opposite instructions. ADR-0025 sharpens the conflict rather than easing it: it commits to being amended when #119 settles its open questions, which is the practice GOVERNANCE.md forbids |
| 9 | "Every port ships a deterministic fake" | Population key: any statement that a fake exists for every port, not the literal string "deterministic fake" — the sixth site below phrases it differently and would be missed by a literal search. tests/fakes/ defines five doubles (FrozenClock, SeededIdGenerator, InMemoryWriter, FakeService, FakeMcpConfig) against 15 ALL_PORTS entries, and FakeMcpConfig is a fake for McpClientConfig — a port ALL_PORTS does not contain (item 4). The claim stands in six places: ADR-0003 point 7, docs/architecture/requirements-analysis.md, docs/contributing/development.md, CONTRIBUTING.md, domain/ports/__init__.py, and packages/theurian-core/tests/fakes/__init__.py, which adds a second false claim — "a conformance test asserts it" — where test_ports.py contains no occurrence of "fake" at all. ADR-0003's own Still owed section already refutes its point 7, so the ADR contradicts itself within one file |
| 10 | ADRs still carry Still owed items addressed to Milestone 6, which the README declares done | Population key: any Still owed section, not one literal heading. Live figures, not a dated reading, with this cell's own keys and a section opener spelled as a line start (git grep -c "^Still owed…" -- 'docs/adr/0*.md' \| wc -l): under the exact string Still owed, with the milestone that will satisfy it: the count is 18 of 36, and under the concept 27 of 36. Only the literal pair is pinned. test_the_roadmap_still_owed_row_counts_the_literal_sections reads the first N of D in this cell and recomputes both halves — numerator against the grep, denominator against the live ADR file count — so that pair moves with every ADR that lands and cannot be anchored. The concept figure beside it is recorded-only: that test's own docstring records that the concept count is a hand classification across several opener spellings and is deliberately not recomputed, so it is as current as the last person who re-derived it — re-derived 2026-09-24, with this cell's own keys, when Phase A slice S4b discharged ADR-0036's last owed item (the fifth event below), moving it from 28 to 27; before that, re-derived at PR #683 when slice B3 discharged ADR-0034's section, also moving it from 28 to 27; before that, 2026-09-13, at 88244bf2, the commit that added ADR-0035 and is named here rather than as "on this branch", which is what the previous provenance said and which stops resolving the moment that branch is squashed. Recorded-only is not unattackable, and the key that produces it is stated here so it can be: git grep -l -E "^(- \*\*)?Still owed" -- 'docs/adr/0*.md' \| wc -l returns 27, over eight distinct spellings — the opener lines partition 18 + 2 + 2 + 1 + 1 + 1 + 1 + 1, which sums to the 27 and is how the two figures are checked against each other rather than read side by side. At 88244bf2 the first bucket held 19 and the total was 28; Phase B slice B3 discharged ADR-0034's owed section (PR #683), taking the literal bucket to 18 and the total to 27 without emptying any spelling, so the eight distinct spellings are unchanged — the same way ADR-0035 joined ADR-0026's existing phase spelling rather than adding a ninth, and it is the first discharge to move these figures where every earlier move in the Phase B wave was an addition. That key is a mechanization of the hand classification and not a redefinition of it, which is checkable rather than asserted: run at f0b1743 it returns 22, reproducing the anchor below exactly. The dated reading it replaced, kept as the anchor: 15 of 29 literal and 22 of 29 by concept at f0b1743 on 2026-09-05, across seven opener spellings. Three different events have moved it since, and the numbers alone cannot tell them apart — two of the three move exactly the same pair of figures. ADR-0030 moved the denominator and the concept numerator, but not the literal one: it heads no Still owed section, and it carries one line that opens an obligation with that string — the bullet at ADR-0030:1521, - **Still owed, and not a test obligation — the known-key set is re-taken whenever clause 8's version floor moves.**. It is counted for the same reason ADR-0024's prose debt is counted: the key is the obligation and not the layout. It is the eighth spelling, and the seventh was the last one this cell knew about. This cell said that each Phase B design ADR moves the denominator and both numerators, "because each one opens its owed section with the exact literal string — chosen so that an ADR which ships no behaviour and owes every property in it is visible to this cell's own key instead of joining that blind spot". ADR-0035 falsified it, and the correction is worth more than the sentence was. It is a Phase B design ADR on three attributes it states about itself — it ships no code (ADR-0035:22), it names slice B4 as the owner of one of its gaps (:280), and its owed items are addressed to "a Phase B slice, after the write path lands" (:296) — so it is squarely inside the population the sentence quantified over, and the membership needs nothing beyond a Phase B design ADR that does not head the literal spelling. It opens its owed section with ADR-0026's Still owed, with the phase that would satisfy it: instead, because what will satisfy its items is a phase rather than a milestone, and that is the honest spelling for a design that waits on Phase B's write API. The claim that held was never about every design ADR: it is about ADR-0031 through ADR-0034, the four write-path and candidate-generation designs, each of which headed the literal string when it was written — ADR-0034 discharged its section in slice B3 (below) and no longer does, which is the literal count's move from 19 to 18. So the convention was a convention of that wave and not a property of the class, and the blind spot the old sentence said had been designed away has one member: an ADR that ships no behaviour, names no new pin of its own, and is invisible to the literal key. That is not the same as owing everything, and this cell said it was — the overstatement is corrected here rather than smoothed away, because misreporting an ADR's verification state is the defect this row exists to catch. ADR-0035's Compliance section partitions three ways and opens by saying it does: two entries rest on pins that already pass — test_no_registered_tool_can_reach_a_canonical_write, which the ADR calls "the pin ADR-0013 and ADR-0026 already name" and says interactive curation inherits "rather than adding one", and test_generation_writes_only_under_the_proposal_directory, which an affordance drafting through ProposalService "inherits" too; both were run green at 88244bf2 rather than taken on the ADR's word; one entry carries two properties held by prose and review; and two entries are Still owed, the mechanism-time driving test for decisions 3, 4 and 5a, and the T-3 threat-model entry. Decision 3 sits on both sides — FR-I3's routing is held today, its driving property is not — so even "owes every property it newly decides" would overstate it. The keys above count spellings, not debt, and nothing in either one reports how much an ADR owes: reading "has a Still owed section" as "owes all of itself" is the inference that produced the wrong sentence, and the Compliance section is what settles it. Whoever writes the next design ADR chooses a spelling and thereby chooses which numerators move — that is the thing to decide deliberately, and it is the reason the concept key exists beside the literal one. ADR-0035 is therefore the third event, and in the figures it is the twin of the first: denominator and concept numerator by one each, literal numerator unmoved, which is what ADR-0030 did too. They are different events all the same — ADR-0030 arrived as a prose bullet in a spelling nothing had used before, the eighth, while ADR-0035 arrived as a heading in a spelling ADR-0026 already had, so it added no ninth and the spelling count stayed at eight. A reader holding only the deltas would take them for one recurring thing. The three are measured as a set difference against the anchor rather than inferred from the numbers, the treatment the ab07590b→f0b1743 range gets below: over f0b1743→88244bf2 six files joined the concept population — ADR-0030, ADR-0031, ADR-0032, ADR-0033, ADR-0034, ADR-0035 — of which the four Phase B wave ADRs also joined the literal one, and none left either population, which is why every movement in this range is an addition and 15→19 and 22→28 need no discharge to explain them. A fourth event, measured the same way — 2026-09-20, on the branch of PR #776, whose sha is not reachable from origin/main and so is named by its pull request: against the state this cell last recorded — 18 literal and 27 by concept, of 35 — one file joined the concept population, ADR-0036, and none left either. It heads ADR-0026's existing Still owed, with the phase that would satisfy it:, so the literal numerator is unmoved at 18, the concept numerator moves 27 → 28, the denominator 35 → 36, and no ninth spelling appears. In the figures it is the twin of ADR-0035 and for the same reason: its owed items wait on a phase — Phase A's own slices — rather than on a milestone, which is what makes that spelling the honest one rather than a choice about which numerator to move. A fifth event, measured the same way — 2026-09-24, in the Phase A slice-S4b work; no sha on that branch is reachable from origin/main and no pull request is open for it as this is written, so it is named by its slice, a58fdcb5 being the reachable commit its committed baseline was measured at: against the state the fourth event left — 18 literal and 28 by concept, of 36 — one file left the concept population, ADR-0036, and none joined either. S4b discharged its last owed item, the committed baseline and the advisory CI comparison, and the section went with it: the concept numerator moves 28 → 27, the literal numerator is unmoved at 18 — that ADR never headed the literal spelling — and the denominator is unmoved at 36, no ADR file having been added or removed. No spelling disappears: the phase spelling keeps ADR-0026 and ADR-0035, so the partition becomes 18 + 2 + 2 + 1 + 1 + 1 + 1 + 1 and the eight stand. It is a discharge and not a rewording, and only the set difference says which — the key stopped seeing ADR-0036 because the obligation is gone and its Compliance section carries the slice-S4 entry instead, not because an opener moved, which is exactly the pair ADR-0018's rewording is recorded below to keep distinguishable. The sentence this cell used to carry here was falsified by its own re-derivation, and is corrected rather than dropped. It said that neither ADR-0029 nor ADR-0030 contains the string Still owed, quoting git grep -c "Still owed" -- docs/adr/0029-*.md docs/adr/0030-*.md as matching nothing. Re-run at 4431951c, the commit that branch squashed into, it answers docs/adr/0030-…:1 — and the same key answers identically at 88244bf2, so nothing in this range moved it. Anchored to the commit rather than to "this branch" for the reason stated above: a branch reference stops resolving the moment it squashes, which is what happened to this one. ADR-0029 is still outside both counts — it contains the string nowhere, so both numerators skip it correctly. It is not that they head What this does not close — ADR-0027 heads both that section and a Still owed one, so the heading excludes nothing. ADR-0029 does carry owed items; they sit outside both counts by spelling, which is a recorded limitation of the key rather than a statement that it owes nothing. Two of the eight are not headings at all — ADR-0024's Still owed to ADR-0018, and **not** satisfied by point 4: and ADR-0030's bullet above, each a recorded debt written as prose and each counted because the key is the obligation and not the layout. The earlier readings, kept so the direction is visible: 17 of 26 literal and 21 by concept at the ADR-drafting change, and 17 of 24 and 19 by concept at f702736. The literal count fell by two while the total rose by three, and the two are not the same event — measured as a set difference between ab07590b and f0b1743 rather than inferred from the numbers. docs/adr/0015-dco-over-cla.md left both populations: its section is gone, discharged by c7d49ff7 (#67, the required status checks). docs/adr/0018-single-writer-synchronous-in-m1.md left only the literal one, having reworded its opener to Still owed, with the issue or milestone that will satisfy it: — the debt is unchanged and the key stopped seeing it. One discharge and one rewording, which a count on its own cannot tell apart, and that is why this cell states the difference and not just the delta. The concept population moved over the same range by different members — 21 → 22, and no spelling change among them: 0015 left with its section, and so did ADR-0025, whose debts were discharged when #119 landed; 0024, 0027 and 0028 joined, being ADRs written since. This cell used to say, in the present tense, that ADR-0025 "heads its section Still owed, with the part of the decision each discharges:". That was true at ab07590b and is false at f0b1743 — the section is gone. The spelling is kept on the record as a ninth — counted against the eight live spellings the concept key returns above, and outside them, because ADR-0025 carries the string nowhere today (git grep -c "Still owed" -- 'docs/adr/0025-*.md' exits 1 with no output) and so joins neither numerator. It is recorded as a spelling a literal sweep would miss if the section ever returns, not as a member of either population. An earlier reading of this cell called it "an eighth", which collides with ADR-0030:1521's bullet above — that one is live and is the eighth of the eight. ADR-0026 does still head its own Still owed, with the phase that would satisfy it:, and it is no longer alone in it — ADR-0035 heads the same spelling, which makes it one of the two non-literal spellings with more than one member, beside ADR-0027 and ADR-0028's shared Still owed, with the issue that will satisfy it:. The other five each have exactly one ADR, per the partition above. ADR-0036 joined that same phase spelling on 2026-09-20 (the fourth event, above), taking its membership to three — 0026, 0035, 0036 — while the count of spellings with more than one member stays at two; it left again on 2026-09-24 (the fifth event, below), so that membership is two today, 0026 and 0035, and the count of spellings with more than one member is still two. The two pre-existing variants are ADR-0012's Still owed: and ADR-0013's Still owed, with the milestone that brings the feature under test: — ADR-0013 being the very ADR Phase B claims to discharge, so a literal-string sweep would skip the one that matters most to this roadmap. That the count moved because this document's changes moved it is the point of stating the key: the number is a measurement, and measurements have a date. The clearest instance: ADR-0004, ADR-0005 and ADR-0017 each name an empty-db-rebuild CI job as the thing that would discharge FR-K4. Re-measured 2026-09-05 at f0b1743 with git grep -l empty-db-rebuild rather than rg, because rg skips dot directories and the served corpus lives in one: 11 files, of which 5 are documents saying the job does not exist (docs/adr/0004, 0005, 0017, docs/protocol/migrations.md, and this roadmap) and 6 are the served-corpus snapshots of three of them; .github/workflows/ holds six workflows and none is it. The gate question is settled, and settled means recorded rather than reasoned: the owner #64 is open and carries post-1.0, so FR-K4's proof is outside Phase 0's exit criteria by label, not by judgement. The rest of this row is not settled — each remaining Still owed item still needs to be discharged or re-addressed to a phase |
| 11 | Found during this re-verification. The root CHANGELOG says it records "milestone completions"; it records Milestone 0 and nothing after | CHANGELOG.md has three entries — the 2026-08-20 note recording this roadmap's adoption, plus "Milestone 0 complete" and "Project started" — while the README declares Milestones 0 through 6 done. The file's own statement of scope is still false for six completed milestones, 1 through 6; the adoption note is a governance event, not one of them. (The evidence in this cell was "two entries" until the README-repositioning change added the third — a line this document falsified itself, corrected in the same change rather than left for Phase 0.) |
| 12 | ~~Found during this re-verification. SECURITY.md contradicts itself about whether sensitivity is refused at write time~~ Discharged. |
The two passages were: the migration schema accepts public\|internal\|confidential\|restricted "with no refusal (unlike tenant and ACL group, which are refused at write time)", against "sensitivity, tenant and ACL group are refused at write time". The code agreed with the first — only tenantId and aclGroup raise UnenforceableScopeError — and the row said Phase 0 must fix it before the axis gained a gate. The axis gained its gate instead, and the fix came with it: b857c1a9, #352, closing #119. Sensitivity is now an enforced read control rather than a write-time refusal, and SECURITY.md was rewritten alongside it. Re-verified 2026-09-05 at f0b1743: the surviving passage states the first reading and adds the four places the axis is enforced (build, retriever predicate, canonical gate, changeSensitivity purge); a second passage further down states the same split — tenant and ACL group refused at write time, sensitivity excluded at build time and re-checked at the gate. The two are consistent, and neither says sensitivity is refused at write time. The retired passage now survives only inside this cell, as history: git grep -n "sensitivity, tenant and ACL group" returns 1 line, this row. Quoting it here is what keeps it in the population, and that is deliberate — a discharged row that deletes the claim it discharged leaves a later reader no way to check the discharge |
This document records direction. It is not a commitment to dates, and nothing in it describes a capability that ships today. The recommended first move under the adopted plan is Phase 0 and Phase A in parallel, together with drafting ADR candidates #3 through #6.