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.
    ┌────────────▼─────────────┐       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 on cyclopts and tabulate alone.
  • tests/b_integration/test_runtime_dependency_graph.py asserts that importing punix.cli leaves mixinv2 out of sys.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, 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.