Launchfile Provider Contract

Status: draft for review. SPEC.md defines the file; this document defines what a provider does with it — the runtime counterpart. It consolidates ratified decisions D-37 (execution mode vs. environment), D-38 (install/dev + source), D-39 ($storage.*), and D-40 (the app/provider build line). Parts marked 📐 are not-yet-ratified elaborations — the cross-invocation state/event model (a design note) and --deps-only. Parts marked ✅ are implemented in the reference providers today.

A provider translates a Launchfile into a running (or described) deployment on one target — Docker Compose locally, native services on macOS, Terraform for AWS, Kubernetes, etc. The format captures intent; the provider maps intent to execution (P-1, P-5, P-11). This contract is what keeps “same file, every provider” (12-Factor X) honest.

Status legend: ✅ implemented in a reference provider · 🔶 specified, partially implemented · 📐 proposed (pending RFC / design note).


1. Relationship to the other specs

Doc Defines
SPEC.md The file contract — fields, types, expressions.
DESIGN.md Format design decisions (D-*), principles (P-*), limitations (L-*).
PROVIDERS.md The runtime contract — verbs, lifecycle, modes, selection, build line, state.
CLI-ROADMAP.md The reference CLI surface (launch <verb> [target] [flags]).

The provider contract is normative for anyone implementing a provider, whether or not they use the reference @launchfile/sdk.


2. Verbs — the operational surface

A provider exposes a subset of these operations. up/down/status are the minimum for a runtime provider; a translation-only provider (e.g. AWS → Terraform) may implement only translate.

Verb Purpose Status
up Provision resources, prepare, and run the app (or a selected subset). ✅ docker, macos-dev
down Stop and (with --destroy) remove the deployment. ✅ docker, macos-dev
status Report what is running for a deployment. ✅ docker, macos-dev
logs Stream/print component logs. ✅ docker
env Print a component’s resolved environment as export K=V. ✅ macos-dev
list List known deployments. ✅ docker
translate Emit target artifacts without deploying (IaC, manifests). 📐 (AWS probe)

Identity & re-location: a provider keys each deployment by a stable id (slug / directory / content hash) so the CLI can re-find it later (see §8).


3. Lifecycle slots (D-37)

A provider operates on slots, not raw command names. A slot is a lifecycle phase; which command fills it is a mode-resolution detail (§4). Two slots are mode-aware; the rest are invariant.

Slot Purpose Command by mode When
prepare make the app runnable (deps, compile, package) artifact build · source install on change / on demand
release one-off tasks before serving (migrations) release per deploy
run the long-running process artifact start · source dev every launch
bootstrap post-run setup against the running app bootstrap on demand after run
seed / test / … ad-hoc seed / test / custom on demand

Providers SHOULD surface progress on slot boundaries (prepare.start/end, run.healthy, …) — see §8. prepare MUST run on demand / on input change, not on every run.

12-Factor V (build/release/run): prepare generalizes factor V’s build (the source command is an install, not a build). prepare is the slot; build is the artifact-mode command that fills it.


4. Execution modes (D-37, D-38)

A provider runs in exactly one mode per launch — artifact (built image/platform build) or source (run from the working tree). Mode is requested globally and resolved per component.

Run resolution, source mode, per component:

  1. dev present → run dev from source (in the component’s source: dir).
  2. else image present → run as artifact (the image is the only runnable form).
  3. else → run start from source (prepare’s build produces any output start needs).

Prepare (for a component resolved to source): install if present, else build.

Field partition — exactly one of the first two sets is active per component per launch:

Set Members Active in
Artifact image, build, start artifact mode
Source source, install, dev source mode
Invariant provides, requires, depends_on, health, storage, env, release, bootstrap, seed, test always

Resources (requires) have no source form — provisioned identically in both modes. A cloud/artifact-only provider MUST ignore source-mode fields.


5. Component selection & --deps-only

up/down/status/dev accept an optional component selector (verb argument, not a file field — D-37).

  • Selecting a component starts it plus its transitive downward dependency closure — its depends_on target components and every closure member’s requires backing services (D-41). Selecting nothing acts on all components. ✅ (docker, macos-dev — selectComponents() / selectionClosure() in the SDK)
  • The closure is downward only: up backend never starts frontend (a reverse-dependency) or unrelated components, and already-running dependencies are left untouched (idempotent). depends_on is honored as a hard prerequisite (D-16), so a selected component’s depends_on targets come along — they are not left down for the operator to satisfy. A future --no-deps opt-out starts only the directly-named components.
  • --deps-only[=requires|supports] 📐 — provision the resource closure of the selected (or all) components and start no component. It never traverses depends_on. requires = mandatory; supports = optional (L-6, orchestrator-activated). A backing service modeled as a component (not a requires) is not picked up — select it explicitly.

6. Build: portable contract vs. provider specialization (D-40)

  • Portable contract (every provider MUST be able to build from): runtime + commands (build/install/start/dev/…).
  • Provider specialization (fenced): in-repo recipes a provider discovers — build.dockerfile/target/args (OCI family), nixpacks.toml, Procfile, etc. Rules: discovered, not enumerated; never the sole build path (a provider that understands none of an app’s specializations MUST still build it from the contract); ignored safely (unknown recipe → fall back to contract, never error). build.dockerfile/target/args are reclassified as OCI-family hints — never removed. No general x-<provider>: block is admitted.

A specialization makes a matching provider more faithful; it never makes the app deployable only on that provider.

Reduced-portability diagnostic (D-40): a validate-only, non-fatal, suppressible warning that fires when an app’s only build path is a provider-specific recipe (a Dockerfile) or a prebuilt image: with no portable runtime/commands contract. It is never emitted by operational commands (up/down/logs/…), so the image-first catalog is unaffected in normal flows — only an explicit validate surfaces it. A second case (D-43): a source-needing app with no reachable origin — no image:, evaluated detached, and no repository: to fall back to. Same treatment: validate-only, non-fatal, suppressible.

Source acquisition (D-43)

Any build or source-mode operation needs the app’s source tree. Every provider resolves the tree through this precedence — a local provider trivially lands on rule 2, the checkout the Launchfile sits in:

  1. Orchestrator-supplied source. A source the orchestrator hands the provider — a repo URL + ref, a tarball, or a local tree — MUST take precedence over everything below. This is the home-#2 channel (D-36): forks, private mirrors, and per-environment ref choices (main to staging, a tag to prod) all arrive here, never in the file.
  2. Attached context. A Launchfile is attached iff it was read from within the app’s own source tree — the checkout of the app it describes. That tree is the source; a remote provider MAY package and ship it as its build context. A file fetched standalone (a catalog entry, a URL) into a cache or temporary directory is detached, even though it momentarily sits in some directory.
  3. Detached fallback — repository:. For a detached file, the repository field (SPEC.md § Repository) is the canonical origin the provider MAY fetch, at the ref named by its # fragment (default: the repository’s default branch). Fetching and building a remote origin falls under the per-source confirmation rules (SPEC.md § Execution modes and source trust).

The in-file origin — URL and fragment alike — is a baseline default, never a lock: rule 1 always wins when the orchestrator supplies a source. A provider MUST NOT treat the fragment as pinning a deployment to that ref.

When no step resolves a source — detached, nothing supplied, no repository: — the operation fails with a clear error naming the missing origin; the D-43 validate diagnostic exists to surface exactly this case ahead of time.

A translation-only provider (§2) cannot ship a tree or perform a fetch; it MUST instead record the origin it would acquire (the resolved URL + ref, or “orchestrator-supplied” / “attached tree”) in its emitted artifacts or conformance report, so the acquisition step is explicit rather than silently out of band.


7. Expression resolution — provider-supplied values ✅

The file declares intent; the provider supplies values (P-11). A provider MUST resolve the $-expressions in env values and set_env values into concrete strings before the app sees them — a contract ratified and implemented today (D-33/D-35/D-36/D-39), covering every namespace in the table below. Whether that obligation also extends to command strings is contested and unresolved. This section previously asserted that it does; SPEC.md § Command interpretation and §10 rule 11 below hand the command string to a POSIX shell, which owns $ too. Neither passage has been withdrawn, and this section takes neither as the rule until #205 settles it — see Command strings below before writing one.

The three homes (D-36). Every value in a Launchfile has exactly one home. The provider owns home #3 — values it computes from its own routing, storage, provisioning, and PATH strategy. The app names the need; the provider resolves the value, the same expression yielding a different concrete string per provider. (Home #1 is the app’s command/intent; home #2 is per-environment config the orchestrator supplies — neither is the provider’s to invent.)

Resolution order. Reserved namespaces are matched before any user-named resource, so a resource or volume named app/storage cannot shadow them. An unknown reserved key resolves to the empty string (L-4), so a provider that doesn’t supply a given value degrades gracefully (P-13) rather than erroring.

Expression Home-#3 value the provider supplies Source
$app.*url, host, port, name, authority, scheme, tls the app’s own public address, computed from the provider’s routing strategy D-33, D-35
$secrets.<name> an app-wide generated secret D-18
$components.<name>.* a sibling component’s endpoint, resolved by consumer vantage (§8)
$storage.<name>.path the filesystem path the provider provisioned for the named volume D-39
$<resource>.<prop> / enclosing $url, $host, … a provisioned resource’s connection properties D-7

Command strings — unsettled, and divergent in practice. Whether a $-expression inside a commands.* string is a Launchfile reference at all is not decided. Two ratified passages point opposite ways: SPEC.md § Expression Syntax scopes the $ system to set_env values and env defaults, while SPEC.md § Command interpretation and §10 rule 11 below hand the command string to a POSIX shell — which owns $ too, and for which $VAR and ${VAR:-default} are its own syntax. Nothing resolves the overlap, so the reference providers each answer it differently:

Slot @launchfile/docker @launchfile/macos-dev @launchfile/aws
bootstrap resolves $app.* and $secrets.* only resolves $app.*, $secrets.*, $components.*, resource properties not implemented
release resolves $app.* and $secrets.* only no resolution — raw string to the shell no resolution — raw into cloud-init
build / install not implemented (source builds in the image build) no resolution — raw string to the shell no resolution — raw into cloud-init
dev not implemented (source-mode run slot D-38 lets an artifact-executing provider ignore) no resolution — raw to the process manager not implemented (recorded as an explicit D-38 source-mode ignore)
start no resolution — raw into the compose command: no resolution — raw to the process manager no resolution — raw into the systemd unit

$storage.* is populated for no command string, on any slot, on either deploying reference provider. Where resolution does run it covers the whole string, so an unresolvable reference — including a shell variable the resolver does not recognize — becomes "" — the same silent-empty L-4 defines for an unknown property, though L-4 does not sanction it for a namespace the provider never populated — rather than reaching the shell.

What to do meanwhile. Nothing here licenses $ in a command: the same expression resolves on one provider and slot and vanishes on another, which is exactly the P-5 breakage this document exists to prevent. There is also no portable workaround. Routing the value through an env: variable and reading it back as a shell variable fails in both directions. On the slots that resolve (docker bootstrap, docker release, macos-dev bootstrap) resolution covers the whole string and blanks the $VAR too — catalog/apps/paperclip’s bootstrap is destroyed on @launchfile/docker today for precisely this reason. On the slots that do not resolve, the variable arrives only if that provider puts it in that slot’s environment, and not all do: @launchfile/aws publishes env/set_env to SSM Parameter Store and sources nothing in the cloud-init that runs build and release, where an unset $VAR aborts the script under set -euxo pipefail. Until #205 lands, read the table for the provider and slot you actually target, verify the variable actually arrives there, and expect neither $ form to be portable.

Tracked, not promised. Settling this — and reconciling the four passages that disagree — is #205. The table above records the present state as fact, not as a target; it is not a decision, and a new provider should implement to whatever #205 lands, not to the divergence described here.

Storage paths (D-39) — the home-#3 obligation made concrete. The declared storage.<name>.path is the canonical / container path. A provider that provisions a volume MUST resolve $storage.<name>.path to the path it actually used and inject it wherever the app references it, so the path never has to appear in a command:

  • a container provider bind-mounts the volume at the declared path → $storage.<name>.path = that path (e.g. /data/cache);
  • a native provider provisions a host directory → $storage.<name>.path = that directory (e.g. .launchfile/storage/<component>/cache).

The same Launchfile is therefore correct under both, and an author never hardcodes a path only one provider understands — the exact failure D-36/D-39 close. A provider that does not provision storage leaves $storage.* unresolved (→ "").

Why resolution is the provider’s job, not the file’s: a path, URL, or secret that varies by provider is home #3 — if the app embedded it, the file would stop being portable (P-1, P-5). Resolution is the mechanism that keeps “same file, every provider” honest, and is the concrete enforcement point for the D-36 litmus.


8. Deployment state & the event model 📐 (cross-invocation state design note)

Providers persist deployment state so status/env/down work across shells, and so separate invocations can compose one app (launch up backend && launch dev frontend) by sharing the runtime-resolved values (actual ports, generated secrets, captures) that env inheritance cannot carry sibling-to-sibling.

Model: event-sourced state, the file as the shared projection.

runtime → [events] → reduce() → atomic write ─┐
                                              ├─ state file (LAUNCHFILE_STATE)
local watcher ← emit ← diff() ← fs change ────┘

SDK (pure, no I/O) provides the vocabulary and folds — LaunchEvent, DeploymentState, reduce, diff, resolveRef(state, ref, vantage). The provider/orchestrator owns the I/O: atomic write (temp + rename(2), flock(2)), fs.watch, terminal rendering, and deployment-id resolution.

  • Resolution by vantage: resolveRef picks an endpoint’s published vs internal address from the consumer’s vantage (host-native → localhost:3001; in-network → backend:3000). Endpoints therefore carry both.
  • One stream, three surfaces: persistence (the rendezvous file), terminal/UI statuses, and reactivity (depends_on gates, supervisors, dev reload).
  • Deployment id (so && is one deployment, not two): --state <path>/--name <id>LAUNCHFILE_STATE env › implicit app+dir.
  • launch env reads this state and emits resolved, vantage-aware export K=Veval "$(launch env backend)".

Today each reference provider persists its own state shape (see §9); the unified DeploymentState + event model is the proposed standardization.


9. Reference providers — implemented today ✅

@launchfile/docker — artifact / container

  • Verbs: up, down, status, logs, list.
  • up opts: detach, dryRun, yes. Returns: slug, appName, sourceType (local | catalog | url), sourcePath/sourceUrl.
  • Translation: Launchfile → docker-compose.yml (compose-generator); one compose project per deployment, keyed by slug.
  • Ports: host-port allocation, persisted and collision-avoided across deployments (UC3 worktrees get distinct ports).
  • Build: components with build: are built from source inside docker compose build (BuildKit — nothing from the repo runs on the host); image: services are pulled.
  • Flow: build (from source) → release (one-shot docker compose run --rm per declaring component, in depends_on order; compose brings that component’s backing resources up healthy first, and a non-zero exit fails the deploy) → start (compose up) → bootstrap.
  • Sources: local path, catalog slug, remote URL (with a confirmation prompt for remote, bypassable via yes).
  • Storage: resolves $storage.<name>.path to the bind-mounted container path (D-39).
  • State: DockerState per slug (compose project/path, allocated ports, source info) under the provider state dir.
  • Selection: honors the component selector; the post-up summary reports only the started subset.

@launchfile/macos-dev — source / native

  • Verbs: up, down, status, env.
  • up opts: withOptional, noBuild, detach, dryRun, projectDir.
  • Resources (native, via Homebrew services): postgres, mysql, redis, sqlite.
  • Runtimes: bun, node, python, ruby.
  • Prepare-on-change: lockfile-detect decides when to (re)install — prepare is not re-run on every up.
  • Process management: components are spawned detached; pid/pgid/startedAt/command are recorded so down from another shell can signal the whole group, guarded against pid reuse.
  • Also: health checks, secret generation, persistent storage, env writing.
  • env: prints a component’s resolved environment (§7) — the read surface §8 generalizes.
  • Storage: resolves $storage.<name>.path to .launchfile/storage/<component>/<name> on the host (D-39).
  • State: LaunchState at <projectDir>/.launchfile/state.json, keyed by Launchfile content hash; holds resources, secrets, ports, processes.
  • Selection: narrows components to the selected set’s downward depends_on closure (selectionClosure) after the prereq gate, so every phase honors it.

Mode coverage today

The docker provider is effectively artifact-first (it builds/pulls images); macos-dev is source/native-first. The explicit source/artifact mode taxonomy (§4) formalizes what these two already do in practice and is the bridge to a third, non-local provider.


10. Conformance — what a new provider must do

A provider claiming Launchfile support MUST:

  1. Build from the portable contract (runtime + commands) — never require a provider-specific recipe (§6).

  2. Ignore specializations it doesn’t understand and still launch (§6).

  3. Resolve mode per component for whatever modes it supports; ignore the other mode’s fields (§4). (A cloud provider is typically artifact-only.)

  4. Honor the component selector and --deps-only semantics (§5).

  5. Provision requires resources as a precondition of any selected component, and start the selected components’ downward depends_on closure (the declared dependency targets, transitively); never start unrelated components or reverse-dependencies (§5).

  6. Resolve the reserved expression namespaces it supports$app.*, $storage.<name>.path, resource properties, $secrets.*, $components.*; unknown reserved keys resolve to "" (L-4). A provider that provisions storage MUST inject $storage.<name>.path so the path never appears in a command (D-36/D-39, §7).

  7. Persist resolved deployment state and resolve cross-component references by consumer vantage (§8). Providers SHOULD interoperate via the shared state file so invocations compose.

  8. Report gaps, not silent drops — if a field can’t be honored, surface it (the AWS probe’s conformance report is the model). Two fields carry the hard form of this rule:

    • schedule — a provider that does not execute a component’s schedule MUST emit a launch-time warning naming the component and the field (D-51); a translation-only provider records the same gap in its conformance report, which is its equivalent of launch. The warning states what this provider does and MUST NOT assert what the app will do — a component may schedule itself, and a warning that misstates the author’s app is worse than the silence it replaces. Silence is the wrong default here because an unexecuted schedule produces no error and no missing endpoint: the component is started once at launch, which reads as a successful first run, and nothing afterwards distinguishes a scheduled component from an unscheduled one. This is distinct from the unknown-key tolerance (P-6; SPEC.md’s “unknown keys are ignored”): schedule is a specified field, and known-but-unexecuted is a gap to report, not an extension to skip.

    • env.<NAME> with required: true — a provider MUST NOT substitute a fabricated value for an unsupplied required environment variable (D-52). Both terms are narrow:

      • A required variable is unsupplied when none of the file’s own value sources yields a value for it: no generator:, no default: (including an expression default), and no resource set_env: binding that actually injects. The test is arrival, not declaration — a set_env: binding on a supports: resource injects only when that optional resource is provisioned (SPEC.md §Supports), so with the resource absent the variable is unsupplied despite a binding existing for it. A value that does arrive is supplied by the Launchfile and is not a fabrication: generator: secret is the sanctioned way to say “any strong value will do”, and default: is the author having already answered. Providers resolve those sources first; only what survives all of them is unsupplied.
      • Fabricated means a value the provider invented rather than obtained: a constant such as PLACEHOLDER, a guess derived from the variable’s name, a value read from a documentation-only field such as example:, or any other synthesized stand-in.

      required states that the app cannot start without the value (SPEC.md §Environment Variables); inventing one turns an unmet precondition into a launch that appears to have succeeded. That is a stronger violation than the silent drop this rule already forbids — a dropped variable still lets the program’s own presence check fire, while a fabricated one satisfies that check with a lie and defers the failure past every point where it could be diagnosed. The obligation is keyed to the verb (§2), not to the provider’s class, since one provider may expose several. Every verb in §2 falls in exactly one of these four branches:

      • Deploying verbs (up, and any verb that provisions or runs) — for a component it is launching, a provider MUST either obtain the value from its own operator-facing channel (the launching environment, a prompt, a secret store; which one is the provider’s choice, P-11/L-3) or fail, naming the component and the variable. A prompt counts as an operator channel only in an interactive session. A non-interactive invocation MUST take the fail branch — converting a fast, named failure into a job hanging on stdin is a different silent failure, and this rule exists to prevent that class. Component selection bounds which components this reaches (§5, rule 4): an unsupplied variable in a component outside the start-set is not a launch-blocking gap.
      • translate, where there is no launch at which to fail — a provider MUST leave the variable absent from the emitted artifact (absent, not empty: an empty value is a fabrication of the “it’s fine” kind, and rule 6’s "" for unresolvable expressions does not apply here) and list it as unmapped on its conformance report.
      • Verbs that report a component’s resolved environment (env; status where its output includes resolved environment) — a provider MUST report the variable as unsupplied rather than omit it from the output. These verbs neither deploy nor emit an artifact, so neither branch above fits, but env in particular is where an operator goes to find out what an app’s environment resolves to; silently dropping the one variable they are missing is the failure this rule exists to forbid, in the place it does the most damage. Because env output is designed to be eval’d (§2), the report MUST NOT be a bare value line on stdout — a comment or a diagnostic on stderr keeps the output evaluable.
      • down, list, logs, and a status whose output does not include resolved environment — no obligation. Teardown resolves nothing, list enumerates deployments, logs streams process output, and a status that reports only what is running (§2’s definition, and what both reference providers implement) resolves no environment; none of them resolves or emits environment values, so an unsupplied variable is not reachable through them.

      Where the variable is also sensitive: true (D-18) this is a security requirement as well as a correctness one: a fabricated credential is a publicly known constant credential.

      Adoption status: at the time this rule was written, the deploying branch is satisfied by no reference provider — docker fabricates and macos-dev silently omits; aws satisfies the translate branch. A provider author reading this section should treat the rule as normative and the reference implementations as not yet conformant, not the reverse. See D-52 → Conformance at adoption.

  9. Grant or refuse host: capability entries — mount/forward the capability’s coordinate and populate its properties, or refuse the component with a clear surfaced message; never deploy a component whose required capability was silently dropped (§11).

  10. Honor the failure semantics (SPEC.md § Failure semantics) — a provider executing a deploy MUST run a declared release after the component’s required resources are ready and before start, and MUST fail the deploy on its error. A provider that cannot run one-shot commands says so (item 8), never skips release silently. A provider MUST NOT silently substitute a default for an unparseable duration — it surfaces the error with the disposition of the slot or health check the duration belongs to. Numeric timeout defaults for absent durations remain provider-side; a provider MUST document its defaults.

  11. Interpret command strings with a POSIX shell (SPEC.md § Command interpretation) — or report the command unhonored per item 8. A provider MUST NOT split a command on whitespace and execute the first token: any command using &&, a pipe, a redirection or variable expansion is silently mangled, and the failure names a binary the author never wrote.

A translation-only provider (IaC/manifest emitter) satisfies the contract by mapping the fields above to its target and listing what it cannot map — it need not implement up/down. This is a statement about which verbs a provider must offer, not an exemption from the rules governing the verbs it does offer: where a rule above is keyed to a verb (rule 8’s required branches), the verb being invoked governs, and a provider that exposes both translate and up takes each branch in turn.


11. Host capabilities — the grant/refuse fulfillment mode ✅ (D-44)

requires/supports entries marked host: (SPEC Host capabilities) are not provisioned — they are granted or refused. This is a distinct fulfillment mode from provisioning backing services (§10 item 5). A provider MUST do one of:

  • Grant — mount or forward the capability’s underlying coordinate (e.g. bind-mount /var/run/docker.sock, or forward a TCP endpoint) and populate the capability’s properties — $socket, $url, $api for container_runtime — so the entry’s set_env wiring resolves.
  • Refuse — decline to deploy the component, with a clear, surfaced message naming the capability it cannot grant (§10 item 8: report gaps, not silent drops). A refusal is user-visible output, not a line buried in a warnings array.

Required vs optional follows the entry’s home: a requires capability MUST be granted or the component refused; a supports capability MAY be left ungranted — the component still deploys, its capability set_env vars are simply absent, and the provider SHOULD note the un-granted capability so the degradation is visible.

The legacy top-level host: block carries the same semantics (its keys are the same capabilities in block spelling) and MUST be honored equivalently — a file using the new entry form and a file using the legacy block get the same grant/refuse outcome.

Reference refuse behavior — @launchfile/docker. The Docker provider refuses required host capabilities: a component with a required container_runtime (or legacy host.docker: required), network: host, or privileged: true capability is excluded from the generated compose project with a surfaced refused: … message — it declines rather than attempting Docker-in-Docker. Optional (supports) capabilities are left ungranted with a note. A provider that can safely grant (e.g. a VM-per-app provider mounting the runtime socket into an isolated guest) grants and populates the coordinates instead.


This contract consolidates the provider-facing halves of D-37 (modes, slots, selection), D-38 (install/dev/source), and D-40 (build line), plus the cross-invocation state/event design note. DESIGN.md remains the file-format decision log; provider-runtime decisions live here.

esc
Type to search the docs