Skip to content

Design decisions

Each decision below affects multiple parts of the codebase. Together they're the constitutional layer: change one and you have to update the conformance suite. The decisions are frozen in their respective stages; this page is a single index.

The D# identifiers are stable references for use in commit messages, code comments, and other docs; the reader needs the decision name and its rationale.

Frontend / IR

D1: Default store root

~/.punix/store

User-overridable per command (--store-root). Not in the canonical hash: same artifact hashes identically regardless of where the store lives.

D2: Token-equality drives equality

The frontend uses token-level equality for type structural equality (comparing the canonicalised representation, ignoring variable names). This makes "rename a module" a no-op for the canonical derivation.

D3: No subtyping, no first-class type members

The decidability proof of the inheritance calculus requires both. Adding either is rejected by theorem; review preference doesn't enter into it. A feature that seems to require subtyping should be re-classified per the feature-decision procedure; it's almost always a (b) (provably-host) feature in disguise.

D4: No imports; whole-tree composition

PCL programs compose all *.pcl files in scope into one namespace. No import keyword. Conflicting top-level module names are [E3] (located).

D5: Recipe identity is in the canonical hash

canonical.recipe = { name, source_hash }. The source_hash is the content hash of the recipe's .py + sibling files. Editing recipes_lib/std/autotools.py::_postinstall.sh ⇒ every package using std.autotools gets a new store path ⇒ rebuild on next punix build.

This is the cache-staleness fix the v2 architecture makes structural. The bug it kills: pre-Stage-3 builds cached "across" recipe edits (you edited the recipe but old artifacts still resolved).

Realise

D6: Sandbox is bubblewrap if available, else fall-through

The realise layer's sandbox is BubblewrapSandbox when bwrap is on PATH (Linux); SimpleSandbox (chroot-and-rebind) is the default. The choice is automatic; the recipe contract is identical.

D7: Conformance + corpus is the success bar

A "one-tier 200+ package" pitch is marketing. v2's success bar is core ≈ 20 packages green on both archs + the demos deploy. The community corpus is best-effort and does not gate a release.

Deploy

D8: gen-NNN.json is the rollback contract

Generations carry everything rollback needs:

  • store_paths (the pinned closure),
  • services (with environment stored as refs for hash-exclusion),
  • config_files (path + sha256 + the producing recipe's ref and recipe_code_hash),
  • recipe_registry_hash (the ordered set of recipes that ran),
  • source_hash (the SHA-256 of the PCL bytes at deploy time),
  • backend, deployed_at, generation, stack_name.

Rollback never re-evaluates. O(1) is the property.

D9: Single os.replace of current is the only state-switch

Every deploy mutates exactly one observable piece of state: the current symlink. That mutation is one Transport.atomic_symlink_switch call. Crash at any earlier point ⇒ previous gen wholly live.

Reviewer rule: a PR that adds a second mutable state-switch point (even one) breaks D9. The "kill mid-deploy" conformance test monkeypatches the atomic primitive and asserts current survives. The test catches the violation; the rule above pre-empts it.

D10: --target ssh://user@host[:port] URL scheme

Chosen over --ssh-host / --ssh-port separately. The --key flag is separate (not embedded in the URL) so the same --target value works whether you're using key auth, ssh-agent, or a different key per environment.

D11: Closure-push idempotency via transport.exists(p)

A push is if not transport.exists(p): transport.push(p, p). For a content-addressed store path, existence on the target IS correctness: same hash-name ⇒ same bytes by construction. No per-file checksum needed. See Concepts: transport.

D12: Environment= in unit, default (revisitable revised)

Originally: secrets land in Environment=KEY=VALUE lines inside the 0644 unit, on the grounds that an EnvironmentFile sidecar costs an extra file per stack, with a note to re-evaluate before the v2.0 evidence run.

Revised (B3c / ADR-027). The re-evaluation happened and went the other way. A service whose environment holds a resolved secret now renders as EnvironmentFile=/etc/punix/env/<svc>.env at mode 0600, automatically and with no opt-out: a secret must not sit in a world-readable file, and the extra-file cost is not worth the exposure. environmentFile = true opts a secret-free service in too. Environment= remains the default only where no secret is involved.

D13: Reject \n / " in env values (partly relaxed)

Values containing raw newlines are rejected by the systemd generator with a located message: both the unit and the EnvironmentFile are line-based, so a newline would forge a directive. The operator encodes (base64) at the source. Escaping cleverly, the alternative, has been a bug source in every config system that tried it.

Double-quotes are refused only in an inline Environment=KEY=VALUE line. In an EnvironmentFile they're escaped and round-trip exactly; under revised D12, a secret containing a quote is fine.

D14: Cross-arch deploys fail clearly

When the deploy host's arch doesn't match the target's, the closure has been built for the wrong arch. v0.6 explicitly does NOT solve this: the deploy fails before pushing with an arch-mismatch error. The clean fix is Stage 8's RealiseRemote(transport, system) (build on the target).

When a decision changes

Most are frozen. A handful are revisitable:

  • D12: already revised: EnvironmentFile is now automatic for secret-bearing services. Left in the list as the worked example of a revisitable decision actually being revisited.
  • D6: eventually a third sandbox (Docker-based)?
  • D14: Stage 8 lifts to RealiseRemote.

The rest are foundational. D3 is never lifted: the decidability proof would no longer go through.

How they're enforced

Every decision above has at least one test in the conformance suite that would fail if the decision were silently reversed. The D# identifiers themselves serve as stable references in commit messages and code comments.