fix: derive class-content emit ctx from authoritative scope state - #624
Merged
Conversation
emit-classes computed its emit context from the per-aspect __scopeHandlers
attribute, which is only populated for parametric aspects (bind augmentation) or
for includes propagated from a parent that already has it — and is ABSENT for a
static aspect reached through a static include chain. That aspect's class
content was then emitted with an EMPTY context, so its host-class (nixos/darwin)
content collapsed to a base identity at the shared host merge: a static
user-scoped aspect whose content named `user` collapsed N users -> 1 (all but
one dropped before evaluation), and an aspect delivered from several scopes
keyed inconsistently (base vs context-qualified), surfacing as duplicate
unique-option definitions once any per-context keying was applied.
Read the authoritative scope context from pipeline state (the same source
bind.nix uses: state.scopeContexts.<currentScope>), layering the aspect's own
handlers on top so fan-out child bindings still win; child scopes only (root
scope keeps the historic handler path). Key each class-content entry by the
entity kinds its function NAMES and that are present in ctx: `{ user, ... }`
fans per user, `{ host, ... }` dedups to {host=<h>} across the scopes it is
delivered from, `{ persist, ... }`/`_:` stays singular.
Existing fleet configs are byte-identical (verified via drvPath diff on real
nixos/k8s hosts); only previously-miskeyed per-user content changes. Adds the
user-scoped-host-class-fanout regression suite.
theutz
approved these changes
Jun 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A static (constant-name) aspect included via
den.schema.<kind>.includeswhose host-class (nixos/darwin) content names an entity kind — e.g.nixos = { user, ... }: …— collapsed N sibling entities → 1 at the shared host merge. With three users on a host, only one user's content survived; the other two were dropped before evaluation bydedupByKeyon a sid-free aspect identity.Root cause
emit-classesderived its emitctxfrom the per-aspectaspect.__scopeHandlersattribute. That attribute is only populated for parametric aspects (bind augmentation) or for includes propagated from a parent that already has it — and is absent for a static aspect reached through a static include chain. So such content was emitted with an empty ctx, collapsing to a base identity. The same content delivered from multiple scopes (e.g. a host aspect that also hashomeManagercontent, reaching both the host scope and the user scopes) keyed inconsistently (base vs context-qualified), surfacing as duplicate unique-option definitions (programs.steam.package defined multiple times) the moment any per-context keying was applied.The authoritative scope context was always present in pipeline state right next to the drop point —
state.scopeContexts.${currentScope}— and is exactly whatbind.nixalready reads.Fix (one handler, a unification)
emit-classesnow reads the scope's context from pipeline state (the same source/pattern asbind.nix), layering the aspect's own__scopeHandlerson top so fan-out child bindings still win. Gated to child scopes; the root scope keeps the historic handler-only path.nixos = { user, ... }:→{user=<u>}→ fans per usernixos = { host, ... }:→{host=<h>}→ dedups across every scope it's delivered fromnixos = { persist, ... }:/_:→ no entity kind → singular (shared infra aspects keep deduping; no double option declaration)Validation
just ci: 1044/1044 (addsuser-scoped-host-class-fanoutregression suite — the missing coverage that let this through).