Architecture
Setu splits into a control plane that orchestrates, a data plane that moves bytes, and a console operators drive — with one shared engine that runs identically in the cloud and at the edge.
Setu answers one hard question: how do you move data between systems when the cloud often can’t reach one side of the transfer, and the credentials for that side must never leave the customer’s network? The answer is a strict control-plane / data-plane split plus a hybrid edge agent. The cloud decides what should happen and records what did; the actual extract → transform → load runs wherever the data lives.
Read this page as the map. Every claim here is grounded in the router (apiserver/internal/api/api.go), the placement function (placement.go), and the schema migrations under apiserver/internal/db/migration/. The concepts page then walks the same machinery from an operator’s point of view.
The three planes
Everything in Setu belongs to exactly one of three planes. The boundary between them is the whole design. State the invariant plainly: the control plane never opens a connector and never moves a row; the data plane never makes a product decision. That separation is what lets one half live in Setu’s cloud and the other half live inside a customer’s VPC.
| Control plane | apiserver (a Go chi HTTP server) backed by PostgreSQL. Owns workspaces, connections, datasets, pipelines, runs, schedules, secrets, and agents. It orchestrates work and records history but never touches edge credentials and never moves data — it stores secret references (and cloud secrets sealed with AES-256-GCM) and routes jobs by placement. |
|---|---|
| Data plane | engine (the E/T/L library) plus its two hosts: the cloud worker (a River job consumer) and the outbound-only agent that runs next to private data. This is the only plane that opens connectors, reads rows, applies operators, and writes to a destination. |
| Console | A Next.js web app: the connection wizard, visual mapper, live run theater, and catalog browser. It talks only to /api/v1 over a user session and never sees the data plane directly — it observes runs through the API, not by touching connectors. |
engine is a plain library. The worker runs it in the cloud; the agent runs the same engine near private data. Because both stage rows through a shared blob store, a run can cross the cloud↔edge boundary without either side opening an inbound port to the other.The Go monorepo
Setu is a single Go workspace. go.work stitches five modules together so the apiserver, worker, and agent can all import the shared engine without a published package. The dependency arrows all point inward toward engine:
go 1.26.4
use (
./agent
./apiserver
./engine
./packs
./worker
)| apiserver | The control plane. internal/api (the router + handlers), auth (sessions, token hashing), internal/catalog (discovery reconciliation), secrets (AES-GCM cipher + resolver), internal/scheduler (cron firing), store/sqlc (generated queries), and the run-orchestration + agent-job protocol. |
|---|---|
| worker | The cloud data-plane host. A River job consumer (internal/migrate) that drives the engine for cloud-reachable stages — durable, retryable, resumable. Splits into worker.go (mode decision), staged.go, incremental.go, direct_test.go, and gc.go (artifact sweep). |
| engine | The heart: connector (Source/Sink interfaces), connectors (concrete drivers incl. the blob stage connector), operator + transform + expr, pipeline (the Document/Plan model), runner, record/schema, match (mapping suggestions), and blob staging. Imports no other Setu module. |
| agent | The edge data-plane host. A single binary (cmd/setu) that enrolls once, long-polls the control plane for jobs, and runs the same engine locally. internal/exec holds the per-kind executors (test/discover/preview/extract/load); internal/config holds the edge secret store that never leaves the host. |
| packs | Domain packs: optional bundles of extra connectors/operators registered into the engine registry. Core never imports a pack — the dependency points inward. |
Containers & the blob seam
Here is the whole system as running processes. Note the two data-plane hosts flanking a shared object store: the blob is the seam that lets extract and load run on different sides of a trust boundary.
The apiserver never connects out to the agent — it can’t, the agent has no inbound ports. Instead the agent pulls work by long-polling /agent/v1/jobs/next. Both the worker and the agent run the engine and both read/write the same blob store under an identical key layout, so an extract staged by an agent can be loaded by the cloud worker (or vice-versa):
// worker/internal/migrate/staged.go and apiserver/internal/api/agent_jobs.go
// both format the run's staging prefix identically, so whichever host stages the
// extract, the other can read it back.
func runPrefix(workspaceID, pipelineID, runID uuid.UUID) string {
return fmt.Sprintf("ws/%s/pl/%s/run/%s/", workspaceID, pipelineID, runID)
}The router: every subsystem at a glance
The apiserver’s Router() is the single source of truth for what the control plane does. A short middleware chain wraps everything, then four trust zones mount side by side:
r := chi.NewRouter()
r.Use(middleware.RequestID) // correlation id per request
r.Use(middleware.Recoverer) // a panic becomes a 500, not a crash
r.Use(corsMiddleware(corsAllowlist())) // console origin allowlist
r.Get("/healthz", ...) // open: liveness
r.Get("/readyz", s.readyz) // open: readiness (pings the pool)
r.Handle("/metrics", metrics.Handler()) // open: Prometheus scrape
r.Post("/internal/scheduler/tick", ...) // machine cron: its own bearer token
r.Route("/api/v1", ...) // USER trust boundary (session cookie)
r.Route("/agent/v1", ...) // AGENT trust boundary (agent key)Inside /api/v1 (behind authMiddleware) live the whole console surface: auth & profile, workspaces / members / invites, connectors and connections, operators, pipelines, schedules, datasets & folders, mapping suggestions, search, overview, notifications, runs (with SSE event streams and artifact download), secrets, and agents. Inside /agent/v1 lives the edge protocol only. The subsystems, grouped:
| Identity | Sessions, workspaces, memberships, invites, roles (owner > admin > editor > viewer). |
|---|---|
| Catalog | Connectors (specs), connections, discovery, datasets, folders, tags, previews, profiles. |
| Authoring | Operators, expression validation, pipelines, the Document spec, mapping suggestions. |
| Execution | Runs, run events (SSE), quarantine, run artifacts, cancellation, schedules. |
| Edge | Agents, enrollment tokens, agent-job dispatch/poll, extract/load specs. |
| Ops | Health, readiness, Prometheus metrics, the external scheduler tick. |
The data model
Every row is scoped to a workspace (migration 0013 stamped a workspace_id onto every top-level table and closed a systemic IDOR). Users reach a workspace through a membership. A connection carries a placement (cloud or agent) and, when agent-placed, an agent_id — this single pair decides where each stage of a run executes. A pipeline wires a source connection to a target and stores a canonical spec Document; a run is one execution with a status, a phase, and a dry_run flag; a bound agent_job carries edge work by kind.
Fields that carry the design
connection.placement+connection.agent_id— the routing decision (migration 0024). Cloud connections run their stage in the worker; agent connections pin their stage to that agent. The FK isON DELETE SET NULL, so revoking an agent never cascade-drops the connection.connection.config— JSONB persisted verbatim. Secret references (${secret.NAME}) are kept as-is; plaintext credentials are never stored. Theprovidercolumn was renamed fromkindin migration 0006 for the dataset-centric model.run.status— a real PostgresENUM(pending → running → succeeded | failed | cancelled).run.phase— atextcolumn with a CHECK, widened over time: 0019 addedextract|load, 0022 addeddirect.run_event— an append-only, identity-keyed live log streamed to the console over SSE.run_artifact— one row per staged run pointing at the blob prefix, with anexpires_atthe GC sweeps.extract_checkpoint/load_checkpoint— per-(run, stream)resume cursors (0020 / 0019). A retried run reads these to skip already-staged parts and already-loaded rows.pipeline_state— the durable per-(pipeline, stream)incremental watermark (0002), advanced only after a load commits — distinct from the per-runextract_checkpoint.agent.key_hash— only the SHA-256 of the agent key is stored; the plaintext key is shown once at enrollment.agent_job.payloadcarries IDs only, never secrets.
run_event, quarantine, pipeline_state, the checkpoints) inherit scope through their parent run/pipelineand carry no workspace_id of their own. The secret primary key was rewritten to (workspace_id, name) so a name is unique per workspace, not globally.Request trust boundaries
The router mounts four distinct trust zones, each with its own authentication. A request never crosses from one into another:
r.Get("/healthz", ...) // open: liveness
r.Get("/readyz", s.readyz) // open: readiness (pings the pool)
r.Handle("/metrics", metrics.Handler())// open: Prometheus scrape
// Machine cron → force a scheduling pass. Its own bearer token, not a session.
r.Post("/internal/scheduler/tick", s.schedulerTick)
r.Route("/api/v1", func(r chi.Router) { // USER trust boundary
r.Post("/auth/login", s.login) // unauthenticated: mints a session
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware) // everything else needs a live session
// ...connections, pipelines, runs, datasets, agents...
})
})
r.Route("/agent/v1", func(r chi.Router) { // AGENT trust boundary
r.Post("/enroll", s.enroll) // public: exchange a token for a key
r.Group(func(r chi.Router) {
r.Use(s.agentAuthMiddleware) // else: authenticate by agent key
r.Get("/jobs/next", s.agentJobNext)
r.Post("/jobs/{id}/result", s.agentJobResult)
})
})| /api/v1/* | User session (browser cookie), validated by authMiddleware, which injects the user + active workspace. The console lives here. |
|---|---|
| /agent/v1/* | Agent key sent as Authorization: Bearer <key>. agentAuthMiddleware hashes it (SHA-256), looks up an active agent by key_hash, injects the agent + its workspace, and best-effort touches last_seen_at. A separate boundary from user sessions. |
| /internal/scheduler/tick | A dedicated bearer token from SETU_TICK_TOKEN, compared in constant time. When the env is unset the route 404s, so it is never left open. |
| /healthz · /readyz · /metrics | Open, unauthenticated. Liveness, readiness (DB ping with a 2s timeout), and Prometheus metrics. |
func (s *Server) agentAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := bearerToken(r) // Authorization: Bearer <key>
if key == "" { s.unauthenticated(w); return }
hash := auth.HashToken(key) // only the hash is ever stored
agent, err := s.q.GetAgentByKeyHash(r.Context(), &hash)
if err != nil { s.unauthenticated(w); return }
// Liveness is advisory: never fail the request if the touch write errors.
_ = s.q.TouchAgentSeen(r.Context(), sqlc.TouchAgentSeenParams{ID: agent.ID, Version: agent.Version})
ctx := context.WithValue(r.Context(), ctxKeyAgentID, agent.ID)
ctx = context.WithValue(ctx, ctxKeyWorkspaceID, agent.WorkspaceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Request lifecycle: a run, end to end
Tie it together with the path a single run takes. The operator clicks Run; from there the control plane decides placement and either enqueues a cloud job or dispatches an agent job — but it never moves a byte itself.
/api/v1/pipelines/{id}/runsession · editor+Creates a run (status pending), then orchestrates it by placement. Responds 202 Accepted with the run, re-read so a fast-failing run reflects its real status.
- 1Create the run.
runPipelineloads the pipeline in the caller’s workspace and callsCreateRun— apendingrow withphase = extractanddry_runfrom the query string. - 2Derive placement.
orchestrateRunloads the source and destination connections and calls the purederivePlacement(src.placement, src.agent, dst.placement, dst.agent), yielding where extract and load each run. - 3Reachability gate. The stage that runs first on an agent (extract if the source is agent-placed, else load) is checked against
agentReachable(active + seen within 90s). If it’s offline, the run is markedfailedwith an actionable message instead of hanging on an un-leased job. - 4Dispatch. If extract is agent-placed,
CreateAgentJob(kind=extract)enqueues an outbound job (IDs only) and emits a run event. Otherwiseenq.EnqueueMigrate(runID)puts a River job on the queue for the cloud worker. - 5Execute. The chosen host runs the engine: extract → (stage to blob) → load, or a direct single pass. Progress lands as
run_eventrows and live counters on the run. - 6Bridge the seam (if crossing). When extract ran on an agent,
onAgentExtractResultrecords the artifact, flipsphasetoload, and dispatches the load to the cloud worker or the destination’s agent — per the destination placement. - 7Observe. The console tails
GET /runs/{id}/eventsas Server-Sent Events until the run reaches a terminal status.
The edge job protocol
Because the agent has no inbound ports, all edge work flows through an outbound queue the agent long-polls. The lease protocol is a small durable state machine in Postgres — no message broker required.
-- Atomically claim the oldest pending job for this agent. FOR UPDATE SKIP LOCKED
-- lets concurrent leasers each grab a distinct row without blocking, so the same
-- job is never handed out twice.
UPDATE agent_job SET
status = 'leased',
attempt = attempt + 1,
lease_expires_at = now() + make_interval(secs => sqlc.arg('lease_seconds')::int),
updated_at = now()
WHERE id = (
SELECT j.id FROM agent_job j
WHERE j.agent_id = sqlc.arg('agent_id') AND j.status = 'pending'
ORDER BY j.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;| Lease TTL | 60s. The agent renews via POST /jobs/{id}/heartbeat while working; a heartbeat only lands while status = 'leased'. |
|---|---|
| Long-poll | GET /jobs/next?wait=N retries the atomic lease every 1s until a job appears or wait (capped at 30s) elapses, then 204. |
| Retry | attempt vs max_attempts (default 3). The reaper (RequeueExpiredJobs) returns a stale lease to pending, or to terminal expired when attempts are spent. |
| Cancellation | Cancelling a run flips its in-flight pending/leased jobs to failed; a late result for an inactive run is ignored, never resurrecting it. |
With the planes, the data model, and the lifecycle in place, the core concepts page walks through what those tables mean to an operator: connections and placement, datasets, pipelines, runs, and the staged-vs-direct execution decision.