Skip to content

feat: gen-schema entity port + resolver decoupling + delivery-edge unification - #563

Merged
sini merged 101 commits into
denful:mainfrom
sini:feat/entity-gen-schema-port
Jun 15, 2026
Merged

feat: gen-schema entity port + resolver decoupling + delivery-edge unification#563
sini merged 101 commits into
denful:mainfrom
sini:feat/entity-gen-schema-port

Conversation

@sini

@sini sini commented May 21, 2026

Copy link
Copy Markdown
Collaborator

What this delivers

This branch began as the gen-schema entity port and grew into a five-part stabilization of den's resolver and delivery pipeline. Each part builds on the last and is covered by CI. 1023/1023 tests (up from 825 at branch start).

1. gen-schema entity port (the original scope)

  • den.schema now uses gen-schema's mkSchemaOption/mkSchemaEntryType (sidecar extraction, computed fields, __functor wrapping); resolvedCtxModule extracted to shared _types.nix.
  • Entities own their id_hash via gen-schema mkInstanceType (context-free kind+name identity).
  • Flat declaration forms alongside the legacy two-level form for both hosts and homes (den.hosts.igloo = { … }, den.homes."tux@igloo" = { … }), preprocessed to the canonical shape — all consumers see the unchanged { system.name = entity }.
  • den.reservedKeys (user-extensible structural keys); settings reserved as structural.

2. Isolation + delivered-child host (microvm guests)

  • Kind-level isolated marker, recorded per-scope at scope creation.
  • Isolation-aware subtree collection (an isolated entity is its own delivery root).
  • Routes deliver re-instantiating payloads verbatim and keyed (microvm *.config re-runs eval with the base module list); reinstantiate kept as a core route flag.
  • Projected (in-context) hasAspect re-keyed by entity id_hash rather than scope-string, fixing a false-negative (core.impermanence → identity paths) for ancestor-nested hosts.
  • (Note: the intermediate guest-os class — added and removed within this branch, never in a release — left no surface; isolated guests carry honest nixos identity.)

3. Resolver decoupling — binding half

Purifies who binds what, where emission lands, removing the host/user-specific machinery the resolver core carried (zero host/user/homeManager literals remain in the fx resolver core).

  • A pure arg classifier over the schema entity DAG; synchronous relationship fan-out for descendant entity args (replaces the cross-scope deferral carrier).
  • Kind-generic spawn materialization and a generic parent-chain root-owner lookup.
  • Fixes the host-aspect→all-users leak (Bug: homeManager content on host-included aspects is silently dropped unless the aspect is user-parametric #609): a host-scope { user, … } homeManager aspect no longer leaks to every user.
  • den.lib.perHost/perUser/perHome are kept as deprecated shims (they shipped in main) — now thin aliases over the new rule.

4. Delivery-edge unification — delivery half

Collapses nine ad-hoc delivery mechanisms into one (S, T, P, M) edge algebra: a single mechanism-free mode switch, one constructor per mechanism, an explicit per-root context projection, and a record-level toposort for ordering — production now materializes delivery as one toposorted edge fold per root, replacing the four hand-threaded phase-fold sites.

  • New user-facing deliver { from; to; at ? []; mode ? "merge"; } primitive; route and provides retained as permanent thin shims over it (signatures unchanged).
  • findHostScopeId's name-infix heuristic dissolved into an exact scope-creation link.
  • The four delivery sites (fxResolveFull, fxResolveImports, the per-host re-walk, the spawn re-entry) all fold the single materializeUnified engine over topoSortEdges(unifiedEdges); the per-mechanism phase orchestration is deleted. The toposort is stable (construction-order tiebreak), so byte-equivalence to the prior phase order holds.
  • The edge-trace is the production edge set: edgeTrace renders the captured edges the production fold dispatched (oracle ≡ production by construction, no drift surface), with legacyEdgeTrace retained as the differential's "before". A production-vs-legacy differential proves the prior spawn rewalk undercount + dedup-suppressed twins are eliminated.
  • A frozen, versioned edge-schema parity contract + assertEdgeParity + a parity corpus, so den-hoag (v2) can be validated against the v1 production edge set as a pure diff. Schema/runbook live in the den-architecture papers.
  • One intended runtime fix: cross-host config resolution over a pipe-consuming peer now builds over assembled contexts (was a latent attribute missing throw; witness test added).

5. Constraint-registry unification + sibling-exclusion fixes (#613)

The #613 scope-isolation fix (conditional-guard hasAspect) is extended to the exclude/substitute application path, and the constraint registry is unified.

  • aspects.X.excludes and policy-name excludes no longer leak across sibling entities (an eval-order-dependent bug: a host walked earlier suppressed a sibling that included the same aspect/policy). Both flavors are now entity-scoped (scope + ancestors).
  • The fleet-wide flatConstraintRegistry is deleted: all three readers (check-constraint, the policy dispatch, and the late-policy dispatch) go through one scope+ancestor lookup. The late-policy dispatch scopes to the sibling it emits for, so schema-tier excludes (den.schema.KIND.excludes) still reach their descendants.
  • Dedup: one cycle-guarded foldScopeAncestors underlies the constraint walk and the guard pathSet walk (the latter also gains the full cycle guard).

Breaking change

One breaking change versus the merge base — a semantic change, no API removals:

No public API is removed: perHost/perUser/perHome, route, provides, the legacy two-level entity form — all retained (the first three as warning shims). Release notes will be drafted as heads-up GH discussions at release time per maintainers.mdx.

Dependency note

den's root flake is a pure library (outputs = _: import ./nix). gen-schema is resolved via the CI template lock, pinned to github:sini/gen-schema — the same personal-repo + CI-lock-fallback pattern den already uses for nix-effects. Staying as-is for now.

Verification

  • den CI 1023/1023 (nix develop -c just ci).
  • Delivery-edge slice (part 4, deliver-API era) is byte-identical downstream: a real nix-config fleet host's full system closure builds to the same derivation hash at the delivery-edge baseline vs that point — not flake-source-only, the same store path. Transitively covers agenix identity paths, the microvm guest, impermanence, and home-manager delivery.
  • The toposort production switch is in-repo byte-stable: each of the four fold sites was verified against the entity-isolation suite (real evalModules output) + full CI with no diffs; the oracle-vs-production differential gates the edge-set faithfulness. A nix-config host system-closure-hash re-verification across the swap is the only out-of-repo step, taken at the den input bump.
  • delivery-edge contract fixtures, deliver API suite, the edge-toposort gates (fx-materialize-unified, fx-unified-edges, fx-edge-unification-gate, fx-oracle-production-differential, fx-edge-parity), and the BUG: Unexpected result for host.hasAspect #613 sibling-exclusion regressions all green.

Status

Ready for review. Deliverability audit complete (dependency resolution, breaking-change docs, leftover-scaffolding sweep, templates): gen-schema stays at sini (matches nix-effects); flake-parts-modules template pin fixed; perHost shim restored; docs corrected. Specs, plans, the per-task deviation ledger, and the frozen parity contract live in the den-architecture papers; a net-behavior-change summary is published as a gist.

@github-actions github-actions Bot added the allow-ci allow all CI integration tests label May 21, 2026
@sini
sini force-pushed the feat/entity-gen-schema-port branch from 6574fac to a370d30 Compare May 21, 2026 22:37
@sini
sini force-pushed the feat/entity-gen-schema-port branch 7 times, most recently from b2bcfd4 to 1b56211 Compare June 5, 2026 19:36
@sini
sini force-pushed the feat/entity-gen-schema-port branch 5 times, most recently from e3584fe to b1619a4 Compare June 12, 2026 21:17
@sini sini changed the title feat: port entity schema to gen-schema feat: gen-schema entity port + resolver decoupling + delivery-edge unification Jun 13, 2026
@sini
sini marked this pull request as ready for review June 13, 2026 22:01
@sini
sini requested a review from vic as a code owner June 13, 2026 22:01
sini added 13 commits June 13, 2026 15:03
Replaces hand-rolled schemaEntryType with gen-schema mkSchemaOption.
Sidecars: includes, excludes. Computed: isEntity (structural content only).
Extracts resolvedCtxModule (id_hash, resolved, collisionPolicy) to
_types.nix for entity type reuse. collisionPolicy flows through deferred
module merge to entity instances (not a sidecar) preserving existing
ctx.host.collisionPolicy resolution path.
den.hosts now accepts both forms:
  - Legacy: den.hosts.x86_64-linux.igloo = { ... }
  - Flat:   den.hosts.igloo = { system = "x86_64-linux"; ... }

The outer option type uses a permissive submodule with deepMergeAttrs
freeformType (lib.recursiveUpdate-based merge that avoids the infinite
recursion lib.types.anything causes with cross-option references).
The apply function preprocesses flat entries into two-level form and
re-evaluates through the original attrsOf systemType, so all 6
consumers see the canonical { system.name = hostConfig } shape.
Same pattern as den.hosts: deepMergeAttrs + preprocessHosts + apply.
Cross-entity host lookup and osConfig injection preserved.
Covers: id_hash, freeform, topology, meta introspection,
isEntity computed, schema includes sidecar.
Update flake inputs and references to match the renamed repo
at github:sini/gen-schema.
gen-schema flattened _meta into _-prefixed options and renamed
sidecars → collections. nix-effects changed bindAttrs so true is a
literal param, not an optionality marker — translate __args values
to fx.bind.optionalArg before bind.fn.
sini added 22 commits June 14, 2026 12:12
The resolver binding rule (spec §3) claims transitive descendants fan out
by DAG nesting ({ host, user } at environment -> for-each-host-for-each-
user), but only DIRECT children worked. fanOut enumerated children off
scopeCtx.${scopeKind} (the scope's own record), so a descendant-of-a-
descendant (toy under pet) was looked up as host.toys (absent) -> inert;
and the fanned intermediate (pet) lived only in the aspect's
__scopeHandlers, invisible to the next level's enumeration.

Fix: thread fanned intermediates through a boundEntities accumulator;
enumerate each descendant off its PARENT-kind record (availRecords =
scopeCtx // boundEntities); and fan the shallowest reachable descendant
first (not alphabetical head) so an intermediate binds before its child.
Direct/cartesian behavior unchanged.

Found by the formal-rule coverage audit. New test:
relationship-fanout/test-transitive-descendant-chain.

CI 987/987.
The transitive fan-out fix inlined pure schema-DAG logic in the bind
handler: `schema.${argKind}.parent or scopeKind` (twice) and the
shallowest-reachable descendant filter. That knowledge belongs in
arg-class.nix, the pure schema-DAG module that already owns
isDescendantOf/childrenOf. Add parentKindOf + fanableDescendants there;
bind.nix calls them. Removes the duplicate parent expression and keeps
parent-chain access in one module. No behavior change.

CI 987/987.
pathSetByScope and the old flat pathSet were written from the same node
under the same guard with the same key space — flat == union of the
per-scope buckets, exactly. Guards already read pathSetByScope (denful#613), so
flat was redundant STATE maintained only for the structural hasAspect +
capture consumers. Make pathSetByScope the single membership record; add
identity.flattenPathSetByScope (union of buckets) and point the five
consumers (has-aspect x2, capture, two internal-api tests) at it. Drop
the flat field from defaultState and the now-dead test fixtures.

One membership mechanism instead of two — also the HOAG node shape.
Behavior-preserving (provable key-equality; fx-constraints exact-count
guards it). CI 987/987.
Three branch-local duplications the new shared modules should own:
- schema-kind parent walk: schema.nix ownerChain reimplemented the same
  den.schema.${k}.parent traversal arg-class already owns. Add
  arg-class.ancestorChain (the one schema-DAG ancestry walk); isDescendantOf
  and schema.nix's owner lookup both derive from it.
- includes-chain prefix check: compile-conditional re-inlined the
  isAncestor prefix match it already imports constraint.nix for. Export
  constraint.isAncestorChain; both sites use it.
- deepMergeAttrs option-type was byte-identical in entities/host.nix and
  home.nix; _types.nix (their shared-helper module) now owns it.

No behavior change. CI 987/987.
…ges (Task 16)

Expose `unifiedEdges` as a lazy thunk on the fxResolveFull result, beside
`edgeTrace`. It is the union of:
  - the top-level mechanism edges (default-fold + provides + routes +
    instantiate) the oracle (edge-trace.nix) builds from end-state, MINUS its
    `spawnEdges` rewalk arm (which undercounts each spawn as one edge);
  - the SURFACED spawn edges, collected from the drain-fold spawn (mkDrained);
  - the per-host + B′ instantiate projection edges (mkInstantiateEdges).

mkDrained now returns { classImports; spawnEdges; } so the host-own invocation's
spawn `.edges` feed the union (the B′ invocation discards them — B′ delivery is
covered by the per-host mkInstantiateEdges). The per-host projection is factored
into `perHostProjection`, shared by mkInstantiateArgs (module assembly,
unchanged) and the edge collector. edge-trace.nix gains `extractTopLevelEdges`
(the per-component seam) so oracle and unified set share the exact constructor
calls.

Additive, non-breaking: unifiedEdges is not yet consumed by production
materialization (exposed for a later task). 996/996 CI green.
…ortEdges (Task 17)

Build materializeUnified, ONE ordered-dispatch fold that interleaves provides +
routes in topoSortEdges order, reusing the EXISTING per-spec materializers
(provides.applyOneProvide | route.applySimpleRouteEdge | applyComplexRouteEdge).
Design B (order-only): independents keep the provides-before-routes construction
order via a STABLE topoSortEdges, so the unified fold is byte-equivalent to
phase2∘phase3; synthesize edges land after their producers exactly as
applyRoutes' internal toposort already orders them. Additive + non-breaking — no
production site switches; the engine is proven equivalent, not yet consumed.

- toposort.nix: lock-in comment on the stable ascending-index Kahn emit order.
- provides.nix: factor applyOneProvide out of the applyProvidesEdges fold body
  (identical per-spec body, byte-stable).
- route.nix: surface the per-spec materializers + ordering helpers.
- materialize-unified.nix: the engine.
- resolve.nix: lazy materializeEquiv surface (dispatch order + both fold results).
- fx-materialize-unified.nix: equivalence proof over plain / provides / routes /
  provides+routes / isolated-guest canaries + a doFinalMerge case. Equivalence
  holds byte-exact on every canary (dispatch order identical, structural
  fingerprint identical).
- fx-toposort-edges.nix: independent-edges-stable unit test.
…fied

The per-host (resolve.nix perHostProjection) and spawn (edges/materialize.nix
assembleSpawnSubtree) sites still ran the legacy phase2 (applyProvidesEdges) -
phase3 (applyRoutes) folds purely to feed their edge collectors' perScope
content source. Delivery already flowed through materializeUnified (Task 17).

Expose the post-fold accumulator from materializeUnified via a new exposeAcc
flag (one fold, both the merged output and {classImports;perScope}), then
repoint both edge collectors to that accumulator and delete the redundant
phase2/phase3 folds. The applyProvides/applyRoutes injection params drop out of
mkSpawnNode/assembleSpawnSubtree accordingly.

applyProvidesEdges + the applyRoutes wrapper + route.nix:applyRoutes are kept:
they remain the materializeEquiv byte-equivalence oracle the fx-materialize-
unified suite compares against.

Byte-stable: full CI 1011/1011, no diffs.
Task 18.2 + 18.3 (one CI-green unit).

18.2 — Make edgeTrace the PRODUCTION delivery-edge object. Its fold-ordered
provides+routes portion is now CAPTURED from the production materializeUnified
folds (materializeUnified{exposeEdges=true}.edges) at all three sites — top-level
(resolve.nix), spawn (.edges in edges/materialize.nix assembleSpawnSubtree), and
per-host (edges/instantiate-edges.nix mkInstantiateEdges) — instead of being
re-derived via providesEdges/routeEdges. The default-fold (merge) + instantiate
(flake-output) edges stay constructor-built (deterministic structural edges, no
drift surface). unifiedEdges is now an alias for edgeTrace (one representation).
The legacy end-state re-derivation (extractEdgeTrace, with its spawn rewalk arm)
is kept as a distinct legacyEdgeTrace field for the differential. The
fx-unified-edges + fx-edge-unification-gate suites repoint their oracle to
legacyEdgeTrace.

18.3 — New fx-oracle-production-differential suite diffs edgeTrace (production)
against legacyEdgeTrace on a spawn + an instantiate topology: production is a
superset of legacy-minus-rewalk, and the production-only delta carries the spawn's
surfaced fold edge / the per-host fold. Re-baseline the delivery-edges fixtures:
the suppressed route twins are gone (production captures kept routes only), the
host-level spawn rewalk edge is gone, and instantiate topologies grow by their
per-host fold edges.
…ins from legacy arm

The subset relation is production ⊇ legacy\rewalk\suppressed: production folds
orderedKeptRoutes only, so dedup-suppressed route twins are never materialized
(their absence is faithful). Today every CI suppressed twin key-aliases its kept
sibling, but stripping suppressed from the legacy arm keeps the gate sound for a
future distinct-key suppression (rule-2 redundant-root / adapterKey).
edge-trace.nix is now the LEGACY end-state re-derivation (the legacyEdgeTrace
differential arm), not the live trace; the live edgeTrace is the captured
production object. default.nix: the spawn surfaces real edges via the shared
constructors now. materialize-unified.nix: it IS production at every site (Task
17) + exposeEdges feeds the captured trace. materialize.nix: the spawn .edges
provides+routes are captured, consumed by edgeTrace. sourceVia docs kept intact.
fmway
fmway previously requested changes Jun 15, 2026
Comment thread nix/lib/entities/_types.nix
…alog)

check-constraint read the fleet-wide flatConstraintRegistry, so a sibling
entity's exclude leaked into a node's check — eval-order dependent: a host
walked earlier (iceberg < igloo) suppressed a sibling host that INCLUDED the
same aspect (igloo.networking.hostName resolved to the default instead of the
included value). This is the denful#613 sibling-leak, but for the exclude APPLICATION
path (check-constraint) rather than the conditional-guard hasAspect pathSet that
denful#613 fixed.

Fix: look excludes/substitutes up in the ENTITY-scoped registry (currentScope +
ancestors via scopedConstraintRegistry), mirroring the denful#613 guard fix. ownerChain
is preserved so within-scope include nesting still isolates. The scope walk is
cycle-guarded (visited set) — scopeParent can carry a cycle in spawn/forward
merged sub-pipelines, and check-constraint runs for every node.

Regression test mirrors github.com/tschan/den-hasaspect-bug (the bogus+working
pair); both directions now pass. Full CI 1021/1021.
@fmway
fmway self-requested a review June 15, 2026 02:27
@fmway
fmway dismissed their stale review June 15, 2026 02:28

stale

…elete flat

The denful#613-analog follow-up + simplification. All three exclusion readers
(check-constraint, dispatch-policies, policy/schema late dispatch) now go through
ONE scope+ancestor lookup (scopedConstraintsFor / scopedConstraintsForScope); the
fleet-wide flatConstraintRegistry — which leaked excludes across siblings,
eval-order dependent — is deleted (write + init gone).

Key: the late-policy dispatch scopes to the SIBLING it emits for (sib.scopeId),
not the parent scope, so schema-tier excludes (den.schema.KIND.excludes,
registered at the resolved kind scope) still reach their descendants — no
position-independent broadcast special-case needed.

Dedup: extracted foldScopeAncestors (cycle-guarded scope+ancestor fold); the denful#613
guard's collectScopeConstraints AND the guard pathSet (scopedPathSet) now reuse it
instead of three near-identical walks — scopedPathSet also gains the full
visited-set cycle guard.

Both exclusion flavors are now sibling-isolated: aspect-content (aspects.X.excludes)
and policy-name. Regression test issue-613-policy-exclude-sibling added. Full CI
1023/1023.
@sini
sini merged commit 2589d73 into denful:main Jun 15, 2026
31 checks passed
xfo-0 added a commit to xfo-0/nixfos that referenced this pull request Jun 18, 2026
Behavior-preserving on AO05/nl0x/grpht (toplevel byte-identical bar the
self-source hash; diff-closures empty, known_hosts/nix.conf unchanged); also
clears the installer niri-route->all-hm-users blocker via #563 resolver
decoupling. #563 is still an OPEN draft.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-ci allow all CI integration tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants