Skip to content

fix: derive class-content emit ctx from authoritative scope state - #624

Merged
theutz merged 1 commit into
mainfrom
feat/user-scoped-host-class-fanout
Jun 26, 2026
Merged

fix: derive class-content emit ctx from authoritative scope state#624
theutz merged 1 commit into
mainfrom
feat/user-scoped-host-class-fanout

Conversation

@sini

@sini sini commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

A static (constant-name) aspect included via den.schema.<kind>.includes whose 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 by dedupByKey on a sid-free aspect identity.

Root cause

emit-classes derived its emit ctx from the per-aspect aspect.__scopeHandlers attribute. 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 has homeManager content, 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 what bind.nix already reads.

Fix (one handler, a unification)

  1. Authoritative ctx. emit-classes now reads the scope's context from pipeline state (the same source/pattern as bind.nix), layering the aspect's own __scopeHandlers on top so fan-out child bindings still win. Gated to child scopes; the root scope keeps the historic handler-only path.
  2. Key by named args. Each class-content entry is keyed by the entity kinds its function names and that are present in ctx:
    • nixos = { user, ... }:{user=<u>}fans per user
    • nixos = { host, ... }:{host=<h>}dedups across every scope it's delivered from
    • nixos = { persist, ... }: / _: → no entity kind → singular (shared infra aspects keep deduping; no double option declaration)

Validation

  • just ci: 1044/1044 (adds user-scoped-host-class-fanout regression suite — the missing coverage that let this through).
  • Byte-identical existing output: real nixos + k8s hosts produce the same toplevel derivation pre/post fix (drvPath diff). Only previously-miskeyed per-user content changes.

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.
@sini
sini requested a review from vic as a code owner June 26, 2026 07:42
@github-actions github-actions Bot added the allow-ci allow all CI integration tests label Jun 26, 2026
@theutz
theutz merged commit 3932adf into main Jun 26, 2026
31 of 38 checks passed
@theutz
theutz deleted the feat/user-scoped-host-class-fanout branch June 26, 2026 08:32
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