Skip to content

Extending Punix

How to add things. Each section starts with a classification: this extension is which of the four feature-decision-procedure categories? If you haven't read the procedure yet, do that first — adding things in the wrong place is the most common architectural mistake.

Adding a backend (e.g. launchd, supervisord, docker-compose)

Classification: below the seam (provably-host). The whole point of a backend is to produce host-specific config files and lifecycle commands.

Steps:

  1. Implement the Transport Protocol (if your backend needs a new transport class). The shape is in src/punix/deploy/transport.py. Ten methods: run, push, read, write, exists, list_dir, remove, home, readlink, atomic_symlink_switch. The failure mode is TransportError.

Most new backends won't need a new transport — LocalTransport or SshTransport already cover "local fs" and "remote fs". You need a new transport class only if the deploy target isn't a filesystem (e.g. a k8s API).

  1. Implement a generator at src/punix/deploy/generators/<backend>.py. The shape is one function:
def render(stack: ServiceStack) -> tuple[ConfigFile, ...]:
    """Turn the composed stack into the set of config-file
    writes the deploy will perform — stack-declared files plus
    any backend-specific files (e.g. systemd units)."""

See src/punix/deploy/generators/systemd.py for the reference implementation.

  1. Register the generator in src/punix/cli.py's _GENERATORS mapping:
_GENERATORS = {"systemd": systemd_gen.render, "launchd": launchd_gen.render}
  1. Optionally implement an activator (the effect side). A backend that only renders config needs nothing more — but to start/restart/enable units it provides an Activator. The Protocol (src/punix/deploy/activate.py) is one method:
class Activator(Protocol):
    def activate(self, units: Sequence[str]) -> tuple[UnitResult, ...]: ...

Register a factory in the ACTIVATORS registry (the effect-side mirror of _GENERATORS):

ACTIVATORS: dict[str, Callable[..., Activator]] = {"systemd": SystemdActivator}

_deploy_one selects the activator by backend post-compose. A backend with a generator but no activator renders config fine and fails only when --activate/--enable is requested — so "render now, activate later" is a valid intermediate state. (See ADR-023 for the full extensibility ladder; the activator is the irreducible host-code residue and stays core-only.)

  1. Write the conformance + dispatch tests. tests/c_e2e/test_backend_registry.py shows a stub backend dispatching through the registry without a core edit (the "seam admits a backend without a fork" proof); tests/c_e2e/test_activation.py covers the activation lifecycle. Add the five atomic-update properties (as in test_conformance_stage4.py) for your backend — substitute the backend name in the stack PCL's backend = "..." field.

  2. Update the documentation — at minimum, docs/deploy/stacks.md (mention the new backend), docs/deploy/activation.md, and an entry in docs/status/what-works.md.

Caveats:

  • The unit-file path is currently hardcoded per backend (e.g. systemd writes /etc/systemd/system/<svc>.service). User-mode deploys need a --unit-dir override — that's the unit_dir parameter the generator should accept, defaulting to the system path. See known limitations.
  • The lifecycle wire-up (systemctl daemon-reload, then per-unit restart/enable after the atomic flip) is implementedSystemdActivator runs it via the Transport, dispatched through the ACTIVATORS registry (K-S0a). The PCL service vocabulary is still systemd-shaped (serviceConfig/timer are [Section] passthroughs); de-leaking that for a neutral renderer is the deferred K-S0b slice.

Adding a recipe (e.g. std.maven, std.cargo)

Classification: below the seam (provably-host — recipes run in the sandbox).

Steps:

  1. Write the recipe module at src/punix/realise/recipes_lib/std/<name>.py. The pattern is either a class implementing Recipe.run(req, sandbox) or a function (see existing recipes for the choice).

  2. Side files (_postinstall.sh, _dep_env.py, etc.) go alongside the recipe module. They're automatically content- hashed into the canonical derivation — so editing _postinstall.sh invalidates the cache for every package using your recipe. This is the property; don't try to opt out.

  3. Register in recipes_lib/std/__init__.py.

  4. Conformance test: a real package using recipe = "std.<name>" builds, the resulting store path has a built marker + a derivation.json. Add to test_conformance_stage3.py or a sibling.

  5. Document in docs/language/recipes.md's registry table.

Adding a source kind (e.g. type = "ipfs")

Classification: below the seam (provably-host — needs the network).

Steps:

  1. Add the dispatch case in src/punix/realise/sources.py alongside source.type == "url" / source.type == "git" (the fixed_output verify-after-fetch case lives in src/punix/realise/realise.py). Implement the fetch into dest.

  2. Hash the source bytes into the canonical derivation — the declared hash field is what matters. If your source kind doesn't allow a stable upstream hash, model it as type = "fixed_output" (verify after fetch).

  3. Error surface: name a new [E#] if the failure mode is meaningful (e.g. "no IPFS gateway reachable"). Document in docs/reference/error-codes.md.

  4. Conformance test: source mismatch ⇒ located error.

Adding a type-system feature (e.g. a new primitive type)

Classification: depends. Native if the IC rules cover it; fold-over-value if it's a deterministic transform; © totalize- as-data if it can be encoded into existing types.

Do not add subtyping or first-class type members. The decidability proof depends on their absence — see invariants.

Steps (if native or ©):

  1. Extend the AST at src/punix/frontend/ast.py.
  2. Extend the type checker at src/punix/frontend/types.py — add a new ScopeType case + a new [E#] if errors at this site need their own code.
  3. Extend the lowerer at src/punix/frontend/lower.py — what IR node does this become? Existing IR nodes are in src/punix/ir/nodes.py.
  4. Extend the canonicalisation if your IR node is new — its .canonical() method MUST produce a deterministic JSON-able structure.
  5. Write the type-system conformance test at test_conformance_stage1.py — a minimal program that uses the feature; a minimal program that produces the new error.

Adding a CLI command

The CLI is cyclopts-based; entry points are in src/punix/cli.py.

@app.command
def newcmd(arg: Annotated[str, Parameter(help="...")], *, flag: ...) -> None:
    """Docstring becomes the --help text."""
    ...

For commands that touch deploy state, follow the existing pattern: take --transport-root / --target flags so the command is testable hermetically.

Conformance test: at minimum, an exit-code test (--help exits 0; malformed arg exits 2). See test_cli_*.py for the patterns.

What you don't add

  • Subtyping. Ever.
  • First-class type members.
  • Untyped escape hatches in the pure layer.
  • A second mutable state-switch point during deploy.
  • A subprocess.run call outside Transport in the deploy layer.
  • Anything that reads os.environ above the seam.

These are not bureaucracy; each is the conformance pin of a property the user is paying for. Bypass it and the property is gone.