compose_file without a leading slash 422s the create — cast validates nothing, and its own docs teach the broken value #49

Closed
opened 2026-07-14 20:54:54 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-14 20:54:54 +00:00 (Migrated from github.com)

What happens

A first apply, planning create application core, got a bare 422 from Coolify. The manifest said:

build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }

Coolify 4.1.2 validates docker_compose_location on create and rejects a path without a leading slash. The correct value is /docker-compose.yaml. By the time the 422 lands, apply has already created the project and the environment — so a pre-flightable, deterministic, manifest-shaped error is being paid for with a half-applied run.

Root cause

cast passes the field through verbatim and validates nothing about its shape:

  • manifest.ts:25 requires compose_file to be present on a dockercompose app, and nothing else. It is a z.string().
  • resolve.ts:361 sends it as docker_compose_location: app.build.compose_file.

Meanwhile cast's own draft writer already knows the right shapedraft.ts:459 defaults the field to "/docker-compose.yaml", leading slash included, because that is what it reads off a live box.

So cast writes the correct value when it generates a manifest, and accepts the incorrect one when it consumes one.

The part that makes this worth a hard validation rather than a note

docs/semantics.md:85 teaches the broken value. The documented example of a compose app is:

build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }

That is the exact string Coolify 422s on create. Anyone hand-writing a manifest from the docs writes a manifest that cannot be applied to a fresh box — and will only find out mid-apply, after the project exists.

Why it went unnoticed for so long

Only a create can surface it. diff happily reports the field, and apply never re-validates an existing application — so the live founder-facing box, hand-built with /docker-compose.yaml, worked for months while the manifest carried a value that had never been sent to a create. The difference between the two even sat on a reviewed drift list, dispositioned as "expected — cosmetic". (Written up as D-265 in heavy-duty/incubator#10; fixed on the manifest side in heavy-duty/incubator#9.)

Suggested

Unlike the multi-destination 400 (#41) and the domain 409 (#44), this one is fully knowable before a single API call is made — it is a property of the manifest, not of the instance. So it should not be a translated error; it should be a refusal at parse time, at zero cost, on every verb:

  1. manifest.ts — refine compose_file to require a leading /, with a message that names the fix:

    core.build.compose_file must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects a create otherwise) — write /docker-compose.yaml, not docker-compose.yaml.

    A refine, not a silent normalization: cast should not quietly rewrite what the manifest says. The manifest is the artifact under review, and a value that would 422 should be fixed in the file, in a commit, once — not repaired in memory on every run.

  2. docs/semantics.md:85 — fix the example. It is the source of the bug.

  3. Worth a look while in there: base_directory is passed through the same way. If Coolify has the same expectation of it, the same refinement applies.

## What happens A first apply, planning `create application core`, got a bare **422** from Coolify. The manifest said: ```yaml build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml } ``` Coolify 4.1.2 validates `docker_compose_location` on create and **rejects a path without a leading slash**. The correct value is `/docker-compose.yaml`. By the time the 422 lands, `apply` has already created the project and the environment — so a pre-flightable, deterministic, *manifest-shaped* error is being paid for with a half-applied run. ## Root cause cast passes the field through verbatim and validates nothing about its shape: - `manifest.ts:25` requires `compose_file` to be **present** on a `dockercompose` app, and nothing else. It is a `z.string()`. - `resolve.ts:361` sends it as `docker_compose_location: app.build.compose_file`. Meanwhile cast's **own draft writer already knows the right shape** — `draft.ts:459` defaults the field to `"/docker-compose.yaml"`, leading slash included, because that is what it reads off a live box. So cast writes the correct value when it generates a manifest, and accepts the incorrect one when it consumes one. ## The part that makes this worth a hard validation rather than a note **`docs/semantics.md:85` teaches the broken value.** The documented example of a compose app is: ```yaml build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml } ``` That is the exact string Coolify 422s on create. Anyone hand-writing a manifest from the docs writes a manifest that cannot be applied to a fresh box — and will only find out mid-apply, after the project exists. ## Why it went unnoticed for so long Only a **create** can surface it. `diff` happily reports the field, and `apply` never re-validates an existing application — so the live founder-facing box, hand-built with `/docker-compose.yaml`, worked for months while the manifest carried a value that had never been sent to a create. The difference between the two even sat on a reviewed drift list, dispositioned as *"expected — cosmetic"*. (Written up as D-265 in [heavy-duty/incubator#10](https://github.com/heavy-duty/incubator/pull/10); fixed on the manifest side in [heavy-duty/incubator#9](https://github.com/heavy-duty/incubator/pull/9).) ## Suggested Unlike the multi-destination 400 (#41) and the domain 409 (#44), **this one is fully knowable before a single API call is made** — it is a property of the manifest, not of the instance. So it should not be a translated error; it should be a refusal at parse time, at zero cost, on every verb: 1. **`manifest.ts`** — refine `compose_file` to require a leading `/`, with a message that names the fix: > `core.build.compose_file` must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects a create otherwise) — write `/docker-compose.yaml`, not `docker-compose.yaml`. A refine, not a silent normalization: cast should not quietly rewrite what the manifest says. The manifest is the artifact under review, and a value that would 422 should be fixed in the file, in a commit, once — not repaired in memory on every run. 2. **`docs/semantics.md:85`** — fix the example. It is the source of the bug. 3. Worth a look while in there: `base_directory` is passed through the same way. If Coolify has the same expectation of it, the same refinement applies.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#49
No description provided.