Skip to content

Config vs deploy: what's checked when

Punix splits everything it does into two phases with a strict boundary between them:

  • Config phase: type-check + evaluate the PCL source. No network. No filesystem writes. No secrets read. No time read. Nothing happens to your machine.
  • Deploy phase: realise the closure, push it to the target, flip the live symlink. Everything that touches the OS happens here.

This split isn't a coincidence of implementation — it's the architectural foundation of every operational guarantee Punix makes. This page is the operator-facing explanation of why the split matters.

What you get from the split

Config errors caught before any side effect

punix check stack.pcl     # type-checks; no I/O on the system

By the time punix check returns, every type error, every undefined reference, every missing required field has been reported with file:line:col and a clear error code. No build has run; no file has been written; no network request has been made. You can run check in CI on every PR with no infrastructure setup at all.

Permuting source gives byte-identical builds

Because the config phase is order-independent and deterministic, reordering modules in your PCL files, renaming internal bindings, or splitting one file into many — yields byte-identical store paths. The conformance suite proves this against a fully- permuted corpus.

Operational consequence: you can refactor PCL safely. There's no "the package rebuilt because we re-ordered the source" failure mode.

Secrets can't end up in the build

A {from_env = "DB_PWD"} reference flows through the config phase as a reference string — the actual env-var value is never visible to the type checker, the IR, or the canonical build hash. The value is resolved only at deploy time, and only where the backend needs it (a systemd Environment= line).

Operational consequence: two deploys of the same stack with different secret values produce byte-identical store paths. The secret value never enters the store, the build cache, or the deploy manifest. It exists on disk only in the rendered unit file on the target machine.

Rollback never re-evaluates

Rollback reads the previous generation's manifest, verifies the pinned paths exist, and flips the live symlink. It doesn't re-run the config phase at all. Even if the source PCL has been deleted, rollback works.

Operational consequence: rollback latency is one syscall. There is no "the rollback was slow because the original deploy was slow." Rollback's cost is independent of the closure size.

Tests run real code, hermetically

Because every effect the deploy phase performs goes through a uniform abstraction (the transport layer), tests of deploy code run the same code path as production — they just point the transport at a sandbox directory instead of at /. No subprocess mocks, no fake filesystems.

Operational consequence: bugs that surface in CI are bugs that would surface in production. There's no class of "the test mocked it but the real call behaved differently."

A picture

            ┌──────────────────────────┐
            │     punix check          │   Config phase:
            │     punix build --dry-run│   pure, deterministic,
            │     punix store path     │   no side effects.
            │     punix why            │
            └────────────┬─────────────┘
                         │  Boundary
            ┌────────────▼─────────────┐
            │     punix build          │   Deploy phase:
            │     punix service deploy │   reads env, writes files,
            │     punix service rollback│  rsyncs to remote, flips
            │     punix store gc       │   the live symlink.
            └──────────────────────────┘

Above the line: anything you can run safely in CI, on your laptop, on a build server. Type-checks and predicts; does not act.

Below the line: anything that changes machine state. Always hermetically containable via --transport-root / --target.

What's deliberately not across the boundary

Some features that seem natural in similar systems are deliberately excluded because they would erase the boundary:

  • Import-from-derivation (running a build to discover what the next build needs). Excluded — it would require running effects during the config phase.
  • Reading filesystem state into config. Excluded — same reason.
  • Time-dependent configuration (e.g. "use the current time as a tag"). Excluded — same reason.
  • Subtyping in PCL. Excluded — it would break the decidability proof that makes punix check provably terminating.

These aren't bureaucratic vetoes. Each one would weaken at least one operational guarantee above. The exclusion is the guarantee.

Reading order

To follow how this boundary cashes out:

  1. Three pillars — the operational properties that the boundary enables.
  2. Content addressing — how config becomes a store path deterministically.
  3. Generations and rollback — how the deploy phase records what it did.
  4. Transport and deploy targets — how the deploy phase is target-agnostic.
  5. Secrets — the canonical example of "this lives below the boundary, and it must."