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
Transportfor 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 verifyalready does exactly this, so the shape is settled: rollback just has not adopted it. Until it does,punix verifyis 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
currentsymlink. - Fix:
punix service set-current STACK Nfor 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 buildon 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_fileworks in practice; the conformance test doesn't pin it. - Fix: add a
from_fileconformance 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.Strinterpolates andIntrenders decimal; anything else is a located[E1]. Widening this later is easy; narrowing it is not. - Why the
fprefix:${…}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 nof"""…"""either, so a multi-line command cannot interpolate at all (it takes its values throughrecipeArgs). - Where assembly still lives below the seam:
punix.ir.runtime.concatis the primitivef"…"lowers to, and exec paths are still composed in Python at the deploy seam (f"{package_path}/bin/{binary}").url_per_archremains 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", whereCaddy.outwould read better). - Fix: add
outas a typed member of every package module. Requires a frontend type-system change.
Related¶
- Roadmap: Stages 7, 8, 9, 10.
- Reference: decisions: design pins for some of these limitations.