Skip to content

fix: match Docker's null-value policy#60

Merged
lesnik512 merged 3 commits into
mainfrom
fix/null-merge-inconsistency
Jul 14, 2026
Merged

fix: match Docker's null-value policy#60
lesnik512 merged 3 commits into
mainfrom
fix/null-merge-inconsistency

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Closes the last item outstanding from the audit (#52): extends refused an explicit null that the gate accepted, so a document valid on its own became invalid through inheritance. Design: planning/changes/2026-07-14.07-null-value-policy.md.

Breaking, deliberately. A bare environment: / volumes: / healthcheck: / etc now raises. Such a document is not valid Composedocker compose config rejects it — so it only ever worked here, and it silently did nothing.

The problem was arbitrariness, not a missing feature

compose2pod's handling of an explicitly-null service value was an accident of which keys happened to get a hand-written validator. labels: (null) was refused while environment: (null) was accepted — purely because one routes through validate_map and the other had a validator that early-returned on None.

I measured every key against real docker compose config (v5.1.2) rather than reasoning about it. compose2pod diverged on 13 keys, in both directions:

compose2pod Docker
entrypoint refuses accepts
environment, env_file, volumes, tmpfs, healthcheck, depends_on, networks, hostname, container_name, secrets, configs, pull_policy, ulimits accepts refuses

Docker's rule is exact: an explicit null is accepted for command, entrypoint and deploy — where it means "not specified" — and refused everywhere else. That is now one check at the gate, not 13 per-key decisions to keep in sync. Re-measured after the fix: 56/56 keys match Docker.

The merge: null means "not specified" — except where it means "reset"

A null in the extending service inherits the base's value, as Docker does.

Except command and entrypoint, where Docker treats a null as a reset — it erases the inherited value and the image's own default runs. Verified against docker compose config: the child's rendered config has no command key at all.

Critically, deploy tolerates a null and inherits, so the reset set is {command, entrypoint}not the null-allowed set. I got this wrong first time by generalizing from two keys, and it regressed command: the merged service silently ran the base's argv instead of the image default. Review caught it.

The tell is worth recording: the entire suite passed identically with and without that bug, because nothing asserted the merged command's value. The new tests assert at emit level — a reset command must not put the base's argv in the podman run line.

Verification

  • Gate: 56/56 keys match Docker's null verdict.
  • Merge: 11/11 keys match Docker's inherit-vs-erase verdict, checked at emit level, through transitive and diamond extends chains.
  • No over-rejection: absent keys, x- nulls, environment: {KEY: null} (host-passthrough) and labels: {KEY: null} (empty label) all still work — this is about a null key value, not a null map value.

632 tests at 100% coverage; integration green against real podman.

🤖 Generated with Claude Code

compose2pod's treatment of an explicitly-null service value was ad hoc -- an
accident of which keys happened to get a hand-written validator. Measured
against `docker compose config` (v5.1.2) key by key, it diverged on 13, in both
directions:

  entrypoint:                          compose2pod refused, Docker accepts
  environment, env_file, volumes,      compose2pod accepted, Docker refuses
  tmpfs, healthcheck, depends_on,
  networks, hostname, container_name,
  secrets, configs, pull_policy, ulimits

The split had no design behind it: `labels:` (null) was refused while
`environment:` (null) was accepted, purely because one routes through
validate_map and the other had a validator that early-returned on None.

Docker's rule is exact: an explicit null is accepted for command, entrypoint
and deploy -- where it means "not specified" -- and refused everywhere else.
That is now one check at the gate, not 13 per-key decisions.

extends treats a null side as "not specified", so the other side's value
survives: a null in the extending service inherits the base's value (verified
byte-for-byte against docker compose config), a null in the base takes the
child's, and a null on both survives resolution for the gate to refuse. That
restores the invariant in the accepting direction -- a document valid standalone
stays valid through extends.

Breaking: a bare `environment:` / `volumes:` / etc now raises. Such a document
is not valid Compose; it only ever worked here, and it silently did nothing.
The first pass applied "a null side means not specified, so the other side's
value survives" uniformly. Docker splits: a child's null `environment:`/
`volumes:`/`deploy:`/... inherits the base's value, but a child's null
`command:`/`entrypoint:` ERASES it -- the image's own default runs. Verified
against docker compose config: the child's rendered config has no command key.

So the uniform rule regressed `command`, which main had right: the merged
service silently ran the base's argv instead of the image default.

The reset set is {command, entrypoint}, not the null-allowed set -- `deploy`
tolerates a null and inherits.

The suite passed identically with and without the bug, because nothing asserted
the merged command's VALUE. The new tests assert at emit level: a reset command
must not put the base's argv in the podman-run line.
_validate_ulimits raised on a null while _validate_pull_policy returned clean --
two registry keys disagreeing about the same value through the same seam. Null
policy belongs in one place, the gate, so neither shape validator special-cases
it now: a null reaching a validator is a wrong shape like any other. The
emitters stay null-tolerant as defense-in-depth, since run_flags is reachable
without the gate and PULL_POLICY_MAP[None] would be a raw KeyError.

The architecture doc claimed a base-side null "cannot be observed in a document
that passes the gate". False for exactly the three keys that paragraph is about:
the gate allows a null command/entrypoint/deploy, so a base with `command:` and
a child that sets one is valid, reaches the merge, and the child's value wins --
as Docker does.
@lesnik512 lesnik512 merged commit 8ba9033 into main Jul 14, 2026
7 checks passed
@lesnik512 lesnik512 deleted the fix/null-merge-inconsistency branch July 14, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant