Theurian — Requirements and Architecture Analysis
Status: accepted for Milestone 0 Last updated: 2026-08-01
This document is the answer to the twenty-one questions asked before implementation started. It is the reference every ADR, schema, and package boundary in this repository traces back to. When this document and an ADR disagree, the ADR wins and this document must be corrected.
1. Functional requirements
Identifiers (FR-*) are stable and referenced from ADRs, tests, and issues.
1.1 Project management
| ID | Requirement |
|---|---|
| FR-P1 | Register a Git repository as a Project with a stable projectId, root path, repository URL, default branch, and knowledge directory. |
| FR-P2 | Unregister a Project without deleting Git-tracked knowledge. |
| FR-P3 | List registered Projects and report per-Project status (migration state, index state, last seen commit). |
| FR-P4 | Serve many Projects from one daemon process, with no process-global "current project". |
| FR-P5 | Treat each Git worktree of the same repository as a distinct Project context, never conflated. |
1.2 Knowledge lifecycle
| ID | Requirement |
|---|---|
| FR-K1 | Represent knowledge as an immutable KnowledgeRevision under a mutable KnowledgeItem pointer. |
| FR-K2 | Apply state changes exclusively through declarative YAML knowledge migrations. |
| FR-K3 | Support the operations: createItem, upsertRevision, deprecateItem, restoreItem, addRelation, removeRelation, addAlias, removeAlias, changeSensitivity, changeOwner, registerSpecification, supersedeSpecification, addEvidence, removeEvidence. |
| FR-K4 | Rebuild the entire canonical state from an empty database using only Git-tracked inputs. |
| FR-K5 | Detect a migration whose recorded checksum differs from its file checksum, and fail loudly. |
| FR-K6 | Enforce optimistic concurrency via expectedRevision. |
| FR-K7 | Order migrations by topological sort over dependsOn, and reject cycles. |
| FR-K8 | Re-applying an already-applied migration is a no-op, not an error. |
| FR-K9 | Carry per-revision provenance: source, author, revision, validity window, trust level, sensitivity. |
| FR-K10 | Model typed relations between knowledge, specs, reviews, code, and tests. |
| FR-K11 | Resolve aliases so a renamed item stays reachable by its old identifier. |
1.3 Source ingestion and normalization
| ID | Requirement |
|---|---|
| FR-S1 | Ingest Markdown, YAML, JSON, JSON Schema, OpenAPI, AsyncAPI, Git diffs, Git commit metadata, GitHub pull requests, reviews, and issues. |
| FR-S2 | Normalize every source format into one Canonical model without losing the structured fields of structured sources. |
| FR-S3 | Record a SourceAnchor (provider, repository, commit SHA, blob SHA, path, line range, URI, external ID) for every canonical document. |
| FR-S4 | Register new source formats by adding a SourceParser adapter, with no change to domain or application code. |
| FR-S5 | Verify the checksum of every source file referenced by a migration. |
| FR-S6 | Refuse to read any source file outside the Project root, including via symlink. |
1.4 Retrieval
| ID | Requirement |
|---|---|
| FR-R1 | Filter by Project, tenant, ACL, sensitivity, and validity window before ranking. |
| FR-R2 | Run lexical (SQLite FTS5) and dense (vector) retrieval and fuse with Reciprocal Rank Fusion. |
| FR-R3 | Search RAPTOR summary nodes and expand to parents and children. |
| FR-R4 | Rerank, deduplicate, diversify, and pack results within a caller-supplied token budget. |
| FR-R5 | Return provenance on every hit: itemId, revisionId, snapshotId, indexBuildId, sourceAnchors, raptorPath. |
| FR-R6 | Return safety metadata on every hit: contentClassification, mayContainInstructions, executable. |
| FR-R7 | Pin a snapshotId so results are reproducible for the lifetime of a task. |
| FR-R8 | Search across several registered Projects in one call when the caller is authorized for all of them. |
FR-R1 is two of five axes as of #119 phase 4. SqliteIndexStore._scope
builds the WHERE clause every retriever uses, and it filters on Project, on
sensitivity, and on status — a check FR-R1 does not name. Tenant and ACL have no
column and hold no content: a migration naming a non-default tenant or ACL group
is refused at write time (#110). The chunks table still carries trust_level
and namespace, which no query reads. sensitivity was in that list until #119:
its values are ingested — a restricted document is stored — and it is now
filtered on against the deployment's declared ceiling rather than merely returned
labelled. The per-axis register below states each disposition one by one
(#63, #119).
Per-axis disposition — the register that closes
#63. Each of FR-R1's five
named axes, plus the status check _scope adds that FR-R1 does not name, with
what the pre-1.0 product actually does about it and the PR that established that
disposition. The maintainer's recorded decision deferred tenant, ACL group and
sensitivity to #119, the
successor to this issue, and noted that landing AuthorizationProvider would be
necessary but not sufficient — its local adapter is "allow all" (the port table
above), so #119 also had to give sensitivity a retrieval predicate. It has: the
grant is resolved from the operator's serving profile, the build excludes what is
above it, and _scope/_node_scope emit it (phases 3–4). Tenant and ACL group
remain refused at write time rather than filtered. Project, status, sensitivity
and (on request) the validity window are what is enforced pre-1.0.
| Axis | Pre-1.0 disposition | Mechanism | Landed |
|---|---|---|---|
| Project | Enforced — a pre-ranking WHERE predicate every retriever builds from | chunks.project_id = ? in SqliteIndexStore._scope |
#32 |
| status | Enforced — a pre-ranking WHERE predicate when the caller has not passed includeUnapproved; may_surface at the canonical gate otherwise |
chunks.status = ? in _scope (added only when include_unapproved is false); may_surface in domain/enums.py |
#32 |
| tenant | Refused at write time — a migration naming a tenantId other than local is rejected; no index column |
migrate validate/migrate apply |
#110 (phase 1) |
| ACL group | Refused at write time — a migration naming an aclGroup other than default is rejected; no index column |
migrate validate/migrate apply |
#110 (phase 1) |
| sensitivity | Enforced — against the deployment's declared ceiling, not the caller's request: the build writes no row above it, every retriever filters on it before ranking, the canonical gate re-checks the item's current class, and a changeSensitivity past the build's own ceiling purges the item out of the published index in the same migrate apply (a reclassification into the ceiling waits for the next build — ADR-0025's recorded residual) |
may_disclose in domain/enums.py; chunks.sensitivity IN (…) and nodes.sensitivity IN (…) in _scope/_node_scope; exclusion in IndexBuilder._build; revisions_to_purge in application/migration_engine.py |
#119 phases 3–6; ADR-0025 part 4 (two-corpora suite) discharged in phase 6 |
| validity window | Caller-chosen refinement, not a default filter — omitting asOf filters on nothing; applied after ranking, never inside the retriever depth loop |
knowledge.search's optional asOf → ValidityPeriod.contains, in Python, on both answer paths |
#112 (phase 2) |
The enforced predicates are exactly what _scope emits, and this register is
pinned to that same source the way SECURITY.md is:
three enforced axes — chunks.project_id, chunks.status and
chunks.sensitivity.
tests/unit/test_gate_call_sites.py checks both this block and SECURITY.md's
against what _scope emits — the axis tokens and the spelled count — so the
three copies cannot drift, and enumerates every may_surface call site (bare,
aliased, or module-attribute) so the status gate cannot silently gain one.
The validity-window axis is no longer wholly unenforced, and it is
deliberately not unconditional either
(#63 phase 2).
knowledge.search takes an optional asOf timestamp; passing one applies
ValidityPeriod.contains, in Python, identically on both answer paths —
CanonicalVisibility.at_moment on the ranked path and a plain check inside
mcp.search._scan on the unranked fallback. SqliteCanonicalStore never
compares a timestamp: an earlier version gave list_items a current_at
parameter that filtered in SQL, and it is deleted rather than kept, because it
compared the stored validFrom/validTo against asOf as SQLite TEXT and so
silently disagreed with the Python comparison whenever the two were authored
in different UTC offsets — a HIGH finding in review round 1 of PR #112.
CanonicalVisibility.at_moment also runs strictly after the depth-doubling
loop that decides how many times a retriever is asked for more, never inside
the check that loop's own exit condition watches — folding it in there was a
second, CRITICAL finding in the same round: a caller-chosen moment would have
let that loop's retriever-call count move with asOf, reviving the
single-withheld-row timing oracle FIRST_PASS_DEPTH exists to blunt (see
theurian.application.visibility.Visibility.at_moment's docstring). Omitting
asOf filters on nothing more than before this parameter existed — a default
validity filter was rejected, because it would make freshness.isWithinValidity
constant-true on every published result and give the ranked path a
stale-index statistics residual with no way to turn off, rather than only
while an index build is behind. Sensitivity is now enforced as a control
(build-side exclusion, the _scope/_node_scope predicate, and a
same-migrate apply purge on reclassification); tenant and ACL group are
discharged degenerately, refused at write time so that no stored row carries a
non-default value to filter — a deployment that ever stores a second tenant
needs a real control rather than this argument
(#119 closed both halves;
ADR-0025).
FR-R5's snapshotId and indexBuildId are realized once per response, on the
retrieval block, not repeated on every hit in results. One
knowledge.search response is answered from exactly one canonical state, and,
when ranked, from exactly one index build; a per-hit copy would repeat one
string per result and could never differ between hits. Verified:
retrieval.snapshotId is byte-identical to knowledge.status.stateHash,
because both are read from the same ActiveState within one request, so
provenance can be cross-checked without a second call. What stays per hit is
the part that makes an individual claim checkable — itemId, revisionId,
sourceAnchors — which is what a citation actually needs. See
schemas/mcp/retrieval-metadata.schema.json and
schemas/knowledge/retrieval-result.schema.json for the normative shape.
1.5 Review knowledge
| ID | Requirement |
|---|---|
| FR-V1 | Ingest pull requests, reviews, threads, inline comments, resolution state, target files/lines/commits, fix commits, merge commits, CI results, and linked issues as structured records. |
| FR-V2 | Classify review comments into the eleven categories in §21 of the brief. |
| FR-V3 | Generate a KnowledgeCandidate from a review thread that meets the promotion gate. |
| FR-V4 | Never auto-approve a candidate; approval is a human act recorded as a migration. |
| FR-V5 | Ingest raw reviews successfully even when LLM-based candidate generation fails. |
| FR-V6 | Emit human-readable Markdown views of reviews as derived artifacts only. |
1.6 Specification and traceability
| ID | Requirement |
|---|---|
| FR-T1 | Register structured specifications in their native format (YAML/JSON/OpenAPI) without forcing Markdown. |
| FR-T2 | Store typed traceability edges with evidence and confidence. |
| FR-T3 | Answer: implementations of a spec, tests verifying a spec, unimplemented specs, unverified specs, code without a spec. |
| FR-T4 | Detect the seven drift conditions in §22 of the brief. |
| FR-T5 | Evaluate a configurable per-change-type traceabilityPolicy. |
| FR-T6 | Report contradictions between specifications, and between review knowledge and current specs. |
1.7 Interfaces
| ID | Requirement |
|---|---|
| FR-I1 | Expose the full feature set through the theurian CLI with machine-readable JSON output. |
| FR-I2 | Expose read and propose operations through MCP over Streamable HTTP. |
| FR-I3 | Route AI writes to proposal files under .theurian/proposals/, never into approved state. |
| FR-I4 | Run usefully with no Claude Code installed. |
| FR-I5 | Provide a Claude Code plugin that consumes only the CLI, MCP, health API, public schemas, and compatibility metadata. |
1.8 Setup and lifecycle
| ID | Requirement |
|---|---|
| FR-L1 | /theurian:setup and theurian setup share one application service. |
| FR-L2 | Setup is idempotent and repairs only what is missing. |
| FR-L3 | Installing the plugin alone registers no OS service and starts no daemon. |
| FR-L4 | SessionStart performs a bounded health check only. |
| FR-L5 | Uninstall offers independent choices per artifact class and never deletes approved knowledge without explicit confirmation. |
| FR-L6 | Exactly one daemon process per user per machine. |
2. Non-functional requirements
| ID | Requirement | Target |
|---|---|---|
| NFR-1 | Single daemon | Exactly 1 process for ≥10 concurrent MCP clients. Enforced by an OS file lock plus a port health probe, not a PID file alone. |
| NFR-2 | SessionStart latency |
p95 ≤ 300 ms, hard cap 2 s, then degrade to a warning. |
| NFR-3 | Search latency | p95 ≤ 500 ms for a 10k-revision Project, warm index, excluding external reranking. |
| NFR-4 | Index availability | The previously published index answers every query while a new build runs. Zero read downtime. |
| NFR-5 | Reproducibility | Same migrations + same content + same schema/engine version ⇒ same state_hash ⇒ byte-identical canonical state. |
| NFR-6 | Branch switch | Switching to a previously built state_hash is O(1). A descendant state applies only the delta. |
| NFR-7 | Write model | One serialized write queue; reads use independent WAL connections. |
| NFR-8 | Transaction duration | No external I/O (LLM, network, subprocess) inside a write transaction. Target < 50 ms per write txn. |
| NFR-9 | Portability | macOS (arm64, x86_64) and Linux (x86_64, arm64) at 1.0; Windows behind the same DaemonManager port. |
| NFR-10 | Offline | All features except GitHub ingestion work with no network. All tests pass with no network. |
| NFR-11 | Observability | OpenTelemetry traces and metrics, off by default, never emitting knowledge bodies or tokens. |
| NFR-12 | Uninstallability | Every file Theurian creates is enumerable by theurian uninstall --dry-run before deletion. |
| NFR-13 | Test coverage | ≥ 80% line and branch coverage on theurian-core. |
| NFR-14 | Resource ceiling | Idle daemon < 150 MB RSS with 5 registered Projects. |
| NFR-15 | Cold start | theurian daemon start to first healthy /health ≤ 3 s. |
3. Security requirements
| ID | Requirement |
|---|---|
| SEC-1 | Bind the daemon to 127.0.0.1 only. Never 0.0.0.0, never a non-loopback interface, in the OSS Core. |
| SEC-2 | Validate Origin and Host on every MCP request (DNS-rebinding protection). |
| SEC-3 | Require a bearer token with ≥ 256 bits of entropy from a CSPRNG on every MCP and management request. |
| SEC-4 | Store the token in a 0600 file inside a 0700 directory, and prefer macOS Keychain / Linux Secret Service when available. |
| SEC-5 | Never write the literal token into a Claude Code config file. Configs carry an environment-variable reference only. |
| SEC-6 | Never log, trace, or error-message a token. Redaction is applied at the logging sink, not at each call site. |
| SEC-7 | Resolve every path with realpath and assert containment in the Project root; reject symlinks that escape. |
| SEC-8 | Bound parser input: max file size, max archive expansion ratio, max nesting depth, wall-clock timeout. |
| SEC-9 | Never build a shell command by string concatenation. git and gh are invoked as argument vectors with shell=False. |
| SEC-10 | Validate Git and external URLs against an allowlist of schemes and reject private-network destinations (SSRF). |
| SEC-11 | Scan content for secrets before it becomes an approved revision; block or warn per policy. |
| SEC-12 | Validate every MCP tool input against its published JSON Schema before it reaches application code. |
| SEC-13 | Authorize projectId on every call. A client authorized for Project A must never read Project B. |
| SEC-14 | Never mix content across sensitivity levels, ACL groups, tenants, namespaces, or Projects into one RAPTOR summary. |
| SEC-15 | Tag every retrieval result contentClassification: untrusted-knowledge, mayContainInstructions: true, executable: false. |
| SEC-16 | Treat imperative text inside knowledge bodies as data. Summarization prompts wrap source content in a delimited, explicitly-untrusted region. |
| SEC-17 | AI-originated writes produce proposal files only. There is no MCP path to mutate approved state. |
| SEC-18 | Setup never overwrites an existing file destructively; it backs up or shows a diff and asks. |
| SEC-19 | Every external I/O call carries an explicit timeout. |
| SEC-20 | The cloud port assumes TLS, OAuth 2.1 / OIDC, audience and scope validation, tenant isolation, rate limits, and an audit log — but the OSS Core must not require any of them. |
3.1 Explicit non-goals
- Theurian is not a secrets manager and never stores third-party credentials on behalf of a user beyond its own local token.
- Theurian does not sandbox the knowledge it serves. It labels content as untrusted; enforcing that label is the calling agent's responsibility.
4. OSS requirements
| ID | Requirement |
|---|---|
| OSS-1 | Apache-2.0, with LICENSE at the repo root and in each published artifact. |
| OSS-2 | README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, GOVERNANCE, CHANGELOG. |
| OSS-3 | Issue templates and a pull-request template. |
| OSS-4 | Semantic Versioning per artifact; Conventional Commits enforced in CI. |
| OSS-5 | The DCO-versus-CLA decision is recorded in an ADR (ADR-0015: DCO, enforced by a sign-off check). |
| OSS-6 | Dependency license scanning in CI, with an allowlist. |
| OSS-7 | SBOM (CycloneDX) generated and attached to every release. |
| OSS-8 | Automated dependency updates (Dependabot). |
| OSS-9 | CodeQL, secret scanning, SAST, and dependency review in CI. |
| OSS-10 | Publish a Python package, a Docker image, and a Claude Code plugin. |
| OSS-11 | Release artifacts carry SHA-256 checksums. |
| OSS-12 | Installers for macOS and Linux. |
| OSS-13 | A third party can clone, install, run, and test the project from a clean machine with no paid API key. |
| OSS-14 | Reproducible build instructions with a committed uv.lock. |
| OSS-15 | No hard dependency on any specific LLM, embedding, or cloud vendor. Every such capability sits behind a port with a deterministic fake. |
5. Claude Code plugin requirements
| ID | Requirement |
|---|---|
| CP-1 | The plugin is a separate release artifact with its own version, CHANGELOG, tests, CI job, CODEOWNERS, and docs. |
| CP-2 | The plugin contains no Python import of theurian.*. Its only channels are the CLI, MCP, the health API, public schemas, and compatibility metadata. |
| CP-3 | The plugin ships the twelve commands in §9 of the brief. |
| CP-4 | The plugin ships a SessionStart hook that is a bounded health check and nothing more. |
| CP-5 | The plugin does not declare an mcpServers entry in its manifest. Claude Code auto-connects plugin MCP servers at enable time, which would contradict "install alone does nothing". The plugin carries the connection as a template; /theurian:setup installs it. See ADR-0012. |
| CP-6 | The plugin declares coreCompatibility (minimum, maximumExclusive) and protocolVersion, and refuses to operate — safely, with upgrade instructions — outside that range. |
| CP-7 | No setup logic lives in the plugin. Every command is a thin adapter over theurian <verb> --json. |
| CP-8 | Uninstall is granular and never removes approved knowledge without explicit confirmation. |
| CP-9 | The plugin must remain movable to its own repository without changing Core. |
6. /theurian:setup state machine
/theurian:setup and theurian setup are the same application service
(SetupService.run(SetupRequest) -> SetupReport). The plugin command is a
presentation shell over theurian setup --json.
6.1 States
stateDiagram-v2
[*] --> Preflight
Preflight --> Aborted: unsupported OS/arch, or no Git repo
Preflight --> PlanBuilt
PlanBuilt --> AwaitingConsent: plan contains a mutating step
PlanBuilt --> Applying: plan is empty (already converged)
AwaitingConsent --> Aborted: user declines
AwaitingConsent --> Applying: user approves
Applying --> Verifying
Applying --> Degraded: a non-critical step failed
Applying --> Halted: a critical step failed
Verifying --> Converged: all checks pass
Verifying --> Degraded: a non-critical check fails
Halted --> [*]
Aborted --> [*]
Degraded --> [*]
Converged --> [*]
Degraded is a success-with-warnings terminal state, not a failure: a missing
gh token must not prevent local knowledge from working.
6.2 Step-level state, in order
Every step is a SetupStep with an independent probe result: Satisfied
(skip), Missing (create), Conflicting (back up or ask) — and NotApplicable,
which StepStatus carries so a report never claims to have checked something it
skipped. Satisfied may still carry a detail — a reservation: a finding
with no work attached. The block being correct and the shell exporting its value
are different claims, and row 7 is where they come apart.
_reservations turns each one into a report warning, and both surfaces that
publish a plan call it, because they had drifted. The verification pass carried
the sentence and ended a real run Degraded, while the PlanBuilt report that
theurian doctor and theurian setup --dry-run both return carried no warnings
at all: on one machine, theurian setup said degraded with the sentence and
theurian doctor --json said "warnings": [] and exited 0, the caveat sitting
in the payload the whole time as the detail of a step whose status reads
satisfied — which is where a reader stops. Pinned in
tests/integration/test_setup_cli.py:
test_doctor_calls_a_line_it_will_not_touch_a_warning_and_not_a_problem,
test_the_plan_setup_prints_carries_the_same_reservation_doctor_does, and
test_doctor_says_nothing_about_a_machine_with_nothing_below_the_block as the
control that keeps the first two from passing on a command that warns
unconditionally.
A reservation is not a problem. doctor's healthy and problemCount count
what setup would change and what it would ask consent for, and a reservation is
neither, so neither field moves and the exit code stays 0: a non-zero exit that
no command Theurian ships can clear is how a health check stops being read.
Measured on a real theurian doctor --json over one sandbox with and without a
shadowing line: problemCount is 4 both times, and the second run carries one
extra env-reference: … warning. The status half of _reservations' condition
is load-bearing and pinned, because every machine carries NotApplicable steps
that explain themselves — row 3's supply-chain note is one on every platform —
and matching on detail alone would turn each of them into a finding about this
install
(tests/integration/test_setup_service.py::test_a_step_that_is_not_applicable_and_says_why_is_not_a_warning;
the state a run with no findings at all reaches is
::test_a_healthy_machine_ends_the_run_converged).
| # | Step | Probe | Action when Missing |
Action when Conflicting |
|---|---|---|---|---|
| 1 | Platform check | uname / platform |
— | abort with a clear message |
| 2 | Core present | theurian --version on PATH |
install (explicit user action) — not implemented, see below | version mismatch → offer upgrade |
| 3 | Artifact integrity — not implemented, see below | SHA-256 vs the release manifest | verify before install | abort; never install an unverified artifact |
| 4 | Data directory | ~/.theurian exists, mode 0700 |
mkdir -p, chmod 700 |
tighten mode, report the change |
| 5 | Token | token exists and is ≥ 32 bytes | generate via CSPRNG | reuse; never regenerate silently |
| 6 | Token storage | file mode 0600, or Keychain entry | write | chmod, report |
| 6a | Serving profile | ~/.theurian/auth/serving-profile names a ceiling this build can honour |
— never Missing: an undeclared ceiling is NotApplicable, and the summary names the default in force |
report the refusal and its remedy; setup never writes or repairs that file |
| 7 | Env reference | ~/.theurian/env holds a current Theurian-owned block |
write the block, or rewrite a stale one, leaving every other line alone | markers that delimit no single block: report, never write |
| 8 | Daemon service | LaunchAgent / systemd user unit present | install a user-scoped unit | show a diff, back up, ask |
| 9 | Daemon running | GET /health returns 200 |
start the service | reuse the existing daemon |
| 10 | Single instance | lock held by exactly one PID | acquire | reuse; never kill a live daemon |
| 11 | Project registered | projectId present in the registry |
register the current repo | reuse; update the root path if the repo moved |
| 12 | .theurian/ layout |
required directories exist | create | leave existing files untouched |
| 13 | .gitignore entries |
derived paths ignored | theurian init appends a marked block; setup only reports |
— see below |
| 14 | MCP connection | a theurian entry with the right URL |
write via a merge-not-replace update | back up, show a diff, ask |
| 15 | MCP health | initialize handshake succeeds | — | report and continue in Degraded |
| 16 | Migrations valid | migrate validate is clean |
— | report; never auto-repair data |
| 17 | Initial index | an active_index exists for the current state_hash |
build | reuse |
| 18 | Serena detection | a serena MCP entry exists |
— | report coexistence, change nothing |
| 19 | Report | — | print the changed-files list | — |
Row 6a is not numbered, and that is deliberate. It arrived with the
deployment serving profile (#119,
ADR-0025) — long after this table was written — and it belongs beside the token,
because it is the other operator-owned file in auth/. Inserting it as a numbered
row would move every row below it, and "§6.2 row N" is cited across the tree,
the threat model included. StepId carries it as SERVING_PROFILE, STEPS
places it after token-storage, and probe_serving_profile states why none of
its three arms is Missing.
Rows 2 and 3 are required, not implemented. Setup neither installs Core nor verifies an artifact.
- Row 2 has no
Missingbranch and no install.probe_corereportsSatisfiedorConflicting, and in practice reportsSatisfied: the executable it checks comes from_executable()incli/setup_commands.py, which falls back tosys.argv[0]— the program currently running — soConflictingneeds anargv[0]that does not resolve. Setup cannot report that Core is missing, because setup is Core. - Row 3 performs no check.
probe_artifact_integrityreturnsNotApplicableunconditionally rather than claiming one it cannot make, and there is no download for it to check against: the user installs Core withuv tool installorpipxbefore setup runs at all.
Both rows stand as requirements. Row 3 is the setup-step half of T-16, which §19's threat table maps to OSS-7, OSS-11 and this row; OSS-11 itself — artifacts carry checksums — ships. The gap is tracked at #80, which diagnoses exactly that split and records that a successor issue for the control itself is still owed — so this row is owned as a record, not as scheduled work. It was filed as #39, which closed on its documentation half while the install-time control stayed unbuilt. Why closing it is a change to how Theurian is obtained rather than a probe added here is recorded under T-16.
Row 7's "the Theurian-owned block only" ships, and its tri-state is not where
this table first put it. probe_env_reference compares the marked block and
nothing else; apply_env_reference rewrites that span alone, and every byte
outside it survives byte for byte
(#128) — the span being the
marked block, or, on a machine 0.1.0.dev0–dev2 set up, the unmarked rendering
those versions wrote, which is replaced in place rather than appended beside. A
file that ended without a newline gains one; nothing else is added and nothing
outside is removed. What moved is which
state carries the requirement. A stale or absent block is Missing — Missing
means "not as setup wants it", not "absent" — so the block-only rewrite is the
Missing action, and this row used to name it under Conflicting. Conflicting
is now the narrower case no rewrite survives: markers that do not delimit one
block, where setup cannot tell which lines are its own. It then writes nothing at
all, --approve-conflicts included, and declares no path
(tests/integration/test_setup_env_file.py::test_markers_that_do_not_delimit_one_block_are_a_conflict_and_not_a_rewrite,
::test_approving_the_conflict_buys_progress_and_never_an_overwrite).
Which markers delimit is decided on lines, and on a count taken first. A
marker is a whole line — the file is split on \n alone, with a trailing \r
dropped from the line's text, so a CRLF file delimits while its \r bytes stay
outside every span — and Conflicting covers exactly two arrangements: two or
more start lines anywhere in the file, and a start line with no end line after
it. The starts are counted over the whole file before a span is chosen, which is
what makes the second one's position irrelevant. An end line with no start above
it, and a second end line, are ordinary lines the merge keeps. The rule is
asserted over the population rather than shape by shape, in
tests/unit/test_env_file_merge.py::test_no_arrangement_of_the_markers_loses_a_line_outside_the_block:
every file those three symbols build up to five lines long, 363 of them, 229
refused and 134 merged with every line outside the delimited block surviving in
order. The first cut of this work searched for substrings instead; measured
against it, 39 of the 363 took the wrong refusal decision and 16 of those
reported success while dropping 19 of the user's lines.
Row 7 also has a Satisfied arm that does not mean converged. A shell keeps
the last assignment it reads, so a line below the block assigning
THEURIAN_MCP_TOKEN again is what the machine exports. The probe stays
Satisfied, because the block is correct and applying the step would write the
same bytes, and it is not Conflicting, because a conflict asks for consent to
do something and there is nothing here setup wants to do — that line is not
Theurian's to edit (SEC-18). It is reported instead: the step carries a detail,
_reservations turns it into a warning on both surfaces above, and a real run
ends Degraded
(tests/integration/test_setup_env_file.py::test_an_assignment_below_the_block_is_reported_rather_than_edited_away).
The warning names the path, the variable and the start marker to move the line
above, exactly once, and never the line itself
(::test_the_override_warning_names_the_variable_and_never_the_line_it_found). A
bare export THEURIAN_MCP_TOKEN or a commented-out assignment is not an
override and leaves the run converged
(::test_a_line_that_only_mentions_the_token_leaves_the_run_converged). Currency
is asked first: a block that is stale and shadowed is Missing, rewritten,
and reported by the re-probe afterwards — one warning, not a report instead of
the fix
(::test_a_stale_block_with_a_later_assignment_is_rewritten_rather_than_reported).
What finds that line is a heuristic over the direct assignment forms, and its
boundary is recorded rather than closed. contains_shadowing_assignment reads
one line at a time and recognises a first word spelled THEURIAN_MCP_TOKEN=…, or
that word following export, declare, typeset or readonly. It is wrong in
both directions, measured with /bin/bash sourcing the block and then the line:
| A line below the block | The shell exports | The run says |
|---|---|---|
[ -n "$HOME" ] && THEURIAN_MCP_TOKEN=x |
x |
nothing; Converged |
if [ -n "$HOME" ]; then THEURIAN_MCP_TOKEN=x; fi |
x |
nothing; Converged |
{ THEURIAN_MCP_TOKEN=x; } |
x |
nothing; Converged |
eval 'THEURIAN_MCP_TOKEN=x' |
x |
nothing; Converged |
| an assignment inside a quoted heredoc body | the block's value | the warning |
The four misses are pinned as the recorded boundary, one case per shape and
each measured through a real bash rather than restated against the function
(::test_a_shape_the_heuristic_does_not_recognise_leaves_the_run_silent), so a
change that begins warning on one of them has to come here and to that list and
say so. On those four machines the step's summary still reads
"<data_dir>/env exports THEURIAN_MCP_TOKEN by reference" — true of the block,
and incomplete about the machine.
Not extended, and that is the decision rather than a to-do. What a line does
is settled by the shell at run time — eval takes a string that need not exist
until then, and a heredoc body is not shell at all — so no line-level rule
separates these, each shape added would move the boundary without closing it, and
a probe that runs somebody's shell profile is not a probe. The answer is in the
wording instead: both published sentences say the line appears to assign and
the block appears to be overridden, which is what keeps the last row honest —
that run warns about a file doing exactly what setup wanted, and the shell was
asked before the assertion was written
(::test_the_sentence_about_a_line_it_cannot_read_claims_only_that_it_appears_to_assign,
which holds the summary as well as the detail, because a reader who stops at
a status of satisfied sees only the first; dropping the hedge from the summary
alone survived all 2,442 tests while the detail's was held).
Row 13 is a report in setup and a write in theurian init.
probe_gitignore has no apply. It answers Satisfied when .theurian/state
appears anywhere in the file, Missing otherwise with "Run theurian init", and
NotApplicable outside a Git repository — so a .gitignore whose markers do not
delimit one block still probes Satisfied while that one string is in it. Setup
never opens that file for writing, so nothing is at risk in the meantime; what
the report does not do is say so. ensure_gitignore, which init calls, is the
writer, and #128's class was swept there too: markers matched as whole lines, the
start lines counted first, and both refusals raised as a ProjectError that
init_command renders as error: plus a remedy and exit 1 rather than the Typer
traceback it was
(tests/integration/test_init_gitignore_block.py::test_the_refusal_reaches_a_person_as_an_error_line_and_not_a_traceback,
::test_markers_that_do_not_delimit_one_block_leave_the_file_exactly_as_it_was).
A .gitignore is tracked, so a rule lost there shows in git diff — a
mitigation, not the fix, and worth nothing to somebody running init in a tree
that already has changes in it.
Row 17 names two artefacts, and the step answers the first of them. The
predicate — "an active_index exists for the current state_hash" — is two
questions: whether the canonical state for the migrations on disk has been
applied, and whether a retrieval index has been built over it. Since
#451 probe_initial_index
answers the first one truthfully: the state database
ProjectPaths.of(root).database_for(state_hash) exists, for the hash the
loaded migration set resolves to — the value theurian project status
publishes as stateHash. It used to ask whether an active-state pointer
existed at all, and that pointer is never removed once a first migrate apply
writes it, so from that moment the step answered "Knowledge state is built."
for every later migration set, and the arm naming theurian migrate apply was
unreachable
(tests/integration/test_setup_service.py::test_the_initial_index_step_answers_whether_the_current_state_is_built).
Its detail publishes the separation rather than leaving it to be inferred:
"This is the canonical state theurian migrate apply writes. The retrieval
index over it is separate: theurian index build builds it and theurian index
status reports it."
(::test_the_initial_index_detail_names_the_commands_that_build_and_report_the_index).
The active_index half is answered by no step at all. StepId has 19
members and none of them is the retrieval index, so a doctor run says nothing
about whether an index exists. Measured 2026-09-03 on a sandbox project one
migrate apply past theurian init and with no index build: doctor --json
reported all 19 steps, initial-index among them as not-applicable with
"Knowledge state is built.", while theurian index status on the same tree
answered built: false and stale: true, with the remedy "Run theurian index
build." The gap is a silence rather than a false sentence — the summary is
about canonical state, and the detail names the command that owns the other
half — so this row stands as a requirement, the way rows 2 and 3 above do.
Wiring an index-side answer into the steps is owned by
#528, which is backlog with a
pull-forward condition: it re-enters scheduling when doctor gains its next
index-side arm, or at the stable-rc review. Both shapes that wiring could take
are held. Adding a step moves the count
tests/unit/test_setup_domain.py::test_every_step_of_the_specification_has_an_identifier
asserts; extending an existing step instead moves nothing there, so two further
rules cover it — tests/unit/test_setup_claims.py::test_no_setup_step_reads_the_retrieval_index
scans the setup surface for an index read spelled by name, and
tests/integration/test_setup_service.py::test_no_step_changes_its_answer_when_a_retrieval_index_appears
runs a whole doctor against a tree with a published index and the same tree
without one and requires the two reports to match, step for step and field for
field. What neither of them sees is a step branching only on a pointer that is
present and broken, which those tests record rather than chase.
Row 17's action columns describe work no run performs. The step has no
Missing or Conflicting arm and no apply function: STEPS pairs
probe_initial_index with None, all three of its arms return
NotApplicable, and the only Conflicting it can reach is
SetupService._probe's net for a probe that raised — "Could not check
initial-index.", which a .theurian resolving outside the working tree produces
(#237, T-5). Setup builds nothing here; theurian migrate apply writes the state
database. So this row's build and reuse columns are the requirement and not a
description, the same standing rows 2 and 3 have — §6.4 says it once more from
the journal's side, about rows 16–17 together.
6.3 Idempotence contract
Running setup twice must produce a second report where every step is
Satisfied and the changed-files list is empty.
This said the contract "is asserted directly in
tests/e2e/test_setup_idempotence.py". That file has never existed. What holds
part of it is tests/integration/test_setup_service.py, against fake service and
MCP-config adapters — test_a_second_run_never_regenerates_the_token and
test_a_step_setup_does_not_perform_is_never_reported_as_changed. The end-to-end
statement, against a real LaunchAgent in a disposable profile, is owed with the
rest of the E2E suite: #65,
and see tests/e2e/README.md for which acceptance criteria have no test at all.
6.4 Halting, not rollback
Seven steps journal to ~/.theurian/setup-journal.jsonl: the ones that carry an
apply — rows 4–9 and 14, that is data-directory, token, token-storage,
env-reference, daemon-service, daemon-running and mcp-connection. Each of them
appends one line when it reaches its apply: "applied" when the apply
returns, "failed" when it raises
(test_the_step_that_stopped_the_run_is_journalled_as_failed). Reaching the
apply is the condition, not being one of the seven — a step whose probe found it
Satisfied never reaches it, a Conflicting one never does either (approval
buys progress on the rest of the list, never an overwrite), and neither does any
step after a critical failure has halted the run. The other twelve steps — rows
1–3, 6a, 10–13 and 15–18; row 19 is the report itself and not a step — have no
apply and never journal, so the file records what setup did and not what it
looked at.
The journal is append-only and a record is {"step", "event", "detail"}. There
is no path field — but detail is prose written for a person, and it does name
paths: an applied record carries the step's own action, two of the seven of
which embed an absolute path (data-directory's "Create …/.theurian with mode
0700", and env-reference's "Write …/env, exporting …" for a file that is not
there or "Rewrite the Theurian-owned block in …/env" for one that is), and a
failed record carries the verbatim text of the exception that stopped the step,
which normally names the path that refused. So the file is a trace to read and
not a ledger to parse:
it holds no inverse action, _apply replays nothing, and no production code
reads it back — uninstall_command builds its list from the service path and the
MCP config alone. Enumerating created paths so uninstall can delete them is a
separate requirement, NFR-12, and it is not wired (R-10).
An append either completes or answers False, and never anything between the
two. The record is written through an io.BufferedWriter, which loops until the
buffer is empty and raises whatever the flush or the close hit; a bare os.write
is permitted to write fewer bytes than it was handed and return that count
without raising, which under a file-size limit or a full disk left a truncated
record and reported success. The bytes that did reach the disk stay there — the
file is O_APPEND, so truncating back to a remembered length would discard a
concurrent writer's record rather than this one's — but the run does not then
claim the journal among the files it wrote
(test_an_append_that_could_not_complete_leaves_the_journal_undisclosed). This
is per append and not per run: a line an earlier append landed stays on disk and
stays disclosed when a later one fails, on both the applied and the failed arm
(test_a_step_that_applied_and_could_not_be_journalled_keeps_the_earlier_line_disclosed,
test_a_failure_that_could_not_be_journalled_keeps_the_earlier_line_disclosed).
The file is created 0600 rather than at whatever the process umask allows,
because those lines are local absolute paths and raw exception text, and changed_paths
now points every reader of a halted report straight at them. The arm that fails
to tighten the data directory is precisely the arm that leaves this file's parent
0755 — a refused chmod is what put the run there — so the directory around it
is not what keeps it private
(test_the_journal_is_created_private_inside_a_directory_that_is_not). The mode
is re-asserted on every append too, by an os.fchmod on the open descriptor
before the write, which the creation mode cannot reach on its own in either
direction: a journal that 0.1.0.dev0 or 0.1.0.dev1 created through
Path.open("a") is 0644 under the usual umask and is repaired by the next
append rather than carried for the installation's life, and a umask that can only
take bits away — 0277 creates the file 0400 — would otherwise leave every later
run's O_WRONLY open failing EACCES and the journal silently never written
again. A refused fchmod, a journal owned by another account, skips the append
and answers False, which is the same trade the 0600 creation already makes. It
records the token step and never the token value
(test_the_journal_never_records_the_token_it_watched_being_minted; T-9 in
the threat model).
A critical step failing during apply halts the run at that step. The report's
state is halted, and changed_paths lists:
- the declared
pathsof every step whose apply finished — declared, not re-measured. A step that returned is taken at its word, and the rule that makes that safe is that every apply here writes or raises. One shipped step is an exception to it.apply_token_storageisapply_token, which mints only when there is no token, so on every fresh install the token step ahead of it has already written the file and this apply returns having done neither. Its declared path is truthful only because its predecessor wrote it, and the order is therefore pinned rather than incidental (test_the_token_is_minted_before_the_step_that_stores_it). What swapping the two moves was measured, and it is notchanged_paths: both steps declare the same artefact and whichever runs first writes it, sostate,changed_pathsand both outcomes come back identical under either order. It is the journal that goes wrong — an applied record carries the step's ownaction, and under the swap the second entry reads "Generate a 256-bit token with the system CSPRNG." for a step that generated nothing, an event claim about work that did not happen in the file an operator reads to repair a machine. On a run that does not halt,_verifyre-probes each step and degrades the run when one is still missing; a halted run returns before that pass, so nothing re-checks the trust there. The class an apply that finishes without writing belongs to — this one, and an external tool that exits successfully without writing what it said it would — is recorded as #153; - for the step that failed partway, those of its declared
pathsthat this run moved — provenance, not existence, described below; - the setup journal, when this run appended to it and the append reached the
disk (
test_a_halted_run_names_the_journal_among_the_files_it_wrote,test_a_halted_run_leaves_the_journal_it_wrote_on_disk).
A halted report's warnings are the failures that stopped the run, and carry
no reservations. _reservations is called from the verification pass and from
the PlanBuilt report, and a halted run reaches neither; Aborted and
AwaitingConsent return earlier still, over a blocking conflict and over a plan
nobody has approved. So §6.2's "a later line appears to assign it again" caveat
is absent from exactly those three reports. That is recorded rather than closed,
in _reservations' own docstring: each of the three hands the reader a larger
question first, and the step's detail travels with the report in all of them —
what is missing is the promotion to a warning, not the sentence.
Provenance, not existence. Each of the failing step's declared paths is
reduced to (st_ino, st_mode, st_size, st_mtime_ns) by os.stat immediately
before the apply and again in the arm that assembles the halted report. Absent
before and present now, or present on both sides with a different signature, is
disclosed; the same signature on both sides is not. st_mode is in there because
the data-directory step's write is a mode change — tightening an existing 0755
directory to 0700 moves nothing else, so a signature blind to permissions could
not see that step happen at all. os.stat and not os.lstat, deliberately:
every apply here writes through a symlink rather than replacing one, and a
~/.claude.json that is a link into a dotfiles repository is an ordinary machine
(test_a_write_through_a_symlinked_declaration_is_disclosed). When the
observation itself fails on either side — EACCES on a parent, ELOOP, a name too
long — the path is disclosed anyway: "nothing was written" and "I could not look"
are different answers, and when the run cannot tell, it says so. Whether a
declared path predated the run does not change any of this, which is what the
earlier existence check got wrong.
Both directions are measured. The first five rows are driven by shipped steps;
the last two are driven by a synthetic one-step plan (_step_over) run through
the real SetupService, because no shipped apply can be driven into them —
apply_data_directory's chmod is its last statement, no apply locks its own
parent, and the fixture's docstring records the rest:
| The failing step's declared path | Driven by | In changed_paths |
Pinned by |
|---|---|---|---|
a pre-existing 0755 ~/.theurian whose chmod was refused — inode and mode unmoved |
data-directory | no | test_a_directory_the_run_could_not_tighten_is_not_reported_as_one_it_wrote |
~/.claude.json, left byte-identical by a failed claude mcp add — a file Theurian never writes itself |
mcp-connection | no | test_a_config_theurian_never_writes_is_not_claimed_when_claude_refuses |
a directory at auth/mcp-token, which makes the store raise before it writes |
token | no | test_a_credential_that_was_never_minted_is_not_offered_for_rotation |
| a service definition the manager raised instead of writing | daemon-service | no | test_a_step_that_failed_before_writing_does_not_claim_the_file_it_never_made |
a token file created and written, then a chmod that raised |
token | yes | test_a_step_that_wrote_its_artefact_before_failing_still_discloses_it |
| an artefact whose mode moved and nothing else | _step_over |
yes | test_a_step_that_changed_only_a_mode_before_failing_still_discloses_its_artefact |
| a path that stopped being statable mid-run | _step_over |
yes, and the run still returns a halted report rather than a traceback | test_a_path_that_stops_being_statable_is_disclosed_rather_than_assumed_untouched |
Row two's "no" is about the failed arm only. ~/.claude.json does appear in a
converged run's changed_paths: the mcp-connection step declares it, and
claude mcp add wrote it. "A file Theurian never writes" means Theurian's own
process never opens it — the write is delegated to the claude CLI — which is
exactly why a run where claude refused may not claim it and a run where
claude succeeded may.
The last row's pin holds the disclosure, not the mechanism behind it. The
apply locks the artefact's parent, so the second observation is unobservable and
its signature is None against a tuple — the row therefore passes on the
signature comparison alone, and would keep passing if the known flag that
distinguishes "absent" from "could not look" stopped being consulted. Isolating
pins for the two unknown arms, and for st_ino, st_size and st_mtime_ns
individually, are deferred to
#155.
The env file is the same "yes" as the token: apply_env_reference opens it for
writing, which truncates, so a write that raises after that has already moved
size and mtime and the path is disclosed. That row is read off the open flags
rather than measured: no test drives a truncation followed by a write that
raises, so it is an inference and not a pinned arm.
What the truncation replaces is now preserved by construction
(#128). The merge runs
before the open — the apply reads the file, computes the new contents with
merge_env_file (this data directory's block, every other byte as it was), and
only then opens — so a file whose markers it cannot delimit is never opened at
all, and what a completed write puts back includes the lines the run did not
author. Pinned in tests/integration/test_setup_env_file.py:
test_an_undelimited_env_file_stops_the_run_before_anything_is_written asserts
the bytes rather than the state, and
test_lines_around_a_stale_block_survive_it_being_rewritten and
test_upgrading_a_pre_marker_file_keeps_the_lines_added_to_it assert the whole
file after one.
What is left is the window between the truncation and the write's last byte,
where a device error leaves a prefix of the merged contents on disk. A short
write is not in that window — the write goes through an io.BufferedWriter, for
the reason given above for the journal — and the window is unpinned for the same
reason the disclosure row above is. Nothing is replayed either way: the journal
holds no inverse action, which is why a halted run says where it stopped rather
than offering to undo it.
Paths created implicitly on the way are not listed; a step discloses its declared
artefacts only. That category is wider than the data directory's auth/
subdirectory: the service adapters create ~/Library/LaunchAgents on macOS and
~/.config/systemd/user on Linux the same way. An adapter's own temporary files
are outside it too — a .plist.tmp or .service.tmp can survive an install that
failed, and since it is nobody's declared artefact it is absent from
changed_paths. It is not absent from the report: the failed record's detail
and the report's warnings carry the same reason string, so an exception whose
text names the temporary path puts that path in both
(#152, whose body carries the
same correction). One more file arrives
without setup writing it: the macOS service definition points launchd's
StandardOutPath and StandardErrorPath at <data_dir>/daemon.log, so that file
shows up after a successful setup, written by the service manager rather than by
setup, and belongs in no run's changed_paths. The systemd unit logs to journald
and creates no such file.
The list is de-duplicated in first-seen order, which is the order paths were
accumulated into the report rather than the order the filesystem saw them: each
applying step's paths as the run reaches that step, then the journal appended
last, although its first line reaches the disk as soon as the first applying step
is done and therefore ahead of everything after that step in the list. A
credential minted before the failure appears exactly once, and early, where an
operator will read it
(test_the_changed_paths_keep_the_order_they_were_first_written_in). A run that
wrote nothing names nothing: on a HOME that refuses writes the run halts at
data-directory — the first step that writes, rows 1–3 having no apply to reach
— creates nothing, journals nothing, and lists no path at all
(test_a_home_it_cannot_write_to_halts_the_run_and_names_the_path_that_refused).
Nothing is automatically undone.
That is a design decision, not a missing feature. Deleting a token another
session may already be holding is its own defect, so setup reports where it
stopped rather than reversing. The remedy Core names for an unwanted credential
is theurian auth rotate, in probe_token's own conflict detail; what that
command does is replace the value in place, rewrite the Theurian-owned block in
the env file that points at it — through the same merge setup performs, so lines
somebody added around that block survive the rotation
(tests/integration/test_auth_rotate.py::test_rotation_keeps_the_lines_the_user_added_to_the_env_file)
— and restart the daemon when it can. Two things stop that rewrite and
neither stops the rotation: markers that delimit no single block, which leave the
file untouched, and an OS-level refusal — a read-only checkout, a file another
account owns, a full disk — which leaves it wherever the write reached. By then
the token has already been replaced, so refusing over a comment marker or a
permission bit would leave an exposed credential in place; the file to repair is
named in nextSteps instead, and the second arm names the exception's class and
not its message, which carries whatever the OS put in it
(::test_rotation_leaves_an_env_file_it_cannot_delimit_alone_and_says_so and
::test_a_rotation_survives_an_env_file_the_os_will_not_let_it_write each assert
all three together;
::test_the_refusal_names_the_kind_of_failure_and_not_what_the_os_said holds the
message out). _restart_daemon restarts only where
detect_manager finds a service manager and that manager reports the service as
something other than not-installed; otherwise the command answers
daemonRestarted: false and names the restart in nextSteps. A halted run has
usually stopped before daemon-service registered anything, so that is the arm an
operator acting on a halted report reaches.
No client is reconfigured by it, and none needs to be: what a client
configuration holds is a reference — ${THEURIAN_MCP_TOKEN} in the MCP entry,
THEURIAN_MCP_TOKEN="$(cat <token path>)" in the env file — so the same
references keep working after the value behind them changes. What a process
holds is the expansion, taken once at its own startup, which is the third
participant auth_commands' module docstring names alongside the file and the
daemon: a shell that has already sourced ~/.theurian/env ran its $(cat …)
then, and a running client session expanded ${THEURIAN_MCP_TOKEN} then. Both
keep the old value until re-sourced or restarted, and _restart_daemon returns
that instruction on every path it can take — including the one where the restart
succeeded.
Steps 16–17 are not an exception to any of this: they have no apply either, so setup neither builds nor restores an index or a migration state today. §6.2 records that build as a requirement, not as a description of what the step does.
7. Open questions
Each carries a chosen default so implementation is not blocked. Defaults marked (ADR) are recorded as accepted decisions and can be revisited by superseding the ADR.
| # | Question | Default chosen |
|---|---|---|
| OQ-1 | Does the plugin ship an mcpServers manifest entry? |
No — setup installs it, so plugin install alone is inert. (ADR-0012) |
| OQ-2 | How does the token reach Claude Code without landing in a config file? | ${THEURIAN_MCP_TOKEN} expansion in the MCP headers block, exported from ~/.theurian/env. Verified supported. (ADR-0011) |
| OQ-3 | Default embedding provider? | A deterministic hashing embedder ships in-tree so Core works offline; real providers are opt-in adapters. (ADR-0009) |
| OQ-4 | Which vector store at 1.0? | sqlite-vec behind the VectorStore port, with a pure-SQLite brute-force fallback. (ADR-0014) |
| OQ-5 | Is snapshotId per-request or per-session? |
Per-request and explicit. There is no server-side session state. (ADR-0002) |
| OQ-6 | Multi-project search authorization model, locally? | The local token grants all registered Projects; per-Project ACL is a cloud-port concern with the interface defined now. |
| OQ-7 | Do we support Git submodules as Project boundaries? | Not at 1.0. A submodule is registered as its own Project if wanted. |
| OQ-8 | Where does ingested review evidence live? | .theurian/review/, as durable structured files — not .theurian/cache/, and not rebuildable from the GitHub API: upstream comments are editable and deletable, so a discarded copy is data loss rather than a cache miss. The SQLite serving store is the derived, deletable half. (ADR-0030 decision 3, which withdrew this answer's 2026-08 form and the ADR-0004 list entry behind it) |
| OQ-9 | Conventional-commit scope names? | core, plugin, schemas, docs, ci, packaging. |
| OQ-10 | DCO or CLA? | DCO, enforced by a sign-off check. (ADR-0015) |
| OQ-11 | Do knowledge bodies support transclusion/includes? | No at 1.0. Composition is a relation, not a text include — includes would break content hashing. |
| OQ-12 | Migration file naming? | <ulid>-<kebab-slug>.yaml; the ULID is authoritative, the slug is cosmetic. |
| OQ-13 | How are proposals reviewed? | As a normal pull request. Theurian generates the files; Git and GitHub do the review. |
| OQ-14 | Windows at 1.0? | Interface only. DaemonManager has a Windows implementation slot; it is not a release gate. |
| OQ-15 | Does Core ever call an LLM by default? | No. Summarization and reranking are opt-in; without them RAPTOR degrades to extractive summaries. |
8. Recommended architecture
8.1 Layering
flowchart TB
subgraph Interface["Interface / composition roots"]
CLI["cli/ — Typer"]
MCP["mcp/ — MCP tools"]
DMN["daemon/ — HTTP, lifecycle, locking"]
end
subgraph App["application/ — use cases, orchestration"]
SETUP["SetupService"]
MIGR["MigrationService"]
IDX["IndexingService"]
RET["RetrievalService"]
REV["ReviewService"]
TRC["TraceabilityService"]
end
subgraph Domain["domain/ — entities, value objects, invariants, ports"]
ENT["Entities and value objects"]
PORT["Ports (Protocols)"]
end
subgraph Infra["infrastructure/ — adapters"]
SQL["sqlite/"]
VEC["vector/"]
RAP["raptor/"]
GIT["git/"]
GH["github/"]
FS["filesystem/"]
SEC["secrets/"]
end
Interface --> App
App --> Domain
Infra -.implements.-> PORT
Interface -.wires.-> Infra
style Domain fill:#1f6f4a,color:#fff
style Infra fill:#5a3a7a,color:#fff
The dependency rule: domain/ imports nothing from application/ or
infrastructure/; application/ imports domain/ only. Adapters are
injected at a composition root. This is enforced mechanically by a banned-import
lint rule in pyproject.toml and by a test that walks the import graph.
8.2 Process architecture
flowchart TB
subgraph Terminal["One user, one machine"]
subgraph CC["Claude Code"]
MA["Main agent"]
SA1["Sub agent A"]
SA2["Sub agent B"]
SA3["Sub agent C"]
end
OTHER["Other MCP clients / CI / plain CLI"]
end
MA -->|Streamable HTTP| D
SA1 -->|Streamable HTTP| D
SA2 -->|Streamable HTTP| D
SA3 -->|Streamable HTTP| D
OTHER --> D
subgraph D["Single Theurian daemon — 127.0.0.1:7419"]
LOCK["OS file lock + port probe"]
REG["Project registry"]
WQ["Single write queue"]
RP["Read pool (WAL)"]
PUB["Index publisher"]
end
D --> PA["Project A — .theurian/"]
D --> PB["Project B — .theurian/"]
D --> PC["Project C — .theurian/"]
Every request carries its own context. There is no connection-scoped or
process-global currentProject.
{ "projectId": "backend-service", "snapshotId": null, "agentId": null, "taskId": null }
8.3 Concurrency model
- Reads: N independent SQLite connections in WAL mode,
busy_timeout=5000. - Writes: one asyncio task owning one write connection, fed by a queue. No other component may open a write connection.
- Index publication: a single publisher performs the atomic swap of
active_indexes. Builds happen off to the side and become visible only on swap. - External I/O (LLM, GitHub, subprocess) happens strictly outside write transactions. The pattern is: read → release → call → re-acquire → write.
9. Domain model
erDiagram
PROJECT ||--o{ KNOWLEDGE_ITEM : contains
PROJECT ||--o{ SPECIFICATION : contains
PROJECT ||--o{ REVIEW_EVENT : observes
PROJECT ||--o{ TRACEABILITY_EDGE : records
KNOWLEDGE_ITEM ||--|{ KNOWLEDGE_REVISION : "has (immutable)"
KNOWLEDGE_ITEM ||--o| KNOWLEDGE_REVISION : "current_revision_id"
KNOWLEDGE_ITEM ||--o{ KNOWLEDGE_ALIAS : "known as"
KNOWLEDGE_REVISION ||--o{ SOURCE_ANCHOR : "anchored to"
KNOWLEDGE_REVISION ||--o{ KNOWLEDGE_RELATION : "source of"
KNOWLEDGE_REVISION ||--o{ KNOWLEDGE_EVIDENCE : "supported by"
REVIEW_EVENT ||--o{ REVIEW_THREAD : contains
REVIEW_THREAD ||--|{ REVIEW_COMMENT : contains
REVIEW_THREAD ||--o| REVIEW_RESOLUTION : "resolved by"
REVIEW_THREAD ||--o{ KNOWLEDGE_CANDIDATE : "may yield"
KNOWLEDGE_CANDIDATE ||--o| KNOWLEDGE_REVISION : "approved into"
SPECIFICATION ||--o{ TRACEABILITY_EDGE : "traced by"
MIGRATION_HISTORY ||--o{ KNOWLEDGE_REVISION : "produced"
9.1 Core invariants
| ID | Invariant |
|---|---|
| INV-1 | A KnowledgeRevision is immutable once written. Corrections create a new revision. |
| INV-2 | KnowledgeItem.current_revision_id must reference a revision of that same item. |
| INV-3 | content_sha256 must equal SHA-256 of the revision body as stored. |
| INV-4 | valid_from < valid_to when valid_to is set. |
| INV-5 | A relation's source and target must belong to the same Project unless the relation type is explicitly cross-project. |
| INV-6 | supersedes must be acyclic. |
| INV-7 | A KnowledgeCandidate may only become approved knowledge through a migration authored by a human identity. |
| INV-8 | Every approved revision has at least one SourceAnchor or an explicit authored-in-theurian marker. |
| INV-9 | A revision's sensitivity may only be changed by changeSensitivity, which creates an audit record — never by editing a body. |
| INV-10 | Migration IDs and revision IDs are ULIDs; lexical order equals creation order. |
9.2 Statuses
KnowledgeItem.status∈draft | proposed | approved | deprecated | superseded | rejectedtrust_level∈unverified | inferred | reviewed | authoritativesensitivity∈public | internal | confidential | restrictedSpecification.status∈draft | active | superseded | retired
Only approved items are returned by default retrieval; other statuses require
an explicit opt-in filter, so an unreviewed proposal can never masquerade as a
team decision.
10. Ports and adapters
| Port | Responsibility | Milestone-0 adapter | Later adapters |
|---|---|---|---|
CanonicalStore |
Persist and query canonical entities | sqlite/ |
PostgreSQL, document DB |
VectorStore |
Store and ANN-search embeddings | brute-force SQLite | sqlite-vec, pgvector, external |
EmbeddingProvider |
Text → vector | deterministic hashing embedder | OpenAI-compatible, local ONNX |
SummarizationProvider |
RAPTOR node summaries | ExtractiveSummarizer — real, not a fake: trigram-frequency sentence selection (Milestone 6) |
any chat model |
RerankingProvider |
Reorder candidates | identity | cross-encoder, hosted |
ReviewProvider |
Fetch PRs/reviews/threads | fixture-backed fake | GitHub, GitLab |
SpecificationProvider |
Discover and parse specs | filesystem | external spec registries |
SourceParser |
Bytes + media type → normalized doc | Markdown, YAML, JSON | OpenAPI, AsyncAPI, PDF, DOCX |
ObjectStore |
Large blobs | local filesystem | S3-compatible |
AuthorizationProvider |
May principal P do A on Project X | local single-user allow | OIDC/RBAC, tenant ACL |
SecretStore |
Store and read local secrets | 0600 file | macOS Keychain, Secret Service |
DaemonManager |
Install/start/stop/uninstall a user service | LaunchAgent, systemd user | Windows, Docker Compose |
Clock |
Time | system clock | frozen clock in tests |
IdGenerator |
ULIDs | ULID | seeded generator in tests |
Clock and IdGenerator are ports for a reason: without them, "same inputs ⇒
same state_hash" cannot be asserted in a test.
Every port ships a deterministic fake in tests/fakes/. No test in this
repository may call an external network service.
11. Source, Canonical, and Index layers
flowchart LR
subgraph S["Source Layer — bytes as authored"]
S1["Markdown"]
S2["YAML / JSON"]
S3["OpenAPI / AsyncAPI"]
S4["Git commit / diff"]
S5["GitHub PR / review / issue"]
S6["PDF / DOCX (future)"]
end
subgraph C["Canonical Layer — the record of truth"]
C1["KnowledgeRevision"]
C2["Specification"]
C3["ReviewEvent / Thread / Comment"]
C4["SourceAnchor"]
C5["Relation / TraceabilityEdge"]
end
subgraph I["Index Layer — rebuildable, never authoritative"]
I1["Chunks"]
I2["FTS5"]
I3["Embeddings"]
I4["RAPTOR nodes"]
I5["Graph edges"]
I6["Rerank features"]
end
S -->|SourceParser: parse, never interpret| C
C -->|Indexer: derive| I
I -.->|every hit resolves back to| C
C -.->|every record anchors back to| S
Layer rules:
- The Source Layer is never rewritten by Theurian. It is read, hashed, and anchored.
- The Canonical Layer is the only layer that may be cited as team knowledge.
- The Index Layer is disposable by definition. Deleting it must never lose information.
- A structured source stays structured in the Canonical Layer. Markdown rendering is a projection, not a conversion.
- Anything under
.theurian/generated/is a projection of the Index or Canonical layer and is git-ignored.
12. Core / Plugin boundary
flowchart TB
subgraph P["Claude Code Plugin — independently versioned"]
CMD["commands/*.md"]
HK["hooks/hooks.json + scripts/"]
SK["skills/"]
TPL["mcp/theurian.mcp.json (template)"]
COMPAT["compatibility.yaml"]
end
subgraph PUB["Published contract — the only permitted surface"]
A1["theurian CLI, --json"]
A2["MCP over Streamable HTTP"]
A3["GET /health, GET /capabilities"]
A4["schemas/**.json"]
A5["protocol + compatibility metadata"]
end
subgraph CORE["Theurian Core — independently versioned"]
INT["theurian.* internal modules"]
end
P --> PUB
PUB --> CORE
P -. "forbidden: python import theurian.*" .-x CORE
style CORE fill:#5a3a7a,color:#fff
style PUB fill:#1f6f4a,color:#fff
Enforcement, not convention:
- The plugin directory contains no
.pyfile that importstheurian. A CI check greps for it and fails the build. - The plugin's scripts are POSIX shell and invoke
theurian … --json. - Contract tests in
tests/contract/execute the real CLI and validate its output againstschemas/cli/*.json. Both artifacts consume the schema; neither owns the other. schemas/is co-owned in CODEOWNERS. A schema change requires both maintainer groups.
13. Plugin / Core compatibility model
Three versioned things, deliberately decoupled:
| Thing | Example | Changes when |
|---|---|---|
| Core version | 0.4.0 |
any Core release |
| Plugin version | 0.2.1 |
any plugin release |
| Protocol version | theurian/v1 |
the wire contract changes incompatibly |
plugins/claude-code/compatibility.yaml:
pluginVersion: 0.2.1
coreCompatibility:
minimum: 0.4.0
maximumExclusive: 0.5.0
protocolVersion: theurian/v1
Resolution algorithm, run by SessionStart and by every command:
flowchart TD
A["Read plugin compatibility.yaml"] --> B["theurian --version --json"]
B -->|CLI absent| Z1["Advise the installer, then /theurian:setup.<br/>Do not install anything."]
B --> C{"core in [minimum, maximumExclusive)?"}
C -->|no, core too old| Z2["Stop. Show: upgrade Core to >= minimum"]
C -->|no, core too new| Z3["Stop. Show: upgrade the plugin"]
C -->|yes| D{"protocolVersion matches?"}
D -->|no| Z4["Stop. Protocol mismatch, show both versions"]
D -->|yes| E["Proceed"]
Rules:
- Pre-1.0, a MINOR bump of Core may break the protocol;
maximumExclusivetherefore pins to the next MINOR. Post-1.0 it pins to the next MAJOR. - "Stop" means: print an actionable message and exit non-zero. It never means install, upgrade, downgrade, or delete anything.
- "Advise the installer" means the two installer commands
probe_corenames, and then/theurian:setup— the orderplugins/claude-code/scripts/session-start.shprints, quoted once in the Claude Code integration doc. Advising/theurian:setupon its own was unrunnable: it shells out to thetheurianbinary whose absence produced the message. - Core exposes
system.capabilitiesso the plugin can degrade per-feature rather than all-or-nothing when only an optional capability is missing.
14. Milestone 0 implementation plan
Delivered in this change:
| # | Deliverable | Location |
|---|---|---|
| 1 | Monorepo skeleton, uv workspace, pinned toolchain | pyproject.toml, packages/theurian-core/pyproject.toml |
| 2 | This analysis | docs/architecture/requirements-analysis.md |
| 3 | ADRs 0001–0015 | docs/adr/ |
| 4 | Architecture docs with Mermaid | docs/architecture/*.md |
| 5 | Domain entities, value objects, invariants | packages/theurian-core/src/theurian/domain/ |
| 6 | Fourteen ports as Protocols (what this change delivered; the set has grown since — ADR-0003 point 5's Milestone 7 amendment names ALL_PORTS as the register and carries the current count) |
packages/theurian-core/src/theurian/domain/ports/ |
| 7 | Compatibility resolution logic + tests | .../domain/compatibility.py |
| 8 | Path-boundary security primitives + tests | .../security/paths.py |
| 9 | Public JSON Schemas | schemas/ |
| 10 | Claude Code plugin skeleton, 12 commands, SessionStart hook | plugins/claude-code/ |
| 11 | OSS governance documents | repo root |
| 12 | Threat model | docs/security/threat-model.md |
| 13 | Path-filtered CI | .github/workflows/ |
| 14 | Unit tests, lint, strict type checking, all green | packages/theurian-core/tests/ |
Explicitly not in Milestone 0: SQLite schema, the migration engine, the daemon, MCP tools, ingestion, retrieval, RAPTOR, GitHub. Those are Milestones 1–8.
15. Key technical risks
| ID | Risk | Impact | Mitigation |
|---|---|---|---|
| R-1 | Single-daemon guarantee fails under a race (two claude launches at once) |
Two writers on one SQLite file → corruption | OS-level flock on a lock file plus a port health probe plus a startup handshake; loser exits 0 after confirming the winner is healthy. Tested by launching N processes concurrently. |
| R-2 | state_hash is unstable across machines |
Every branch switch rebuilds; caches never hit | Hash only normalized, ordered, content-derived inputs. Never absolute paths, mtimes, or locale-dependent ordering. Golden-vector test. |
| R-3 | RAPTOR summaries leak facts not present in the source | Knowledge platform emits fiction | Partly shipped. The extractive half is built and now called: theurian index build --raptor runs infrastructure/raptor/extractive.py over every node, and it selects sentences verbatim, so it cannot state a fact its children do not contain. Every node written carries child references (node_derivation, one edge per source) and real provenance — nodes.summary_model, summary_model_revision and summary_prompt_hash are the configured provider's, asserted on rows a build wrote by test_a_document_nodes_provenance_names_the_revision_it_was_built_from. What is not shipped: opt-in abstractive summaries, and the entailment check that would validate them — there is none anywhere in the tree. Residual: the mitigation is the extractive default's verbatim selection, which is a property of the one adapter that exists rather than of the port; the first abstractive adapter needs the entailment check with it (#115). |
| R-4 | Prompt injection through ingested knowledge | An agent executes instructions embedded in a document | Content is always labeled untrusted and MCP results carry mayContainInstructions (both shipped — the safety triple on every hit, SEC-15/T-3). The summarization step that would additionally wrap source in a delimited untrusted region is still unbuilt, for a narrower reason than before: the one adapter that exists is extractive and builds no prompt, so there is nothing to delimit in. Summary text reaches an answer path now — the retrieval CL added a summary retriever and a surfaced leaf carries its summary ancestry as raptorPath — and because that adapter copies source sentences verbatim, an injected instruction survives summarization unchanged and can appear in a title. Node-derived text therefore carries the same safety triple as a leaf excerpt: it rides inside a result that carries mayContainInstructions and the rest, and retrieval-result.schema.json documents each title as untrusted content under that caveat. Documented as a shared responsibility with the calling agent (#115). |
| R-5 | sqlite-vec is pre-1.0 and may break |
Index layer breaks on upgrade | Dense retrieval ships as an exact scan in SqliteIndexStore.search_dense, not through a VectorStore adapter: infrastructure/vector/ is empty and sqlite-vec is imported nowhere in src/, so the pre-1.0 dependency cannot break a path that does not use it. If an ANN adapter ever lands it stays confined to that package (test_layering.py::test_volatile_dependencies_are_confined enforces it) and its version is pinned exactly (ADR-0014, #115). |
| R-6 | MCP Python SDK 2.x is young; the API changed from 1.x (FastMCP → MCPServer) |
Daemon breaks on upgrade | Version pinned; the SDK is confined to mcp/ and daemon/; a contract test asserts the wire protocol, not the SDK API. |
| R-7 | Index build blows up wall-clock and memory on a large monorepo | Setup appears hung | Incremental builds by default; builds are cancellable; progress is reported; the old index keeps serving. The RAPTOR forest is now build-time cost rather than a pending mitigation, which is why it ships opt-in: index build --raptor adds one summarize call per node over what the build just wrote, and nothing has measured that on a large repository (ADR-0008 decision 3's amendment records the gap). Two bounds are recorded against the extractive default's MAX_TOTAL_INPUT_CHARS (1,000,000 characters). A Document node is charged its item's whole body, so a single document past that limit fails the build rather than producing a summary nobody could read. A Domain node is charged one summary per document of its kind, so its input alone grows with the corpus; MAX_CHILDREN_PER_DOMAIN (500) fans a large kind out into batches of at most 500 × 250 × 4 = 500k characters — half the limit — so the ~1050-document same-kind wall an earlier review found is gone. The Catalog tier is not itself fanned out, so a scope holding one kind at roughly half a million documents (a thousand Domain batches) would still meet the limit at the Catalog node — a ceiling raised about 500×, not removed (ADR-0008 decision 2's fan-out amendment). Incremental subtree rebuild — the part that would limit blast radius — is still deferred (#115). |
| R-8 | Write-queue serialization becomes a throughput bottleneck | Slow ingestion | Batch operations inside one transaction; keep transactions short; measure before optimizing. Correctness outranks throughput. |
| R-9 | Setup corrupts a hand-tuned Claude Code config | User loses their configuration | Merge, never replace; back up with a timestamp; show a diff; ship a --dry-run. |
| R-10 | Uninstall leaves orphaned OS services or files | Users cannot cleanly remove Theurian | Largely unmitigated, and this row claimed three mitigations that do not exist. The setup journal is not a path record: a line is {"step", "event", "detail"}, with no path field — detail is prose, naming a path in two of the seven applied actions and in whatever exception text a failed record carries — so it is a readable trace of what setup did and not a list anything can delete from (§6.4). Enumeration before deletion is NFR-12, and it is not wired — uninstall_command builds its list from the service path and the MCP config alone, and reads SetupStep.paths nowhere; that field's docstring records the gap. There is no E2E test either: tests/e2e/ holds test_daemon_single_instance.py and test_migration_workflow.py and nothing about uninstall, which is owed with the rest of the suite (#65). Which paths uninstall may claim to own at all is #127. What does hold today: uninstall takes --dry-run, and setup discloses what a run wrote in changed_paths (§6.4). |
| R-11 | Optimistic concurrency is too coarse and blocks routine parallel work | Contributors fight the tool | expectedRevision is per item, not per store; conflicts produce a readable three-way report. |
| R-12 | Reviews are personal data (author identity, opinions) | Privacy and compliance exposure | Store author identity as the provider's stable ID plus display name; support redaction at ingestion; document retention in SECURITY.md. |
| R-13 | Deterministic fakes drift from real providers | Tests pass, production fails | Fakes implement the same Protocol; contract tests run against fakes in CI and against real adapters in an opt-in, credentialed job. |
| R-14 | Cross-tenant leakage through a shared RAPTOR node | The most severe correctness failure possible | Live on the build path as of Milestone 6's forest builder. A tree identity of project + tenant + sensitivity + ACL group + namespace + status makes mixing structurally impossible rather than policy-checked, held by three refusals: SummaryNode rejects a declared child scope differing from the node's own, IndexableNode rejects a declaration standing for no source, and application/forest_builder.py derives each declaration from the source it summarises. The sensitivity in that tuple is the item's current classification, stamped at build time the way status is — a changeSensitivity moves it without writing a new revision — so a reclassified item is partitioned under its new label on the next build, never under the revision's authored one; this is uniformity at build time, not a serving control. Asserted over rows a real build wrote — test_forest_builder.py::test_no_node_stands_on_chunks_that_disagree_on_a_scope_component, transitively over node_derivation, on the three axes a corpus can vary (namespace, sensitivity, status) — with the scope key itself asserted exhaustively (test_scope_isolation.py). Tenant and ACL group cannot be varied by any corpus, because the write path refuses a revision naming a non-default value, so those two axes are structural rather than exercised. Residual: a node is read back into a response now — the retrieval CL serves a surfaced leaf's raptorPath title — so this control is load-bearing for serving at last, and it holds: a title ships only above a leaf that cleared the gate and whose ancestors share its six-component scope, so it carries nothing across a scope boundary. Per-axis enforcement of sensitivity is no longer deferred and is no longer this row's residual: since #119 it is a serving predicate on both halves of the derived index — _scope over chunks and _node_scope over nodes — a build writes no row above the deployment's declared ceiling, and a changeSensitivity moving an item past that ceiling purges the affected rows and re-derives the affected scopes' trees in the same migrate apply (ADR-0025 parts 1 to 3). That changes nothing above it: the scope tuple is still what makes mixing structurally impossible at build time, which is a different property from a deployment's ceiling and is the one this row is about (#115). |
16. OSS publication risks
| ID | Risk | Mitigation |
|---|---|---|
| O-1 | "Theurian" collides with an existing trademark or PyPI/npm name | Check PyPI, npm, and trademark registries before the first public release; the name is a config value, not hardcoded in 400 places. The deadline has passed: theurian 0.1.0.dev0 was uploaded 2026-08-07. PyPI is settled — the name is held by this project's own release workflow (https://pypi.org/simple/theurian/ → 200, measured 2026-08-08). npm is free and unheld (https://registry.npmjs.org/theurian → 404). No trademark check is recorded anywhere in this repository, so that third is neither done nor owned. Near-miss PyPI names are covered under T-16 in the threat model. |
| O-2 | Users commit a SQLite file or a secret into .theurian/ |
Ship .gitignore entries at init; add a pre-commit hook example; doctor warns when a derived artifact is tracked. |
| O-3 | An issue reporter pastes proprietary knowledge into a public issue | Issue templates warn explicitly; no knowledge body ever enters a doctor payload; theurian doctor --report substitutes the paths Theurian wrote and withholds the configuration values it only read. Plain doctor --json redacts nothing and is not for sharing. |
| O-4 | A dependency's license is incompatible with Apache-2.0 | License scan in CI with an allowlist; a copyleft dependency fails the build. |
| O-5 | The security-report channel is unclear, so a vulnerability is filed publicly | SECURITY.md with a private reporting path and a stated response SLA. |
| O-6 | Contributors are unclear whether this is community- or vendor-governed | GOVERNANCE.md states the model, the maintainer set, and how decisions are made, before external contributions start. |
| O-7 | A future managed offering is seen as a bait-and-switch | The OSS/commercial boundary is documented up front; Core must remain fully functional offline; no feature is removed from Core to sell it back. |
| O-8 | LLM-generated knowledge of uncertain provenance ends up in a public repo | Provenance is a first-class field; trust_level distinguishes inferred from reviewed; candidates are never auto-approved. |
| O-9 | An unmaintained pre-1.0 dependency becomes a supply-chain liability | Dependabot, SBOM, and an ADR-recorded exit path per pre-1.0 dependency. |
17. Initial ADRs
| ADR | Title | Decision |
|---|---|---|
| 0001 | Monorepo with independently released artifacts | One repo, two release trains, one shared contract directory. |
| 0002 | Single local daemon over Streamable HTTP | One process per user; explicit per-request context; no stdio. |
| 0003 | Ports and adapters as the top-level structure | Domain depends on ports; adapters are injected; lint-enforced. |
| 0004 | SQLite is a derived artifact, never a Git-tracked one | Git holds migrations and content; the database is rebuilt. |
| 0005 | Knowledge migrations are YAML, not SQL | Storage-independent; portable to PostgreSQL or a document DB. |
| 0006 | Immutable revisions with optimistic concurrency | Append-only history; expectedRevision guards conflicts. |
| 0007 | State-hash-partitioned databases for Git branches | One database per reachable migration set; atomic switching. |
| 0008 | RAPTOR forest, not a single tree | Trees are partitioned by project/tenant/sensitivity/ACL/namespace. |
| 0009 | No vendor lock-in for LLM and embedding providers | Deterministic in-tree defaults; real providers are opt-in adapters. |
| 0010 | Three-layer knowledge model | Source, Canonical, Index — with an explicit authority rule. |
| 0011 | Local MCP authentication and token handling | Loopback + Origin check + bearer token + env-var reference. |
| 0012 | The plugin does not auto-register the MCP server | Setup installs the connection so install alone stays inert. |
| 0013 | AI writes produce proposals, never approved state | The write path goes through Git review. |
| 0014 | Exact dependency pinning and pre-1.0 isolation | Pin everything; isolate pre-1.0 libraries behind ports. |
| 0015 | DCO over CLA | Sign-off enforced in CI; recorded per OSS-5. |
18. Initial directory structure
The tree in §11 of the brief is adopted with these deliberate changes:
| Change | Reason |
|---|---|
domain/ports/ added |
Ports are domain contracts. Putting them under domain/ is what makes the "domain never imports infrastructure" rule expressible as a lint rule. |
schemas/protocol/ added |
Protocol and compatibility metadata is a contract in its own right, co-owned by both artifacts. |
plugins/claude-code/mcp/ added |
Holds the MCP connection template that setup installs (ADR-0012). Deliberately not .mcp.json at the plugin root, which Claude Code would auto-load. |
tests/fakes/ added |
Deterministic port fakes shared by unit, integration, and contract tests (OSS-15). |
examples/sample-project/ populated |
A third party must be able to run a real .theurian/ without writing one first (OSS-13). |
assets/ added |
Logo and brand files, kept out of docs/. |
19. Threat model, v1
Full document: docs/security/threat-model.md.
Summary of the trust boundaries and the top findings:
flowchart TB
subgraph TB1["Trust boundary 1: the loopback interface"]
direction LR
CLIENT["Any local process that can open a socket"] -->|"HTTP + bearer token"| DAEMON["Theurian daemon"]
end
subgraph TB2["Trust boundary 2: ingested content"]
REPO["Repository files"] --> PARSER["SourceParser"]
GH["GitHub API"] --> PARSER
PARSER --> CANON["Canonical store"]
end
subgraph TB3["Trust boundary 3: the retrieval result"]
CANON --> RESULT["MCP result"] --> AGENT["AI agent"]
end
subgraph TB4["Trust boundary 4: the filesystem"]
DAEMON --> FS["Project root only"]
end
| ID | Threat | STRIDE | Severity | Control |
|---|---|---|---|---|
| T-1 | A local process without the token reads all knowledge | Information disclosure | High | SEC-3, SEC-4 |
| T-2 | A browser page reaches the daemon via DNS rebinding | Spoofing | High | SEC-1, SEC-2 |
| T-3 | Instructions embedded in knowledge steer an agent | Tampering / EoP | High | SEC-15, SEC-16, R-4 |
| T-4 | A crafted contentFile path reads ~/.ssh/id_ed25519 |
Information disclosure | Critical | SEC-7, FR-S6 |
| T-5 | A symlink inside the repo points outside it | Information disclosure | Critical | SEC-7 |
| T-6 | A zip/YAML bomb at ingestion, or a search query that burns seconds of CPU | DoS | Medium | SEC-8 |
| T-7 | A hostile Git URL triggers an internal request | SSRF | Medium | SEC-10 — per control: the repository allowlist is discharged on the gh review-ingestion path (read before any spawn, ADR-0030); private-network rejection is reduced there, with a four-member divergence class recorded as the residual; the scheme allowlist does not apply there (no URL in the vector). On the $ref path, recorded-never-fetched only, and both remaining controls are owed to #429 (#129 closed on the wording, not the controls) |
| T-8 | The token is written into a config file that gets committed | Information disclosure | High | SEC-5, ADR-0011 |
| T-9 | The token appears in a log or a crash report | Information disclosure | High | SEC-6 |
| T-10 | Confidential and public knowledge merge into one summary | Information disclosure | High | SEC-14, R-14 |
| T-11 | A client authorized for Project A reads Project B | EoP | High | SEC-13 |
| T-12 | An agent silently rewrites an approved decision | Tampering | High | SEC-17, ADR-0013 |
| T-13 | Two daemons corrupt the same SQLite file | Tampering | High | NFR-1, R-1 |
| T-14 | Setup overwrites a user's configuration — the MCP entry, and ~/.theurian/env since #128 |
Tampering | Medium | SEC-18, R-9 |
| T-15 | A secret in a document becomes an approved, indexed revision | Information disclosure | High | SEC-11 — theurian propose accept scans every body it would land, and the migration document's own author-written fields with it: the title, description, labels, scope paths, the parsed contentFile, contentType and the date fields, each operation's free text and chosen names, and every string of a source anchor (provider, sourceUri, filePath, repository, externalId, commitSha, blobSha) (#336); and, with them, the artifacts an acceptance writes — the migration file's raw bytes (so 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 although accept lands it nowhere, because the command deletes the migration and the bodies, leaves the record behind and then tells the author to open a pull request with that directory in it (#361). block by default per security.secretScan, with a best-effort in-house detector whose finding locations never reproduce the value; human review of the authored migration (ADR-0013) and supersede/retire with the withdrawal→purge trigger stand beside it. Refusals on this path scan every author-derived string they would print — both whole and as the cut that will print, the detector not being monotone under truncation — whatever its type, and either print it cut to 200 characters or drop it whole when the detector reports it, over a population proved by reflection over proposal_service.py's own syntax tree — every interpolation and every argument handed to an error class that renders it, keyed on the enclosing function so a bare identifier is not a module-wide wildcard (#360, #339; driving test tests/integration/test_proposal_refusal_names.py). What it does not reach: the document's derived fields — the ULID- and hash-shaped identifiers and the fixed vocabularies, each barred by a mechanism rather than by choice; a credential the detector does not report — one a third party truncated before the gate saw it, one whose candidate run overruns the 255-character cap four of the six specific families carry, and one whose entropy a long tail dilutes — each the best-effort detector's own residual and live in the body scan as much as in a refusal; migration_loader.py's MigrationError prefix, a different producer's population, reached through resolve_context by every command that resolves a project context (#537); accept --json's migrationFile/bodyFiles success fields, full-length by recorded decision; and draft-time scanning, still owed (#330). theurian index build is SEC-11's second control and scans every body it indexes, with the source anchors and relation notes served beside it — every text channel of the approved, in-ceiling corpus this deployment serves by default, on every rebuild — and it reports and never refuses, because by then the content is already served whatever the index holds; an unapproved body reachable through includeUnapproved and a superseded revision in the store are outside that population and are recorded as residuals in the threat model and SECURITY.md (#329; #198 is closed, having shipped the propose accept half). theurian ingest runs no scan of its own |
| T-16 | A compromised release artifact is installed | Tampering | Critical | OSS-7, OSS-11, setup step 3 |
20. /theurian:setup test strategy
The single highest-risk surface: it touches the OS, the filesystem, the user's config, and the network, and users will run it exactly once and judge the project by it.
Layer 1 — unit (no OS effects)
| Test | Asserts |
|---|---|
| Step probes are pure | Each probe maps an observed environment to Satisfied/Missing/Conflicting with no side effects. |
| Plan derivation | Environment → ordered plan. Table-driven over ~20 environment permutations. |
| Empty plan on converged input | A fully-set-up environment yields zero steps. |
| Config merge | Merging into an existing Claude Code config preserves unrelated servers, keys, and formatting. |
| Diff generation | The rendered diff exactly matches the change that would be applied. |
.gitignore block |
Appends only missing entries; re-running appends nothing. |
| Token masking | A token never appears in a SetupReport rendered to text or JSON. |
| Compatibility gate | The version matrix resolves to proceed/stop-old/stop-new/stop-protocol. |
| Halt is terminal, not success | SetupState.HALTED is a terminal state and HALTED.is_success is False; a halted run is a failure, not success-with-warnings. |
Layer 2 — integration (real filesystem, fake OS service)
Run against a temp HOME and a temp Git repository, with DaemonManager
replaced by a recording fake.
| Test | Asserts |
|---|---|
| Cold setup | All directories, token, and .theurian/ created with correct modes. |
| Second run | Zero changed files; every step Satisfied. |
| Third run after deleting one artifact | Only that artifact is recreated. |
| Pre-existing MCP config | Backed up; the serena entry survives untouched. |
Read-only HOME |
The run halts at data-directory — the first step that writes, the three probes ahead of it having passed — creates nothing under HOME, and names the directory that refused the write in warnings: the raw PermissionError, which carries the path and no remedy. changed_paths is empty (test_a_home_it_cannot_write_to_halts_the_run_and_names_the_path_that_refused). |
| Existing token | Reused, never regenerated. |
| Wrong file mode | Corrected and reported for the data directory only. A world-accessible ~/.theurian probes Missing with "Tighten … to 0700" and the apply performs it. A 0644 token file or a 0755 auth/ probes Conflicting by design — tightening is not enough once a credential has been exposed, so the detail names theurian auth rotate — and a Conflicting step is never applied, --approve-conflicts included. A 0644 env file is not seen at all: probe_env_reference asks only whether the Theurian-owned block is current, so a converged run never reaches the apply — the one place that mode is re-asserted — and leaves it as it found it. A run that does rewrite the block tightens it on the way through, which is the arm every machine set up by 0.1.0.dev0–dev2 takes on its first upgraded run (tests/integration/test_setup_env_file.py::test_an_env_file_left_group_readable_by_an_older_version_is_tightened). |
| Critical failure mid-plan | The run halts (state = halted); nothing is undone, and changed_paths discloses the finished steps' declared artefacts, whichever of the failing step's declared artefacts this run moved, and the setup journal this run appended to — de-duplicated, first-seen order, so a credential minted before the failure appears exactly once (§6.4). |
| Project already registered elsewhere | Detected; no duplicate registration. |
Layer 3 — E2E (real CLI, real daemon, real service manager)
Runs on macOS and Linux runners inside a disposable user profile.
| Test | Asserts |
|---|---|
| First run from a clean machine | The success block in §7.2 is printed; MCP handshake succeeds. |
| Run three times | Idempotent; one daemon; one registered project. |
| Plugin installed but setup not run | No LaunchAgent/systemd unit; no listening socket; no data directory. |
| SessionStart on a fresh session | Completes under the latency budget; performs no install. |
| Ten concurrent clients | Exactly one daemon PID; ten successful handshakes. |
Two concurrent setup invocations |
One wins; the other converges without corruption. |
| Serena already configured | Both servers are listed; neither is modified. |
| Incompatible plugin/core versions | Setup stops with upgrade instructions and changes nothing. |
| Uninstall matrix | Each of the eight uninstall options removes exactly its own scope. |
| Approved knowledge survives plugin removal | Git-tracked files are byte-identical afterwards. |
Layer 4 — properties
- Idempotence: for any environment E,
setup(setup(E)) == setup(E). - Non-destructiveness: for any pre-existing file F not owned by Theurian, F is unchanged or backed up. Never silently modified.
- Enumerability: every path setup creates appears in
uninstall --dry-run. Stated as a requirement (NFR-12), not as behaviour: uninstall enumerates the service path and the MCP entry only, and nothing readsSetupStep.paths(R-10).
21. Conditions for splitting the plugin into its own repository
The monorepo is a starting point, not a commitment. Split when any two of these hold:
| # | Condition | Why it matters |
|---|---|---|
| 1 | The protocol has been stable for two consecutive Core MINOR releases | The shared contract no longer needs lockstep edits. |
| 2 | A second client plugin exists (Cursor, Zed, VS Code) | plugins/ becomes a crowd, and each client's release cadence diverges. |
| 3 | Plugin-only changes exceed 60% of pull requests over a quarter | Core CI is being paid for by changes that do not touch Core. |
| 4 | Plugin maintainers are a distinct group that does not overlap Core maintainers | CODEOWNERS is doing repository-boundary work. |
| 5 | Release coupling causes an incident | Plugin releases have blocked or broken a Core release. |
| 6 | The marketplace requires a dedicated repository layout | An external constraint. |
Preconditions that must already be true — they are all satisfied by the Milestone 0 design, which is what keeps the split cheap:
- The plugin has zero source-level dependency on Core (CP-2).
schemas/is consumable as a versioned, published artifact.- Contract tests run against an installed
theurian, not a source checkout. - The plugin has its own CHANGELOG, version, and release workflow.
- No CI job requires both trees in one checkout except the shared E2E suite.
Split procedure, when triggered:
- Publish
schemas/as a versioned package consumed by both repositories. - Move the shared E2E suite to a scheduled cross-repository workflow that installs both published artifacts.
git subtree splitonplugins/claude-code/to preserve history.- Point the marketplace at the new repository; keep a deprecation shim for one MINOR cycle.
- Delete the tree from this repository and record the move in an ADR.