setu:: docs
Start

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 planeapiserver (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 planeengine (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.
ConsoleA 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.
The load-bearing idea
The 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.work — the workspacego
go 1.26.4

use (
	./agent
	./apiserver
	./engine
	./packs
	./worker
)
apiserverThe 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.
workerThe 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).
engineThe 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.
agentThe 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.
packsDomain 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):

the blob key layout — worker and agent MUST agree byte-for-bytego
// 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:

apiserver/internal/api/api.go — Router() skeletongo
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:

IdentitySessions, workspaces, memberships, invites, roles (owner > admin > editor > viewer).
CatalogConnectors (specs), connections, discovery, datasets, folders, tags, previews, profiles.
AuthoringOperators, expression validation, pipelines, the Document spec, mapping suggestions.
ExecutionRuns, run events (SSE), quarantine, run artifacts, cancellation, schedules.
EdgeAgents, enrollment tokens, agent-job dispatch/poll, extract/load specs.
OpsHealth, 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 is ON 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. The provider column was renamed from kind in migration 0006 for the dataset-centric model.
  • run.status — a real Postgres ENUM (pending → running → succeeded | failed | cancelled). run.phase — a text column with a CHECK, widened over time: 0019 added extract|load, 0022 added direct.
  • 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 an expires_at the 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-run extract_checkpoint.
  • agent.key_hash — only the SHA-256 of the agent key is stored; the plaintext key is shown once at enrollment. agent_job.payload carries IDs only, never secrets.
What is NOT workspace-scoped
Child tables (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:

apiserver/internal/api/api.go — Router (abridged)go
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/tickA 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 · /metricsOpen, unauthenticated. Liveness, readiness (DB ping with a 2s timeout), and Prometheus metrics.
apiserver/internal/api/api.go — agentAuthMiddleware (the second boundary)go
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))
	})
}
Why two boundaries, not one
Edge credentials are the whole threat model. An agent authenticates with a key that is scoped to job pull/lease/heartbeat/result — it can’t enumerate connections or read other workspaces. Every agent route re-scopes by the agent id injected here, and a by-id job read that isn’t owned by the calling agent returns 404. A stolen agent key is useless against the user API, and a stolen session can’t impersonate an agent.

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.

POST/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.

  1. 1
    Create the run. runPipeline loads the pipeline in the caller’s workspace and calls CreateRun — a pending row with phase = extract and dry_run from the query string.
  2. 2
    Derive placement. orchestrateRun loads the source and destination connections and calls the pure derivePlacement(src.placement, src.agent, dst.placement, dst.agent), yielding where extract and load each run.
  3. 3
    Reachability 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 marked failed with an actionable message instead of hanging on an un-leased job.
  4. 4
    Dispatch. If extract is agent-placed, CreateAgentJob(kind=extract) enqueues an outbound job (IDs only) and emits a run event. Otherwise enq.EnqueueMigrate(runID) puts a River job on the queue for the cloud worker.
  5. 5
    Execute. The chosen host runs the engine: extract → (stage to blob) → load, or a direct single pass. Progress lands as run_event rows and live counters on the run.
  6. 6
    Bridge the seam (if crossing). When extract ran on an agent, onAgentExtractResult records the artifact, flips phase to load, and dispatches the load to the cloud worker or the destination’s agent — per the destination placement.
  7. 7
    Observe. The console tails GET /runs/{id}/events as 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.

apiserver/internal/db/query/agent_job.sql — LeaseNextJobsql
-- 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 TTL60s. The agent renews via POST /jobs/{id}/heartbeat while working; a heartbeat only lands while status = 'leased'.
Long-pollGET /jobs/next?wait=N retries the atomic lease every 1s until a job appears or wait (capped at 30s) elapses, then 204.
Retryattempt vs max_attempts (default 3). The reaper (RequeueExpiredJobs) returns a stale lease to pending, or to terminal expired when attempts are spent.
CancellationCancelling 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.