Skip to content

Latest commit

 

History

56 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gen-link

CI License: MIT Sponsor

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-view's referenceResolution declares the query and gen-scope answers it), never checks a contract (gen-algebra / gen-schema do), never unions raw graphs (gen-scope does), never materializes (gen-view does). It stores nothing between calls and carries no domain knowledge (NixOS, home-manager, den).

Table of Contents

Overview

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):

  • includes are 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 wire at 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.

Position in the Ecosystem

gen-prelude / gen-merge         (pure base — builtins + byte-mode module merge)
gen-algebra  — record algebra: has / assertSatisfies         (capability check)
gen-identity — hashIdentity                                  (the one mint)
gen-schema   — checkRefinements, refined                     (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-view     — referenceResolution + viewRelation             (edge resolution, materialization)
gen-view     — viewRelation + placement                       (terminal move)

gen-link (Class B) — federation conductor
  depends on: gen-scope, gen-view, 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 its substrate siblings: they supply the domain vocabulary; gen-link supplies the federation sequencing.

Gen Ecosystem

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-identity The substrate's one identity mint — hashIdentity
gen-schema Typed registries + 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-view The derived-view constructor (referenceResolution — the forward includes arm, delegating to gen-scope)
gen-view viewRelation + placement — the terminal move into a class evaluation. It replaced the retired gen-edge (S,T,P,M) algebra at this repository's one call site (the conductor oracle) before ADR-0010 §3's retirement landed, and reaches here through ci/flake.nix alone — lib/** reads nothing from it
gen-link This lib — cross-flake aspect federation conductor

Usage

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 link Conductor

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 and is REQUIRED — a source with no facets writes `keySemantics = { }` and says so,
  #   because the omission's other reading ("the vocabulary was not passed") is what silently blinds
  #   the completeness guard to that source's own holes.
  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/…). A key that is no DECLARED hole on that requirer is refused by name:
  #   the entries for a node are exactly its declared holes, no more (here) and no fewer (step 3).
  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    = [ { identifier; relata; origin; node; identity } ];   # what `wire` bound, with the minted identity
  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):

  1. 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-scope gmap, no content re-evaluation. Per-node alias renames apply here.
  2. Disjoint union. overlay the origin-rewritten subgraphs (gen-scope). Origin makes the union collision-free by construction.
  3. Mint, in staged passes. Each wire entry contributes a RELATUM to its requirer — label the facet name, value the filler's identifier — and every merged node is emitted to gen-scope's mintStrata at 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 — forced on every field of the result, so the refusal is a property of the call rather than of a consumer that happened to read the manifest.
  4. Resolve cross-origin references. Declare a gen-view referenceResolution (the forward includes arm, Néron et al. 2015 rule (X)) over the merged graph as the scope, injecting that same gen-scope as its query authority. Resolution is active-edge-driven and lazy — gen-link does not scan.
  5. Type-check each active cross-origin edge. Capability → gen-algebra record (requires ⊆ provides); refined → gen-schema checkRefinements. A type failure is a loud, named error at the edge.
  6. Record the manifest. Every cross-origin includes edge and every wired hole is written to the manifest with both endpoints' ids — the flake.lock pattern (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.

Declaring Facet Ports

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.

Public API

The flake's .lib exposes:

link { sources, wire ? {} } → { graph; manifest; nodes; bound; resolved }

The federation conductor (above). sources entries are { registry; keySemantics; origin ? []; alias ? {}; }keySemantics carries no default, and a source that omits it is refused by name; wire is { "<requirerRef>" = { <facet> = "<fillerRef>"; }; }, whose keys for a node must be exactly that node's declared holes. References are origin-qualified path-strings or structured { origin; path }.

Identifier and identity

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-qualifiednamespace.<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-identity'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.

References & Origin

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".

Federation Steps

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.

Facets & Contracts

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.

Manifest

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.

Testing

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:

  1. Origin-union two toy collections that each define apps/media/pg → assert their federated nodes have distinct origin-qualified identifiers.
  2. Wire a capability edge and type-check it via gen-algebra; the unsatisfiable variant throws named.
  3. Resolve the cross-origin include via gen-view referenceResolution → the requirer sees the provider's tags (a stubbed construct returns null and the assertion catches it — the construct is genuinely load-bearing).
  4. 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.
  5. Materialize the linked node's class content through gen-view — the content is authored into the scope graph's data component, and the channel's viewRelation folds it into the cell placement names.

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 suite

52 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.

Theoretical Foundations

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.

About

Cross-flake aspect federation conductor — Class-B gen library

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages