Skip to content

Known limitations

Every project has this list. Most keep it in a bug tracker no one opens. The v2.0 release contract is to publish it.

Each item names its class:

  • deliberate: ruled out by design. It isn't coming, and the entry says why.
  • deferred: we know how to do it and it's on the roadmap.
  • gap: a real bug we know about and haven't fixed yet.

If you hit something that isn't on this page, it's a bug report we want.

Deploy

scope = "user" is local-only

A stack declaring scope = "user" writes under your home directory as you (units to ~/.config/systemd/user/, activated with systemctl --user, no root). But the home directory is resolved on the machine running punix, so deploying a user-scope stack to a remote host with --target is refused, so your local path never lands on someone else's machine.

  • Class: gap: the resolution needs to move to the target.
  • Impact: "manage my own machine" works; "manage my account on ten machines" does not. Run the deploy on the machine you want configured.
  • Fix: ask the Transport for the target's home, and drop the local resolution.

See manage your dotfiles for what user scope does today.

verify_pinned_paths checks the deploy-side store

punix service rollback STACK --target ssh://... runs the verify step on the local store (Store(store_root)); the remote store is never consulted through Transport. For shared-fs fixtures this passes incidentally. For a real cross-host deploy where the target's store was GC'd independently, the verify misses it.

  • Class: gap: fix is straightforward.
  • Impact: Stage ⅚ demo: yes (low; shared fs). Production: a silent failure mode that surfaces at service start, long after the rollback returned.
  • Fix: route the existence check through Transport.exists. punix verify already does exactly this, so the shape is settled: rollback just has not adopted it. Until it does, punix verify is the command that catches a target-side collection.

Forward roll is "redeploy", not a command

There's no punix service set-current STACK N. To roll FORWARD after a rollback, re-run punix service deploy (which creates a new generation). The historical generations remain on disk.

  • Class: deferred: Stage 8 polish.
  • Impact: "oops, I rolled back to the wrong gen, take me back to gen-002" requires either re-deploying or manually editing the current symlink.
  • Fix: punix service set-current STACK N for any preserved gen.

Cross-arch deploys fail at push, not config time

Deploying an x86_64-built closure to an aarch64 target ⇒ the pushed binaries fail at service-start time with "exec format error". The deploy itself succeeds (the closure pushed cleanly); the runtime fails.

  • Class: deferred: Stage 8.
  • Impact: an x86 dev box can't directly deploy to an aarch64 server without a same-arch build farm (or running punix build on an aarch64 jumpbox first).
  • Fix: build on the target host through the transport seam: Stage 8.

The PCL service vocabulary is systemd-shaped

serviceConfig and timer are [Section] directive passthroughs, so a stack that uses them is written against systemd's vocabulary. The seam itself is systemd-agnostic: a backend is a renderer plus an activator, both dispatched from registries. The fields a user writes are systemd-shaped.

  • Class: deferred: de-leaking it is a real design slice.
  • Impact: a second backend can render and activate, but a stack authored for systemd won't port to it unchanged.
  • Fix: a neutral service vocabulary that each backend lowers, with the passthrough kept as an escape hatch.

Secrets

from_file lacks its own conformance test

The from_file resolver is wired but only from_env has a dedicated conformance test. from_file adds a race-condition surface (Vault-style writers, atomic-replace) that deserves its own property test.

  • Class: deferred: Stage 8.
  • Impact: from_file works in practice; the conformance test doesn't pin it.
  • Fix: add a from_file conformance test. Trivial.

EnvironmentFile is chosen for you, and you can't opt back out

A service whose environment contains a resolved secret is rendered with a 0600 EnvironmentFile= sidecar, never inline Environment= lines, whether or not you asked (B3c / ADR-027). A secret must not land in a world-readable unit. Setting environmentFile = true opts a service in for non-secret values too; there is no way to opt a secret-bearing service out.

  • Class: deliberate.
  • Impact: none we know of. Noted because "where does my secret end up on disk" deserves a straight answer, and because the mode is not yours to choose (the kernel owns file modes).

Secrets in source not yet wired

source = { url = "...", token = { from_env = "FETCH_TOKEN" } }: the parser accepts it; the realiser doesn't use it. Secrets are deploy-time only.

  • Class: deferred: Stage 8 (or later).
  • Impact: can't fetch private-archive sources with a token.
  • Fix: route secrets through the fetch layer at realise time. Requires care: realise is normally idempotent / cacheable, but a fetch authenticated by a secret can't be cached on the secret value itself: that would defeat the hash-exclusion property.

Language

No computed strings (interpolation is one literal, and only over references)

PCL has no ++, no concat(), and no computed strings. It has exactly one interpolating literal, opt-in per string:

option version : Str = "15.1.0"
option url : Str = f"https://github.com/BurntSushi/ripgrep/archive/refs/tags/{version}.tar.gz"
  • Class: deliberate, and narrower than it was. notes/03-specs.md §7 forbade interpolation outright; ADR-034 amended it on 2026-09-01, keeping §7's caution and discarding its prohibition. The stated reason, decidability and termination, had already been overtaken by ADR-021's bounded comprehension: PCL already computes, and keeps computation total by construction. Concatenating a fixed number of strings is total.
  • What is still absent, and stays absent: expressions in the braces. A hole is a reference ({version}, {Mod.field}), and never a call, a conditional or an arithmetic expression. Str interpolates and Int renders decimal; anything else is a located [E1]. Widening this later is easy; narrowing it is not.
  • Why the f prefix: ${…} already appears 98 times in corpus PCL strings, meaning shell. Opt-in means those 372 shell-bearing strings are untouched by construction, and $ carries no meaning to the lexer anywhere. An f-string that also carries a shell ${…} is refused, located, at the ${. There is no f"""…""" either, so a multi-line command cannot interpolate at all (it takes its values through recipeArgs).
  • Where assembly still lives below the seam: punix.ir.runtime.concat is the primitive f"…" lowers to, and exec paths are still composed in Python at the deploy seam (f"{package_path}/bin/{binary}"). url_per_arch remains the realiser-side precedent for a source that varies by system.

(Corrected 2026-08-31, then rewritten 2026-09-01 when ADR-034 landed. It previously read "may lift in a later stage", was corrected to "normative, will not lift", and is now neither: the capability shipped in the form §7's own reasoning permits.)

Pkg.out not exposed as a PCL member

The type checker doesn't know about out as a member of a package module. Stack PCL references packages by module name string (package = "Caddy"); compose_stack resolves the name to the store path at compose time.

  • Class: deliberate.
  • Impact: stack PCL is slightly less self-documenting (you write "Caddy", where Caddy.out would read better).
  • Fix: add out as a typed member of every package module. Requires a frontend type-system change.