> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xorcise.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> The constraints and invariants the XORCISE codebase depends on — the rules to hold, and the reasons obvious-looking simplifications are wrong.

This document is for people **changing** XORCISE. It records the constraints the codebase depends on and the reasoning behind them — the things you would otherwise have to rediscover by breaking something.

It is deliberately not a user guide. How to install XORCISE, run an agent against a mission, read a trace or interpret a score is documented for **users** across the rest of this site. Nothing here repeats that; everything here is a rule, an invariant, or the reason an obvious-looking simplification is wrong.

Rules are stated in the imperative and are testable. Where a rule is machine-enforced, the enforcing file is named — if you disagree with a rule, change the enforcement and this document together, in the same change.

## 1. Shape of the product

XORCISE is **one pip distribution**. There is no server tier, no second package, and no fork per deployment: `xorcise up` boots one long-lived local process that serves the REST API, the web UI and the OTLP receiver, and drives Docker over the local socket.

* **All internal code lives under `xorcise.core.*`.** The bare `xorcise.*` namespace is reserved for future products, and `scripts/check_single_distribution.py` fails CI if a second distribution appears. Do not add top-level modules.
* **Imports must have no side effects.** Importing any package must not read config, open a database, touch the network, or start a thread. Settings load only when `get_settings()` is called.
* **Bring Your Own Model.** XORCISE bundles no LLM and no key. Every feature that needs a model — the judge, terrain attribution — uses the operator's own OpenAI-compatible endpoint. Never add a bundled or default model, and never ship a credential.
* **Vendor-neutral about the agent under test.** XORCISE never launches the agent and ships no agent templates. The agent is given a connect prompt and reaches the run over the network on its own terms.

**The data model is agent-centric.** The agent is the first-class record: it is registered before anything else can happen, and a run belongs to exactly one agent, one mission and one budget. Run creation resolves the named agent and refuses with a 409 if none is registered — there is no anonymous run. Results accumulate into that agent's track record, which is what makes scores comparable across runs.

XORCISE judges an agent by **what it did** — its recorded trace and what it submitted — never by inspecting its source. Do not add a code-analysis path to the grading flow.

`xorcise.core.eval` and `xorcise.core.code` are **swap seams** for a future premium tier. `core.code` is an intentionally empty stub. Do not delete it and do not fill it in the open-source tree; only `core.seams` may import a premium implementation.

## 2. Layering and imports

The dependency rule is one-directional and machine-enforced by `.importlinter` (`uv run lint-imports`, wired into CI and pre-commit):

```text theme={"theme":"css-variables"}
cli  >  roles  >  application  >  parts  >  kernel  >  contracts
```

Everything may depend inward; nothing may depend outward. `contracts` is the innermost layer and imports nothing of ours.

* **Delivery surfaces sit above domain modules.** `rest` and `frontend` may import a domain module; a domain module must never import a delivery surface.
* **Part-islands are mutually non-importing.** The runner, headscale and otel parts must not import each other. They communicate through typed clients in `orchestration/clients/`. When a part needs a fact owned by another module, it receives it as an **injected callback**, not an import — the headscale part learns which runs are active this way, and stays pure.
* **The kernel splits: `config` sits below `db` and `observability`.** `config` is shared-kernel and must not import an application-layer module — this is why it re-derives the home path locally instead of importing `xorcise.core.home`.
* **Only the runner part touches Docker.** Ingestion drives image builds through a builder port that it stubs in tests.

## 3. Persistence

The kernel owns SQLAlchemy 2.0 and Alembic; models are owned by the module they belong to.

* **Migrations are hand-written. Autogenerate stays off.** The Alembic environment must never import an application-layer model, so there is no `target_metadata` to diff against. Write the migration by hand.
* **Never add a model-level `ForeignKey` across modules.** The foreign key lives in the migration DDL only. SQLite does not enforce foreign keys by default, and a model-level FK across modules would break part-island independence.
* **Cascades are application-layer and orchestrated by the delivery surface.** Deleting an agent deletes its runs and results. Do not "simplify" this to `ON DELETE CASCADE` — it would not fire on SQLite and would reintroduce the cross-module FK. This is the canonical case of an obvious simplification being wrong.
* **Never migrate populated data on boot.** A database is classified `fresh` (scaffold it to head), `ready` (boot), or `stale` (refuse loudly and defer to `xorcise db upgrade`). Bootstrap happens on `up`; migration is always an explicit operator action.

## 4. Run lifecycle and durable state

A run's state is authoritative **in the database**, not in a process-local map.

* **Persist within the operation that changes the state.** In-memory maps are caches and must always have a durable fallback. A fact that outlives a request, or crosses a process or crash boundary, is written through the `runs` domain layer at the moment it changes.
* **Address external resources by run-derived identity** — the router node is `{run_id}-router`, the container is named for the run — so a reconciler that never saw the creating process can still find and reap them.
* **Make create, teardown, terminal transition and result-recording idempotent and replayable.** Reconcile-on-startup re-runs them to convergence after a crash; anything that is not replayable will corrupt state instead of healing it.
* **The server never calls its own REST API to persist its own state.** Go through the domain layer.
* **Terminal is first-wins and grading is asynchronous.** Sealing happens synchronously so the agent gets an immediate answer; grading runs on a background task. The safety comes from a pair of guards — result-recording is idempotent first-write-wins on `run_id`, and the grade-and-record path is a guarded no-op if it has already run. Do not remove either guard, and do not move grading back onto the request thread. The budget watchdog runs on its own thread.
* **Versions bump only on an explicit update.** Registering a duplicate name is a 409, never a silent overwrite. A mission version is stamped only through the shared atomic-install helper; a new install path that bypasses it silently breaks cross-run comparability.

## 5. Network isolation

Each run gets its own fenced network. The guarantee is that **the mission is unreachable from the host and from other runs** — it is not an egress firewall, and mission containers still reach the internet through ordinary NAT.

* Mission containers run on a Docker network nested inside a fused container, so no route exists from the host to a mission address.
* A private tailnet carries the agent in. The access policy is **fail-closed**: exactly one rule per run, no default-allow. Refuse to apply a policy that does not match that shape.
* **Render subnet allocation and the access policy from persisted non-terminal runs**, unioned with the in-process in-flight set, and reconcile the policy *after* the row persists. Deriving them from in-process state alone lets concurrent runs collide and lets one process clobber another's policy.
* **Delete the run's router node by name on the terminal transition.** A completed run that leaves an online node behind leaks into the next run's allocation.
* Agents reach targets **by IP**; there is no DNS inside the fence. The tailnet login server is never `host.docker.internal`.
* The per-run join key is read through its narrow getter only and is never surfaced on the general run entity.

## 6. Missions

A mission is **`mission.json` plus a pullable OCI image** — never an archive format. The server-to-runner deploy verb carries a thin reference only; mission bytes never ride the wire between them.

* The mission and its runner are **fused into one self-contained image per mission**, with the inner stack baked in and loaded on boot so a deploy needs no inner pull. The cost is that a change to runner control logic requires rebuilding every mission image. That is an accepted trade, not a bug.
* **The two acquisition paths never cross.** A library mission is pulled as a prebuilt fused image; a local mission is ingested and built locally. Both write their installed record through the same shared helper.
* **Attachment bytes never ride run-control.** The agent asks for a companion file by name and receives a short-lived HMAC-signed URL it fetches separately. Signing secrets are generated per process unless explicitly pinned.

## 7. Telemetry and the evidence pipeline

Raw OTLP is **canonical and immutable**. Everything else is derived.

* The live view and the grader read natively off the persisted raw store. The OTLP receiver is ingress-only — it never becomes a routing, correlation or persistence layer.
* **The agent-event projection is a rebuildable cache, never a source of truth.** It is versioned by adapter name and version so a changed adapter invalidates and re-derives it.
* **The grader must never read the agent-event projection.** It consumes the sealed raw record only. This one is enforced by name in `.importlinter`.
* **Adapters must be total.** An unrecognised span becomes a generic event, an adapter exception becomes exactly one error event, and neither ever produces a 5xx. Supporting a new agent must require no frontend change and no core change.
* **Adapter selection is authoritative from the run's recorded agent**, not from agent-emitted resource attributes, which are forgeable.
* A run is **sealed** on terminal; spans arriving afterwards are rejected. Replay never affects a score.
* **Telemetry is optional.** A run with no traces is valid: deterministic checks read artifacts and observed facts, and criteria the judge cannot evidence score `unknown` and are renormalised out rather than scored zero. Never make a trace a precondition for grading.

## 8. Grading

A score is **half deterministic checks, half model judgement**, over the sealed evidence only.

* Deterministic checks assert a named operation over a value resolved from a named source. Fetching is decoupled from asserting so a new check op needs no new resolver.
* **Agent-controlled evidence rides the user role, fenced as untrusted and stripped of fence markers; instructions ride the system role.** Never interpolate agent output into the system prompt.
* The transcript budget is counted with an **explicitly configured tokenizer**. Tokenizers are not interchangeable, so the choice is recorded rather than inferred. Going over budget must fail loudly; never silently truncate evidence.
* A degraded judge produces a recorded, re-gradeable failure — never a silent zero.

## 9. Agent control surface

**REST is the only agent control surface.** There is no MCP plane and no reserved port for one. If an MCP surface is ever wanted, it is a fresh decision starting from the REST contract, not a resumed stub.

* The agent's verbs are `/mission`, `/intel`, `/artifacts`, `/complete`, plus the connect and join helpers. Authentication is a per-run bearer token.
* **There is one submission verb.** The flag is not special: it is the artifact named `flag`, credited by a deterministic check. Never reintroduce a dedicated flag endpoint.
* **Post-terminal run-control is uniformly 409** — no per-verb special cases, no "late submission" path.
* Note that the agent-event vocabulary retains MCP call and result kinds. Those describe the *agent under test* invoking its own MCP tools, which is a different thing that happens to share a name.

## 10. Configuration

Local configuration, including the BYOM judge key, is settable through the REST API so the UI and CLI share one path.

* The config API is a **fixed, judge-shaped contract**, never a generic settings passthrough — a generic one could brick a running server.
* **Secrets are write-only.** A stored key reads back only as its last few characters.
* All writers go through the shared upsert helper and then clear the settings cache. Writing the environment file by any other path leaves the running server serving stale configuration.

<Warning>
  **Security note.** In the default local topology the REST and OTLP planes bind loopback plus, on native Linux, the Docker bridge gateway — the narrowest set that still lets an agent in a container reach them via `host.docker.internal`. They widen to `0.0.0.0` only on an explicit `XORCISE_HOST=0.0.0.0`, or when that gateway cannot be determined at boot. Neither plane authenticates the operator surface. A loopback bind is therefore **not** a security boundary here: every local process and every container on that bridge can read runs, create runs, and write configuration. Do not add secret-bearing endpoints on the assumption that the surface is loopback-only, and do not assume the wildcard case never happens — it is a live fallback path.
</Warning>

## 11. Deferred, and deliberately absent

Stated so nobody re-derives them as gaps:

* **Remote and cloud-hosted mission planes are not implemented.** XORCISE runs on one machine. No Terraform ships in this repo, and the remote and cloud auth commands are placeholders.
* **There is no streaming trace endpoint.** The live view polls with an incremental cursor. This is a deliberate simplification.
* **Telemetry mirroring to an external service is a reserved, default-off seam.** Enabling it fails fast rather than silently exporting: nothing leaves the operator's machine by default.

## 12. Contributor ground rules

Two short rule sets travel with the codebase. They are stated here so a contributor finds every load-bearing rule on one page.

### Coding standards

* ruff (lint+format), mypy `--strict`. No `# type: ignore` without a reason comment.
* Small, focused files (one responsibility). Typed DTOs (`xorcise.core.contracts`) over loose dicts at boundaries.
* TDD: failing test → minimal impl → green → commit.
* CI is GitHub Actions (`.github/workflows/ci.yml`); any CI lane, dependency setup, guard, or test-marker change must be reflected there.

### Security

* The application layer never holds the Docker socket (only `runner`). Nothing leaves by default (egress is opt-in).
* Secrets live in `~/.xorcise/.env` (0600), never in the repo. BYOM keys are the user's.
