ADR-0032: The brain, execution model and data access¶
- Status: Accepted
- Date: 2026-08-18
- Deciders: El, Lachlan
- Relates to: ADR-0002, ADR-0006, ADR-0023, ADR-0030
Context¶
The brain is the least-defined and highest-risk component. core_stack and tenant_stack are both blocked on it. Three questions had to be answered before any of it could be written: who executes an action, how a multi-step decision loop stays stateless, and how the brain reaches tenant data without dissolving the isolation boundary that everything else is built on.
That third question is not hypothetical. ADR-0023 §6 rejected a shared Metabase specifically because it would need credentials to every client database. The brain has exactly that property. Left unexamined, it is a hole in the middle of the isolation story.
Decision¶
1. n8n drives, the brain decides¶
Every interaction is a stateless request and response. n8n holds the loop, and the brain answers one question at a time.
n8n -> brain "here is the work item and the latest result"
brain -> tenant DB load state and config, persist the decision
brain -> n8n "do this next"
n8n -> ... executes, then comes back with the outcome
The loop can run many turns. Each turn is a fresh call, so a restart mid-loop loses nothing and either brain replica can answer the next turn (ADR-0030).
The brain never calls n8n. If it drove the loop it would have to hold state in memory between turns, and it would need n8n endpoints and credentials. Both are rejected.
2. Execution stays in n8n, and so do client credentials¶
The brain proposes. n8n acts. The brain holds no credentials to any client system, no Xero token, no AroFlo key, nothing that can change a client's data outside our own tenant database.
This bounds what a compromised brain can do: it can read and write tenant databases, and it cannot touch the client's actual systems of record.
3. Loops must terminate, visibly¶
Every work item carries a turn counter. A configurable ceiling, low by default, halts the item and flags it for a human rather than looping. An agent quietly burning tokens in a cycle is a cost failure and a correctness failure at once, and it must be visible on the work item rather than only in a log.
4. Approval is a stop, not a wait¶
When the brain proposes an AI-generated action, it marks it requires_approval and n8n stops. Nothing blocks, nothing polls.
The approval record is written to the tenant database (ADR-0026 §2). When a human approves it in the client review app, that write is what triggers the next n8n run. The system is event-driven throughout, and a pending approval consumes nothing.
Expiry re-queues and never auto-approves.
5. Per-tenant database roles, resolved per request¶
The brain does not hold a superuser connection to tenant databases. For each request it connects using a role scoped to that tenant, with grants narrow enough that the engines can do their work and nothing more.
This does not remove the trust concentration: a compromised brain can still request any tenant's role. It removes the far more likely failure, which is a bug in shared engine code touching the wrong tenant's data. The container boundary protects tenants from each other, and this protects them from us.
Connection details for tenant roles are resolved from the registry at request time and never cached to disk.
6. The brain is read-only against the registry¶
Unchanged from ADR-0006, and restated because it is load-bearing here: config is read, never written. The two writers remain Ansible at bootstrap and the BOS Console thereafter.
6a. Tenant identity comes from the credential, never the payload¶
A per-tenant database role (§5) is decoration if the request states which tenant it is, because tenant A's n8n could then ask for tenant B's data.
Each tenant's n8n holds its own bearer token, provisioned by Ansible from that tenant's vault and stored in n8n's encrypted credential store. The brain resolves the tenant from the token. A tenant_id in the body is not trusted, and a request whose body disagrees with its token is rejected rather than reconciled, because a mismatch means something is already wrong.
The brain is reachable only on abos_core, so exploiting this would require already being inside a tenant container. That is precisely the scenario the container boundary exists to bound, which is why it must not be the last line of defence.
Rotation is then a per-tenant Ansible run rather than a global secret change.
6b. Actions come from a closed, versioned catalogue¶
The brain does not return free-form instructions. It chooses from a catalogue of actions that already exist, with typed parameters:
n8n knows how to execute each catalogued action. Anything outside the catalogue cannot be proposed, so the worst an LLM can decide is the wrong action from a known set, never a novel one.
This is ADR-0004's reasoning applied to actions rather than rules: no expression language, no eval, no improvisation. It also makes requires_approval a property of the action type rather than a judgement the model makes about itself.
The catalogue is versioned, and a work item is pinned to the version in force when it started (ADR-0006).
6c. Every decision carries an idempotency key¶
n8n retries on timeout, and the brain may already have written a decision and paid for an LLM call. Without a key, a retry produces a second decision and possibly a second draft.
The key is work item plus turn number. A repeat request returns the stored decision rather than deciding again. Cheap now, and painful to retrofit once there is data.
6d. The response envelope: assessment and action are separate¶
A turn produces two different kinds of output, and conflating them leaks the closed catalogue.
{
"assessment": {
"category": "quote_request",
"priority": "high",
"confidence": 0.82
},
"action": {
"name": "draft_reply",
"params": { "tone": "apologetic" },
"requires_approval": true
}
}
assessment is what the brain concluded about the work item. Fixed, known fields, being columns on the work item. Every turn produces one.
action is what should happen next in the outside world. Chosen from the catalogue, with parameters typed per action. Not every turn produces one, because sometimes the correct outcome is to update the item and stop.
Properties of the work item do not travel as action parameters. Priority is the clearest case: it belongs to the item, not to the act of drafting a reply. Carried on the action it could only be set while drafting, two actions could set it inconsistently, and closing an item could not set it at all.
This also keeps classification out of the catalogue. Classifying is an assessment, not something n8n executes. The catalogue holds only operations with an external effect.
6e. Naming and parameter contracts¶
verb_object, lowercase, snake_case, present tense. draft_reply, create_job, escalate_item, close_item.
- The verb states the real effect, not the intent.
draft_replywrites a draft.send_replysends one. An approval log must distinguish those without anyone opening the code. - Never repurpose a name. A changed meaning becomes
draft_reply_v2, because existing approval records reference the old meaning and those records are evidence. - The object uses domain language, not system language.
create_job, neverpost_aroflo_workorder. The catalogue must survive replacing AroFlo with Simpro, for the same reason the canonical model carries no source fields. - No product prefix in the name. Product is a field on the catalogue entry, so the taxonomy is data rather than string convention.
Every action declares a typed parameter model, being Pydantic and versioned with the catalogue. Without it, params is a free-form dictionary and the closed catalogue leaks.
6f. Amending the catalogue¶
| Change | How |
|---|---|
| Add an action | Code. It needs an n8n branch that can execute it. Define, implement, deploy |
| Give a tenant an action | Config. Enabled per tenant in the registry, so the brain cannot propose what a client has not switched on |
| Change an action's meaning | New name. Never repurpose |
| Remove an action | Deprecate slowly. In-flight work items are pinned to their catalogue version |
This keeps ADR-0003 intact: behaviour is built once in code, and giving it to a client is config.
6g. Reconciliation before acting¶
Before executing an approved action, n8n checks whether the human already did it themselves. Someone replies from their phone, or rings the customer, while the item sits awaiting approval.
The check happens immediately before the action, not on a schedule. A sweep every fifteen minutes still leaves a window in which we act redundantly.
If prior human action is found, the action is aborted and the item closes as closed_manual, recording what the human actually did.
closed_manual is a distinct outcome, neither success nor failure. In value reporting it is neither "we handled it" nor "we got it wrong". A high rate of it means the system is too slow to be useful, which is a different problem from being inaccurate. Recording what was actually sent is also the improvement signal: a draft ignored every time, or reworded the same way every time, says the prompt or the rules are wrong.
This applies beyond email. create_job needs the same check, because someone may have raised it by hand.
6h. Approval surface is a per-tenant mode¶
Two modes, and they are not skins on the same thing. They carry different evidence and different liability.
review_app |
mailbox |
|
|---|---|---|
| Draft stored in | Tenant database | The client's mailbox |
| Edited in | The review app, then re-hashed | Their mail client, invisibly to us |
| Who sends | Us, after approval | The human |
| The record proves | The exact text approved | Only what we proposed |
| Saves the send step | Yes | No |
| Path to higher autonomy | Yes | Dead end |
mailbox is a genuine assist tier: the human still does the sending, so no time is saved on that step, and there is no route from it to acting without approval later. It is also a stronger liability position, since they pressed send in their own client, which suits a cautious client.
A tenant runs one mode, never both. They are alternatives, not features to combine. Two live approval surfaces on the same work item means two places a human could act, so the reconciliation check (§6g) would have to guard against our own other surface as well as the human, and the evidence chain splits: one item could carry an approved-and-hashed record and an unverifiable manual send. The mode is a per-tenant config value with no "both" option.
Changing a tenant's mode is allowed, but only takes effect for new work items (ADR-0006). In-flight items finish under the mode they started in, because their approval records were written under its rules.
review_app is built first, because it is the strategic mode and client-zero is us. mailbox is a fast follow. Both require reconciliation (§6g).
7. Version one is Capture, on client-zero, for email triage¶
One engine, one product, one tenant, and that tenant is us. Progress in its most minimal form, being enough state to move a work item and record an approval. Bill and Remember are not built.
Assist autonomy: every draft is approved. No exceptions until there is a scored eval harness to justify otherwise.
8. Stack¶
Python, FastAPI, LangGraph, Pydantic v2. Every LLM output is parsed into a Pydantic model, never with a regular expression and never with a bare json.loads. LLM calls route through packages/llm-client (ADR-0009). This follows the pattern already proven in octopodia-api.
LangGraph checkpoints persist to the tenant's Postgres, never to local disk or memory (ADR-0030). A violation here is invisible on one host and corrupting on two.
Consequences¶
The brain stays genuinely stateless, so replicas are safe, restarts are free, and the deployment model in ADR-0030 holds.
Because n8n executes, the credentials that can change a client's real systems stay in the component that already holds them, and the blast radius of a brain compromise stops at our own tenant databases.
The cost of n8n driving is that a multi-turn interaction is several round trips rather than one in-process loop. At the latencies involved, which are dominated by LLM calls and upstream APIs, this is not material.
Per-tenant roles mean grants must be maintained as the schema evolves. That is real ongoing work, accepted because it converts the most likely cross-tenant failure, a bug, into an error.
The trust concentration is not eliminated and should not be described as if it were. A compromised brain crosses tenants. It is accepted because the alternative, a brain per tenant, contradicts ADR-0002 and multiplies the maintenance surface by the client count, and because the brain is our own code with no query interface rather than a third-party product with a CVE history.
Alternatives considered¶
- The brain drives and calls n8n is rejected: it would hold loop state in memory, breaking ADR-0030, and would need credentials to client systems.
- The brain executes directly against client APIs is rejected: it duplicates what n8n already does well, and concentrates every client credential in the one component that touches every tenant.
- A brain per tenant is rejected: it contradicts shared engines (ADR-0002), and an improvement would then have to be deployed N times rather than once.
- A superuser connection pool is rejected: simpler, and it makes a routine bug into a cross-tenant data incident.
- Blocking on approval inside the graph is rejected: it makes the brain stateful and holds resources open for what may be days.