0017. Simplified managed stack architecture

Status: superseded by the Stack package architecture Date: 2026-08-17

This decision record describes an earlier managed-stack architecture. The current package architecture and lifecycle contract are maintained in packages/stack/ARCHITECTURE.md.

Decision

Managed local-stack coordination has one implementation and one durable state document. The stack package owns identity, sticky ports, launch selection, lifecycle transitions, and state writes. A detached Supervisor holds the exclusive ownership lease only while the stack is running or an admitted operation is in flight, while stop-time cleanup remains unproven, or while an unproven destroy leaves durable intent destroying. Public handles communicate with it through same-release Effect RPC and a small release-stable maintenance protocol for probe and stop. The package runs independently of the CLI and accepts normalized StackConfig values without reading config.toml. CLI integration belongs to M5 in separate PRs, including Functions command wiring. Future CLI handlers will call this facade without maintaining a second metadata or PID-based liveness path.

The public API has two deliberate entrypoints: Effect-native operations are available from @supabase/stack/effect, while the package root exposes the Promise handle and root Promise functions (createStack, openStack, findStack, listStacks, and inspectStack). Test callers use the public createTestStack helper from @supabase/stack/testing; it owns a unique temporary project root and exact-identity cleanup through await using, while using the same managed state root as ordinary package callers. All default callers therefore coordinate automatic ports through one registry; helper project roots and identities remain isolated. Temporary test stacks are excluded from listings scoped to another project root because their project roots differ, but remain visible to unfiltered package listStacks(). A failed destroy retains its exact project root and managed state for recovery.

The managed document is private, unreleased state under the user-level managed root. It is not a repository contract, service registry, compatibility facade, or independently versioned database schema. Platform entrypoints only provide filesystem, path, process, HTTP, and control-transport services.

Stack identity is the SHA-256 digest of a length-delimited tuple containing the canonical project root, the current Git branch context, and the stack name. The branch context is the full symbolic ref or detached; projects outside Git use ordinary-workspace. This gives separate identities to worktrees, branches, monorepo project roots, and named stacks while keeping identity resolution read-only. Project relocation is not supported: a moved project resolves to a new identity.

The managed directory supplies the stack ID; state.json stores the identity tuple and validates its digest against that directory instead of duplicating the ID. control.json stores the owner session, lease port, and RPC release. The control endpoint is derived from the directory ID, lease port, and runtime environment. The ownership lock remains separate: it protects acquisition before the owner publishes its ready control metadata.

Port assignments describe usable routes and workload bindings. Disabled capabilities do not reserve public listener ports, and the Functions inspector reserves a private port only when debugging is enabled. Service settings belong in the materialized definition only when the local runtime implements them; hosted-only database network restrictions, SSL enforcement, vault, REST auto-exposure, and Storage Analytics settings are excluded. API TLS is locally meaningful but unsupported by the stack gateway, so it is excluded too. Analytics' Vector port is assigned by the runtime rather than stored as a service setting.

Disabling a capability releases its automatic port assignment; re-enabling it may select a new port.

Stack handles are lightweight identity-scoped clients. Creating or opening one does not launch a Supervisor. Successful stop stops services in dependency order, removes every ephemeral runtime resource, persists stopped state, delivers its response, then closes control and releases ownership. The caller waits for both response and lease release. A stopped stack therefore consumes no live process, container, network, socket, listener, or gateway. Status and retained logs are read from durable files only when owner metadata and the ownership lock are both absent. The same handle lazily launches a fresh Supervisor on its next start.

A Supervisor launched for a mutation also exits when that mutation cannot leave running intent behind. In particular, a failed start before the running state is committed releases its control endpoint and ownership lease after reporting the failure. A successful start keeps the Supervisor alive; a sequential idempotent start returns the current status without resetting lazy activation or publishing a synthetic starting transition.

If exact runtime cleanup cannot be proven, status remains stopping and the owner stays available for a retryable stop(); a cleaned stopped stack never retains that owner.

There is no durable generation counter and runtime resources are never adopted across owner sessions. The exclusive stack lease and ownerSessionId identify the only writer and current ephemeral resources. Explicit start, stop, or destroy first removes exact stack-owned remnants and creates fresh resources; failure to complete or validate cleanup fails closed. Persistent data, sticky ports, secrets, definitions, logs, and artifacts remain identity-scoped.

An acquire operation requires persisted running state and prevalidates every exact public claim and retained public or private assignment still requested. Exact values may be shared only with other stopped exact owners; a desired-running owner blocks regardless of observed process liveness. Conflicts never move retained claims. One registry transaction binds public and private claims before one state commit; successful public sockets remain held and are adopted directly. Temporary private TCP listeners remain held until commit and then close, so a private workload gap remains possible. Fresh automatic claims draw from 20000..32767 with a start derived from the stack's project root, identifier, and listener key, and stride 257, making up to 64 bounded EADDRINUSE/EACCES attempts per newly selected binding while skipping durable sibling claims. Sticky values are stable: they do not migrate on their own, but a stopped stack's own owner startup re-plans an endpoint whose saved port differs from the current configuration while it alone holds the stack's lease: it saves the requested endpoint intents, releases the changed endpoints' registry rows, and claims the new ports through its normal endpoint binding. A taken fixed port fails the start with the usual port conflict; the saved intent stays as requested and nothing is rolled back. The requested creations reach the owner through a private, short-lived file the owner deletes after reading, never through argv. A concurrent start attaches to whichever owner wins the lease instead of re-planning again. Failed acquisition preserves the previous successful arrays, including claims removed or reconfigured by the new definition, and releases attempted sockets; those arrays change only after the next successful acquisition. Candidate retries while holding the registry lock use no sleep, and no workload startup runs under the lock. This preserves the fail-closed registry lock and does not introduce a process-liveness lock redesign.

Before a native cold start creates anything, it verifies every retained public and private port is bindable. PostgreSQL lock evidence containing a live or unclassifiable owner PID fails closed; a lock naming a process that no longer exists is left for PostgreSQL's own stale-lock recovery. A live stop/start composition preflights configuration without trying to bind ports that the current Supervisor intentionally owns. Cold recovery never adopts those resources. An actual bound wildcard may cover the internal API and avoid a duplicate listener or gateway adoption; IPv6 wildcards explicitly use dualstack. A remaining separate internal bridge bind can still fail once its topology is discovered. The reservation policy does not guarantee exclusion from custom dynamic or excluded ranges, or from unrelated processes that occupy sticky numbers while the stack is stopped. Focused port coverage runs on macOS, Linux, and Windows in CI.

Every managed document records one concrete runtime selection. Native and container runtimes never mix. For a new stack, an omitted runtime checks that each container engine in turn, Docker then Podman, resolves the same engine target the stack owner pins and probes it with a 10 s timeout; it selects the first reachable engine and native otherwise, where native artifacts are supported. When Docker is skipped, the created Effect handle carries a notice that the selected runtime (Podman or native) is persisted for that stack, so a later switch to Docker requires destroying the stack or choosing a new stack name; callers such as stack start decide whether to print it. Native as root runs only PostgreSQL as an unprivileged user. Existing state is reused without probing. Callers may explicitly select native, Docker, or Podman without fallback. See ADR-0025 for the daemon probe and its interaction with ephemeral Postgres. Persisted state records the resolved exact engine. Capability releases and workload artifacts are persisted as exact version pins (including their concrete native release and container image) rather than ranges or floating tags. Services with derived low-memory image profiles inherit those same service-level runtime.env defaults in native artifacts, and explicit process environment values take precedence. Smoke-only environment files are excluded from packaged profiles. The artifact release is the boundary for service startup defaults; the stack package owns runtime selection and lifecycle around the selected artifact.

Read-only discovery never acquires ownership. Owner metadata points to a Unix domain socket on POSIX or a named pipe on Windows, and the ownership lock is the single-writer authority. Offline status and retained logs are available only when both metadata and lock are absent. Mutations use the live owner or acquire the lease and launch a fresh Supervisor. Ambiguous ownership fails closed.

The stable maintenance protocol contains only probe and stop. Same-release callers use Effect RPC for status, logs, activation, start, and destroy; explicit artifact preparation runs directly in the caller scope and never uses the Supervisor. An incompatible owner can always be stopped, after which the caller may launch the current Supervisor and start again. The first admitted lifecycle operation runs to completion; concurrent lifecycle mutations fail immediately with a conflict rather than joining or queueing.

Capability modules own the new stack's defaults. All capabilities are enabled by default, including Pooler, with image transformation available through Storage's imgproxy workload and Vector included with Analytics. The CLI preserves explicit enablement overrides while leaving omitted values to the package; the shared CLI configuration's defaults remain unchanged for other commands.

PostgreSQL is the only eager capability by default. Start prepares and launches only the eager dependency closure, so the PostgreSQL readiness barrier remains the default startup path. Capabilities configured for lazy activation start when traffic first reaches their stable gateway route. Lazy artifact preparation is controlled by the top-level StackConfig.preparation setting, which defaults to "background". After a successful start, background mode prepares all enabled lazy artifacts with bounded concurrency without launching their processes. That work is one Supervisor-owned session task: stop and destroy cancel and await it, foreground activation joins shared preparations, and completed cache entries remain available without automatic pruning. "on-demand" skips the background task for callers that want full lazy preparation. Eager capability activation remains independent of this setting. That runtime session owns the shared preparation fibers.

Lazy traffic capabilities may retire independently after their configured idle timeout (see the package traffic stopping documentation). The traffic policy is per capability: supported lazy capabilities default to 60 seconds, false disables retirement, eager capabilities never retire, and unsupported capabilities remain opted out. Activity covers in-flight HTTP requests and streams plus open WebSocket or TCP connections; idle HTTP keep-alive sockets do not count. The Supervisor serializes workload retirement with activation and lifecycle operations, and fences new requests for the retiring capability. Unrelated ingress continues; dependencies stay running while their dependants are active, and Studio with pg-meta retires together. Retained data and listeners let the next request wake the capability and restart its workloads. An unproven cleanup marks the participating capabilities as failed and fences new activation across the stack. This is an operation-level result; the workload ledger tracks resources still requiring removal. Unrelated healthy capabilities retain their observations. An explicit stop retries the retained ledger. A committed destructive intent uses a separate destroy-required recovery state, projects destroying, and accepts only a destroy retry until durable state removal has completed. Public status exposes the recovery operation and reason separately from capability observations. CLI text includes those diagnostics and targets recovery commands to the selected stack. A shared cleanup failure describes the operation's incomplete cleanup, even when workload removal succeeded.

In both modes, lazy activation prepares the requested dependency closure with bounded concurrency before starting its workloads. Explicit stack.prepare(...) remains available as a cache-only warmup for callers that want to prepare selected artifacts while stopped or running. The preparation setting is persisted in the stack definition and survives opening and restart; changing it for a running stack follows the existing stop-before-change configuration rule. Preparation never creates runtime resources; ArtifactStore publishes cache entries in the caller's scope, and runtime-driver cleanup closes transfer resources. Caller-owned explicit preparation cancellation only stops that caller's unfinished transfers while completed cache entries remain.

Preparation uses bounded Effect concurrency to overlap transfers with native decompression and archive subprocesses. An Effect RPC worker pool would add coordination without improving this existing native and subprocess work: decompression already uses Bun's native worker pool, while archive listing and extraction run in subprocesses. Revisit this only if measurement identifies a CPU-bound bottleneck.

Packaged first boot preserves the artifact's ordered, same-role PostgreSQL script groups and initializes the database password before migrations; Realtime's combined configuration evaluation remains owned by its artifact. The stack package batches its own PostgreSQL bootstrap round trips and releases the one-shot bundler helper after each build.

Live status exposes the current session's artifact preparation through its artifacts array. Entries identify the workload and capability and move through queued, preparing, downloading, ready, and failed; the latter carries an actionable error for a later retry. preparing includes validation, transfer verification, and extraction around the download. Artifact status is observational and does not imply that the capability's process is running. During stopping or destroying, the owner may continue to expose active preparation until teardown clears it. Offline status supplies no live artifact states and does not scan the cache to recreate a progress snapshot.

Explicit stack.prepare(...) accepts a synchronous onProgress callback for caller-owned preparation. It receives the same phase values, including ready when an artifact is available while its capability remains dormant. The callback's transfer is local to that invocation and is not reconstructed by a separate status request.

Within a lifecycle operation, workloads start as soon as their dependencies complete, with at most four active starts. Dependency waits do not consume a start permit. Selected artifact preparation runs concurrently with this launch; each workload joins its own shared preparation before resolution. Container setup serializes only exact shared-network and volume identity establishment; image pulls, startup migrations, container creation, readiness checks, and long-lived followers run outside that setup boundary.

Native workloads use a two-minute readiness budget because a cold process can spend more than 30 seconds loading shared libraries before serving requests. Container workloads retain the 30-second budget, while PostgreSQL uses its configured health_timeout; every probe remains bounded and returns as soon as its endpoint is healthy.

Runtime input materialization belongs to the workload startup operation. The Supervisor serializes lifecycle operations with cleanup, so the input owner caches only completed values and leaves unfinished resolution in the startup operation's scope. One shared-input gate serializes materialization among concurrent workload starts. Interrupting native startup therefore interrupts OIDC fetches and file materialization; successful Pooler and Vector files remain owned until the runtime cleanup boundary removes them.

Artifact cache trust begins only after a candidate has passed checksum and runtime-path validation, had its metadata written, and been atomically published. Exact-version artifact metadata is authoritative on a cache hit: the store revalidates its format, key, SHA-256 shape, ordered required paths and kinds, containment, and executable shape without fetching an upstream digest. On a cache miss, the source resolves the trusted digest once; downloaded bytes are verified before decoding or extraction, then structurally validated and atomically published. The cache is user-owned and immutable by contract; manual post-publication changes may execute successfully or fail later at workload start. A partially published or malformed entry is never trusted merely because its directory exists.

Effect failures remain operation-specific tagged errors (for example, configuration, preparation, gateway, port, and lifecycle failures), preserving typed recovery and actionable context at the Effect boundary. The Promise facade translates those failures into native rejections only at its outer edge.

Why this replaces ADR-0015

ADR-0015 proposed exported contract fixtures, a repository boundary, and parallel persistent adapters without shipped producers or consumers. Keeping them would preserve a second architecture. Its identity and lifecycle intent survive only where implemented by the stack package and Supervisor.

Testing decision

Tests follow consumed boundaries:

  • stack integration covers identity, sticky ports, durable lifecycle, ownership, stale-owner recovery, and interrupted cleanup;
  • supervisor integration covers detached ownership, RPC, stop, destroy, retirement, and wake-up; and
  • one shared stack-package E2E journey runs in native, Docker, and Podman modes, starts with PostgreSQL alone, activates every other service through realistic traffic, verifies cross-service behavior, then exercises stop/start and retained offline observability.

Future CLI handler integration will cover only argument translation, output, telemetry, and calls into the stack facade. Private implementation tests are retained only where public scenarios cannot make a branch observable.

Consequences

The architecture has one source of truth for lifecycle state and one owner for ephemeral resources. Refactors update the package facade and its real consumers together. The private document format may change with the current build, while destructive cleanup and ownership remain explicit safeguards.

Supervisor state transitions return {snapshot, notifications, reconcile}. The Supervisor commits the snapshot, performs any requested reconciliation, and then delivers notifications while holding admission. Every transition, including intermediate workload readiness, rollback, and cleanup, follows this commit → reconcile → notify contract, so observers cannot receive completion before state and retirement timer decisions are settled.