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 under services/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 under services/core_api/api/v1/routers/ imports from services/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 under services/core_api/application/ imports fastapi. 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 the db singleton from shared.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 in shared/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 not INSERT INTO or UPDATE ... SET the same table. One aggregate — one repository. Proves Layer 6 — Repository pattern applied consistently.
  • spa_feature_slice_isolation — every folder under apps/web/src/features/ is a private slice; cross-slice imports resolve to the SHARED_KERNEL allowlist 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 in contract migrations. Proves the expand-contract deploy pattern from Accelerate.
  • platform_admin_authz.yml — every staff-platform route gates on session_kind === "staff_platform". is_root is 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.