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)
▲ ▲
└─────┘
| edge | via |
|---|---|
| app → greeter → strcase | greeter imports strcase |
| app → banner → strcase | banner imports strcase → strcase in-degree 2 |
| app → banner → frame | banner imports frame |
| app → frame | app 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"; publishesgreet= greet +upper. - banner:
import "../strcase"+import "../frame"; publishesbanner=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- builds it in the backend:
ocamlfind ocamlc -a project.elpeagainstelpe.cma→project.cmain the store, - loads it:
Dynlink.loadfile "<store>/project.cma"in the frontend VM, - returns whatever the loaded module’s
exportregistered.
- builds it in the backend:
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_srcnever escapes its project root.importis the SOLE boundary-crosser, and everyimportre-roots.
- Inside a project, paths resolve relative to the root (the
elpe buildCWD), so addressing is descend-only —local_src "shared", neverlocal_src "../shared". A monorepo makes the monorepo the root...inside is neither needed nor allowed; todayconfined()(eval-vm/cli/src/addpath.rs) already rejects any..or absolute path in alocal_src. - Crossing to another project is the only place
..is legitimate, and it goes throughimport "../greeter".importowns a climb-allowed resolution and then re-roots: greeter’s ownlocal_src/importresolve relative to greeter, not the original CWD. Recursive purity — nested flakes. - What
importcaptures 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:
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.cmooff the wire),- a backend recipe that emits
project.cma(link againstelpe.cma’s.cmis,-a), - the
export/importrefs +Dynlink.loadfileglue inelpe/lib, with the import graph walked transitively frontend-side (each loaded module runs its own imports) and memoised so a diamond apex loads once.