Architecture¶
The codebase is organised around one boundary: the eval/realise seam. Above it: pure config (typed, decidable, terminating, no effects). Below it: host-side effects (build, transport, service lifecycle). Every other architectural choice descends from this split.
FRONTEND new code
┌──────────────────────────┐
│ parser → types → lower │ ~ 2 000 LoC, single-pass
│ (E1–E6 located errors) │ typed structural checker;
└────────────┬─────────────┘ cannot diverge.
│
┌────────────▼─────────────┐ NativeEvaluator — an inheritance-
│ IC CORE │ calculus evaluator written for v2
│ CIA-resolved values │ (record/inheritance IR, tabled lfp,
│ canonical derivation │ late binding); order-independent.
└────────────┬─────────────┘
│
════════════ THE SEAM ════════════════════════════════════════
│
┌────────────▼─────────────┐
│ REALISE │ reused old Punix, ~unchanged
│ Store, sandbox, fetch, │ behind the FFI
│ GC, output_hash │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ DEPLOY / SERVICES │ new code
│ Transport · Manifest │ (~ 1 800 LoC; tests run
│ generators · systemd │ hermetically, no subprocess
└──────────────────────────┘ mocks).
Module layout¶
src/punix/
├── frontend/ # PCL → AST → typed → IR
│ ├── parser.py – hand-written recursive descent
│ ├── ast.py – typed AST nodes
│ ├── types.py – ScopeType pass, E1–E6
│ ├── lower.py – IR canonicalisation (sorted, deterministic)
│ ├── loader.py – whole-tree directory composition
│ └── closure.py – build-closure pre-pass (E7)
├── ir/ # IR runtime
│ ├── nodes.py – IR node dataclasses (incl. IrSecret)
│ ├── evaluator.py – CIA arbitration, canonical derivation
│ └── runtime.py – Resolved / ChainEntry / Contribution
├── realise/ # below the seam — sandbox + fetch + GC
│ ├── realise.py – the Realise{Local,DryRun,…} adapters
│ ├── store.py – Store(path), is_valid, gc(roots)
│ ├── output_hash.py – fixed-output verify, [E13]
│ ├── recipes_lib/std/ – the recipe registry
│ └── …
├── deploy/ # the deploy half
│ ├── transport.py – Transport Protocol + Local/Ssh impls
│ ├── manifest.py – Generation, Manifest.atomic_switch
│ ├── stack/ – compose_stack + one reader per generator
│ ├── secrets.py – resolve_secrets(stack, env, fs)
│ └── generators/ – one module per backend (systemd in v0.6)
├── cli/ # one module per command area; __init__ registers them
├── corpus.py # batch realise (punix build)
└── migrate/ # RCL → PCL migrator (Stage 7 active)
Reuse map¶
| Layer | Source | Status |
|---|---|---|
| Inheritance calculus + canonical derivation | punix.ir.evaluator.NativeEvaluator, new for v2 |
The shipped evaluator |
| Sandbox, recipes, fetcher, Store, GC | Old Punix (the predecessor) | Reused unchanged behind the FFI |
| Frontend (parser, types, lowering) | New for v2 | ~2,000 LoC |
| Deploy (transport, manifest, stack, generators) | New for v2 | ~1,800 LoC |
| Migrator (RCL → PCL) | New for v2 | Active development |
Where MIXINv2 sits¶
Punix's design descends from the inheritance calculus, and MIXINv2 is that calculus's reference implementation; it is not what Punix runs. The shipped evaluator is punix.ir.evaluator.NativeEvaluator, and MIXINv2 is a development dependency ([dependency-groups] dev, resolved from PyPI) used by exactly one thing: the dual-engine differential harness at src/punix/evaluator/ + tests/d_differential/, which runs both engines over the same programs and asserts they agree.
That separation is enforced, not merely intended:
punix/evaluator/**is excluded from the wheel ([tool.uv.build-backend] wheel-exclude), so the shipped artifact depends oncycloptsandtabulatealone.tests/b_integration/test_runtime_dependency_graph.pyasserts that importingpunix.clileavesmixinv2out ofsys.modules. Nothing on the production path may import it.
"Built on the inheritance calculus" is a claim about the design rather than the dependency graph. Deviation D-A in the project's register tracks the possible swap; it is currently classified as measured, cheap, and unmotivated: there is no consumer that the swap would serve.
Two halves, two disciplines¶
Above the seam; pure config.
- Nothing performs an effect. No
subprocess, noopen(…, "w"), no network read. The type checker, the IR lowerer, the evaluator all live here. - The whole layer terminates and stays decidable as a structural property: finite named tree ⇒ finite label maps ⇒ structural recursion. The proof anchor is non-negotiable.
- Permuting source ⇒ byte-identical IR ⇒ byte-identical canonical derivation ⇒ byte-identical store path. The conformance suite enforces this end-to-end (see Conformance).
Below the seam; host effects.
- Everything that touches the OS goes here.
Transport.run/read/write,Store.realise(req),Manifest.deploy(stack, gen). - Every effect has a named entry point on a small Protocol; nothing reaches around it to call
subprocessdirectly. - Color-blind code: the same code drives
LocalTransport("/")(prod),LocalTransport(tmp_path)(tests), andSshTransport(...)(remote). Adding a backend is implementing the Protocol, nothing else; see Extending Punix.
Why the seam matters¶
Hermetic tests by construction. The design rule is "no subprocess mocks"; enforced structurally: every effect goes through Transport, and LocalTransport(tmp_path) is a real implementation that writes to a sandbox dir. Tests run real code paths against real bytes; the only thing changing is the root prefix.
Cache correctness is the canonical-derivation hash. The hash includes the recipe identity (recipe id + arg values + hook bodies). Edit a build script ⇒ different identity ⇒ different hash ⇒ different store path. The whole "did I rebuild after editing the recipe?" question goes away; you can't not rebuild, because the old path doesn't satisfy the new identity.
Secrets are a seam phenomenon. A {from_env="NAME"} reference flows through the pure layer as a reference (the lowerer emits IrSecret, the evaluator serialises as from_env:NAME); resolution to a value happens only at the deploy boundary, in secrets.resolve_secrets. The pure layer literally cannot see the value; no escape hatch, no review discipline required.
Related¶
- Feature-decision procedure: how to classify a new language feature before writing it.
- Extending Punix: how to add a backend, a recipe, a new fetcher, etc.
- Reference: decisions: the constitutional layer of design choices.
- Concepts: three pillars: the user-facing version of the seam's consequences.