Skip to content

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, no open(…, "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 subprocess directly.
  • Color-blind code: the same code drives LocalTransport("/") (prod), LocalTransport(tmp_path) (tests), and SshTransport(...) (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.