Skip to content

Invariants

These are the rules every PR has to respect. They're not "best practices" — each is enforced by at least one conformance test, and breaking one means the PR is rejected (or the test stops being green).

The eval/realise seam

The pure layer performs no effects. No subprocess, no open(…, "w"), no network read, no env read, no time read. The frontend, the IR, the evaluator: all pure.

  • Enforced by: structural — every effect goes through Transport, Store, or os.environ, and those imports only appear below the seam.
  • The conformance pin: hermetic tests (LocalTransport(tmp_path)) run real code paths against sandbox bytes. If a pure-layer module ever called subprocess, those tests would have to mock it; the "no subprocess mocks" rule catches it.

The pure layer is decidable. A new feature cannot break the finite-tree-→-structural-recursion property of the type checker.

  • Enforced by: review (no subtyping, no first-class type members, no untyped escape hatches).
  • The conformance pin: punix check is called over a 200-package corpus in the test matrix; if termination breaks, CI hangs.

The atomic-flip primitive (single mutable state-switch)

Every deploy mutates exactly one observable piece of state: the current symlink, via exactly one Transport.atomic_symlink_switch call.

  • Enforced by: review + a conformance test that monkeypatches atomic_symlink_switch to raise and verifies current is unchanged.
  • PR-time rule: if you're adding a new place where current (or any similar live-state pointer) mutates, you're breaking this invariant. The right move is to fold the new operation INTO the existing primitive, or push it pre-flip (into the write-staging phase, before the symlink switch).

Content-addressing must include recipe identity

The canonical-derivation hash includes the recipe identity — that means the recipe's name PLUS a content-hash of its source files (autotools.py, _postinstall.sh, _dep_env.py, etc.).

  • Enforced by: a conformance test that edits a recipe hook body and asserts the dependent packages' store paths change.
  • PR-time rule: if you're adding a new recipe, its source files MUST be hashed into the canonical derivation. The recipes_lib/std/*.py files already are; user-defined recipes must be too.

Secret values never reach the pure layer

SecretRef lowers to IrSecret; IrSecret serialises as a reference string (from_env:NAME), never the value. The pure layer literally has no access to the resolved value.

  • Enforced by: structural — the lowerer maps SecretRefIrSecret, and IrSecret.canonical() emits only the reference string.
  • The conformance pin: two deploys with different env values for the same from_env reference yield byte-identical store paths.
  • PR-time rule: if you're adding a new way for the pure layer to interact with secrets, you're violating this. The right place for the interaction is at the deploy seam, in secrets.resolve_secrets.

Conformance suite is a release gate

A regression in any conformance test blocks the release — not the PR, the release. There is no "we'll fix this in a follow-up"; the fix lands in the SAME PR.

  • Enforced by: CI on every push and PR.
  • PR-time rule: if a conformance test fails, fix the code before asking for review. If the property is genuinely wrong (the requirement is incorrect, not the code), update the test in the same PR and call out the change in the PR description.

"No subprocess mocks" in deploy-layer tests

Tests that exercise the deploy layer use real transports, not mocks of subprocess.run.

  • Enforced by: review — there is no subprocess.run patch in any Transport-layer test. LocalTransport(tmp_path) is the hermetic fixture; localhost_sshd is the SSH fixture (auto- skips when sshd / ssh-keygen aren't on PATH).
  • PR-time rule: if you find yourself reaching for monkeypatch.setattr("subprocess.run", ...) in a deploy test, stop and re-route through Transport. The point of the Protocol is that no test ever needs to.

"Strict host-key checking" stays on, even in tests

SshTransport always passes -o StrictHostKeyChecking=yes. Tests opt into the strict-mode flow by pinning a known-hosts file the fixture controls — not by disabling the check.

  • Enforced by: review + the SSH conformance fixture (tests/c_e2e/conftest.py::localhost_sshd) pre-populates a known_hosts rather than skipping verification.
  • PR-time rule: never default -o StrictHostKeyChecking=no or =accept-new. The fixture's known_hosts is the right pattern for any new SSH-based test.

Closure-push contract is content-addressed

if not transport.exists(p): transport.push(p, p) — that's the entire push primitive. For a content-addressed path, existence IS correctness; no per-file checksum needed.

  • Enforced by: structural — _push_store_closure has no per-file checksum code.
  • PR-time rule: don't add a "verify the remote bytes match" step. That would break content-addressing's "same hash ⇒ same bytes by construction" guarantee. If you think you need to verify, the bug is upstream: a wrong hash got into a gen-NNN.json.

Generations are FIFO-trimmed but current is never collected

Manifest.trim_generations(stack, keep=10) removes oldest first but never removes the currently-live generation.

  • Enforced by: a test that holds current at gen-001, deploys nine more, calls trim, and asserts gen-001 is still on disk.
  • PR-time rule: if you're adding a new generation-removal code path, it must read current_generation_number(stack) first and refuse to remove that gen.