Launchfile Provider Contract
Status: draft for review.
SPEC.mddefines 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):
preparegeneralizes factor V’s build (the source command is an install, not a build).prepareis the slot;buildis 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:
devpresent → rundevfrom source (in the component’ssource:dir).- else
imagepresent → run as artifact (the image is the only runnable form). - else → run
startfrom source (prepare’sbuildproduces any outputstartneeds).
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). 🔶 — the reference CLIs implement it on up/dev only; down/status refuse a selector rather than accepting it and acting on every component.
The reference CLIs spell the selector as the flag --components <name>[,<name>…] on up/dev (comma-separated, repeatable), not as a trailing positional: up already takes a target (a path or catalog slug), so a bare name could not be told apart from it. --component (singular) is a different verb’s argument — bootstrap’s single-component limiter — and the two are not interchangeable. The verbs that own a selector refuse the spelling they do not implement rather than ignore it. In the unified launchfile CLI: up/dev refuse --component, bootstrap refuses --components, and down/status refuse both; the remaining verbs (logs, diagnose, list, validate, inspect, schema) still parse either spelling and ignore it. In the macOS provider’s own launch CLI, which has no dev or bootstrap verb: down/status refuse --components, and no verb refuses --component — launch does not parse the singular spelling, so up, down and status ignore it.
- Selecting a component starts it plus its transitive downward dependency closure — its
depends_ontarget components and every closure member’srequiresbacking services (D-41). Selecting nothing acts on all components. ✅ (docker, macos-dev —selectComponents()/selectionClosure()in the SDK) - The closure is downward only:
up --components backendnever startsfrontend(a reverse-dependency) or unrelated components, and already-running dependencies are left untouched (idempotent).depends_onis honored as a hard prerequisite (D-16), so a selected component’sdepends_ontargets come along — they are not left down for the operator to satisfy. A future--no-depsopt-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 traversesdepends_on.requires= mandatory;supports= optional (L-6, orchestrator-activated). A backing service modeled as a component (not arequires) 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/argsare reclassified as OCI-family hints — never removed. No generalx-<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. Both diagnostics are suppressed by the LAUNCHFILE_NO_PORTABILITY_WARNINGS environment variable (the suppressPortabilityWarnings option on @launchfile/sdk’s lintLaunch); validate --detached opts a standalone read into the D-43 case (an ordinary in-tree read stays attached and silent for it). These names are reference-CLI surface, not normative spec.
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:
- 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 (
mainto staging, a tag to prod) all arrive here, never in the file. - 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.
- Detached fallback —
repository:. For a detached file, therepositoryfield (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 #288 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 |
$app.endpoints.<name>.* — url, host, port, scheme, authority, tls |
the public address of the named published endpoint, from the same derivation as $app.*; "" where the provider publishes no per-endpoint address |
D-63 |
$secrets.<name> |
an app-wide generated secret | D-18 |
$components.<name>.* |
a sibling component’s endpoint, resolved by consumer vantage (§8) | — |
$components.<name>.<endpoint>.* — host, port, protocol, url |
the same sibling endpoint identified by its D-6 name, registered for every declared endpoint independent of D-27 publication; url only when the endpoint’s protocol names a URL scheme; protocol/url from the effective listener (D-61) |
D-66 |
$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 |
Publication context ($app.*) under an owning orchestrator. $app.* stays home #3 — the provider computes it from its routing strategy (D-33, D-35, D-36). When the provider runs embedded under an orchestrator that owns routing — a reverse proxy, tunnel, or edge in front of it — the routing strategy has moved upstream, and the provider’s own answer (e.g. http://localhost:<port>) is no longer the app’s public address. In that operation the provider resolves $app.* from the public URL the orchestrator supplies via the provider’s orchestrator-facing channel (@launchfile/docker: ComposeOpts.appUrl; @launchfile/macos-dev: LaunchUpOpts.appUrl), the same way §10 rule 8 has a required: value arrive through the operator-facing channel — and it MUST refuse a malformed value rather than degrade, guess, or fall back to its own routing answer. The supplied URL asserts the public address of the app’s primary endpoint only; a provider MUST NOT derive other published endpoints’ public addresses from it (D-58 rule 4). That fence holds under per-endpoint publication (D-63 rule 5): while a URL is supplied, a provider that publishes per-endpoint addresses resolves $app.endpoints.<name>.* from it for the primary and "" for every other named endpoint; a provider that publishes no per-endpoint address — @launchfile/macos-dev today (#294) — resolves "" for every named endpoint, the primary’s included, even while a URL is supplied, and $app.* alone reads that URL (D-63 rules 2 and 4). Either way the provider warns naming each endpoint that resolves "" and the file references. Unset, the provider’s own routing strategy stands, unchanged, for every endpoint. The same answer reaches the operator: a provider’s operator-facing status text — what up and status print — shows the supplied URL for the primary endpoint when that endpoint’s effective listener is http or https, or when a declared https-origin entry names it (D-60 rule 4 fixes that entry’s url as the https origin for every listener it admits, ws and grpc included). It shows its own routing answer for every other endpoint and whenever no URL is supplied. Outside those two cases a supplied URL is an http/https address that asserts nothing about what a ws, tcp, udp or grpc listener speaks, so such a primary keeps the provider’s own printed form even while $app.url reads the supplied URL (D-58 rule 2). In those two cases the address printed is the address $app.url resolved to on that run. One reference provider has no such channel today. @launchfile/aws composes $app.* from a Terraform load-balancer reference (${aws_lb.main.dns_name}, providers/aws/src/translate.ts:219) that no caller can supply, because the address does not exist until apply time; a publication-context channel there is a separate proposal (#304), not an extension of this one.
Per-endpoint publication ($app.endpoints.<name>.*, D-63). An app that publishes more than one endpoint reaches the non-primary ones by the provides entry’s name:. A provider MUST resolve the six properties through the same derivation it uses for $app.* — one function, called per endpoint with that endpoint’s own host port and effective listener — so the primary’s $app.endpoints.<name>.url and $app.url are one value. A provider that publishes no per-endpoint address resolves every property to "" (L-4), the primary’s included, and says so: at launch, a warning naming each referenced endpoint; on translate, a conformance-report entry. Today: @launchfile/docker resolves each entry from publishedAddress() (providers/docker/src/app-url.ts), the same call $app.* and the status printout make; @launchfile/macos-dev allocates one port per component, not per endpoint, and resolves "" (#294); @launchfile/aws fronts one load-balancer address and resolves "" (#487).
https-origin rides this same channel. A requires/supports entry of type https-origin (SPEC.md Public HTTPS origins) asks for a public origin whose scheme is https. Its supplied-resource channel (D-56, §10 item 5) is the publication-context channel above — not a second one: where the orchestrator already owns the edge, the URL it supplies for $app.* is what satisfies the entry, and the entry’s url property resolves to that same string. A supplied URL whose scheme is not https does not satisfy the entry: under requires: the provider refuses the component, naming the entry and the scheme; under supports: the entry is left unfulfilled. No probe, no network call — the scheme check is syntactic, and D-56 rule 3 stands: the provider does not verify the origin exists or is ready.
at: names under a supplied publication context. A provides entry may declare at: — the names its listener answers at under the app host (SPEC.md Host names under the app host, D-68). An orchestrator that owns routing and supplies the publication URL also owns those names: it, not the provider, creates the DNS records, routes and certificates. A provider MUST NOT infer from a supplied URL that any at: value is set up (D-58 rule 4), and it probes nothing (D-56 rule 3). It launches the component and returns §10 item 8’s at: report for every declared value in the warnings it already returns, stating that the address the names map to is the orchestrator’s. The orchestrator decides what to do with a report, and MAY treat it as fatal. No new channel ships with at:; one through which an orchestrator states which values it set up, so the report is not raised for them, is a separate proposal (#543 is the open issue on that kind of channel).
set_env beats env: while the binding is active. A requires/supports entry’s set_env and a component’s env: can declare the same variable. When the entry is active — provisioned, granted, or orchestrator-satisfied — its set_env value takes precedence and the app receives it; when the entry is inactive the env: value applies unchanged and the binding’s keys are absent (D-8). This binds every binding, not one type: it is the ordering set_env always implied and no document stated, and without it the same file starts in cleartext on one conforming provider and in TLS on another. The concrete case that forced the sentence: gitea declares GITEA__server__PROTOCOL: http in env: and https in an active certificate binding’s set_env — a provider applying env: last boots the app cleartext with CERT_FILE and KEY_FILE set and reports success.
The effective listener. A provides entry’s protocol/port are its declared values; its effective values are what the listener speaks in the configuration this deployment selected (SPEC.md Native TLS, D-61). They are equal unless a bound certificate entry is active, in which case the effective protocol is https and the effective port is the declared port:. A provider resolves every listener-derived URL from the effective values — $components.<name>.url, $components.<name>.<endpoint>.url with its protocol (D-66 rule 3), and $app.url where it computes that from its own direct publication of the listener — and reads the declared values everywhere else. The orchestrator-supplied-inputs rule below still wins: a supplied publication context is the app’s public address, and an effective listener does not override it.
Orchestrator-supplied inputs — one rule across the channels. Several provider channels let the orchestrator supply a value the provider would otherwise compute or provision: source (D-43 rule 1), volume content (D-50), a required: env value (D-52, §10 rule 8), resource satisfaction (D-56, §10 rule 5), and publication context (D-58, above). One rule covers them all: the supplied input takes precedence over the provider’s own computation or provisioning, and the orchestrator owns the correctness of what it supplies through its channel. Each channel’s D-* entry states its own malformed-value posture; none may silently degrade.
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 #288 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 #288; the evaluation that framed the three options is on #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 #288 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:
resolveRefpicks an endpoint’spublishedvsinternaladdress 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_ongates, supervisors, dev reload). - Deployment id (so
&&is one deployment, not two):--state <path>/--name <id>›LAUNCHFILE_STATEenv › implicit app+dir. launch envreads this state and emits resolved, vantage-awareexport K=V—eval "$(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. upopts: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 byslug. - Ports: host-port allocation, persisted and collision-avoided across deployments (UC3 worktrees get distinct ports).
- Build: components with
build:are built from source insidedocker 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 --rmper declaring component, independs_onorder; 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>.pathto the bind-mounted container path (D-39). - State:
DockerStateper slug (compose project/path, allocated ports, source info) under the provider state dir. Checked field by field on load: a malformed key is dropped with a warning naming the state file and the key, every other key is kept, unknown keys survive a load-and-save round trip, and an absent or malformedportsmap loads as empty. The recorded Launchfile hash is recompared onup; a mismatch warns, naming the deployment, and the deploy continues. - Selection: honors the component selector; the post-
upsummary reports only the started subset.
@launchfile/macos-dev — source / native
- Verbs:
up,down,status,env. upopts:withOptional,noBuild,detach,dryRun,projectDir.- Resources (native, via Homebrew services):
postgres,mysql,redis,sqlite. - Runtimes:
bun,node,python,ruby. - Prepare-on-change:
prepareruns on demand, not on everyup— a fingerprint of the prepare command plus the dependency manifests and lockfiles in its working directory, recorded per component inLaunchState.prepared; a first launch or a changed fingerprint re-runs it. - Process management: components are spawned detached;
pid/pgid/startedAt/commandare recorded sodownfrom 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>.pathto.launchfile/storage/<component>/<name>on the host (D-39). - State:
LaunchStateat<projectDir>/.launchfile/state.json, keyed by Launchfile content hash; holdsresources,secrets,ports,processes,generatedEnv,operatorStorage,appUrl,primaryEndpoint,prepared. - Selection: narrows
componentsto the selected set’s downwarddepends_onclosure (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:
-
Build from the portable contract (
runtime+commands) — never require a provider-specific recipe (§6). -
Ignore specializations it doesn’t understand and still launch (§6).
-
Resolve mode per component for whatever modes it supports; ignore the other mode’s fields (§4). (A cloud provider is typically artifact-only.)
-
Honor the component selector and
--deps-onlysemantics (§5). -
Provision
requiresresources as a precondition of any selected component — unless the orchestrator supplies satisfaction for an entry through the provider’s documented supplied-resource channel (D-56), in which case the supplied resource wins and the provider MUST NOT provision its own for that entry. Satisfaction is per entry, keyed by the entry’sname ?? type: where two same-type entries exist and only one is supplied, the provider still provisions for the other. Supplied properties speak the standard resource-property vocabulary (D-7/D-46), and the same channel MAY satisfysupports:entries, which a provider never provisions on its own. The provider does not verify a supplied resource exists or is ready — readiness is the orchestrator’s precondition and the orchestrator owns its channel’s correctness (the D-50/D-52 posture). A provider without such a channel remains conformant by always provisioning. For every type alike, an entry the provider cannot provision and that nothing supplies takes the refuse branch — the selected component is refused before launch with a surfaced message naming the entry, never started without the resource, and ontranslatereported unmapped (item 8) — so a provider’s three outcomes for arequiresentry are provision, accept supplied, or refuse, with no fourth (D-64). When refusal leaves no selected component to launch, the provider says so itself and exits non-zero without invoking its platform, on a dry run as on a real one — never an empty launch handed to the platform to fail, nor an empty plan printed as if it were valid (D-64 rule 3). An entry that declaresuses(SPEC.md Resource uses) is provisioned only when every declared use is covered — each covered use’s properties registered under<use>.<property>in the entry’s map, so$<resource>.<use>.<property>resolves — and is accepted from the supplied channel only when the supplied map carries every registered<use>.<property>key for every declared use (D-56 rule 1); otherwise the entry takes the same refuse branch, naming the entry and the uncovered use. A named occurrence of a repeatable use (- db: cache) is covered per name — its properties registered under<use>.<name>.<property>, one unit per name — and a refusal names the entry, the token and the name. A token the provider does not recognise is uncovered: no provider can claim to cover a use it does not know. Also start the selected components’ downwarddepends_onclosure (the declared dependency targets, transitively); never start unrelated components or reverse-dependencies (§5).https-originis a backing service in this same mode, with one shape worth stating because the resource sits downstream of the app rather than upstream. A provider MUST do one of three things for the entry: provision a public HTTPS origin in front of the named endpoint and resolve the entry’surlto it; accept one supplied through the channel above — which for this type is the publication-context channel (§7, D-58) — when its scheme ishttps; or refuse the component with a clear surfaced message naming the entry, exactly as for apostgresit cannot provision. A supplied URL whose scheme is nothttpsdoes not satisfy the entry and takes the refuse branch. Undersupports:the entry is optional in the usual way: the component deploys, the entry’sset_envis absent, and the provider notes the un-granted dependency. Ontranslatethere is no launch at which to refuse, so the provider reports the entry unmapped on its conformance report (item 8). Nothing here licenses a reachability probe: the check is syntactic and the origin’s existence is the orchestrator’s precondition.
Acertificatebinding (SPEC.md Native TLS, D-61) is asupports:backing service in this same mode, delivered — never provisioned — through the channel above:cert_fileandkey_fileare app-filesystem paths the orchestrator supplies, and D-56 rule 3 stands, so the provider does not verify them. Three branches, and no fourth: not selected, the component deploys its declared HTTP baseline, the binding’sset_envis absent, and the provider notes the un-granted dependency (item 5’ssupports:shape, D-8); selected and satisfied, the provider wires theset_env, publishes the effectivehttpslistener, and resolves listener-derived URLs from the effective values; selected but unsatisfied — either property missing — or selected on a provider that cannot activate native TLS, the provider refuses before launch with a message naming the entry and what is missing, and MUST NOT fall back to HTTP: a cleartext listener that every sibling URL addresses as TLS is the silent success this rule exists to forbid. Ontranslatethere is no launch at which to refuse, so the entry is reported unmapped (item 8).key_fileis credential-bearing and registers with the provider’s redactor before anything is generated (D-56 rule 5), whatever its vocabulary membership.at:on aprovidesentry (SPEC.md Host names under the app host, D-68) is the firstprovidesfield that asks a provider for a resource, wheretls:activates one the app supplies. It does not carry this item’s refuse-or-provision duty, which D-64 scopes to arequiresentry. For each declared value a provider sets up what it can — requests for a matching name reach that entry’s listener with the requested host inHost(a forwarded header does not stand in for it), DNS resolves the name, and where the provider terminates TLS it serves a certificate valid for the name — and reports every value it did not fully set up (item 8). Matching is fixed by the file: an exact label first, otherwise the pattern of that depth.at:changes no$app.*or$app.endpoints.*value (§7). One case refuses. A provider refuses the component before launch, naming the entry and the value, only when it installs or controls an edge that selects a backend by host name and that edge has no route, and no catch-all, that carries the declared name to this listener. The test is operational: if a request for the declared name still cannot reach the listener after the operator maps the name (a hosts file, a DNS record), refuse; otherwise launch and report. Neither@launchfile/dockernor@launchfile/macos-devrefuses forat:today, because each publishes the listener’s port directly with nothing in front that routes by host name, so every request that reaches the port reaches the listener with itsHostintact. A provider that later puts such an edge in front changes those conditions, and this rule then applies to it. -
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>.pathso the path never appears in a command (D-36/D-39, §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.
-
Report gaps, not silent drops — if a field can’t be honored, surface it (the AWS probe’s conformance report is the model). Three fields carry the hard form of this rule:
-
schedule— a provider that does not execute a component’sscheduleMUST 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”):scheduleis a specified field, and known-but-unexecuted is a gap to report, not an extension to skip. -
at:on aprovidesentry — for every declared value a provider did not fully set up (item 5), it MUST launch the component and emit a report at the end of provisioning (D-68 rule 5, in the form D-51 givesschedule). The report names the entry and the names it answers at, says where requests arrive when the provider knows that address — under a supplied publication context it does not, and says the address is the orchestrator’s (§7) — and gives the operator’s options: map the name with a hosts file or DNS, or use a provider that routes host names. It states what this provider did not set up and MUST NOT assert what the operator’s environment or the app will do. A translation-only provider records the entry unmapped in its conformance report. A launch that says nothing is non-conformant: it is the healthy-looking deployment of an app that cannot be used, whichat:exists to remove. -
env.<NAME>withrequired: 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:, nodefault:(including an expression default), and no resourceset_env:binding that actually injects. The test is arrival, not declaration — aset_env:binding on asupports:resource injects only when that optional resource is provisioned or orchestrator-satisfied (SPEC.md §Supports; rule 5, D-56), 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: secretis the sanctioned way to say “any strong value will do”, anddefault: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 asexample:, or any other synthesized stand-in.
requiredstates 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;statuswhere 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, butenvin 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. Becauseenvoutput is designed to beeval’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 astatuswhose output does not include resolved environment — no obligation. Teardown resolves nothing,listenumerates deployments,logsstreams process output, and astatusthat 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: all three reference providers now satisfy their branches. aws satisfies
translate(it emits nothing for the key and records the gap, gradedblockerwhensensitive: true); docker and macos-dev satisfy the deploying branch — each reads the launching environment as its operator channel, then fails naming the component and the variable, before it provisions, writes, or starts anything. macos-dev also satisfies theenvbranch, reporting an unsupplied variable as a#comment so its stdout stays evaluable. This closed with #192, which brought the catalog and the catalog harness along with it. See D-52 → Conformance at adoption. - A required variable is unsupplied when none of the file’s own value sources yields a value for it: no
-
-
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). -
Honor the failure semantics (SPEC.md § Failure semantics) — a provider executing a deploy MUST run a declared
releaseafter the component’s required resources are ready and beforestart, and MUST fail the deploy on its error. A provider that cannot run one-shot commands says so (item 8), never skipsreleasesilently. 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.Adoption status: both deploying reference providers fail
upwhen a component that declareshealth:never becomes healthy — docker through its post-upcontainer gate, macos-dev through a post-start sweep of every declared check (#376); each leaves what started running for inspection, its own call under P-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. -
Mask a
sensitivecapture on every display surface, and reveal it only on the operator’s explicit request (SPEC.md § Command Capture, D-62). A capture whose entry declaressensitive: trueprints masked on the provider’s own stdout exactly as it would in an API or UI — the D-18 obligation applied to the capture. The one act that shows it is a flag on the command the operator invoked (launchfile bootstrap --revealon the reference CLI: boolean, no alias, no terminal heuristic), and masked output MUST end with one line naming that act, so a first-time operator is never left holding a value they cannot obtain. The reveal path exists onbootstrapbecause that is the stage the operator runs for its output (D-34);releasecaptures stay masked with no reveal path. Independently of display, the provider MUST register every sensitive capture value with its redactor at extraction — before any result object, failure record, or log line is built, and also when revealing (the D-56 rule 5 discipline). Revealing changes what the terminal prints, never what the redactor holds: a bootstrap that fails after producing the value MUST NOT carry it raw into the failure recorddiagnoseprints, into a state file, or into a log (CWE-532). -
Preserve a minted value across the provider’s own upgrade, or refuse (D-69) — a
generator: secret/generator: uuidvalue is minted once and then preserved (D-49), and the provider’s own version bump is not an exception. A provider that changes how it mints such a value MUST keep an already-minted value where it can, and where it cannot it MUST refuse the operation — naming the app, the component and the variable, and stating the re-key steps — rather than emit or run something that destroys it. A later output-shape decision (D-47’s 32 bytes hex) governs values minted from the change forward; an already-minted value keeps its shape and the divergence is reported per item 8, not corrected in place. A preserved value stays where the target already holds it and MUST NOT be inlined into a generated artifact the operator commits (D-18).generator: portis exempt, as it is under D-49.
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,$apiforcontainer_runtime— so the entry’sset_envwiring 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.