Skip to content

Latest commit

 

History

History
904 lines (662 loc) · 109 KB

File metadata and controls

904 lines (662 loc) · 109 KB

Stack package architecture

This document defines the stack package architecture. The package implements the service graph, lifecycle, composition, proxy, commands, and detached owner described here. CLI integration is a separate consumer concern.

Core model

Use a graph of service instances whose lifecycle one reducer per owner decides. Keep application readiness separate from that lifecycle. Add a proxy that starts services on public traffic and sleeps them on public inactivity. Run finite commands with the same identity, artifacts and execution primitives, without treating them as services.

The critical simplification is:

Starting a process and waiting for that process to become healthy are different operations.

Lifecycle decisions are pure transitions applied under one gate per owner; executing them is separate. An execution reports generation-tagged outcomes, so a late outcome never affects a newer generation. There is no second lifecycle state outside the reducer.

Preserve the useful identity and instance use cases while replacing the underlying machinery. The contract intentionally stays small and behavioral.

The mental model: a service runs itself; the orchestrator connects services

Each service execution runs one generation's launch, readiness check and cleanup. The orchestrator's reducer decides when each happens, for one service or a whole stack. Identity and resource ownership are foundations for both.

flowchart TB
    Handle["Public service or stack handle"] --> Orchestrator
    Proxy["Proxy: traffic and idle signals"] --> Orchestrator
    Composition["Stack composition<br/>dependency edges and input wiring"] --> Orchestrator
    Orchestrator["Orchestrator<br/>owner gate and lifecycle reducer"] -->|launch, stop, reprobe| Executor["Service execution<br/>one generation's runtime and cleanup"]
    Executor -.->|generation-tagged outcomes| Orchestrator
    Executor --> Backend["Native or container backend<br/>launch, observe, stop, remove"]
    Context["Shared stack context<br/>identity, owned data, sticky ports"] -.-> Orchestrator
    Context -.-> Executor
Loading

Dependency declarations and input wiring both belong to stack composition, enforced by the orchestrator. REST's definition accepts a database URL. It does not declare a database-service dependency or know where that URL comes from. Composition can wire the URL from a managed database instance and declare its readiness edge, or supply an external URL without a managed lifecycle dependency. The orchestrator resolves that wiring, enforces the declared ordering, and passes ordinary configuration values to REST.

A URL implies a connection requirement, not ownership of another service. Do not infer stack graph edges by inspecting URLs. Keep the managed producer reference, readiness requirement and consumer input mapping together in the composition declaration; the service does not maintain a parallel dependency list.

Boundary Knows Does not decide
Recipe/definition Typed configuration inputs, such as a database URL; own runtime behavior Where inputs originate or which stack services must run
Stack composition Concrete dependency edges and how values populate service inputs Runtime lifecycle implementation
Orchestrator Graph, lifecycle state, demand, admission, timers and input resolution How PostgreSQL launches or a container stops
Service execution One generation's processes, readiness check, cleanup and data When to launch, stop or sleep, or other services' state

Calling public service.start() or service.stop() goes through the orchestrator. The execution boundary is internal, so callers cannot bypass the reducer. Sleep is an internal idle policy over the same stop/launch mechanics; it has no public service method or RPC endpoint.

Each long-running service is an individually identified instance with its own executor, lifecycle and health. There is no capability layer, hidden service group or aggregate service handle. Composition connects instances; it does not hide their identities.

Implementation organization

Keep the implementation Effect V4 from the domain inward. Promise is the outer facade for package consumers and the boundary for foreign APIs; adapt a foreign Promise once at its leaf with typed errors. Host-owned execution fibers own admitted transitions, while callers may cancel only their wait. Use bounded streams and backpressure for command and log transport.

Organize by cohesive responsibilities. The package shape is:

  • src/ modules: the lifecycle reducer, orchestrator, service execution, owner, detached host, networking, persistence, and RPC boundary.
  • services/: one definition per service, owning its configuration, endpoints, launch settings, and readiness. Catalog.ts validates and dispatches creation; Recipe.ts defines their contract and ProcessRecipe.ts shares process mechanics.
  • composition/Supabase.ts: default Supabase membership, dependency edges, and input wiring.
  • host/: endpoint projections, command execution, and command attachment transport.
  • runtime/: native and container adapters.
  • Commands.ts: public finite-command descriptors.
  • effect.ts: Effect-facing composition and services.
  • index.ts: Promise-facing public boundary. PromiseClient.ts derives its types from the Effect handles and adapts them by shape: Effects become cancellable calls, Streams async iterables, and returned handles are adapted recursively. Only operations whose inputs differ (plain creations, Promise command sinks) and the per-kind creation type are written by hand.
  • testing.ts: disposable, composed session stacks for tests, with database checkpoints, in Promise and Effect forms.
  • Defaults.ts: shared local-development credentials, exported once as ./defaults.

This is navigational guidance, not a required file scaffold. Split modules when a responsibility needs it; avoid one folder or interface per operation. Keep service definitions narrow, with graph edges and input wiring in composition. Do not introduce capabilities, projections, recovery journals, reservation mechanisms beyond the machine-wide per-OS-user port registry, public sleep APIs, or extra lifecycle states to force this shape. That registry is the one deliberate reservation and the only saved public-port assignment.

Application services use Effect Context.Service and Layer.effect; consumers obtain their dependencies from the Effect context. Each owner receives an isolated orchestrator graph. Commands share the host lifetime alongside the owner. Individual executors, recipes, and process handles remain scoped resources because a stack owns multiple independently identified instances.

1. Follow Compose's useful separation

Docker separates container lifecycle from health. A container can be running while its health is still starting. Compose can wait for a dependency's health before starting its dependent, but stop does not wait for the application to become healthy. Docker startup documentation, Compose stop implementation, engine stop implementation.

Use that separation for both backends:

Chart 1: one service instance's lifecycle

stateDiagram-v2
    [*] --> Stopped: register (unarmed)
    Stopped --> Starting: demand or explicit start
    Starting --> Running: session available
    Starting --> Failed: launch failed, cleanup confirmed
    Running --> Stopping: unexpected exit, stop, restart or idle sleep
    Starting --> Stopping: unexpected exit, stop or restart
    Stopping --> Failed: unexpected exit, cleanup confirmed
    Stopping --> Stopped: stop, restart or idle sleep, cleanup confirmed
    Stopping --> Stopping: cleanup failed, retried on request
    Failed --> Starting: demand, behind the breaker
    Failed --> Stopped: explicit stop
    Stopped --> [*]: destroy owned data and registration

    note right of Running
        Health is observed separately.
    end note
    note left of Stopped
        Data and public port assignments
        survive until explicit destruction.
    end note
Loading

Each phase after Stopped belongs to one generation. The final marker means the registration no longer exists. destroy of a running instance stops it, then removes its data under a storage reservation, which it takes once any running storage operation releases it. A generation whose cleanup fails stays stopping, holding its resources; the next stop, restart, start or waiter retries the remaining cleanup. A workload that exits on its own enters Stopping at once, so no traffic routes to its dead endpoint while cleanup runs; it counts toward the breaker then, and ends Failed once cleanup is confirmed. A dead process is never reported as running, nor unfinished cleanup as stopped. Failed data removal leaves the registration present. Restart composes stop and launch; it adds no stable state.

Chart 2: health does not hold the lifecycle gate

flowchart LR
    Launch["Launch completes"] --> Waiting["Lifecycle: running<br/>Health: starting"]
    Waiting -->|readiness succeeds| Healthy["Lifecycle: running<br/>Health: healthy"]
    Waiting -->|readiness fails or times out| Unhealthy["Lifecycle: running<br/>Health: unhealthy"]
    Waiting -->|ordinary stop| Stopping["Lifecycle: stopping"]
    Healthy -->|ordinary stop| Stopping
    Unhealthy -->|ordinary stop| Stopping
    Stopping -->|cleanup confirmed| Stopped["Lifecycle: stopped"]
Loading

Only running + healthy satisfies a readiness wait. A health failure records an error and leaves the process running; it fails the explicit waits that needed it and is re-checked while traffic still waits on it.

For one service instance, running means its process has launched. It does not mean the application is ready. Every instance exposes its own observation. Separately observe health: starting, healthy, or unhealthy. A failed launch records its error; unsuccessful cleanup must not be reported as a successful stop. Destroy removes the registration only after stopped resources and owned data are safely removed.

Example: PostgreSQL launches, so its generation is running. Its initialization and health are still pending. A stop now interrupts only the readiness check and cleans the session. A stop arriving while the runtime is being acquired waits for that acquisition, then cleans what it produced; a stop arriving during preparation abandons it.

Dependency waits and health waits never hold the owner gate or the execution lock. Preparation (artifact downloads and image pulls) runs at the start of each launch, outside the execution lock, and is idempotent. Bounded native pre-launch steps such as initdb remain part of runtime acquisition. Actual launch and stop work have bounded success and failure paths.

2. Ownership, instances and composition

Concept Contains
Stack Identity, complete owned-instance registry, runtime choice, security, resource namespace
Composition Explicit member selection, dependency edges, input wiring and eager/lazy activation policy
Service recipe Typed configuration inputs, native/container launch specifications, readiness and optional storage operations; no stack graph knowledge
Service instance Immutable ID, recipe/configuration, runtime handle, lifecycle and health observations; graph relationships belong to stack composition
Command job One finite artifact-backed execution belonging to the stack

The default Supabase composition is a factory that registers, selects and connects instances. Registration establishes ownership; membership establishes participation in the default application. Registering or starting another instance never implicitly adds it to that composition. A shadow database is an ordinary database instance with its own ID, password, ports and data, owned by the same stack but managed individually.

flowchart TB
    subgraph Namespace["Stack identity and owned resources"]
        subgraph Application["Default application composition"]
            DB["Primary database"] --> REST["REST"]
            DB --> Auth["Auth"]
        end
        ShadowA["Standalone database A"]
        ShadowB["Standalone database B"]
        Command["Temporary command invocation"]
    end
Loading

A composition is an explicit selection over the same instance registry and graph, not another StackHost or lifecycle engine. With no declared dependencies, starting a standalone database selects only that instance and its internal owned resources. Dependency checks still use the shared graph when edges exist. Copying an initialization profile from the primary is setup input, not a permanent runtime dependency.

Proposed standalone instance API

This sketch retains the existing services.create shape while making creation independent of default composition membership:

const shadow = await stack.services.create({
  service: "database",
  config: {
    version: "17",
    endpoints: { sql: { port: "auto" } },
  },
});

try {
  await shadow.start();
  await shadow.ready();
  // Run the migration or comparison against this instance.
} finally {
  await shadow.destroy();
}

Typing the creation API

Use a discriminated union of creation definitions. DatabaseDefinition is configuration input, while DatabaseInstance is the live handle returned by creation. Neither type needs a runtime class hierarchy. The following abbreviated types illustrate the public contract; the actual configuration types come from each service module's validated schema.

type DatabaseDefinition = {
  service: "database";
  config: DatabaseConfig;
};

type RestDefinition = {
  service: "rest";
  config: RestConfig;
};

type ServiceDefinition = DatabaseDefinition | RestDefinition;

type ServiceInstances = {
  database: DatabaseInstance;
  rest: RestInstance;
};

interface ServiceCollection {
  create<Input extends ServiceDefinition>(
    input: Input,
  ): Promise<ServiceInstances[Input["service"]]>;
}

The service literal selects the permitted configuration and the returned handle. An inline call with service: "database" infers DatabaseInstance; it needs neither a generic argument nor as const. For a definition saved in a variable, use satisfies DatabaseDefinition to validate it while preserving the discriminator. A definition whose kind is only known at runtime returns the corresponding union of handles. Keep kind/config pairs correlated as a discriminated union, rather than giving service and config independent unions. As the catalog grows, the union and instance mapping can be derived from one typed catalog rather than duplicated.

Use a service-to-config map and mapped creation union for this typing while keeping dependency requirements in composition rather than individual service definitions. Runtime schema validation remains necessary at persisted/control boundaries; TypeScript alone does not validate received data.

Creation assigns a unique instance identity and isolated data directory; endpoint allocation belongs to that instance and stays sticky until destruction. The same call can create a second independent database. No shadow service type or special lifecycle is required. Launch and health remain separate. The caller owns the decision to destroy temporary instances; closing a handle is not destruction. Existing initialization/snapshot options still apply where the CLI needs a prepared database.

Make operation scope explicit in the proposed API:

Operation Selection
instance.start/stop/restart/destroy() That instance, subject to the ordinary graph rules
stack.composition.start/stop/restart() Default composition members; start also includes their declared prerequisites
stack.stop() All owned service instances, including standalone instances
stack.destroy() All owned resources, including standalone instances and command jobs

Namespace stop also cancels and settles attached jobs before StackHost shutdown; composition stop does not reserve services on behalf of commands. Namespace destruction performs shutdown before owned-data removal. The CLI's full stop uses namespace scope; restarting the application composition does not restart shadows. Composition startup never resurrects a standalone instance. Membership and eager/lazy activation are separate: eager means start when the containing composition is started, not include every registered eager instance. The existing stack-wide start convenience, if retained, delegates to default composition startup only.

For example, REST binds to the primary database; a shadow database has no relationship to it unless the caller asks to copy its initialization profile. Functions may run without PostgreSQL or Auth. An API URL in configuration is not automatically a hard lifecycle dependency.

For example, Studio and pg-meta are separate service instances. Each has its own ID, configuration, lifecycle and health. Composition declares the relevant edges explicitly. Storage and imgproxy follow the same rule when selected; there is no parent handle that starts private children or computes combined health.

flowchart LR
    Database["Database instance"] -->|ready before launch| PgMeta["pg-meta instance"]
    PgMeta -->|ready before launch| Studio["Studio instance"]
Loading

The service: "database" discriminator selects a service definition and its configuration type; it does not name a group or identify a unique instance. Two databases have the same definition kind and different instance IDs. Operations and dependency edges address instance IDs. Short-lived initialization helpers remain implementation details of an operation; they do not justify a hidden hierarchy of long-running services.

3. One lifecycle authority per owner

Each owner holds one pure reducer, Lifecycle.ts: reduce(state, event, now) returns the next state and the commands to execute. It owns every service's intent (eager, lazy or stopped), phase (stopped, starting, running, stopping or failed, each tagged with its generation), work leases, waiters, idle epoch, crash-loop breaker, storage reservation and pending destroy. Every lifecycle writer is an event: explicit start, arm, stop, restart and destroy; traffic admission and release; storage reservation; graph updates; and execution outcomes. No other component holds lifecycle state, so status, admission, storage eligibility and retries all read the same record.

The owner gate

Orchestrator.ts applies events under one gate per owner. One step reduces a batch of events, starts the resulting executions and timers, publishes the new state, then resolves the waiters the step admitted or failed. Executions and timers run outside the gate and report back as events. An event tagged with a generation, idle epoch or breaker cooldown that is no longer current changes nothing, so a late outcome cannot affect a replacement.

Command Executed as
Launch, Stop, Reprobe A fiber in the owner scope driving that service's execution
ArmIdleTimer, ArmCooldownTimer A timer that reports IdleElapsed or CooldownElapsed
AdmitConnection, FailConnection Completing that waiter, after the state it was decided on is visible
RequestRejected The requesting caller's failure

Explicit stop and restart wait on the reducer state itself: a stop completes when its generation leaves stopping, and fails while that generation's cleanup is pending.

The execution boundary

Service.ts executes one generation at a time and owns its resources from before preparation until cleanup is confirmed. A launch prepares, acquires the runtime under the service's execution lock, reports the live session, then runs the readiness check. A stop fences its generation under the same lock, so a launch of that generation that has not acquired its runtime never will; it then interrupts the attempt while it prepares or checks readiness, and cleans only that generation's session, never a newer one. Every outcome is reported: SessionAvailable, LaunchSucceeded, ReadinessLost/ReadinessRecovered, LaunchFailed, SessionLost as soon as a runtime exits on its own, and Exited once cleanup is confirmed, or StopFailed while resources are still held.

A generation whose cleanup failed stays stopping with its cleanup failure recorded. Nothing launches and no storage operation is admitted while it holds resources, and its waiters fail with the cleanup error. The next explicit stop, restart or start, or the next waiter, retries only the remaining cleanup.

Waits and cancellation

Caller activity Effect of cancellation
Waiting for the owner gate Leave without dispatching anything
Waiting for traffic admission Withdraw the waiter; a lease already granted is released
Waiting for an explicit operation Withdraw the wait; the admitted execution continues in the owner scope
Waiting for readiness or following logs End the observation without changing lifecycle
Running an attached command invocation Terminate and clean up that invocation

Each waiter has a kind. A traffic waiter carries a lease and holds through a breaker cooldown. An explicit waiter (start, ready, restart, composition start) opens no lease and fails fast with its target's or a prerequisite's outcome when that fails, loses readiness or fails its cleanup; it waits without a budget or cap. The reducer holds no clocks for waiters. The orchestrator enforces the wake budget, 120 seconds by default, with one timeout around the whole traffic wait, from the client's arrival through the gate wait and the wait for readiness. On expiry it withdraws the waiter, which releases any lease already granted, and fails only that caller with the stage it was blocked on; the shared launch continues. At most 256 traffic waiters queue per service; beyond that, admission fails at once.

A service is stopped by an explicit stop, never by a disconnected caller.

Demand, admission and readiness

A service has demand while it holds leases or waiters, after an explicit start, while it is eager and has not failed, or while any transitive dependent has demand or is starting, running or stopping. Demand launches a stopped or failed service whose intent is not stopped and whose prerequisites are ready. A failed eager service relaunches only on new demand, so a broken launch is not retried in a loop.

Admission requires the target and its whole prerequisite closure to be ready. An inspector acquisition needs only a live target session. A readiness failure is not terminal: the session keeps running, and the waiters that awaited that readiness itself, or explicitly through a dependent, fail with its error. While any other waiter is still blocked on it, the reducer keeps re-checking that generation, one check at a time and one second apart after a failed re-check, until it recovers, its generation ends or no waiter needs it; the waiters' budgets bound how long that lasts. Losing a prerequisite never replaces a healthy dependent; a crashed prerequisite with running dependents relaunches because they keep demand on it.

A destroy that holds its storage reservation owns the service: removing it from the graph succeeds even while work leases are outstanding, and a lease released afterwards is ignored.

Crash-loop breaker

Each launch failure or unrequested exit counts once per generation. Three failures, each within five minutes of the previous one, open the breaker for a fixed 30 seconds; a failure after a longer quiet spell starts a new count. While open, admission fails at once naming the last cause; the cooldown's end re-admits one shared recovery attempt, and a further failure reopens it for the same 30 seconds. An explicit start, restart or composition start resets the breaker.

Chart 3: dependency awareness lives in the reducer

Here stack composition has wired the primary database's connection URL into REST configuration and declared a readiness dependency. The service executions never call each other.

sequenceDiagram
    participant Caller
    participant O as Owner gate and reducer
    participant DB as Database execution
    participant REST as REST execution

    Caller->>O: StartRequested(REST) and a readiness wait
    O->>DB: Launch(generation 1)
    Note over O: REST has demand but its prerequisite is not ready
    DB-->>O: SessionAvailable, then LaunchSucceeded
    O->>REST: Launch(generation 1) with the resolved database URL
    REST-->>O: SessionAvailable, then LaunchSucceeded
    O-->>Caller: Waiter admitted
Loading

If the database fails, loses readiness or fails its cleanup, the explicit wait fails and REST is not launched.

The reducer applies a few graph rules:

Operation Rule
Start Arm the target and its prerequisites; demand launches each prerequisite, then the target once they are ready
Stop Reject while any transitive dependent has demand, is up or winding down, or is still armed; composition stops dependents first
Restart The same dependent check, then stop and relaunch; composition restart stops in reverse dependency order, then performs composition start
Sleep An armed lazy service sleeps after its idle timeout once it is ready and has no demand, including demand from its dependents
Destroy Reject while a dependency edge still references the instance; then the ordinary stop, a storage reservation for data removal taken once any storage operation releases it, and the graph update removing it. A pending destroy refuses new starts, restarts and storage operations; any other exit gives it up

Validate composition references, input/output types and cycles when registering or changing the graph. Wiring uses only a source instance ID and named output mapped to a target instance ID and named configuration input. The service catalog declares those input/output schemas. There is no expression language, arbitrary callback or general JSON-path evaluator. Resolve bindings into ordinary configuration at launch. Bind references to immutable instance IDs, not names. Derive ordering from this one graph. A graph change requires every affected instance to be stopped and unarmed, and the reducer's verdict is checked before the change is persisted.

Composition start first assigns or reuses and binds every configured public listener of its selected members, including lazy members, and registers their routes. One worker per member then waits for its prerequisites' outcomes: a lazy member is armed (also after a prerequisite failed), and an eager member is started and awaited for readiness, or reported as blocked when a prerequisite failed. Prerequisites outside the configured members run as eager standalone instances. It does not select unrelated standalone instances. Successful start returns member observations with their public endpoints; credentials reads render usable connection strings from these assignments without starting lazy backends. A failed launch or readiness check makes the operation return an error with per-instance outcomes; a running but unhealthy instance remains running, and completed steps are not rolled back.

After binding the shared API listener, default composition setup uses the endpoint renderer to populate ordinary configuration values: Studio receives apiUrl and publicApiUrl, Auth receives externalApiUrl, and Functions receives apiUrl. When the database has a configured SQL endpoint, Functions also receives its rendered databaseUrl; this saved value is refreshed when callers recompose the composition, and does not create a database readiness dependency for Function execution. These values are saved in each instance's configuration; individual starts and normal host reopening consume that configuration. This is concrete default setup code, not declarative input wiring or a public listener-binding API. The endpoint renderer owns the host/runtime reachability rules; custom configurations supply plain URLs through the same inputs. URL values do not imply graph edges.

Composition restart is two passes: stop the selected instances in reverse dependency order, then perform composition start. If the stop pass fails, report partial results without beginning the start pass. Cancelling composition withdraws its waits; admitted executions continue in the owner scope, and launched instances remain until an explicit stop or owner close. Explicit empty selection is a no-op; there is no generic rollback engine.

4. Recipes own runtime details

A recipe receives ordinary typed configuration and a context for its own resources. A database URL is just an input value: the recipe does not receive its producer’s identity, another service handle, or the stack graph. It does not receive the entire persisted stack document. The orchestrator owns resolving managed outputs into these configuration values.

The backend provides mechanical operations: launch, observe exit/logs, stop and remove exact resources. Native and container implementations differ in commands, mounts, network setup and cleanup, but share the lifecycle contract. Keep one runtime choice per stack; mixed backends and automatic command-runtime fallback are unnecessary for the current scope. Preparation validates that the selected service/command artifact exists for that runtime and platform, and reports an unsupported-platform/artifact error otherwise. A native stack requires native artifacts; Windows native support is not implied by a fallback branch in the current CLI.

A successful launch returns an owned runtime handle with readiness observation and cleanup. One shared readiness program belongs to each runtime launch and has a bounded outcome. Readiness timeout or initialization failure leaves the process running with unhealthy status and an actionable error. It does not automatically stop or restart the process. Stop remains available, and traffic/dependent launches do not treat unhealthy as ready. Initialization that requires a running process belongs to that runtime session, not the start transition. For PostgreSQL, healthy means required catalog initialization and credential reconciliation have completed—not merely that TCP accepts a connection. Stopping the running instance closes that session, settling its health probes and initialization helpers as ordinary resource cleanup. There are not two competing lifecycle operations.

The database keeps only the initialization information needed to reuse its data on a normal stop/start. Data is reusable only within one release line: the PostgreSQL major for stock builds (17), so same-major minor upgrades keep their data, or the exact build for OrioleDB (17.11.0.002-orioledb), since a newer OrioleDB build may refuse older data. The readiness marker records the full artifact version, from which the line is derived. A first start records the requested line before initdb runs (a native line marker in the instance root, or the Docker storage marker), so a first start interrupted before readiness resumes only on that line. PG_VERSION records only the major, so data with neither a readiness marker nor a recorded line counts as the stock line and an OrioleDB request refuses it. Mark initialization complete only after it succeeds. If an ordinary stop interrupts application initialization, the recipe must either support a safe retry or report that initialization is incomplete; do not add a generic persisted operation journal or recovery workflow.

Unexpected process exit is observed by the living host. The execution records the exit result, cleans the exact owned session under its execution lock, and reports the exit once cleanup is confirmed, or a cleanup failure while resources remain. Nothing launches a replacement while resources are held. Old-handle events cannot affect a replacement. This adds no cleanup scanner: the host already owns the handle and its exit observation.

A crashed service stays armed. Demand relaunches it behind the crash-loop breaker; a crashed prerequisite with running dependents relaunches because they keep demand on it. There is no continuous dependency shutdown cascade: existing dependents may observe connection failures until it is back, as they can with Compose. An explicitly stopped prerequisite makes wake fail. Deliberate lifecycle changes still obey graph checks.

5. Sleep stays simple

Public traffic through the proxy is the only activity signal. Sleep is triggered internally by proxy inactivity, never by a public client command. Do not track private connections, background SQL, or inferred workload activity. Add a public sleep operation only if a concrete consumer needs it later.

  • A request or open public stream/connection counts as activity; idle HTTP keep-alive does not.
  • The idle timer and wake route belong to one instance. Sleep stops that instance after the ordinary dependency checks, retains its data and keeps its wake route armed. It does not implicitly stop companion instances. Instances without a supported public wake route remain running until explicitly stopped; no group sleep mechanism is introduced.
  • The next request starts the instance through the orchestrator, ensuring its declared prerequisites are ready, then waits for its own health before forwarding.
  • Explicit stop disables wake. Traffic never reverses it.
  • A prerequisite stays awake while any dependent has demand or is starting, running or stopping. Once the dependent's stop is confirmed, the prerequisite idles on its own timeout.

Sleep is therefore ordinary stop mechanics plus an armed wake route. There is no separate sleep runtime. The idle timer carries an epoch that any new admission advances, so a request admitted before the reducer commits the sleep keeps the instance running. A request that arrives after the sleep was committed waits for the confirmed stop, then is served by the next generation.

Chart 4: sleep, stop and destroy have different retained state

These are outcomes of the same local stop mechanics after graph checks. “Sleeping” describes a stopped instance with wake enabled, not another backend state.

flowchart TB
    Halted["Instance stopped<br/>Cleanup confirmed"]
    Halted -->|internal idle sleep| Sleeping["Sleeping<br/>Wake route armed<br/>Data retained<br/>Public port assignments retained"]
    Halted -->|stop| Stopped["Stopped<br/>Wake disabled<br/>Data retained<br/>Public port assignments retained"]
    Halted -->|destroy| Delete["Remove proven-owned data<br/>and registration"]
    Delete --> Removed["Removed<br/>Owned endpoint claims released"]
    Sleeping -->|public traffic| Wake["Orchestrator starts dependency closure<br/>Waits for health, then forwards"]
    Stopped -->|explicit start| Start["Orchestrator starts selected services<br/>Reuses saved public ports"]
Loading

Sleep retains the public listener needed to wake. Whole-stack stop closes listeners and releases pinned reservations while preserving automatic ones; the next start rebinds the same ports. Destroying an individual instance deletes only the registry rows it owns, not unrelated services' shared listeners or rows; a stack-wide destroy deletes its rows only after the registration is removed, so a failed destroy keeps the automatic ones (a configured port's row is released when its listener closes) and a failed release leaves them for lazy reclamation.

Composition validation permits lazy activation only for instances with a configured public wake endpoint. A route-less prerequisite, such as pg-meta without its own public endpoint, is eager; it is not implicitly armed through a dependent. This avoids a second wake-permission mechanism. Internal sleep is available only for lazy instances with a supported public wake route and an enabled idle policy. The default composition gives every lazy service one, Functions included; the database is eager. Functions inspector access remains possible while health is starting; ordinary application traffic waits for healthy.

6. Commands share execution, not service lifecycle

Provide a stack-scoped finite runner for concrete CLI needs such as pg_dump and psql. Each invocation has a job ID; temp files, logs and runtime resources belong to that stack. Jobs reuse artifact selection and native/container process execution. They have exit results and byte streams, not healthchecks, wake policy or service registrations.

For pg_dump and psql, container mode launches a temporary command container from a compatible PostgreSQL image, overriding its entrypoint to run the client command. It may reuse the database service's image, but it creates a separate container without starting another database server or mounting the server's data directory. Native mode launches the corresponding binary from the selected native artifact. The command definition supplies these two executable forms; the runner supplies stack ownership, arguments, environment, streams and cleanup. Docker supports entrypoint overrides and automatic removal for this execution shape. Docker run reference.

Invocation Native runtime Container runtime
Run pg_dump Spawn the selected client binary Create an attached temporary container running pg_dump
Connect to a managed database Resolved public proxy endpoint The same public proxy endpoint, addressed from the container network
Return the result Stream stdout/stderr and collect exit status Stream stdout/stderr, collect exit status and remove the owned container

The caller supplies ordinary arguments and environment values, including any connection string. The command runner treats them as opaque: it does not resolve service references, infer dependencies or rewrite URLs. The caller can obtain execution-reachable connection values through service.credentials({ from: "runtime" }); from: "host" is the default for ordinary host clients. This extends the existing read operation rather than adding command-specific inputs. The networking/endpoint renderer supplies these concrete values; localhost inside a command container is not generally the host. Managed command traffic uses the public proxy so the existing wake/activity rules apply. Commands publish no listening ports. Dump output streams to the CLI's destination; temporary files, when needed, use the stack/job directory. Use a client version compatible with the target; matching the managed database's selected major is the simple default. PostgreSQL client compatibility.

Managed-local execution uses this temporary-container pattern under stack ownership. Executing a command inside an existing service container is unnecessary for these network clients and need not become a second public command mechanism.

For the managed local backend, the StackHost owns jobs so it can clean up exact resources after client disconnection. Attached-job cancellation stops that job, not a service transition. Explicit StackHost shutdown cancels and settles attached jobs; finishing the last job does not automatically retire the host. Use bounded, backpressured stdin/stdout/stderr transport; do not buffer entire dumps or confuse output EOF with successful exit.

Commands do not participate in the service dependency graph. Traffic to a public endpoint wakes a sleeping service through the proxy, exactly as traffic from any other client does. An explicitly stopped service remains stopped. There are no command-specific readiness checks or lifecycle locks: an explicit stop or restart can disrupt the connection and the command reports its ordinary error. Internal bootstrap helpers use the same low-level runner under their parent service operation.

Migrate managed-local dump/reset execution first. Linked/remote and legacy compose CLI paths remain outside this package's lifecycle; do not create a stack or reserve ports just to run their commands. An explicitly supplied stack handle may still run a job against an external URL. The identity claim applies to everything executed through that stack.

CLI policy remains CLI policy: SQL scripts, migrations, seeds, hosted targets, output files and command flags. The caller chooses a compatible client version; the runner resolves that version to its executable artifact and owns execution.

Proposed public command API

Expose one awaited stack.commands.run operation. PostgreSQL client commands and one-shot service initialization share the same owner-side runner. Initialization runs a service recipe without registering a service instance or changing the service graph.

import { postgres } from "@supabase/stack";

// Connection values are plain data, rendered for the stack runtime.
const { databaseUrl } = await database.credentials({ from: "runtime" });
const result = await stack.commands.run(
  postgres.pgDump({ major: 17 }),
  {
    args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"],
    stdout: (bytes) => destination.write(bytes),
    stderr: (bytes) => diagnostics.write(bytes),
  },
  { signal },
);

// result: { jobId, exitCode }

await stack.commands.run({ type: "auth.initialize", databaseUrl });
await stack.commands.run({ type: "storage.initialize", databaseUrl, filePath });
await stack.commands.run({ type: "realtime.initialize", databaseUrl });

postgres.pgDump({ major }) describes the selected client package and how to launch its native and container forms. The caller chooses the version; 17 is illustrative. The stack's runtime chooses the execution form. The invocation supplies ordinary arguments, environment and byte streams. Initialization invocations select Auth, Storage, or Realtime, provide the database URL, and provide Storage's file path. They require stack credentials already established by database or composition setup; credential lookup is read-only and fails when no stack identity has been established. They resolve the same service recipe artifact, environment, mounts and working directory as normal service launch while leaving no service instance behind. The CLI awaits selected initialization commands concurrently before applying its catalog overlay.

databaseUrl is a plain string. The same invocation can target the primary database, a shadow database or an external database without changing the command definition or the runner. The proxy handles any wake-up caused by connecting to a sleeping managed service. A URL obtained for container execution is already reachable from that container; endpoint address selection remains outside the generic command runner. The orchestrator uses the same endpoint renderer when supplying connection values to service instances. It handles the host gateway and the listener binding needed to reach it, not just hostname substitution, and returns an error if the selected networking configuration cannot reach the endpoint. Caller-supplied external URLs remain unchanged.

Arguments are passed as an argument vector, not interpreted as shell text. Stdin accepts an optional asynchronous byte stream; stdout/stderr callbacks receive bytes and may return Promises, which the runner awaits for backpressure. All examples use byte sinks whose writes await capacity. psql uses the same operation with postgres.psql({ major }), optionally supplying stdin. Callers supply stdout/stderr sinks explicitly. Neither facade collects unbounded output into a return value.

The operation settles after process exit, output delivery and owned-resource cleanup. PostgreSQL client commands return a nonzero process exit in exitCode for CLI-specific handling; initialization commands reject with a typed error on nonzero exit. Preparation, transport, sink and cleanup failures also reject with typed execution errors. Cancellation stops and cleans up the attached job and reports cancellation. The StackHost assigns jobId and owns the resources. No public job registry, job healthcheck, persistent command service or separate launch/attach/wait sequence is needed for these use cases.

7. StackHost owns lifetime; components own behavior

Use StackHost for the detached process that owns one stack identity. It hosts the service executors, composition orchestrator, proxy, command runner and shared resources. A standalone instance needs this owner and its executor without needing composition scheduling. Commands use the runner without entering the service dependency graph.

The host acquires exclusive ownership, constructs the components, exposes Effect RPC, delegates requests and coordinates explicit shutdown. Domain behavior remains in the components: the host does not understand PostgreSQL archives, decide dependency order or implement another service state machine. Existing dependency checks still apply when individual operations target instances with declared graph relationships.

flowchart TB
    Client["CLI or package handles"] --> RPC["Effect RPC endpoint"]
    subgraph Host["StackHost — one detached process per stack identity"]
        RPC --> Composition["Composition orchestrator"]
        RPC --> Instances["Service instance executors"]
        RPC --> Commands["Command runner"]
        Composition --> Instances
        Proxy["Public proxy"] --> Composition
        Resources["Shared ownership, ports and metadata"]
    end
Loading

Keep Effect RPC as the transport initially. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification.

Module Responsibility
StackHost Process lifetime: lease, signals, session lifeline, sweeps, shutdown state machine; serves /identity, POST /shutdown and /rpc (owner handlers plus command RPCs)
Owner Builds the instance and composition RPC handlers, maps domain failures to StackError once, persists definitions and adapts recipes, listeners and the Supabase composition
Orchestrator The single in-memory registry of instance entries and the composition: admission, start plans, activity, idle sleep and exit watchers
Network Public listeners, the shared API proxy and port claims
host/* Service-specific endpoint routes, rendered connection values and stack credential rules

Definition changes (service creation and destruction, composition configuration and Supabase composition) run one at a time in the owner's scope, and a caller that disconnects stops waiting. Creation saves the instance before registering it and removes the saved instance again if registration fails. Until registration completes, a concurrent get or list can already return the new id, while start or status for it fails with an unknown-instance error.

Proposed RPC surface

Area Methods Responsibility
Host host.describe, host.stop, host.destroy Inspect the stack; stop or destroy everything it owns
Registry services.create, services.get, services.list Create and locate instances
Instance lifecycle service.prepare, service.start, service.stop, service.restart, service.destroy Operate one instance
Instance observation service.status, service.ready, service.followStatus, service.readLogs, service.credentials Read state, await health and inspect outputs
Database snapshots database.saveSnapshot, database.restoreSnapshot Database-specific managed storage operations
Composition composition.configure, composition.describe, composition.start, composition.stop, composition.restart Define and operate the application selection and dependencies
Commands runCommand, commandInput Execute an attached command with streamed input/output

These are proposed wire names. Public shadow.start() maps to service.start({ id }); stack.stop() maps to host.stop. Public stack.composition.stop() leaves the host and independent instances available. composition.configure sends validated declarative membership, edges and input wiring, not executable callbacks. Configuration changes use the same graph and lifecycle admission rules as other mutations.

Host lifecycle

Launch the host when an operation needs a live owner. Once started, it remains alive until namespace stop or destruction. Client disconnection, completion of a command, or stopping the final individual instance does not cause automatic retirement. The accepted tradeoff is one resident process for an opened stack until explicitly stopped, even if all its service instances are stopped.

stateDiagram-v2
    [*] --> Starting: operation needs a host
    Starting --> Serving: ownership acquired, metadata loaded, RPC ready
    Starting --> Exited: startup fails or another owner wins
    Serving --> Serving: clients connect or disconnect
    Serving --> Serving: instances start, stop or sleep
    Serving --> Draining: host.stop or host.destroy
    Draining --> Exited: cleanup complete, or failed and reported
    Exited --> [*]
Loading

During Starting, acquire the exclusive stack lease, load the instance definitions and saved resources needed for normal restart, construct components and open the control endpoint. For a container stack, remove containers labeled with this stack and its canonical data root before accepting work. This is cleanup of proven-owned workloads, not adoption or replay of interrupted operations. Other runtime or port conflicts remain errors.

Lease and discovery. The lease is a SQLite write transaction on <stateRoot>/<id>/owner.lock, held for the owner's whole lifetime; the operating system releases it when the process exits for any reason. POSIX record locks belong to the process, so only the lease code opens that file. A waiter may open the file just before a releasing holder unlinks it; after locking, it checks that the path still names the file it locked and otherwise reopens. A stack is live exactly while its lease is held. After taking the lease the owner binds a loopback control listener on an OS-assigned port and, once it serves, atomically publishes owner.json, readable only by its user, with its port, PID, release, lifetime, start time and a random per-owner secret; it retracts the record on exit. A client first try-locks the lease: a free lease means no owner, so discover reports dead stacks without any network probe. Only while the lease is held does a client read owner.json, then validate the stack identity, PID and port through GET /identity. Every control request carries the secret as Authorization: Bearer <secret>, a header HTTP tracing redacts, and the owner rejects requests without it after a constant-time comparison. A stale record, a foreign listener on a previous control port, or another owner that later binds that port therefore never receives this stack's requests; a client treats a rejection as a stale record and resolves the owner again. Concurrent spawners may start several owners; each loser fails to take the lease, reports that on its readiness descriptor and exits, and its spawner attaches to the winner through owner.json.

Release handshake. /identity reports the owner's release: the package version plus a build identifier. The identifier is a digest of this package's module sources and its Effect version: the CLI build scripts embed it in every compiled binary and a source checkout computes it, so a binary and a source run of the same sources interoperate, and any change to the owner or its protocol is a new release. Operations fail with an error asking the user to stop or destroy the stack when the releases differ. stop and destroy use POST /shutdown with the bearer secret and a { "destroy": boolean } body, which do not depend on the RPC schema and so reach owners of any release.

Session lifetime. A session stack is registered by its owner under the lease, so it never exists without a live owner except after that owner dies. Its spawner keeps the owner's stdin pipe open for the life of the creating handle. End of input means the creator is gone, whether it closed the handle or its process died: the owner destroys the stack and exits. Only the creating handle starts a session stack's owner; other handles attach. create({ startOwner: true }) registers a detached stack through its owner the same way; an owner whose startup fails removes the registration it made, so a failed or interrupted first launch leaves no stack behind.

Orphan sweep. Stack-labelled containers and session stacks exist only while their lease is held. After readiness, each owner visits every other stack in its state root in the background, with a bounded time per stack. It skips stacks whose lease is held. For a free lease it takes that lease for the duration of the visit, publishing a sweeper record in owner.json so clients wait for the visit instead of mistaking it for a starting owner, removes containers labelled with the stack and its data root, and destroys the stack through the owner's own destroy path when its lifetime is session. A container-runtime owner also visits stacks that have no registration but left containers labelled with this state root on its pinned engine, such as when their directory was deleted, under the same lease and unregistered check, and removes only those containers; the stack's data in the shared database volume stays. findDeleted selects such a stack by its full id, so a client can destroy those containers on demand. Filtering on the data-root label keeps other state roots untouched. Creating a stack whose identity belongs to a dead session stack reclaims that stack the same way first. The sweep also removes the empty <id>/ and data/ directories a destroy leaves behind, once they are over ten minutes old and hold nothing else.

Lost ownership. The orphan sweep above covers a dead owner whose registration survives; the opposite case is a live owner whose registration disappears out from under it (the stack directory or state root is deleted, moved or unmounted, possibly followed by a new start of the same stack). Every owner polls on an interval (30 seconds by default) and treats its ownership as ended when <stateRoot>/<id>/state.json is confirmed missing (ENOENT) or owner.lock at its path is no longer the file its lease locked. Any other read error keeps the owner running and logs a warning. When ownership ends, the owner stops what it launched once any stop already running settles, closes its listeners and exits. It does not destroy instance data, sweep labels or write the registry, and it retracts owner.json only while the lock file at its path is still its own, so a successor owner's lease is never disturbed. Closing the listeners releases the reservations of configured ports; automatic reservations are kept. A stop already running when the directory is deleted and the same stack restarted can still sweep the successor's containers by label. Leftovers of a stack whose directory is gone are reclaimed by the existing paths: the next container owner's sweep of unregistered labelled stacks, lazy reclamation of reservations whose registration is gone, and destroy --stack-id. Docker database data in the shared volume of such a stack is not reclaimed.

Engine targets. A container stack's owner pins the engine target it resolves at startup, so every command it runs reaches the same engine. The target is not saved with the stack: an owner started after the Docker context or DOCKER_HOST changed resolves the new target, and containers left on the previous engine are not reached from there; switch back to clean them up. Docker pins DOCKER_HOST as --host, else the current context. Podman pins CONTAINER_HOST as --url, else CONTAINER_CONNECTION as --connection; with neither set, a bare podman info decides whether the engine is local or remote. A remote engine (macOS podman machine) pins the default system connection by name, and a local one adds no flags. On rootless Podman, containers that run as the caller's uid with writable borrowed bind mounts get --userns=keep-id so files stay owned by the caller. Containers reach host listeners through the engine's host alias, host.docker.internal or host.containers.internal.

Unreachable engine. An owner of a container stack fails startup with a runtime-unavailable reason when its first container sweep finds the engine CLI missing or its daemon or service not listening; permission, TLS, authentication and timeout failures are ordinary startup errors. destroy then fails with that reason, leaving the registration, port reservations and host data untouched; run it again once the engine is reachable. stop without a live owner takes the same lease and reclaims the registered stack's leftovers (the orphan-sweep path: containers, plus destruction of a session stack) before succeeding; it fails if another process holds the lease or the cleanup fails, and so needs the engine for a container stack.

During Serving, keep the owner alive independently of callers. Sleeping instances still need its public listeners. This is process lifetime management, not automatic service restart or continuous reconciliation.

Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as host.stop. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. After forced termination, the orphan sweep removes leftover containers.

During Draining:

  1. Close admission to new mutations and to proxy traffic that would wake a service at rest. Traffic to starting or running services stays admitted.
  2. Reject queued work that has not begun.
  3. Let executing instance operations settle.
  4. Cancel and settle attached commands and stop owned services in reverse dependency order, with every listener still open (ordered stop, below).
  5. For destruction, remove proven-owned data and metadata after shutdown.
  6. Send the outcome, close the control endpoint and release ownership.

Ordered stop. Stop and destroy close no listener up front. Public and internal dependency traffic share the same listeners, so a dependent's graceful stop still reaches its prerequisite through the stack proxy while it is running. Services stop in reverse dependency order, and each listener closes once its own service has stopped, through the ordinary scope teardown. Traffic that would wake a service at rest is refused for the whole shutdown. Accepted, documented tradeoff: the proxy offers no completion window, so an in-flight request survives only as far as its service's own graceful stop carries it, and a slow client can lose a response.

Draining is one-way. The host holds the single serving/draining fact; the owner reads it for admission. Once a stop or destroy begins, the owner never serves again. A failed stop or destroy reports its error, the stack stays registered with uncertain state, and the owner exits. The next stop or destroy finds no owner and reclaims the stack under its lease.

Successful whole-stack shutdown. stack.stop() reports success only after admitted work settles, every owned live service and command workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close with their services, the shutdown request is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the host exits without resuming service; the stack is not reported stopped and remains registered for the next stop or destroy to reclaim. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and port reservations. destroy follows the same live-workload cleanup, then removes only proven-owned persistent data.

The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes the shutdown request, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a shutdown-exit error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup.

Callers must not start or restart the same stack concurrently with whole-stack shutdown. In particular, replacing an owner between identity lookup and the shutdown request is outside this guarantee. Parallel stacks with separate identities remain independent. Client disposal and Effect scope closure do not implicitly stop a detached stack; disposable fixtures register explicit destruction or use a session stack, which closing its creating handle destroys.

A cleanup failure does not undo completed cleanup. Unexpected host death does not resume interrupted operations or restore live service state. Native launchers stop their process groups when the dead host's pipe closes. The next host startup for that stack sweeps its containers without removing volumes or saved definitions, and any host start in the same state root sweeps them in the background, also destroying the stack if it is a session stack. A forced host termination does not guarantee immediate container cleanup. A lost control response is reported as uncertain; do not blindly retry a mutation.

Request lifetime is separate from execution lifetime

Once an instance operation starts executing, the host owns it until settlement. An RPC disconnect ends the caller's wait without interrupting that operation. Status and log subscriptions end with their connection. Requests not yet admitted remain cancellable as described in the concurrency section.

sequenceDiagram
    participant Client
    participant RPC as RPC handler
    participant Executor as Owner gate and executions
    Client->>RPC: service.start(id)
    RPC->>Executor: StartRequested and a readiness wait
    Executor->>Executor: Reduce and launch in the owner scope
    Client--xRPC: Client disconnects
    Note over RPC: Caller's wait is withdrawn
    Note over Executor: Launch continues
    Executor->>Executor: Reduce the reported outcome
    Client->>RPC: Reconnect; service.status(id)
    RPC->>Executor: Read status
    Executor-->>RPC: Current state
    RPC-->>Client: Current state
Loading

A lost response does not prove failure. While the host lives, callers can inspect its instance observations and in-memory operation result; never blindly repeat creation or restoration. No operation journal or result history survives host death, and there is no command replay or exactly-once execution promise.

Attached commands have a separate contract. runCommand streams started(jobId), stdout/stderr chunks and a terminal exit result. Optional input arrives through bounded commandInput calls; a final call closes stdin. These calls are restricted to the originating invocation/session. Cancelling or losing the execution stream terminates and cleans up that job. The public stack.commands.run() wrapper handles this exchange behind its stdin/stdout interface; no separate public launch/attach/wait workflow is required. The terminal success response follows output delivery and cleanup, with execution/cleanup failures reported as errors.

8. Keep safety infrastructure at its boundary

The reducer state is the lifecycle source of truth

Lifecycle and health come from the owner's reducer state; the service execution adds only execution facts: configuration, the last error, a pending cleanup error, the exit result and the current operation. service.status and service.followStatus join the two, and admission, dependency readiness and CLI presentation read the same state. Endpoint availability comes from the component that owns the listener. The StackHost transports these observations without maintaining another lifecycle copy.

flowchart LR
    Outcomes["Execution outcomes and requests"] --> Reducer["Reducer state"]
    Execution["Execution facts"] --> Status["Instance status"]
    Reducer --> Status
    Reducer --> Admission["Admission and readiness"]
    Status --> RPC["status and followStatus"]
    RPC --> CLI["CLI display"]
Loading

A composition returns the observations of its members. Remove the separate capability status and aggregate stack lifecycle models, their conversion rules and their offline reconstruction. No shared summary model is required. The StackHost's Serving/Draining state describes only the owner process and remains independent of instance observations.

Persist only what normal stop/start needs

Retain stack/instance identity, service configuration and selected versions, composition membership/wiring, credentials and the service-specific information needed to reopen the data. Derive stack and instance data locations from the state root and saved identities rather than persisting duplicate paths. Keep the data itself across stop. Public port assignments are not part of the stack document: the machine-wide port registry (ports.sqlite) holds the only saved assignment and keeps ports sticky across parallel stacks. The active host's exclusive lease prevents competing owners; it is not a crash-recovery subsystem.

Do not persist health, runtime lifecycle projections, transition progress, command queues, operation results or recovery checkpoints. A disconnected caller can reconnect to the same living host; resuming after host death is not part of the contract. Incomplete operations and leftover resources after a crash may require manual cleanup, without automatic deletion of valuable data.

Some foundations are not optional, but they do not need to dominate the service model:

Foundation Keep
Identity Stack and instance IDs; deterministic names and paths for their resources
Ownership One detached StackHost and exclusive lease; client handles do not own service lifetime
Ports Persist automatic port assignments across stop/start; a pinned port is reserved only while listening; never silently relocate
Data Stop retains data; destroy removes only proven owned data
Snapshots DB-specific keyed save, compatible empty-target restore, complete entry publication and credential reconciliation

Port allocation and endpoint ownership

The shared allocator manages physical public listeners. A service definition declares its endpoints; composition may map an HTTP route to a shared listener. The service implementation does not search for public host ports. A requested port is a number or "auto".

Listener owner Allocation key Example
Instance (stack ID, instance ID, endpoint name) Database sql, Functions inspector
Stack (stack ID, listener name) Shared api listener serving REST, Auth, Storage and Functions routes

A shared listener is a networking resource, not a capability or service group. Composition maps each route to an individual instance ID. Traffic is attributed to the matched route's instance for wake and idle decisions; there is no aggregate service lifecycle.

Address Purpose Lifetime
Public Stable endpoint exposed through a dedicated listener or shared route Assignment survives stop/start until its owner is destroyed or explicitly reconfigured
Backend Address where the current process/container can be reached May change on each launch

Reservation happens during startup endpoint setup, before URLs are returned—not on the first request. supabase start and programmatic composition startup perform the same sequence: assign or reuse all configured public ports for the selected composition, bind the proxy listeners, register the routes, then apply eager/lazy activation. Shared listeners are bound once even if every route is lazy. An individual explicit instance.start() likewise prepares its endpoints before launching. services.create() alone records endpoint intentions and does not promise a bound port for "auto".

A lazy database already has a bound SQL proxy listener and a concrete connection URL; lazy REST already has its path on the bound shared API listener. Their backend processes may still be absent. The first request launches the needed backends, waits for readiness and forwards through the already published endpoint. Backend address allocation can wait until that launch; public port assignment cannot. If a required listener cannot bind, startup returns an error with partial outcomes rather than reporting successful startup with unusable URLs.

A host-wide allocation registry coordinates claims across parallel stacks. Before first listener activation, obtain an assignment and bind it. Automatic allocation excludes managed claims and tries to bind an available candidate. Reserve the assignment in the registry before advertising it and retain that socket; do not probe then release it. Later activations bind the saved port. An automatic assignment stays reserved while its stack is stopped. A configured (pinned) port is reserved only while its listener is open, so a stopped stack blocks no other project; the endpoint's address always comes from the configured number, never from the registry. A pinned port inside the native backend range is rejected. A conflict names the port and, for a registry holder, its project and the commands that free it.

flowchart LR
    Client["Client or dependent service"] --> Listener["Stable public listener<br/>Instance-owned or stack-owned"]
    Listener --> Route["Route to one instance<br/>Wake and traffic attribution"]
    Route --> Backend["Current backend address"]
    Allocator["Shared allocator<br/>Persisted listener claims"] -.-> Listener
    Runtime["Native or container runtime"] -.-> Backend
Loading
Operation Dedicated instance listener Route on a shared listener
Start Bind saved port and connect backend Enable this route and bind the saved shared port if needed
Restart Retain assignment and update target Retain shared assignment and update this route's target
Sleep Keep listener for wake Keep route armed for wake
Stop Close listener; retain an automatic claim Disable this route; leave other routes alone
Instance destroy Remove endpoint and release its claim Remove this route; retain the stack-owned listener claim
Namespace stop/destroy Close all listeners; stop retains automatic claims, destroy releases them Same rule

A shared listener stays bound while any route is running or armed. When no route needs it, its socket closes; an automatic claim remains, a pinned one is released with the socket. Removing the final route does not release an automatic claim; namespace destruction or explicit listener reconfiguration does. Sleeping routes still need the listener.

Public port stickiness is a retained contract for automatic assignments. Sleep/wake, stop/start, restart and host reopening preserve every established automatic assignment, including the shared API port. Automatic allocation never takes another stack's retained claim. A pinned port is the configuration's own number, so it needs no retention; its registry row exists only while the listener is open, and a pinned request reclaims a row whose stack has no live owner (a free owner lease), such as after a crash or reboot. If that row was a stopped stack's automatic assignment, that stack receives a new automatic port on its next start. An unrelated process may occupy a saved port while its socket is closed; the next activation reports a conflict and preserves the assignment, never silently relocating it. A destroyed and recreated instance has a new identity and receives a new dedicated allocation.

Standalone and composed instances use the same allocator. Two shadow databases have separate IDs, data and dedicated listeners. Composition membership does not change listener ownership. The runtime reports the backend address; the proxy updates its target without altering the public assignment.

The endpoint renderer produces host-facing or stack-runtime-facing connection values for service.credentials({ from: "host" | "runtime" }) and for composition wiring. This is ordinary networking configuration, not a dependency-aware command API. In container mode, both the rendered address and configured listener reachability must work from that network; report unsupported network configurations before executing the dependent/command.

Owner Responsibility
Service definition Declare endpoint names and workload behavior
Composition Map shared HTTP routes to individual instances
Allocator Assign and persist listener addresses and claims
Runtime Report the launched workload's backend address
Networking/proxy Render reachable connection values, bind listeners, route traffic and attribute activity to instances

Mutable files live under the stack namespace; container resources carry equivalent identity labels. Each managed container is addressed by its unique preassigned launch name for its whole owned lifetime; renaming a managed container is outside the lifecycle contract. Shared immutable artifact caches and host-wide port coordination are justified exceptions. User-requested exports can live at their chosen destination.

The owner serializes updates to saved instance definitions, composition wiring and resource assignments. Lifecycle, health, active operations, runtime handles and errors remain in the live instance observation. There is no durable lifecycle/operation journal, projected capability state or duplicate stack lifecycle state.

Persistence supports reopening normally stopped instances, not reconstructing interrupted execution after owner loss. Do not infer current runtime state from saved configuration. When the host is absent or unreachable, expose the saved definitions and ports separately from unavailable live observations. Container cleanup after owner loss uses live daemon labels, not persisted process state; service recovery, resource adoption, interrupted-operation replay and private-format migration machinery are outside scope.

Durable stack layout

The state root is the stack registry root. Each stack keeps one state document and its owned runtime data together:

<stateRoot>/<stack-id>/state.json
<stateRoot>/<stack-id>/data/<instance-id>/...
<stateRoot>/<stack-id>/owner.lock    lease; opened only by SQLite
<stateRoot>/<stack-id>/owner.json    endpoint of the lease holder
<stateRoot>/<stack-id>/owner.log     owner stdout and stderr, truncated at each owner start
<stateRoot>/<stack-id>/logs/<service>/<instance-id>/<generation>.log   persisted service output

Persisted service logs

The owner is the only subscriber of each instance's output and persists it as records:

  • Format: one record per line, ended by \n, \r\n or a lone \r so carriage-return progress updates become separate records: <ISO time> <stdout|stderr|launch|lost> <launch id>[ truncated] | <text>. Records carry the publish time of their first byte; lines are cut at 32 KiB.
  • Segments: immutable and generation-named. Each owner start opens a new generation on the instance's first output, and a record that would take a segment past 5 MiB starts the next one.
  • Retention: the oldest closed segments except the newest are deleted while an instance holds more than 10 MiB or 64 segments. A failed deletion is retried at the next rotation, with one warning until a pass succeeds.
  • Launch ids: keep increasing across owner starts: an instance continues after the launch id at the end of its newest segment.
  • Lines: state is kept per launch, process and stream. A late partial line of an ended launch waits for its newline for two seconds of quiet, or until the store closes.
  • Loss: chunk sequence numbers span a launch's streams and processes, so any dropped chunk shows as lost, without a stream since the dropped chunks' streams are unknown, and the owner log warns once per launch that dropped output. Chunks the in-memory output buffer dropped, chunks still queued when a bounded drain at close ran out, and records that failed to write are recorded as lost; a write that fails part-way can count records that did land, so the count is an upper bound. Closing aborts filesystem work still pending after 5 seconds; output not yet written by then is not recorded.
  • Readers: every reader, live or offline, reads segments by position, so history and following share one path. A follow can resume at a record position, and a reader that finds its segment deleted reports a lost gap marker, which carries resumeAt instead of a record position.
  • Deletion: destroying an instance deletes its segments, and an owner start removes directories of instances no longer saved, so a failed deletion is retried. Destroying the stack removes logs/; resetting database data keeps them.

Gateway access logs

The shared API listener is the stack's gateway. Each completed request, and each WebSocket upgrade once its status line arrives, becomes one nginx combined line with a millisecond timestamp and a trailing duration. In the target and the Referer, credential query and fragment values (API keys, tokens, token hashes, PKCE codes) are redacted by name on every route, as are values under any name shaped like a secret key or a JWT, values that nest one or a URL with userinfo, and URL userinfo. The proxy hands each line to a bounded sliding buffer after the response settles, so logging never delays a response or holds a target's activity. The owner persists these lines as the gateway stream, logs/gateway/gateway/, with one launch per owner run numbered after the newest retained one; it is not a service instance, so orphan cleanup keeps it.

Shipping logs to Analytics

While the composed Analytics instance is running and healthy, the owner ships the persisted stdout/stderr records of the Auth, REST, Realtime, Storage, Functions and database instances of the composition, and of the gateway stream as Studio's API Gateway source, to its direct backend, never the proxy, so shipping neither wakes it nor counts as activity; standalone instances such as shadow databases are not shipped. The owner logs each target change, and the target is re-selected when the composition, Analytics' health or its launch changes.

  • Bodies: at most 256 events and 1 MiB. Bodies of an instance are sequential, and event ids derive from the instance and record position.
  • Cursor: each instance reads from cursor.json in its logs directory: the position of its last confirmed record plus an optional pending body (its end position, source, event ids, the Analytics launch its latest post went to, and when that post ends at the latest). A missing or unreadable cursor starts at the oldest retained segment, and a cursor in a deleted segment resumes at the next retained one. A failed log read restarts the instance from its cursor after a capped backoff.
  • Why delivery is confirmed: Logflare answers a post once it queued the events and stores them later in per-source batches; a batch holding an id already stored is dropped whole.
  • Stored-id check: the owner reads which ids _analytics.log_events_<token> holds, resolving the token from _analytics.sources, in the database Logflare keeps its sources and events in: _supabase on the host and port of Analytics' database URL (reached at the bound database's endpoint when the composition binds it), with supabase_admin/postgres for credentials the URL omits. A source Analytics does not know, or a failed query, leaves the stored state unknown: the check is retried and shipping waits, with one warning per outage. A known source without a table holds no events. The check depends on Logflare's private table layout, which the owner's Analytics integration test pins.
  • Posting: the pending body is written atomically before each post, and a failed write blocks posting. Only ids not yet stored are posted. A post ends by its 5 second timeout, which the pending body records. The owner polls the stored ids until all are stored, then advances the cursor and clears the pending body.
  • Reposting: a post that succeeded, timed out or broke after it was sent is not repeated to the Analytics launch that took it while that launch may still store it. Ids still missing are posted again once the launch can no longer store them: 5 seconds after the post ended for a launch of this owner, which starts a launch only after the previous one exited, and 60 seconds after it for another owner's launch, whose process may still be draining. Any other HTTP status and a request that was never sent are posted again 5 seconds later.
  • Lost batches: ids the serving launch leaves unstored for 60 seconds of serving are posted again in halves until each unstored one was posted alone. A lone unstored id is skipped once a later post of its source to the launch was stored, which shows Analytics stores that source; otherwise it stays pending and is posted alone again, so an Analytics that stores nothing loses nothing. Each round warns once.
  • Statuses: a 401, 403 or 404 response clears the pending body and pauses the instance with its cursor until the target changes; after any other 4xx except 408 and 429 the body is posted again in halves, and only events still rejected on their own are skipped.
  • Sanitizing: event text and metadata strings have NUL and unpaired surrogates replaced with U+FFFD before posting, because Postgres jsonb rejects them and drops the whole batch.
  • Recovery and retargets: a start, restart or retarget reconciles a pending body before later records; pending records already deleted by retention are skipped with a warning. A retarget waits for a post in flight, and a retired target's stored-event view stays open until its sessions end.

Registry lock

Registry updates use an OS-backed lock through a private node:sqlite connection to <stateRoot>/.registry-lock.sqlite. Each withLock call opens its own connection, disables SQLite busy waiting, and retries BEGIN IMMEDIATE contention for up to five seconds through Effect. Closing the scoped connection releases the lock, including on cancellation or failure; process death also releases ownership. The file stays in place and is accessed only through SQLite. No tables, state records, or WAL are created there. Saved stack data remains in JSON; the lock does not make multi-file operations transactional or recover interrupted operations. This uses the built-in SQLite API available in the pinned Bun runtime and modern Node.js. Every lock connection in a process goes through that one SQLite library, which tracks locks per file across connections, so a second connection in the same process is excluded exactly as another process would be, and closing one connection never releases a lock another still holds.

Stop and destroy

The artifact cache is independent and shared across stacks. Normal stop preserves the stack directory and service data. Destroy removes the state document, owner files and proven-owned, empty parents; the lease file goes last, while its lock is still held; caller-owned paths such as Storage uploads remain untouched.

Snapshots belong to the database instance

Snapshots are initially supported only for database, including the shadow-baseline cache use case. Start with that concrete case.

Expose saveSnapshot and restoreSnapshot on DatabaseInstance only. The common service handle retains lifecycle, health and logs; REST and other instance types do not expose unsupported snapshot methods. Callers identify managed snapshots with opaque keys. No generic snapshot provider registry, mandatory storage interface or whole-composition snapshot is needed until a second concrete use case requires one.

interface DatabaseInstance extends ServiceInstance {
  readonly service: "database";
  saveSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise<void>;
  restoreSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise<boolean>;
}

// `baseline` has already been initialized; `shadow` is a fresh instance.
await baseline.stop();
await baseline.saveSnapshot("baseline");

if (!(await shadow.restoreSnapshot("baseline"))) {
  // A miss leaves the empty instance in place; initialize that same instance.
}
await shadow.start();
await shadow.ready();

The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below cacheRoot; Docker entries share the data volume, in a separate namespace derived from cacheRoot. Docker cache reuse requires the same daemon, stateRoot, and cacheRoot. When the SUPABASE_STACK_TEST_RUN environment variable is set, created volumes and containers also carry a com.supabase.stack-test-run label so the test run that created them can remove them. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents.

Keep the contract narrow:

  • Save requires confirmed stopped, initialized data. The caller explicitly stops first; snapshotting does not secretly stop dependents or restart services. A save publishes only a complete entry and replaces the previous entry for the same key.
  • Restore requires a confirmed stopped instance with empty data. Validate format, artifact/runtime compatibility and initialization profile before installing restored data. A missing key returns false; a compatible published entry returns true; reject a nonempty target rather than overwriting it.
  • Both operations hold the instance's storage reservation in the lifecycle reducer and leave it stopped. While the reservation is held, a start keeps the instance stopped until it is released and another storage operation is rejected at once. A destroy waits for the storage operation to settle, then takes the reservation; once a destroy is pending, new storage operations are refused, so it cannot be starved. No new lifecycle states are necessary; the observable current operation identifies snapshot work. An armed wake route is not a substitute for explicit stop.
  • Native snapshots copy or clone the host data; container snapshots copy database data through a managed volume and helper. Docker data normally lives in a managed volume, while existing host data can be retained through the host-backed fallback; Podman always uses host-backed data. The host storage marker detects a missing or mismatched Docker volume; deleting that volume loses its database data.
  • Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Cache snapshots survive destruction of the source instance because their managed storage is separate; instance snapshots restore only into their own instance.

These are physical database snapshots for the cache use case. A pg_dump invocation remains an ordinary client command for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy.

Resetting database data

DatabaseInstance.resetData removes the selected database instance's owned data and initialization metadata while retaining its registration, configuration, composition bindings, and public port assignments. It is database-specific, alongside snapshot save and restore; other service types do not expose a reset operation.

The caller must stop the database with wake disabled first. Reset holds the same storage reservation as snapshots and leaves the database stopped. Deletion uses the database's ownership checks and runtime-specific filesystem handling. The next normal start initializes a fresh PostgreSQL cluster using the retained configuration. Reset does not apply project migrations or seeds, stop other services, or resume the composition; those decisions belong to the CLI. It adds no lifecycle state or persisted recovery phase.

9. What changes for the CLI

Make lifecycle and health explicit in status instead of forcing them into the old single phase enum. Update repo consumers together; do not add a historical compatibility projection. A presentation label can be derived as follows:

Runtime observation Display/consumer meaning
Stopped, wake disabled, no exit error Stopped
Stopped with armed wake route Sleeping
Launch transition executing Starting process
Running, health starting Running; waiting for readiness
Running, healthy Ready
Running, unhealthy Unhealthy; process still running
Stopping Stopping
Process exited Exit result is visible while ordinary cleanup settles, then stopped; report unexpected exit as failure
Stop cleanup failed Runtime cleanup remains stopping with current process/exit observation and error; stop/restart retries cleanup

Functions serve observes lifecycle and health separately and reports an unhealthy or stopped runtime. DB/storage readiness checks require running and healthy. The current operation remains observable in memory. destroy() issued during a snapshot waits for it to settle, then removes the data. Do not persist it for crash recovery. Health observations are attached to the current launch; they cannot make a replacement healthy or failed.

The public surface stays recognizable: create/open/discover, services create/get/list, lifecycle, credentials, status/logs and preparation. Promise stays an outer adapter over Effect. The important API clarification is launch versus readiness.

Use one explicit contract: start completes launch; ready waits for the current launch's readiness. For an individual instance, restart is serialized stop plus launch, followed by a separate readiness wait where the consumer needs it. Composition orders these ordinary instance operations without a second lifecycle state machine. Default composition start composes the two for its eager members and their dependencies. No readiness wait holds the lifecycle gate.

Update actual consumers together:

Consumer Required sequence
DB start and shadows Launch → await ready → SQL/migrations
Functions serve Launch/restart → await ready → follow logs and status; inspector flags are unsupported
Proxy wake Launch if sleeping → await ready → forward
DB reset/cache Stop selected dependents → stopped snapshot/restore or DB config work → launch/ready → resume dependents
Commands Select identity/artifact → execute/stream → await exit and cleanup

A Functions runtime exit must reach followStatus so serve can report it.

Established CLI integration boundaries

The Stack package remains the owner of identity semantics. It canonicalizes projectRoot, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in Identity.ts; it also owns deriveStackId from that complete tuple and the StackId format. The CLI selects a stack with the public, read-only find, by project root and stack name or by ID; find derives the ID, reads only that state document, and reports the live owner. It never creates a stack, and an unreadable document fails selection rather than reading as absent. discover remains the listing operation for stack list and stack stop --all.

Composition policy stays in the package. composition.supabase owns the managed bindings, the inputs derived from the API endpoint and the stack credentials, and the activation policy; the CLI passes --eager as the eager preference. Creation schemas make every bound or injected input optional, and a launch without a required input fails with a typed ServiceError naming it. Before recomposing a stopped stack, the CLI calls composition.plan, which compares requested creations with the saved instances while ignoring package-managed inputs and normalising the shared API port as composition does. The CLI rejects incompatible members, recomposes changed members with their new configuration under the same identities, and reuses stopped standalone instances that are not incompatible. stack status reports the same plan as configuration drift. Required inputs are checked after dependency inputs are merged and before a start or restart changes lifecycle, so a restart without one leaves the instance running.

The Functions recipe publishes a default Edge Runtime main service built from serve.main.ts. generate-functions-bootstrap.ts bundles it, with its dependencies inlined for offline use, into the committed module serve-main-bundle.ts; pnpm generate refreshes it and a unit test fails when it drifts from the sources. A creation may override it with bootstrap; the default is not saved in the stack document. stack start and the stack-backed functions serve command use the default.

The CLI owns the foreground functions serve session. It attaches to an existing composition member and leaves it available on exit. Supported explicit overrides replace its configuration for the session, then restore it on normal cleanup. If Functions is excluded, the CLI creates and later destroys one standalone instance without changing composition. The package needs no session or recovery API: ordinary create, start, restart, status, logs, and destroy suffice. Functions accepts custom environment values and a database URL; its recipe derives default keys, while the composer supplies the runtime database URL without a dependency edge. Studio receives the stable Functions proxy URL as configuration without making Functions its lifecycle prerequisite. See the command lifecycle for supported flags and cleanup limits.

Artifact knowledge remains in Stack and is exposed to the CLI through the single internal artifacts entrypoint, including the service catalog, PostgreSQL version resolution, and native artifact preparation and verification. A remote db dump --db-url can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by bundled-postgres-client.ts, while managed jobs continue to use stack.commands.run.

Changing internal and public-to-repository contracts is acceptable when callers are updated; preserving valuable data is still required. Keep per-instance configuration replacement through service.restart({ config }). Validate the candidate configuration and prepare its artifacts before stopping the existing runtime; invalid input must leave it running. The admitted restart then performs ordinary stop and launch without waiting for application health inside the gate. Adding or removing a companion means explicitly adding or removing an ordinary instance and updating composition edges. Validate the graph using the same rules as registration; do not implement private-child expansion or group replacement logic. Defer live shared configuration changes and config-bearing whole-stack restart.

10. What we can delete or consolidate

Current complexity Replacement
Start-to-stop ownership handoffs and supersession Serial transitions; health wait outside transition
Lifecycle decisions spread across phase maps, batch claims, traffic and journals One instance record and transition executor; one short graph admission boundary
Capabilities and hidden service groups Remove; each service has an instance ID and its own lifecycle/health
Global runtime specifications reading whole stack state Cohesive recipes with narrow inputs
Durable operation journals and crash reconciliation Remove; persist only normal stop/start definitions and resources
Separate capability/stack lifecycle projections Observe instance state directly; composition returns its member observations
Separate whole-stack behavior Selection over the same graph operations
Managed-local CLI command ownership branches Shared stack job execution

Before the package is considered complete or integrated with the final CLI, remove superseded package capability, supervisor, projection and journal implementations together with tests and exports that only served those implementations. The final package has one implementation path and no compatibility facade or parallel lifecycle implementation.

During this replacement, the in-repository CLI may temporarily fail to compile against the new package. Update its consumers in the final integration step before repository-wide acceptance; do not retain old package exports to keep that intermediate state compiling.

Do not replace every existing file with a new abstraction. A recipe is an ordinary module, a graph is ordinary data, a transition is a bounded Effect, and readiness is observation of an owned runtime. No actor framework, event-sourced history, generic workflow engine, or automatic recovery scheduler is needed.

11. Acceptance scenarios

Verify through consumed integration flows:

  1. Studio and pg-meta appear as individually addressable instances with separate lifecycle/health; no capability or hidden group handle exists. REST accepts the same configuration shape for a managed database URL or an external URL; only composition carries a managed readiness edge. Launch finishes before health; stop during health waiting uses ordinary stop. Stop during actual launch waits for launch settlement. No lifecycle signal pre-empts a transition.
  2. Dependency readiness gates dependent launch; stopping a prerequisite invalidates an outstanding wait and a stale plan cannot restart it. Composition restart stops dependents first, then starts prerequisites first. Racing an individual start with composition stop yields a safe partial failure rather than bypassing a dependency check or holding a batch reservation.
  3. Register two standalone databases, then start/restart the default composition: neither standalone instance is implicitly started or restarted. Namespace stop/destroy still includes both. Two stacks and multiple databases have separate ports/data; a shadow operation does not block Functions restart. Record every public endpoint, stop the stack, start another parallel stack, then reopen/start the original: every original public port is unchanged. Occupying one saved port with an unrelated listener instead produces a conflict without rewriting its assignment.
  4. Start a composition containing lazy database and REST instances: startup returns concrete URLs with their proxy listeners bound and their backend processes still absent. Connecting to the returned database/REST URLs launches the appropriate instances without changing the URLs. No public or RPC sleep operation is exposed. Proxy inactivity can trigger internal sleep; public traffic prevents it; sleep retains wake; explicit stop disables wake; prerequisite idle eligibility is rechecked after dependent sleep.
  5. Unexpected exit disables wake and triggers ordinary cleanup of the owned runtime before another launch; Functions can explicitly restart afterward. Traffic cannot restart an exited prerequisite. A stale exit cannot damage a replacement.
  6. Normal snapshot failures do not publish partial entries or overwrite existing data. Client disconnection leaves admitted snapshot work owned by the live host. Host-crash recovery is not an acceptance requirement; unavailable observations must not be presented as current running/healthy/stopped state.
  7. Container-mode service wiring and dumps receive a reachable runtime-facing database URL as plain data. Dumps stream with bounded buffering and exact cleanup; stackless commands create no managed stack. Stopping/destroying REST leaves Auth on the shared API port working; shared port claims survive removal of the final route.
  8. Namespace stop settles executing work and leaves no live runtime resources; failed cleanup cannot report successful stop. Disconnecting a client does not stop admitted lifecycle/snapshot operations. Stopping the last individual instance or finishing the last command does not retire the StackHost; namespace stop/destroy does.