The brain¶
The shared, stateless decision layer. It reads config from the registry, decides what should happen to a work item, and writes the decision to that tenant's database. It never executes anything itself.
Decided in ADR-0032. Engines and products in ADR-0002.
Shape¶
flowchart LR
N8N["n8n<br/>tenant A"] -->|"tenant_id + work item<br/>+ latest result"| API["FastAPI"]
subgraph BRAIN ["The brain: stateless"]
API --> RES["Resolve config<br/>for this tenant"]
RES --> G["LangGraph<br/>Capture · Progress<br/>Bill · Remember"]
end
REG[("Registry")] -.->|"read-only"| RES
G -->|"per-tenant role"| TDB[("Tenant A<br/>Postgres")]
G -->|"inference"| LLM["packages/llm-client"]
G -->|"next action"| N8N
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class LLM llm
The purple node marks a model call. Throughout these docs, purple means non-deterministic and billable. Everything else is code, a query, or a rule.
The loop¶
n8n drives, and the brain answers one question at a time.
sequenceDiagram
participant N as n8n (tenant)
participant B as Brain
participant D as Tenant Postgres
participant H as Human
N->>B: work item + latest result
B->>D: load state, config, turn count
B->>B: decide
B->>D: persist decision
B-->>N: next action
alt deterministic action
N->>N: execute
N->>B: outcome, loop continues
else AI-generated action
B->>D: write approval record
Note over N: n8n STOPS. Nothing polls.
H->>D: approve in the client review app
D->>N: that write triggers the next run
N->>N: execute
end
Every turn is a fresh stateless call. State lives on the work item, so a restart mid-loop loses nothing, and either brain replica can answer the next turn (ADR-0030).
Approval is a stop, not a wait. A pending approval consumes nothing. The human's approval is itself the trigger for what happens next.
Three things that contain the damage¶
Identity comes from the credential, never the payload. Each tenant's n8n holds its own bearer token, and the brain resolves the tenant from the token. A tenant_id in the body is not trusted, and a body that disagrees with its token is rejected. Without this, the per-tenant database role below is decoration, because tenant A could simply ask for tenant B's data.
Actions come from a closed, versioned catalogue. The brain chooses from operations that already exist, with typed parameters, so the worst an LLM can decide is the wrong action from a known set rather than a novel one. Same reasoning as ADR-0004's structured predicates: no expression language, no eval, no improvisation. requires_approval is a property of the action type, not a judgement the model makes about itself.
Every decision carries an idempotency key, being work item plus turn number. n8n retries on timeout, and without a key a retry produces a second decision and a second draft, having already paid for both.
What it does and does not hold¶
| Brain | Where it lives instead | |
|---|---|---|
| Loop state between turns | No | The work item, in tenant Postgres |
| LangGraph checkpoints | No | Tenant Postgres. Never local disk |
| Config | Read-only, per request | Registry (ADR-0006) |
| Client system credentials (Xero, AroFlo) | Never | n8n |
| Superuser DB access | No | Per-tenant role, resolved per request |
The middle row is the one that breaks quietly. A checkpoint written to local disk is invisible on one host and corrupting on two, because a work item resumed on the other replica finds nothing (OI-049).
Data access, and the boundary it bends¶
This is the one place the isolation story is not absolute
ADR-0023 §6 rejected a shared Metabase because it would need credentials to every client database. The brain has exactly that property, and it is accepted rather than avoided.
A compromised brain crosses tenants. That is stated plainly rather than argued away.
Why it is accepted: it is our own code rather than a third-party product with a CVE history, it exposes no query interface, and the alternative (a brain per tenant) contradicts shared engines and multiplies maintenance by client count.
What reduces the risk:
- Per-tenant database roles, resolved per request, with narrow grants. The container boundary protects tenants from each other, and this protects them from our bugs, which are far likelier than a compromise.
- No client credentials in the brain. A compromise reaches our tenant databases and stops there. It cannot change anything in the client's actual systems of record.
- Read-only against the registry. It cannot rewrite the map.
Loop termination¶
Every work item carries a turn counter with a low default ceiling. Reaching it halts the item and flags it for a human.
An agent cycling quietly is a cost failure and a correctness failure at once, so it must be visible on the work item rather than only in a log.
Version one¶
| Scope | |
|---|---|
| Engine | Capture only. Progress in its most minimal form |
| Product | Email triage |
| Tenant | Client-zero, being Octopodia's own inbox |
| Autonomy | Assist. Every draft approved, without exception |
| Not built | Bill, Remember |
Assist autonomy is not a soft start. There is no scored eval harness yet, so there is no evidence on which to grant more.
Stack¶
Python, FastAPI, LangGraph, Pydantic v2, following the pattern already proven in octopodia-api.
- Every LLM output is parsed into a Pydantic model. Never a regular expression, never a bare
json.loads. - LLM calls route through
packages/llm-client(ADR-0009). No direct SDK calls, no OpenRouter. - Log token usage per request, never prompt content. Prompts carry client data.