Cross-flake aspect federation over origin-labeled subgraphs, implemented as a pure Nix library.
gen-link lets a downstream flake reuse aspects and registry components published by an external flake — rescoping them, aliasing individual nodes, and wiring cross-origin references that are pinned and diffable the way flake.lock pins inputs. A flake exports a subgraph of aspects; another flake imports it, links it against its own graph, and materializes the result.
gen-link is a Class-B conductor in the gen-resolve sense: it owns only sequencing plus three genuinely-new coordinates — an origin coordinate, a disjoint-union-with-relabel over origin-labeled subgraphs, and a resolution manifest — and delegates every computation to an existing gen sibling. It never hashes an identity (gen-schema does), never resolves an edge (gen-resolve does), never checks a contract (gen-algebra / gen-schema do), never unions raw graphs (gen-scope does), never materializes (gen-edge does). It stores nothing between calls and carries no domain knowledge (NixOS, home-manager, den).
- Overview
- Position in the Ecosystem
- Gen Ecosystem
- Usage
- The
linkConductor - Declaring Facet Ports
- Public API
- Testing
- Theoretical Foundations
A collection of aspects is a labeled graph, not a tree of values (Néron 2015): nodes (aspects), includes edges (references to nodes that already exist), a vocabulary (its keySemantics), and its declared holes (facet-requires — the open ports an importer fills). "Reuse a component" means "union a subgraph and resolve references across the seam," never "copy a value."
link federates a list of sources in six steps (§The link Conductor). Two edge kinds, and only two — Backpack's import/signature duality (Kilpatrick 2014):
includesare concrete — the target node exists. Authored by-value (includes = [ config.aspects.base ]) or by-key (aspects.keyRef "y/apps/pg"); at the graph level the distinction disappears — both become edges between node ids, relabeled identically by the origin-rewrite.- holes are parametric — a facet-require to a not-yet-present node, filled by
wireat instantiation, and the only edge that folds into instantiation identity.
Origin makes the union disjoint by construction. Two flakes that each define apps/media/pg become distinct nodes because origin is a datum in the one content-address formula (§Public API — Identity). Instantiation creates identity applicatively (Leroy 1995): y/foo wired to self/pg is a different node from y/foo wired to y/pg.
gen-prelude / gen-merge (pure base — builtins + byte-mode module merge)
gen-algebra — record algebra: has / assertSatisfies (capability check)
gen-schema — hashIdentity, checkRefinements, refined (identity + contracts)
gen-aspects — keySemantics grammar, aspect identity (aspect payload)
gen-scope — algebraic-graph union/query (Mokhov 2017) (graph primitives)
gen-graph — reachability / condensation (visibility)
gen-resolve — reference + resolve/materialize (edge resolution)
gen-edge — (S,T,P,M) materialize (terminal move)
gen-link (Class B) — federation conductor
depends on: gen-scope, gen-resolve, gen-schema, gen-algebra, gen-aspects (+ gen-prelude)
owns: origin coordinate · disjoint-union+relabel · resolution manifest
stores: nothing (accessor pattern)
knows: nothing about NixOS / den / aspect semantics
Consumers (den-hoag, and any downstream flake) wire gen-link the way den wires gen-resolve: they supply the domain vocabulary; gen-link supplies the federation sequencing.
| Library | Role |
|---|---|
| gen-prelude | Pure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils) |
| gen-algebra | Pure primitives (record algebra, search monad, either, intensional identity) |
| gen-schema | Typed registries + hashIdentity / checkRefinements / refined |
| gen-aspects | Aspect type system (keySemantics grammar, the aspect-chain key, keyRef) |
| gen-scope | HOAG scope-graph evaluator + algebraic graphs (overlay / gmap) |
| gen-graph | Accessor-based graph query combinators (reachability, condensation) |
| gen-resolve | Demand-driven RAG evaluator (reference — forward/reverse edge resolution) |
| gen-edge | (S,T,P,M) materialize — the terminal move into a class evaluation |
| gen-link | This lib — cross-flake aspect federation conductor |
gen-link is Class B: nixpkgs-lib-free, depending only on gen-prelude and five sibling .lib values, each self-wiring its own deps. The lib/** surface is pure list/attr combinators + builtins — no module system, no nixpkgs.lib (enforced by ci/tests/purity.nix). The flake exposes a single .lib value output.
# flake.nix
{
inputs.gen-link.url = "github:sini/gen-link";
outputs = { gen-link, ... }:
let link = gen-link.lib;
in { /* ... */ };
}
# Or without flakes (siblings auto-derived from the pinned flake.lock):
let link = import ./gen-link { };
in { /* ... */ }The single entry point is a pure link call. It stores nothing.
link {
# sources: the origin-labeled subgraphs to federate. The importing flake joins as a source too, so
# `self/*` references resolve — `self` is the surface name for its origin []. Per-source `origin`
# (rescope) and `alias` (per-node rename) live on each entry; `keySemantics` is the source's facet
# vocabulary.
sources = [
{ registry = self.aspects; origin = [ ]; keySemantics = ks; } # importer; `self` origin = []
{ registry = a.aspects; origin = [ "a" ]; keySemantics = ks; } # default origin = source identity
{ registry = b.aspects; origin = [ "b" ]; keySemantics = ks;
alias = { "apps/media/pg" = "apps/media/postgres"; }; } # per-node rename
];
# wire: fill federation HOLES (facet-requires) only. Each filler is a node REFERENCE — a structured
# `{ origin; path }` or an origin-qualified path-string — bound by id, never a raw closure
# (defunctionalization, Reynolds 1972). `includes` are NEVER wired; the pipeline (not `wire`) fills
# context args (host/user/…).
wire."b/apps/app".dbreq = "a/apps/media/pg"; # fill b/apps/app's `dbreq` facet-require with a's pg node
}
→ {
graph = <origin-disjoint merged gen-scope subgraph>; # not stored by gen-link
manifest = <ordered list of { kind; from; to; via }>; # diffable: cross-origin edges bound
nodes = { <id> = { origin; node }; }; # the id → source-node index
bound = [ { id; node; origin; holeFillings } ]; # instantiated hole-filled nodes (folded ids)
resolved = { <requirerId> = <resolved provider tags | null>; }; # per-requirer cross-origin resolution
}The six-step sequencing (the only thing gen-link owns; every computation is delegated):
- Normalize + origin-rewrite each source. Ingest the registry into a source-relative graph (nodes keyed by
.key;includes— by-value or by-key — extracted uniformly into id-edges), then rename every node to its federation identifier under the assigned origin and swap every edge endpoint — a uniform relabel over gen-scopegmap, no content re-evaluation. Per-nodealiasrenames apply here. - Disjoint union.
overlaythe origin-rewritten subgraphs (gen-scope). Origin makes the union collision-free by construction. - Mint, in staged passes. Each
wireentry contributes a RELATUM to its requirer — label the facet name, value the filler's identifier — and every merged node is emitted to gen-scope'smintStrataat a pass derived from the wire graph. A relatum resolves only against what strictly earlier passes settled, so an ill-founded filling (a node filling its own hole, or a cycle) cannot resolve and is refused by name. An unwired required facet is a separate, loud, named error. - Resolve cross-origin references. Run gen-resolve
reference(forwardincludesnearest-binding) over the merged graph as the scope. Resolution is active-edge-driven and lazy — gen-link does not scan. - Type-check each active cross-origin edge. Capability → gen-algebra
record(requires ⊆ provides); refined → gen-schemacheckRefinements. A type failure is a loud, named error at the edge. - Record the manifest. Every cross-origin
includesedge and every wired hole is written to the manifest with both endpoints' ids — theflake.lockpattern (Dolstra 2006) applied to cross-origin edges.
Steps 1–2 are the "disjoint-union + relabel" owned row; step 6 is the "resolution manifest" owned row; steps 3–5 delegate. gen-link mints nothing itself: what it owns of step 3 is the pass derivation and the emitter list. gen-link holds no graph between calls — the merged graph is a gen-scope value returned to the caller.
A facet is the sole typed port of federation — the only place a federated edge acquires a type. Classes (payload) are moved to materialization unresolved; channels are inert data. A facet key is declared in a source's keySemantics with category = "facet" and a contract flavor ("capability" — default — or "refined").
A facet keySemantics entry MUST carry a real .option. Without it, gen-aspects builds no facet option and the key falls through the freeform fallback into a nested aspect — which breaks the port model and pollutes the graph with spurious origin-stamped vertices. With the option declared, node.<F> is a genuine option value carrying the per-node contract.
# a facet option: a raw slot carrying { provides ? []; requires ? []; } (capability), read per-node.
facetOpt = merge.mkOption { type = merge.types.raw; default = null; };
keySemantics = {
nixos = { category = "class"; }; # payload — materialized, never typed
dbcap = { category = "facet"; contract = "capability"; option = facetOpt; };
dbreq = { category = "facet"; contract = "capability"; option = facetOpt; };
};A node then expresses its role through that option's value:
config.aspects = {
provider.dbcap = { provides = [ "read" "write" ]; }; # a capability PROVIDER
requirer.dbreq = { requires = [ "read" ]; }; # a REQUIRER (an unfilled hole until wired)
};Capability (default): provides / requires are role-tag lists. gen-link turns the provider's provides into a record and checks requires ⊆ provides via gen-algebra record.has, raising its own named edge error if a tag is missing (record.assertSatisfies is the secondary arbiter on success). Bracha & Cook (1990).
Refined value (contract = "refined"): the keySemantics entry carries a refinedType — a real gen-schema refined type built with genSchema.refined <base> <refinements>, not a raw refinements list. gen-schema checkRefinements reads type.__schema.refinements, so a bare list carries no __schema and the check would silently no-op. Findler & Felleisen (2002); Rondon et al. (2008).
portFacet = { category = "facet"; contract = "refined";
refinedType = genSchema.refined genMerge.types.int [ { name = "positive"; check = v: v > 0; } ];
option = facetOpt; };Facets type an already-established edge; they never resolve one (resolution is the scope-graph query). Conflating the two would put a global constraint solve on the eval path.
The flake's .lib exposes:
The federation conductor (above). sources entries are { registry; origin ? []; alias ? {}; keySemantics ? {}; }; wire is { "<requirerRef>" = { <facet> = "<fillerRef>"; }; }. References are origin-qualified path-strings or structured { origin; path }.
ADR-0016 ruling 5 keeps two things apart, and this library used to merge them.
An identifier is the name a node carries as a vertex: the origin-qualified reference,
"<origin>/<key>", with [] rendered "self". It keys the node map, it is what an edge endpoint
names, and it is what a wire entry writes. It is a string a reader can write by hand.
★ The federated reference grammar is ruled kind-qualified — namespace.<kind-segment>.name,
one grammar with the local den.aspects.name, so that a cross-kind name collision is inexpressible
by construction rather than policed by a uniqueness check. That grammar is not built here: this
migration ships the two-segment form above. It is recorded so the current form is not mistaken for
the settled law.
An identity is the derived content-address, minted once per node by gen-schema's hashIdentity
and reached ONLY through gen-scope's minting entry. It rides as a FIELD on the node —
(link {…}).nodes."<identifier>".identity — never as its name.
gen-link publishes no function that computes either one. There is nothing to construct for an
identifier, and a second route to an identity would be a second minting authority. The four
functions that used to live here — nodeId, keyRefTargetId, instantiatedId, bindNode — are
retired: all four minted whatever input they were handed with no membership test, so keeping them
would have left the ill-founded instantiation expressible on the surface while it was inexpressible
through link.
The manifest carries identifiers and kinds, never identities. Ruling 5 rules the content-address
internal addressing only — consistent within an evaluation, with nothing durable depending on it
across them — and the manifest is designed for a consumer to serialize to a gen-link.lock. The
rows are therefore the readable coordinates, which is also the more useful artefact: an identity is
a computed value a human cannot read back, while the coordinates are the function's own INPUTS, so
a tool can reproduce or query the output from the rows and the identity rebuilds from them.
Each endpoint carries its node's kind alongside its identifier, under fromKind / toKind —
names deliberately distinct from the row's own kind, which means the row's sort ("includes" or
"hole") and is a property of the relation rather than of either endpoint. One string per endpoint
is what keeps the rebuild total once a federation mixes kinds: an identity is "<kind>:" + digest,
so the kind is only the tag prefix, and once the identity stops being serialized no other field
carries it. Without it a consumer holding a row could not name the kind to mint with.
| Function | Signature | Semantics |
|---|---|---|
parseRef |
ref → { __keyRef; origin; path; key } |
Parse a structured or string reference; self maps to origin []. Slash-splitting delegates to gen-aspects keyRef. |
originLabel |
origin → string |
The raw "/"-joined origin list; [] → "". renderOrigin is what builds identifiers. |
renderOrigin |
origin → string |
Surface rendering for manifests/errors/keys: [] → "self". |
| Function | Signature | Semantics |
|---|---|---|
normalize |
registry → { nodesByKey; edges; refByToken } |
Registry → source-relative, origin-free includes-graph. No content re-evaluation (bounded WHNF head-touch only). |
originStamp |
{ normalized; origin; alias ? {} } → { graph; idToNode } |
The origin-rewrite: uniform relabel over gen-scope gmap (by-value and by-key edges alike) + per-node alias. |
disjointUnion |
[ { graph; idToNode } ] → { graph; idToNode } |
overlay the origin-stamped subgraphs (gen-scope union monoid). Collision-free by construction. |
| Function | Signature | Semantics |
|---|---|---|
holesOf |
ks → node → [facet] |
The node's facet keys whose value carries requires (unfilled capability holes). |
providesOf |
ks → node → [tag] |
Union of provides tags across the node's facet keys. |
requiresOf |
node → facet → [tag] |
A capability hole's requires demand. |
contractOf |
ks → facet → "capability" | "refined" |
The facet's contract flavor (default "capability"). |
checkCapability |
{ edgeName; provides; requires } → record | throw |
requires ⊆ provides via gen-algebra record.has; own named error on a missing tag, record.assertSatisfies on success. |
checkRefined |
{ edgeName; refinedType; value } → value | throw |
gen-schema checkRefinements over a __schema-tagged refined type; own named error on a violation. |
| Function | Signature | Semantics |
|---|---|---|
entry |
{ kind; from; fromKind; to; toKind; via ? null } → manifestEntry |
Construct one manifest entry. kind is the ROW's sort (∈ { "includes", "hole" }); fromKind/toKind are the endpoint NODES' kinds. Every field but via is required. |
order |
[ entry ] → [ entry ] |
Deterministic ordering for diff stability. |
The sufficiency claim — gen-link sequences the real siblings and adds only origin + union + manifest — is proven by the conductor oracle (ci/tests/conductor-oracle.nix), one chain end-to-end:
- Origin-union two toy collections that each define
apps/media/pg→ assert their federated nodes have distinct origin-qualified identifiers. - Wire a capability edge and type-check it via gen-algebra; the unsatisfiable variant throws named.
- Resolve the cross-origin include via gen-resolve
reference→ the requirer sees the provider's tags (a stubbedreferencereturns null and the assertion catches it — gen-resolve is genuinely load-bearing). - Rebuild the wired node's identity from its identifier and its relatum's identity through gen-schema directly, and assert the minting run produced the same digest.
- Materialize the linked node's class content through gen-edge.
If any sibling were stubbed, the chain breaks.
nix flake check ./ci # build + run the full suite
cd ci && just ci # run all tests
cd ci && just ci conductor-oracle # run one suite52 tests across 13 suites: conductor-oracle, link, identity, rewrite, facets, normalize, contract, ref, wire, union, demo, smoke, and purity. The purity suite asserts the lib/** surface never touches nixpkgs.lib, enforcing the Class B invariant.
| Feature | Paper |
|---|---|
| Registry as scope graph; resolution as name resolution; D<I<P visibility | Néron, Tolmach, Visser & Wachsmuth (2015) "A Theory of Name Resolution" |
| End-of-path resolution over scopes | van Antwerpen, Poulsen, Rouvoet & Visser (2018) "Scopes as Types" |
| Disjoint union of subgraphs; overlay as the union monoid | Mokhov (2017) "Algebraic Graphs with Class" |
| Hermetic linking with explicit signatures (holes) | Kilpatrick, Dreyer, Peyton Jones & Marlow (2014) "Backpack: Retrofitting Haskell with Interfaces"; Yang (2016) "Backpack to Work" |
| Instantiation creates identity; applicative default (Leroy), generative alternative rejected (Dreyer) | MacQueen (1984) "Modules for Standard ML"; Leroy (1995) "Applicative Functors…"; Dreyer (2005) "Understanding and Evolving the ML Module System" |
| First-class linkable units | Flatt & Felleisen (1998) "Units: Cool Modules for HOT Languages" |
| Binding-time analysis (per-node closed/open) | Jones, Gomard & Sestoft (1993) "Partial Evaluation and Automatic Program Generation" |
| The inspectable wrapped-fn functor realizing open aspects | Palmer et al. (2024) "Intensional Functions" |
| Forward reference / reverse inter-type resolution | Hedin (2000) "Reference Attributed Grammars"; Hedin & Magnusson (2003) "JastAdd" |
| Content-addressed identity; the lock pattern | Merkle (1987) hash trees; Dolstra (2006) "The Purely Functional Software Deployment Model" |
| Capability contract (provide/require) | Bracha & Cook (1990) "Mixin-based Inheritance" |
| Refined-value contract | Findler & Felleisen (2002) "Contracts for Higher-Order Functions"; Rondon, Kawaguchi & Jhala (2008) "Liquid Types" |
| Nominal inhabitance (class IS-A) | Pierce (2002) "Types and Programming Languages" §19.3 |
| Defunctionalize hole-fillings to data before hashing | Reynolds (1972) "Definitional Interpreters for Higher-Order Programming Languages" |
Dataflow-conduit homonym disambiguation (pipe.channel, NOT keySemantics channel) |
Kahn (1974) "The Semantics of a Simple Language for Parallel Programming" |
See the full design in papers/den-architecture/gen-specs/gen-link/2026-07-24-gen-link-design.md and the canonical REFERENCE.md.