Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

mode name
-rw-r--r-- README.md
README

dynlink-input — composable recipe inputs (a non-trivial DAG)

Elpe’s answer to Nix flake inputs: a project publishes a recipe module, another imports it — and inputs import inputs, so they compose into a DAG. The mechanism is dynlink: the backend compiles an input’s OCaml, the consumer Dynlink.loadfiles the result and calls into it.

The graph (5 projects, 3 levels, two diamonds — not a tree)

       app (L3)  ─────────────┐
      /        \               \
 greeter (L2)  banner (L2) ──┐  │
      \        /             │  │
       strcase (L1)        frame (L1)
                             ▲     ▲
                             └─────┘
edgevia
app → greeter → strcasegreeter imports strcase
app → banner → strcasebanner imports strcase → strcase in-degree 2
app → banner → framebanner imports frame
app → frameapp imports frame → frame in-degree 2

strcase and frame are each reached by two paths — the two diamonds that make this a DAG rather than a tree. Because a node is content-addressed by its source, a node reached twice is built once (second path is a cache hit): the DAG is shared, not unfolded.

What each node is

  • strcase (leaf): publishes upper (tr a-z A-Z). No imports.
  • frame (leaf): publishes frame (box stdin in ASCII). No imports.
  • greeter: import "../strcase"; publishes greet = greet + upper.
  • banner: import "../strcase" + import "../frame"; publishes banner = upper | frame.
  • app: imports greeter + banner + frame; composes them into $DESTDIR/card.

Composition is by absolute store path: a consumer builds the imported derivation (self#derivation dep), takes its destdir, and bakes <store>/usr/bin/<tool> into its script (Nix-style ${dep}/bin/…). No PATH lookup — so app importing greeter (but not strcase) still works: greet already points at its pinned upper.

The two surfaces (to add to open Elpe)

  • export : (string -> derivation Lwt.t) -> unit — an input registers what it offers, once at top level.
  • import : string -> (string -> derivation Lwt.t) — a consumer names another project by path; import
    1. builds it in the backend: ocamlfind ocamlc -a project.elpe against elpe.cma → project.cma in the store,
    2. loads it: Dynlink.loadfile "<store>/project.cma" in the frontend VM,
    3. returns whatever the loaded module’s export registered.

The interface is fixed (string -> derivation Lwt.t) so the separately compiled units need no shared .mli. Tool inputs ignore the string arg; a parameterised input (e.g. greeter "world") would use it. Richer typed exports are a later increment.

Confinement / purity

Flake-purity, made crisp — one invariant:

local_src never escapes its project root. import is the SOLE boundary-crosser, and every import re-roots.

  • Inside a project, paths resolve relative to the root (the elpe build CWD), so addressing is descend-only — local_src "shared", never local_src "../shared". A monorepo makes the monorepo the root. .. inside is neither needed nor allowed; today confined() (eval-vm/cli/src/addpath.rs) already rejects any .. or absolute path in a local_src.
  • Crossing to another project is the only place .. is legitimate, and it goes through import "../greeter". import owns a climb-allowed resolution and then re-roots: greeter’s own local_src/import resolve relative to greeter, not the original CWD. Recursive purity — nested flakes.
  • What import captures is still content-addressed (AddPath hashes it), so the trust anchor is the hash, not the location — and the daemon only ever reads files under a client-named root (this is what makes the confinement also close the daemon-root privilege escalation, per campaign mission 1.2).

Known lock (the “on y va” work)

The recipe runs in the embedded VM (curated prim table, static libcamlrun, “no Dynlink of untrusted objects” — doc/internals/design.md). Making import real means:

  1. caml_dynlink_* prims in the curated table (eval-vm/cli/build.rs), gated to inputs already content-addressed into the store (owned by the caller — not an arbitrary .cmo off the wire),
  2. a backend recipe that emits project.cma (link against elpe.cma’s .cmis, -a),
  3. the export/import refs + Dynlink.loadfile glue in elpe/lib, with the import graph walked transitively frontend-side (each loaded module runs its own imports) and memoised so a diamond apex loads once.