SWAMP SERVE
swamp serve turns a Swamp repository into a remote API. This page explains the
architectural decisions behind the server, the security constraints it enforces,
and how admin access is managed.
Architecture
A swamp serve instance is a single process that holds a Swamp repository and
exposes it over a WebSocket API. Clients connect to run model methods, execute
workflows, query data, and manage access. Workers connect to receive dispatched
steps for remote execution.
The server is the single source of truth. It owns the datastore, resolves vault secrets, stores model definitions, and manages the policy snapshot. Clients and workers are stateless — they issue requests and receive responses but hold no durable state.
This is a deliberate centralization. A Swamp repository is a coherent unit: definitions reference each other, workflows chain model outputs, and vault secrets are scoped to the repo. Distributing this state would introduce consistency problems that the current model avoids by not having them.
Hard refusals
swamp serve refuses to start in certain configurations. These are not warnings
— they are hard failures that prevent the server from binding.
Off-loopback without TLS and authentication. If --host is set to anything
other than 127.0.0.1 or ::1, the server requires both --cert-file /
--key-file and --auth-mode other than none. This is not configurable.
The reasoning is straightforward: swamp serve is a control plane. It accepts
arbitrary model method execution, workflow runs, and vault reads. An
unauthenticated, unencrypted control plane on the network is arbitrary remote
execution — anyone who can reach the port can run any command. The hard refusal
prevents this misconfiguration from reaching production.
On loopback, the risk model is different. The server is only reachable from the local machine, where the user already has access to the repository. TLS and authentication add friction without meaningful security benefit in this context.
That said, --auth-mode none is deprecated. The loopback exception made sense
as a convenience for early adoption, but in practice it creates a gap between
local and production setups. Workflows, grants, and token-scoped permissions
behave differently under none than under token or oauth — issues that
surface only at deployment time. Requiring authentication everywhere, including
loopback, closes that gap and makes local development a reliable predictor of
production behavior.
The token and oauth modes also require Swamp authentication
(swamp auth login or an API key with serve:* scope) to start the server
itself — not just to connect as a client. The reason is the same trust boundary
that drives the off-loopback refusal: these modes turn a local Swamp instance
into a network-accessible service that accepts model execution, workflow runs,
and vault reads from remote clients. Knowing who stood up the service is part of
the accountability chain. The none mode is exempt because it is loopback-only
by design and carries no remote access risk.
Remote-only mode
By default, swamp serve runs a local loopback executor alongside the remote
dispatch system. Steps without placement run locally on the orchestrator; steps
with placement are dispatched to workers. This is convenient for mixed workloads
where some steps need remote execution and others do not.
Remote-only mode (--remote-only) disables the local loopback executor
entirely. Every step must declare explicit placement — target, labels, or
platform — so it can be dispatched to a remote worker. A step without
placement fails immediately with:
Step '<name>' has no placement but the server is running in remote-only mode.
Add a placement block (target, labels, or platform) to the workflow step, job,
or workflow so it can be dispatched to a remote worker.The error is deliberate: in remote-only mode, a missing placement is a configuration mistake, not something to silently run locally. The server treats it as a hard failure rather than falling back.
When to use remote-only mode
Remote-only mode is for dedicated orchestrator instances that should never consume local compute. The common deployment pattern is a lightweight orchestrator — a small VM or container that holds the repository, manages the datastore, and coordinates workers — with all actual execution happening on purpose-built worker machines.
The motivation is resource isolation. The orchestrator process handles WebSocket connections, evaluates access policies, proxies capability requests from workers, and serves the REST API. When it also runs workflow steps locally, those steps compete for the same CPU, memory, and file descriptors. A long-running model method or a memory-intensive step can degrade the orchestrator's ability to serve its coordination role. Remote-only mode eliminates this contention by construction: the orchestrator does coordination, workers do execution.
This is distinct from simply not having any unplaced steps. A workflow that
declares placement on every step works the same way with or without
--remote-only. The flag's value is enforcement: it catches workflows that
accidentally omit placement, which would otherwise silently run on the
orchestrator and defeat the isolation.
Configuration
Remote-only mode can be enabled through any of the three configuration surfaces:
- CLI flag:
swamp serve --remote-only - Config file:
remote-only: truein.swamp/serve.yaml - Environment variable:
SWAMP_REMOTE_ONLY=true
The same flag is available on swamp serve daemon enable for daemon
deployments.
The remoteOnly field is reported in both the root GET / response and the
GET /api/v1/health response, so monitoring tools and clients can detect the
mode programmatically.
See Serve Flags for the full flag reference, and Set Up a Remote-Only Orchestrator for a practical setup guide.
Admin materialization
The --admins flag lists principal IDs that receive full admin access. On every
server start, the server materializes a grant of admin on access:* for each
listed principal. This grant implies all actions on all resources.
Materialization is reconciled on every boot. If a principal is removed from
--admins, its materialized grant is removed on the next start. If a new
principal is added, it receives the grant on the next start. The config is the
source of truth — not the grant store.
This design serves two purposes.
Bootstrap. When a server starts for the first time with --auth-mode token,
someone needs to be able to mint the first token and create the first grants.
The --admins flag provides that bootstrap identity without requiring a
separate setup step.
Recovery. If an admin accidentally revokes their own access or corrupts the
grant store, restarting the server with the correct --admins flag restores
admin access. The config always wins.
Policy snapshot and reload
Grants and group memberships are compiled into a policy snapshot — a precomputed
structure that the server evaluates on every access check. The snapshot is
rebuilt on swamp access reload or automatically on every change when
--grant-reload auto is active.
The default manual reload mode is a safety mechanism. Grant changes are staged
until an admin deliberately applies them. A bad grant — an overly broad allow, a
deny that locks out the admin — does not take effect until reload. This gives
the admin a chance to review and revoke before applying.
The auto mode removes this safety net in exchange for convenience. Teams that
trust their grant-creation workflow and want immediate effect use auto. Teams
that prefer a review step use manual.
Deny-wins evaluation
When both allow and deny grants match a request, deny wins. This is not configurable.
The alternative — most-specific-wins or last-writer-wins — creates reasoning problems at scale. With deny-wins, a compliance team can create a deny grant that blocks access to sensitive resources, and no amount of allow grants from other teams can override it. The deny is a hard boundary.
This makes the system predictable: if you see a deny grant, you know it applies regardless of what else exists. See the authorization reference for the full evaluation model.
OAuth and collective-based admission
Token authentication requires admins to mint and distribute credentials for every user. OAuth replaces this with self-service login — users authenticate themselves through swamp-club, and the server decides admission based on collective memberships. No tokens to distribute, no credentials to rotate per user.
The device grant flow
swamp serve uses the OAuth 2.0 Device Authorization Grant (RFC 8628) for
authentication. The device grant exists for exactly this scenario: a CLI tool
running on one machine, with the user approving access in a browser that may be
on a different machine.
The flow works like this:
- The CLI requests a device code from swamp-club
- swamp-club returns a verification URL and a user code
- The CLI displays both and polls for approval
- The user opens the URL in a browser, signs in, and enters the code
- On approval, swamp-club issues an access token
- The CLI receives the token and stores it locally
This is the same pattern used by GitHub CLI and other tools that need browser authentication from a terminal. The security boundary is in the browser approval step — the user sees exactly what they are authorizing and can refuse.
The bootstrap client
On first start with --auth-mode oauth, the server needs to register itself as
an OAuth client with swamp-club and resolve admin usernames to principal IDs.
There are two paths through this — interactive and headless — and the server
picks between them automatically based on whether SWAMP_API_KEY is set.
Interactive bootstrap (device grant). Without SWAMP_API_KEY, the server
uses the OAuth 2.0 Device Authorization Grant to register. A well-known public
client ID baked into the binary initiates this registration — the same pattern
GitHub CLI uses. Client ID secrecy is not the security mechanism; the browser
approval step is. The admin opens a URL, signs in, and approves the
registration.
Headless bootstrap (API key). When SWAMP_API_KEY is set and the token
carries the oauth:manage scope, the server registers itself without any
browser interaction. It validates the key against swamp-club, confirms the
scope, and calls the OAuth client registration endpoint directly. The same key
is used to resolve --admins usernames to principal IDs — another step that
would otherwise require a device grant.
The decision tree on each boot:
Stored OAuth client credentials in vault?
├─ Yes → use stored credentials (no registration needed)
└─ No → SWAMP_API_KEY with oauth:manage scope?
├─ Yes → headless registration via API
└─ No → interactive device grant flowThe headless path exists for containerized and unattended deployments —
Kubernetes pods, CI runners, systemd services — where no browser is available.
The oauth:manage scope gates client registration because registering an OAuth
client is a privileged operation: it creates a credential that can initiate user
authentication flows against the collective's swamp-club account. The serve:*
scope, required separately for instance registration and heartbeats, does not
grant this.
Both paths end in the same state: client credentials stored in the vault.
Subsequent starts load the stored credentials and skip registration entirely,
regardless of which path created them. SWAMP_API_KEY is read fresh from the
environment on each boot, so key rotation is transparent — replace the secret in
the deployment environment and the next restart picks it up.
See Deploy with Headless OAuth for the practical setup guide.
Collective-based admission
Collectives in swamp-club serve as the admission boundary. The admin declares
which collectives grant access, and swamp-club membership controls who gets in.
There is no user management on the swamp serve side — the admin manages team
membership in swamp-club, and the server enforces it.
This inverts the traditional approach where the server maintains its own user
list. Instead, identity and group membership live in swamp-club, and
swamp serve is a policy consumer. Adding or removing a team member is a
swamp-club operation, not a server operation.
Collectives on the token
A user's collective memberships are snapshotted on the server token at login
time. The server uses these memberships for idp-group: grant matching —
connecting swamp-club collectives to swamp serve authorization grants without
requiring the server to call swamp-club on every request.
The tradeoff is staleness. If a user is removed from a collective in swamp-club,
their server token still carries the old membership until the next group refresh
cycle updates it. The --group-refresh-interval (default: 4h) controls how
often the server re-fetches memberships from the OAuth provider, bounding the
staleness window. Token expiry provides a secondary refresh — when the token
expires, re-authentication captures the latest memberships from scratch.
Collectives vs groups
Collectives and IdP groups serve different purposes in the swamp serve
authorization model, even though both originate from swamp-club.
Collectives are the admission gate. The --allowed-collectives flag
controls who can connect to the server at all. A user must be a member of at
least one allowed collective to authenticate. This is a coarse, binary decision:
you are in or you are out.
Groups are for grant matching. Once admitted, a user's fine-grained
permissions come from grants. Grants can target idp-group:<collective-slug>
subjects, which match users whose OAuth token includes that group. This connects
swamp-club collective membership to swamp serve authorization without the
server maintaining its own group directory.
The separation is deliberate. Admission is a policy the server admin controls (which teams can access this server). Authorization is a policy the access control system controls (what each team can do). Conflating the two — using collective membership as both the admission check and the permission boundary — would mean every collective member gets the same access, which breaks down as soon as teams need different permission levels on the same server.
IdP group refresh and deprovisioning
When a user authenticates via OAuth, the server snapshots their group
memberships from the provider's userinfo response. These memberships inform
idp-group: grant matching. The question is: what happens when memberships
change after login?
The server runs a background refresh loop controlled by
--group-refresh-interval (default: 4h). On each cycle, it re-fetches
userinfo for every active session and updates the policy snapshot with current
memberships. This bounds the staleness window — a user removed from a collective
in swamp-club loses matching grants within one refresh interval, not at their
next login.
The refresh loop distinguishes two failure modes:
Transient errors are fail-open. If the provider is temporarily unreachable (network timeout, 5xx, DNS failure), the server retains the last known group memberships and retries on the next cycle. The reasoning: a brief provider outage should not revoke access for every authenticated user. The staleness window extends by one cycle, which is acceptable because the memberships were valid at the last successful refresh.
401 Unauthorized is a hard revocation. If the provider responds with 401,
the user's session is no longer valid at the identity layer — their token has
been revoked, their account disabled, or their consent withdrawn. The server
invalidates the session immediately and all idp-group: grants for that user
stop matching. This is not a transient condition, and retaining stale
memberships would mean the server continues authorizing a user that the identity
provider has explicitly rejected.
This asymmetry — fail-open on transient errors, hard-close on 401 — matches the
trust relationship between swamp serve and the OAuth provider. The provider is
authoritative on identity. When it says "this user is no longer valid," the
server honours that immediately. When the provider is just unreachable, the
server falls back to its last known state rather than making an irreversible
decision based on a network hiccup.
See Serve Flags for the
--group-refresh-interval flag reference, and
Authorization — IdP group refresh
for the behavioral specification.
Two authentication targets
swamp auth login and swamp auth server-login --server authenticate to
different systems. swamp auth login authenticates to swamp-club — the
platform. swamp auth server-login --server authenticates to a specific
swamp serve instance — a team's server. They are different trust relationships
stored in different credential files.
A user who is logged in to swamp-club is not automatically authenticated to any
swamp serve instance. Each server requires its own login, which issues its own
token with its own expiry and collective snapshot.
Token transports on WebSocket
swamp serve accepts tokens on the WebSocket upgrade request via three
transports: the Authorization: Bearer header, the Sec-WebSocket-Protocol
subprotocol, and the ?token= query parameter. The reason for three is that
different client environments have different constraints on what they can attach
to a WebSocket upgrade.
The Authorization: Bearer header is the standard HTTP mechanism. CLI clients,
server-side code, and tools like curl can set it directly. It keeps the token
out of URLs entirely — no query string, no path fragment — so it does not appear
in proxy access logs, browser history, or Referer headers that the server or
intermediaries might emit.
The browser WebSocket API cannot set custom headers. new WebSocket(url) does
not accept an Authorization header — the browser attaches only the URL and an
optional list of subprotocols. The Sec-WebSocket-Protocol transport exists for
this case: the client passes bearer.<name>.<secret> as a subprotocol value,
and the server extracts the token from it. This is a well-known workaround used
by other WebSocket-authenticated systems.
The query parameter (?token=<name>.<secret>) is the simplest transport — it
works everywhere — but it embeds the token in the URL. URLs are logged by
proxies, cached in browser history, and leaked via Referer headers on
subsequent navigations. For environments where neither headers nor subprotocols
are available, the query parameter still works, but the other two transports
avoid this exposure.
When multiple transports are present on the same upgrade request, the server picks the first match in priority order: Authorization header, then Sec-WebSocket-Protocol, then query parameter. The priority favors the transport that leaks the least.
See Serve Flags — WebSocket token transports for the protocol-level reference, and Connect with a token for connection examples.
Webhooks and event-driven workflows
swamp serve --webhook registers an HTTP endpoint that receives external
events, verifies their signatures, and triggers workflow runs. This turns Swamp
from a pull-based system — where cron schedules and manual runs drive automation
— into a reactive one that responds to external events as they happen.
The workflow triggered by a webhook receives the incoming payload through
webhook.* CEL variables: webhook.body for the parsed payload,
webhook.headers for request headers, and webhook.route for the matched
endpoint path. These variables are available in every step of the workflow,
allowing steps to branch on event type, extract resource identifiers, or pass
payload fields to model methods. See the
workflow reference for trigger syntax.
Provider schemes and signature verification
Each SaaS platform signs webhooks differently. GitHub uses HMAC-SHA256 over the
raw body. Stripe prepends a timestamp to prevent replay attacks. Linear, Slack,
and others each have their own algorithm. Rather than forcing a single
verification model, Swamp supports named provider schemes — github, stripe,
linear, slack, and generic — each implementing the correct algorithm for
its platform.
The generic scheme exists for platforms not explicitly supported. It accepts a
configurable header name and optional prefix, covering most HMAC-based
verification patterns without requiring a new scheme per provider. The
webhooks reference documents the full
list of schemes and their configuration.
Secret source indirection
Webhook secrets can be provided as literal strings, @env=VAR (read from an
environment variable), @file=/path (read from a file), or
@vault=<name>:<key> (read from a named vault). The env and file forms exist
because process arguments are visible in ps output — passing a secret as a
literal --webhook argument leaks it to anyone who can list processes on the
machine. Environment variables and file paths are opaque in process listings.
The vault form goes further: environment variables are per-machine and file
paths are per-host, so both require out-of-band provisioning on every machine
that runs the server. A vault secret lives in the repository itself (encrypted
at rest) and travels with it — any machine that can decrypt the vault can
resolve the secret, with no additional setup. The @vault= prefix works with
any configured vault backend (local encryption, AWS Secrets Manager, etc.)
transparently; the webhook configuration does not change when the backend does.
See the vaults reference for vault types and
configuration.
Webhooks vs cron triggers
Cron triggers (trigger.schedule in workflow YAML) run workflows on a time
schedule. Webhooks run workflows in response to external events. The distinction
is pull vs push: cron is for periodic sweeps (check every hour for drift),
webhooks are for immediate reaction (deploy when code is pushed).
In practice, many workflows combine both. A webhook provides the fast path — respond to events within seconds — while a cron schedule acts as a fallback sweep to catch events that were missed due to delivery failures or downtime. Neither replaces the other; they complement.
Multi-instance behavior and continuous reconciliation
A single swamp serve instance owns its runs. If that instance dies — process
crash, node eviction, OOM kill — its in-flight runs become orphans. Nobody
notices, nobody recovers them. For single-instance deployments this is
acceptable: the operator restarts the process, and boot-time reconciliation
(comparing the run tracker against WorkflowRun YAML files) cleans up. But behind
a load balancer with multiple instances sharing a datastore, one instance's
death is invisible to the others unless they actively look for it.
HA mode is now auto-detected based on the datastore configuration. When the datastore supports a control plane (e.g. S3), runs are automatically detached from the requesting client connection and tracked in the shared control-plane store rather than tied to a single process. This is the prerequisite for multi-instance reconciliation — without it, runs die with their connection and there is nothing for a peer to adopt.
The
--detach-runsflag is deprecated — it is still accepted for backwards compatibility but has no effect. HA mode is determined entirely by the datastore.
Why heartbeats, not health checks
The question is: how does instance B know that instance A is dead? External health checks (a load balancer probe, a Kubernetes liveness check) answer a different question — "is the process responding to HTTP?" — and their failure signal goes to the orchestrator, not to peer instances. A peer needs to detect death itself, without relying on external infrastructure that may not exist or may define liveness differently.
Instance heartbeats solve this. Each instance writes a timestamp to the shared
store on a regular interval (--heartbeat-interval, default 30s). A peer that
sees a stale heartbeat — one older than --stale-ttl (default 90s) — considers
that instance dead and eligible for reconciliation. The constraint that
--stale-ttl must be at least 2× --heartbeat-interval provides a margin for
transient delays: a single missed heartbeat (GC pause, network blip, busy disk)
does not trigger a false positive.
This is a protocol-level decision, not an implementation convenience. The
alternative — registering with a coordination service (etcd, ZooKeeper) — would
introduce an external dependency that most swamp serve deployments do not have
and should not need. The shared datastore already exists; heartbeats reuse it.
Reconciliation and per-instance claiming
When an instance detects a dead peer, it scans for that peer's orphaned runs and
adopts them. The scan interval is --reconciliation-interval (default 60s). In
practice, a dead instance's runs are detected within roughly one stale TTL plus
one reconciliation interval — about 150 seconds with defaults.
Adoption is per-instance and claimed. When instance B decides to reconcile instance A's runs, it claims them atomically in the store before starting work. This prevents a race where instances B and C both detect A's death and both try to reconcile the same runs. Only one claim succeeds; the other instance skips those runs and moves on.
Crash safety and heartbeat deletion
A subtlety: a dead instance's heartbeat record is not deleted until after its orphaned runs have been reaped. The ordering matters for crash safety. If the reconciling instance (B) deletes A's heartbeat first and then crashes mid-reap, A's remaining orphaned runs have no dead-peer signal pointing to them — they become invisible to future reconciliation scans. By deferring the heartbeat deletion, any instance that picks up after B's crash still sees A as dead and can resume the reap.
The run tracker and WorkflowRun files
Two data structures track runs, and they serve different purposes.
The run tracker (SQLite, .swamp/run_tracker.db) is the per-instance
liveness authority. It records which runs a specific instance is executing right
now, heartbeats them, and detects stale processes by PID. It is inherently local
— a PID is meaningful only on the machine where the process runs.
WorkflowRun YAML files (in the shared datastore) are the durable run history. They record the full lifecycle of a run — created, started, steps completed, outcome — and survive instance death. Boot-time reconciliation compares the run tracker against these files to detect runs that were in-flight when the process last died.
In a multi-instance deployment, the shared datastore's WorkflowRun files are what a surviving instance reads during reconciliation. The dead peer's SQLite database is inaccessible (it is on a different machine or in a terminated container), but the WorkflowRun files are shared and authoritative.
See Serve Flags for the full flag and environment variable reference, and Run a Multi-Instance Deployment for a practical setup guide.
Config management and multi-instance convergence
Heartbeats and reconciliation solve run recovery — but multi-instance deployments have a second coordination problem: configuration. When an operative creates a model on instance A, instance B needs to see it. Without a shared config mechanism, operatives must distribute definition files to every instance manually, which is error-prone and scales poorly.
Managed config
The managedConfig flag in .swamp.yaml solves this by storing configuration —
model definitions, workflow definitions, vault configs, the extension lockfile,
and pulled extension sources — in the datastore tier alongside runtime data. The
datastore becomes the single source of truth for config, and each swamp serve
instance reads from it instead of from local directories.
This is an opt-in migration, not a default. Existing deployments continue
reading config from local directories until swamp datastore config migrate is
run. The migration copies config into the datastore's config subdirectory, and
sets managedConfig: true in .swamp.yaml.
ConfigPoller
Once managed config is enabled, each swamp serve instance runs a ConfigPoller
— a background loop that checks the datastore for config changes at a 30-second
default interval. The mechanism is pull-based: instances poll the datastore
rather than receiving push notifications.
The poller uses a commitSeq fast-path to avoid unnecessary work. Each config
write increments a sequence number in the datastore. On each poll cycle, the
poller compares the remote sequence number against its last-seen value. If they
match, no config has changed and the cycle completes without reading any files.
If they differ, the poller pulls the changed config.
This design makes the polling cost proportional to the change rate, not the config size. A deployment with hundreds of model definitions but infrequent changes pays only the cost of reading one sequence number every 30 seconds.
Cold start
On startup, a swamp serve instance with managedConfig: true performs an
early config pull before loading extensions. This ensures the instance starts
with the latest shared config rather than whatever was on disk when the
container image was built or last deployed. The cold-start pull is a full pull —
it does not use the commitSeq fast-path because there is no prior sequence to
compare against.
Write propagation
When an operative modifies config through a running swamp serve instance —
creating a model, pulling an extension, updating a vault — the instance pushes
the change to the datastore immediately rather than waiting for the next sync
cycle. This means the change is available to other instances on their next
ConfigPoller cycle, typically within 30 seconds.
The push is fire-and-forget from the write handler's perspective: the local write succeeds regardless of whether the push completes. If the push fails (network partition, datastore outage), the change exists locally and will be pushed on the next successful sync. Other instances eventually converge but may lag during the outage.
Why polling, not push notifications
The ConfigPoller uses polling rather than push notifications for the same reason
heartbeats use the shared datastore rather than a coordination service: it
avoids an external dependency. Push notifications would require a messaging
layer (SQS, Redis pub/sub, a WebSocket fanout service) that most swamp serve
deployments do not have. The shared datastore already exists, and the 30-second
polling interval is acceptable for config changes, which are infrequent and
operator-initiated.
The tradeoff is propagation latency. A config change takes up to one poll interval to reach other instances, plus any datastore sync latency. For the intended use case — operatives deploying new model definitions or pulling extensions — this is indistinguishable from immediate.
See Datastore Configuration — Managed Config for the reference, and Enable Managed Config for the setup guide.
Related
- Set Up OAuth Authentication — practical setup for OAuth mode
- Set Up Token Authentication — practical setup for token mode
- Webhooks — webhook endpoint configuration and provider schemes
- swamp serve how-to guides — practical setup and operations
- Authorization — the grant model reference
- Serve Flags — full flag and environment variable reference
- Remote Execution — how workers connect and execute steps
- The Run Tracker — per-instance run tracking and liveness detection
- Run a Multi-Instance Deployment — practical setup for multi-instance HA deployments
- Enable Managed Config — step-by-step guide for config convergence across instances
- Datastore Configuration — Managed Config
— reference for the
managedConfigfield and config migration