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.
│
┌────────────▼─────────────┐ vendored inheritance-calculus
│ IC CORE │ evaluator (record/inheritance
│ CIA-resolved values │ IR, tabled lfp, late binding);
│ canonical derivation │ 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.py – compose_stack(ev, "Foo") → ServiceStack
│ ├── secrets.py – resolve_secrets(stack, env, fs)
│ └── generators/ – one module per backend (systemd in v0.6)
├── cli.py # the entry point
├── corpus.py # batch realise (punix build)
└── migrate/ # RCL → PCL migrator (Stage 7 active)
Reuse map¶
| Layer | Source | Status |
|---|---|---|
| Inheritance calculus + canonical derivation | Vendored from MIXINv2 (pinned git source) | Reused unchanged |
| 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 |
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.