Every API request in Actionary travels the same eight layers. A record read, a workflow enqueue, an MCP tool call, an agent turn — the path from the browser to Postgres and back is the same shape every time. Each layer earns its keep by containing exactly its concern — nothing more, nothing less.
The table below is the substrate the architecture page sits on. Read it as a specification: where each layer lives, what it receives, and what it sends down to the next.
The full request chain
| # | Layer | Where it lives | Job (one line) | Receives | Sends down |
|---|---|---|---|---|---|
| 1 | Browser / MCP client | apps/web/ + external connectors |
User interaction, render UI, dispatch agent tool | User click or agent decision | HTTPS request |
| 2 | Caddy (reverse proxy) | Caddyfile |
TLS termination, host-based routing (partners.acme.com → tenant, staff.actionarycrm.com → staff) |
HTTPS request | HTTP forwarded to core-api container |
| 3 | FastAPI + middleware | services/core_api/main.py, services/core_api/api/v1/middleware/*.py |
Auth, CSRF, session validation, tenant resolution, DB role selection, request-id logging | HTTP request | Request with auth context + tenant GUC on DB session |
| 4 | Router | services/core_api/api/v1/routers/*.py |
HTTP transport only — parse path / query / body params, dispatch to application service, format response envelope | Request with auth context | Method call to application service |
| 5 | Application service | services/core_api/application/*.py |
Business logic — orchestration, policy checks, composition, validation, envelope shaping | Method call from router | Method call to repository |
| 6 | Repository | services/core_api/infra/repositories/*.py |
Data access — SQL statements, tenant scoping baked into every query, no policy or composition | Method call from service | SQL string + params to DB connection |
| 7 | Shared DB connection | shared/db/connection.py |
psycopg pool, role acquisition (crm_app / crm_platform_admin / crm_migrate), sets app.current_tenant_id GUC on the session |
SQL + params from repository | cursor.execute(...) |
| 8 | Postgres substrate | external, crm-app-postgres-1 |
Executes SQL under row-level-security policies keyed on the tenant GUC | SQL statement | Result rows or exception |
Return path is the same layers in reverse: Postgres → connection → repository (rows → dicts) → service (dicts → domain shape) → router (domain shape → api_response envelope) → middleware (envelope → JSON) → Caddy → browser.
Eight rules — one per boundary, top to bottom
Every layer has one rule. Each rule is a single-sentence invariant plus a one-sentence ban on the shape that violates it. When code violates the rule, the layer boundary the rule protects is not real.
- Layer 1 — Client holds no trust. The browser or MCP client renders and dispatches; it never enforces auth or tenancy. Anything the client asserts is re-checked server-side.
- Layer 2 — Caddy routes, it does not reason. Host maps to tenant or staff surface and TLS terminates here. If routing logic starts inspecting bodies or making auth decisions, it is in the wrong layer.
- Layer 3 — Security lives in middleware. Auth, CSRF, session validation, tenant resolution, and DB role selection happen once, here. If a router or service re-derives identity or tenancy, that logic is misplaced.
- Layer 4 — Routers do HTTP transport only. Parse params in, shape the envelope out, dispatch to a service. If a router does composition, orchestration, or envelope shaping beyond formatting, it is in the wrong layer.
- Layer 5 — Business logic only in services. Orchestration, policy checks, composition, and validation belong here. A service must not write raw SQL, and must not know about HTTP.
- Layer 6 — SQL only in repositories. Every query lives here with tenant scoping baked in. If SQL appears in a service or router, it is in the wrong layer; if policy or composition appears in a repository, that is misplaced too.
- Layer 7 — Connection owns roles and the tenant GUC. Role acquisition and the per-session tenant setting happen here, in one place. Repositories receive a scoped session; they never pick roles themselves.
- Layer 8 — Postgres is the last line, not the only line. Row-level-security keyed on the tenant GUC enforces isolation even if a query above forgets its filter. A missed scope is a performance leak here, never a disclosure.
Where each rule descends from
Every rule above traces to a paper on the design-principles reading list. Actionary did not invent these ideas — it applies them consistently.
| Rule | Descends from | Year |
|---|---|---|
| Client holds no trust | Saltzer & Schroeder, The Protection of Information in Computer Systems — least privilege at the boundary | 1975 |
| Caddy routes, it does not reason | Dijkstra, On the role of scientific thought — separation of concerns | 1974 |
| Security lives in middleware | NIST SP 800-207 Zero Trust Architecture — policy enforcement point at the boundary, once per request | 2020 |
| Routers do HTTP transport only | Fowler, Patterns of Enterprise Application Architecture — the Service Layer chapter, applied at the transport edge | 2002 |
| Business logic only in services | Fowler, PoEAA — Service Layer, plus Martin, Clean Architecture — use-case interactors | 2002 / 2017 |
| SQL only in repositories | Fowler, PoEAA — Repository pattern, plus Evans, Domain-Driven Design — Repository from §5 | 2002 / 2003 |
| Connection owns roles and the tenant GUC | Saltzer & Schroeder — least privilege applied at the connection tier, with role assumption factored to one location | 1975 |
| Postgres is the last line, not the only line | Postgres row-level security (2016) applied as defence in depth per NIST SP 800-53 SC-3 | 2016 |
The “cool mapping” is the point. Every rule descends from a citation on the fifty-year reading list. Fifty years of production experience compressed into eight sentences.
Nine CI-enforced contracts prove the rules
Every merge runs nine assertions against the code. The rules above are stated in English; the assertions below run in Python and TypeScript and fail the build when the shape they protect drifts.
inline_sql_budget.yml— SELECT, INSERT, UPDATE, DELETE only live underservices/core_api/infra/repositories/. Per-file budget; inline SQL in a router or service fails the build. Proves Layer 6 — SQL only in repositories.no_repository_import_from_routers— no file underservices/core_api/api/v1/routers/imports fromservices/core_api/infra/repositories/. Every data-access dependency routes through an application service. Proves Layer 4 — Routers do HTTP transport only on the persistence-reach shape.no_fastapi_in_services— no file underservices/core_api/application/importsfastapi. The service layer stays transport-agnostic; the same method serves the HTTP router, the MCP tool call, the workflow node, and the background worker. Proves Layer 5 — Business logic only in services on the transport-independence shape.no_direct_db_in_services— no application service imports thedbsingleton fromshared.db.connection. Module-level helpers (quote_ident,get_cursor,set_current_db_role) stay importable; the connection object itself never appears above persistence. Proves Layer 5 → Layer 7 boundary.no_psycopg_outside_connection— psycopg connection primitives appear only inshared/db/connection.py. Value types and error classes stay importable. Proves Layer 7 — Connection owns roles.repository_aggregate_naming— every repository is named after its aggregate; two repositories may notINSERT INTOorUPDATE ... SETthe same table. One aggregate — one repository. Proves Layer 6 — Repository pattern applied consistently.spa_feature_slice_isolation— every folder underapps/web/src/features/is a private slice; cross-slice imports resolve to theSHARED_KERNELallowlist or a per-file composition allowlist. Proves Layer 1 — Feature-Sliced Design applied to the client.migration_policy.yml— every migration carries a-- phase:tag (additive / expand / switch / contract / data); destructive verbs only appear incontractmigrations. Proves the expand-contract deploy pattern from Accelerate.platform_admin_authz.yml— every staff-platform route gates onsession_kind === "staff_platform".is_rootis banned on any table, payload, JWT claim, or in-memory user object. Proves the SOC 2 CC6.1 invariant.
Every one of the nine has a docstring naming what it enforces, the allowlist mechanism where one exists, and the exit-code contract. A refactor that regresses the shape cannot ship.
The takeaway
Clean architecture in most codebases is an aspiration a lead engineer defends in code review. Here it is mechanically un-regressable. The eight layers are drawn. Each layer has one rule. Nine CI jobs run the rules against the code at every merge.
Read the architecture page for the substrate the layers run on. Read the design principles for the fifty-year reading list every rule descends from. Read the open-standards stance for what stays portable at every boundary the layers cross.