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:
- Implement the
TransportProtocol (if your backend needs a new transport class). The shape is insrc/punix/deploy/transport.py. Ten methods:run,push,read,write,exists,list_dir,remove,home,readlink,atomic_symlink_switch. The failure mode isTransportError.
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).
- 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.
- Register the generator in
src/punix/cli.py's_GENERATORSmapping:
- 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:
Register a factory in the ACTIVATORS registry (the effect-side mirror of _GENERATORS):
_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.)
-
Write the conformance + dispatch tests.
tests/c_e2e/test_backend_registry.pyshows 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.pycovers the activation lifecycle. Add the five atomic-update properties (as intest_conformance_stage4.py) for your backend — substitute the backend name in the stack PCL'sbackend = "..."field. -
Update the documentation — at minimum,
docs/deploy/stacks.md(mention the new backend),docs/deploy/activation.md, and an entry indocs/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-diroverride — that's theunit_dirparameter the generator should accept, defaulting to the system path. See known limitations. - The lifecycle wire-up (
systemctl daemon-reload, then per-unitrestart/enableafter the atomic flip) is implemented —SystemdActivatorruns it via theTransport, dispatched through theACTIVATORSregistry (K-S0a). The PCL service vocabulary is still systemd-shaped (serviceConfig/timerare[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:
-
Write the recipe module at
src/punix/realise/recipes_lib/std/<name>.py. The pattern is either a class implementingRecipe.run(req, sandbox)or a function (see existing recipes for the choice). -
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.shinvalidates the cache for every package using your recipe. This is the property; don't try to opt out. -
Register in
recipes_lib/std/__init__.py. -
Conformance test: a real package using
recipe = "std.<name>"builds, the resulting store path has abuiltmarker + aderivation.json. Add totest_conformance_stage3.pyor a sibling. -
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:
-
Add the dispatch case in
src/punix/realise/sources.pyalongsidesource.type == "url"/source.type == "git"(thefixed_outputverify-after-fetch case lives insrc/punix/realise/realise.py). Implement the fetch intodest. -
Hash the source bytes into the canonical derivation — the declared
hashfield is what matters. If your source kind doesn't allow a stable upstream hash, model it astype = "fixed_output"(verify after fetch). -
Error surface: name a new
[E#]if the failure mode is meaningful (e.g. "no IPFS gateway reachable"). Document indocs/reference/error-codes.md. -
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 ©):
- Extend the AST at
src/punix/frontend/ast.py. - 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. - Extend the lowerer at
src/punix/frontend/lower.py— what IR node does this become? Existing IR nodes are insrc/punix/ir/nodes.py. - Extend the canonicalisation if your IR node is new — its
.canonical()method MUST produce a deterministic JSON-able structure. - 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.runcall outsideTransportin the deploy layer. - Anything that reads
os.environabove 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.
Related¶
- Architecture — the structure you're extending.
- Feature-decision procedure — apply this before writing.
- Invariants — what your PR will be checked against.
- Reference: conformance — where the new test lands.