This describes the deployed architecture, not an aspirational one. It also states the boundaries that must move before the single-node self-hosted topology becomes a multi-node hosted service.
browser / REST / MCP
|
v
nginx :3000/:3010 ---- static React assets
|
v
FastAPI (enqueue + query only; RUN_INLINE_WORKER=0)
| |
| PostgreSQL | Redis tenant channels
v v
jobs + tenant data <---- workers ----> live SSE progress
^ |
| +---- isolated provider processes / external APIs
|
scheduler (reconciliation and recurring enqueue only)
migrate (one-shot owner) ---> Alembic ---> runtime role is non-super/NOBYPASSRLS
Every first-party Python service and the frontend asset publisher use one immutable application image. PostgreSQL and Redis are private to the container network and bound to loopback for host administration; nginx is the only LAN listener.
| Data | Authority | Isolation / durability |
|---|---|---|
| Leads, signals, workbooks, rows, cell traces, connector runs | PostgreSQL | workspace_id, fail-closed RLS, runtime NOBYPASSRLS role |
| Automations, outreach, watches, ingest, MCP audit | PostgreSQL | tenant RLS; non-tenant schedule mirrors contain no recipient data |
Durable execution queue (jobs) |
PostgreSQL | global control table; tenant identity is required in payloads and checked by handlers |
| Users | PostgreSQL | global authentication table |
| Workspace/member/active-workspace metadata | data/workspaces.db SQLite |
authenticated control plane; shared-volume, single-node boundary |
| Workspace secrets | encrypted values in data/workspaces.db |
role-gated and encrypted at rest; single-node boundary |
| Collection job/stage/LLM detail | per-workspace SQLite ledger | tenant file path; queue job remains durable in PostgreSQL |
| Live progress and reconnect history | Redis | exact tenant channel plus bounded 500-event list |
| Frontend bundle | immutable image -> named volume -> nginx | asset publisher refreshes it on every deploy |
PostgreSQL is the production data plane. SQLite remains a control-plane compatibility layer, not the source of truth for production workbook or lead rows.
- The access token resolves an active user. Refresh tokens are rejected on HTTP, SSE, and WebSocket authentication paths.
X-Workspace-Id(or the stored active workspace) is checked against workspace membership.- The request binds a context-local workspace and sets PostgreSQL
app.workspace_idtransaction-locally. - Owner/admin/editor roles gate mutations and spend; viewers are read-only.
- Application checks provide clear 404/403 behavior while PostgreSQL RLS is the final isolation boundary. A missing workspace GUC fails closed.
Workers do the equivalent with workspace_scope(workspace_id) for each claimed
job. A handler lacking a workspace id or slug fails instead of falling back to
a global tenant.
API transaction
validate role + ownership
create domain run/ledger record
INSERT jobs(status=pending, tenant payload, optional fire_key)
|
v
worker claim: SELECT ... FOR UPDATE SKIP LOCKED
status=processing, worker_id, locked_at, heartbeat
|
v
handler under workspace_scope
emit Redis progress
write page/cell/batch transaction
|
+--> success: completed
+--> retryable error: retrying -> pending at next_run_at
+--> terminal error: failed + domain failure reconciliation
+--> cancellation: preserve cancelled; terminate child process
The queue supports priorities, bounded retries, backoff, heartbeats, stale-claim
recovery, per-job timeouts, killable subprocess work, and fire_key
single-flight constraints. Cancellation is a committed terminal state: parent
finalization cannot overwrite it with success or failure.
API and agent entry points use the same queue. Copilot collection, bulk enrichment, CSV imports, workbook sources, connector imports, refreshes, automations, outreach, and recurring scans are not detached threads inside an API process.
Typed connectors return pages with normalized records, provider record IDs,
cursor/exhaustion state, source totals, and warnings. The worker commits a page,
its row upserts, counters, and the next cursor together. Unique
(workbook_id, source_provider, source_record_id) identity makes replay after a
crash idempotent. A partial unique index permits only one active run per
workbook/connector pair.
connector_runs distinguishes requested, fetched, added, updated, skipped,
target-met, exhausted, and failed states. “100 requested” therefore cannot be
reported as complete after silently returning 50.
The worker publishes only workspace-scoped events. The API subscribes to one exact Redis channel after authenticating workspace membership; it never pattern subscribes to all tenants. A bounded Redis list lets reconnecting clients replay recent events. In-process queues are a development fallback, not the cross-process production transport.
- Tenant-facing URL fetches reject non-HTTP schemes, credentials, encoded IP tricks, private/loopback/link-local/metadata addresses, and hosts resolving to blocked addresses.
- Redirects are handled manually or disabled; every followed hop is checked.
- Browser routes check navigations and subresources before continuing.
- Automation webhooks use DNS-pinned connections with redirects disabled.
- Provider jobs have time/process limits; credentials resolve per workspace and are not placed in queue payloads.
A controlled egress proxy remains the appropriate final boundary for a public hosted service because application DNS checks cannot provide the same network-level guarantee for every third-party browser/TLS stack.
- Scale workers horizontally; PostgreSQL row locking prevents double claims.
- Scale API processes on one host; API containers share the control-plane
data/volume. - Run exactly one scheduler unless scheduler leadership is added.
- Run migrations once with the owner role before starting the runtime image.
- Do not place API/worker replicas on independent hosts until workspace metadata, secrets, and collection-stage ledgers move from SQLite to PostgreSQL or a strongly consistent control-plane service.
The release floor for this architecture is:
- the complete default test suite;
- gated PostgreSQL tests using non-super,
NOBYPASSRLSroles against a disposable database; - a fresh SQLite migration to Alembic head;
- a containerized frontend production build;
- all first-party services on the same image digest;
- authenticated live lead/workbook/filter smoke tests;
- worker-to-API Redis progress proof; and
- LAN health and workbook-page checks through nginx.
- Move workspace membership, secrets, active-workspace state, and collection ledgers into PostgreSQL with explicit control-plane policies.
- Tenantize or remove the remaining global admin-only legacy utilities.
- Put outbound fetches behind an audited egress proxy, move encryption keys to managed KMS, and complete an external security review and restore drill.
- Publish reproducible provider accuracy/coverage/cost evaluations and make the declarative registry a documented contribution surface.
- Close visible product gaps: indexed JSON search/custom-sort cursors and arbitrary cross-page selection at million-row scale (stable default-order cursors, global saved-view querying, server paging, virtualization, range copy/fill, and atomic multi-row paste are shipped), direct Clay API import (audited Clay CSV import is shipped), more templates, and broader CRM and sequencer sync.
That is the distinction between a strong self-hosted Clay alternative today and a credible multi-node public Clay replacement.