Skip to content

fix: aspect provenance collisions and the nested _ alias (#670) - #671

Merged
vic merged 6 commits into
mainfrom
fix/670-same-named-sub-aspects
Sep 3, 2026
Merged

vic merged 6 commits into
mainfrom
fix/670-same-named-sub-aspects

Conversation

@sini

@sini sini commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

#670

Summary

Identity:

  • Derives a submodule's child provider prefix from config.meta.provider rather than the static typeCfg, so a nested aspect re-typed by providerType.merge hands its children the chain that merge rewrote instead of one truncated to its own name.
  • Derives the scopedAspectPolicies key from ownerIdentity inside the handler, as that registry is the only scoped one that merges rather than appends and each caller was naming its own key.
  • Filters __-prefixed keys out of crossKeys because every merged aspect carries a synthetic provides.__functor, which was registering an inert policy per aspect and re-dispatching it on every iteration.
  • Filters wrapper internals out of provides children, as a key defined in more than one file merges into a content wrapper whose __contentValues / __provider / _ were surfacing as provides keys and registering an inert policy.
  • Replaces meta.provider's listOf type with a merge that collapses agreeing definitions and throws on conflicting ones, since a node has exactly one position and concatenation turned two definitions of one path into ["a" "a"].

Alias:

  • Folds a written _ into the provides source at the content-wrapper merge so the documented alias works on nested aspects and not only at root, where mkAliasOptionModule supplies it.
  • Publishes provides and _ as one value on nested aspects, matching root, so a value written through either spelling reads back through both.

Testing:

  • Adds a regression case per fix, and pins that a parametric aspect fanned per user registers one cross-provide policy per fan instance, because each instance's provides already carries its own entity binding.

Validation

  • nix develop -c just ci: 1115/1115, exit 0, zero failures and zero errors, formatting a no-op at the final rev.
  • Falsified each new oracle against the unfixed tree rather than trusting the green. Removing the _ read half fails test-underscore-reads-back-on-nested-aspect, and restoring the listOf provider type fails test-agreeing-definitions-collapse with ["a" "a"] while its single-definition control stays green.
  • Measured the two review findings this branch did not act on. The provides-internals leak reproduces through the provides spelling with no _ involved, so it is pre-existing rather than a consequence of the alias fold, and both arms fail without the filter while the root-shape control stays green. The predicted cost of reading config.meta in freeformType did not appear: chain-100 allocates 719,571 values on this branch against 728,838 on main.
  • Checked the base-identity alternative for the policy registry key by hand. Keying on identity.baseKey collapses the fan instances and delivers /opt/vic to tux, so the ctxId-qualified key is load-bearing and the fan case was already broken before this branch.

sini added 4 commits September 2, 2026 13:21
Two aspects each declaring a sub-aspect called `tools` and delivering
through `provides.to-users` landed only one of the two. Two independent
collisions on one cause: an identity string built from a chain that had
lost its head.

`aspectSubmodule` handed its children a provider prefix read from the
static `typeCfg`, but `providerType.merge` rewrites that chain on the
value when it re-types an included nested aspect (`wrapperToAspect`
injects it from `__provider`). Reading the stale prefix truncated the
chain to the aspect's own name, so `alpha/tools` and `beta/tools` both
gave their children the prefix ["tools"]; the two delivered aspects
shared one identity and gate dedup dropped one.

The cross-provide policies collided the same way. `scopedAspectPolicies`
is the only scoped registry that merges rather than appends, so a
repeated key is a silent drop rather than two entries — and each caller
named its own key, one of them from the bare aspect name. The handler
now derives the key from `ownerIdentity` plus an optional label, so no
caller can choose a colliding one.

Not fixed here, measured and still open: an inline named aspect written
straight into `includes` gets no owner provenance, so two owners can
still collide that way. The type-level prefix cannot carry it — making
the `includes` type depend on `config` overflows the evaluator on
performance.resolve.test-chain-100.
`_` is documented as an alias for `provides`, but only root aspects got
one: aspectSubmodule wires it with mkAliasOptionModule, and a nested key
never reaches that submodule. `_` is structural, so `tools._.to-users =`
arrived at the content wrapper as a plain key and was then overwritten by
the `_` the wrapper publishes — the write vanished with no diagnostic.

Fold a written `_` into the provides source at that merge. A `_` read
back off another wrapper carries `__functor`; that is the read shorthand,
not a write, and stays out of provides.

The read side was asymmetric too. Root publishes `provides` and `_` as
one value — provides children plus the all-children functor — while a
nested key published only the functor, so a value written through `_`
read back through neither spelling. Nested now publishes the same value
for both, leaving the two interchangeable for reading and writing at any
depth.
Review found `test-underscore-reads-back-on-nested-aspect` asserted only
the `provides` half of the read symmetry. Deleting the `_` half of the
change left all seven tests green, so that line had no oracle at all. It
now reads both spellings and the delivered content, and deleting the same
line fails it.

The issue-670 tests likewise asserted only end-to-end delivery, which any
fix that merely keeps two registrations apart would satisfy. Added a case
that exercises the identity path without the policy path.

Also pins the parametric fan: a cross-provide policy registers per fan
instance, and that is load-bearing — each instance's `provides` already
carries its own entity binding, so keying on the ctxId-free base identity
delivers the last instance's binding to every user (tux reads "/opt/vic").
Review recommended exactly that change; the test is what refutes it. That
case was broken before this branch, when both instances collapsed onto one
name, and is fixed by the identity keying as a side effect.

Drops three mechanical findings: `__`-prefixed keys are filtered out of
`crossKeys` (every aspect carries a synthetic `provides.__functor` that
was registering an inert policy per aspect and re-dispatching every
iteration), the unread `ownerIdentity` field is gone from the registry
entry now that the key derives from it, and `writtenUnderscore` uses
lib.optionalAttrs per the repo style rule.
`meta.provider` was `listOf str`, so two files defining one aspect path
each injected the same chain and the module system concatenated them:
`["a"]` and `["a"]` merged to `["a" "a"]`.

That was survivable while children took their prefix from the static
typeCfg — the corruption stopped at the node. Since 483640a a child's
prefix is `config.meta.provider ++ [config.name]`, so a doubled chain
propagates to every descendant and every provides child of the subtree.

Agreeing definitions now collapse and genuinely different ones throw.
Last-wins would have answered `["a"]` here too, but only by accident: it
is silently wrong exactly when two definitions disagree, which is the
failure mode this chain of fixes has been closing. `meta.handleWith` in
the same file already takes a custom merge for the same reason.
@sini
sini requested a review from vic as a code owner September 2, 2026 21:03
@github-actions github-actions Bot added the allow-ci allow all CI integration tests label Sep 2, 2026
A `provides` key defined in more than one file merges into a content
wrapper, which carries `__contentValues`, `__provider` and `_` beside the
real children. `removeAttrs [ "_module" ]` stripped none of them, so they
surfaced as keys of the published `provides`, entered
`__providesForwarded`, and `_` — not being `__`-prefixed — registered an
inert cross-provide policy of its own.

Measured `[ "_" "__contentValues" "__functor" "__provider" "one" "two" ]`
where `[ "__functor" "one" "two" ]` was intended.

Pre-existing, not a consequence of the `_` fold in 75845b2: the control
arm reaches the identical leak through the `provides` spelling with no `_`
involved. Filtering where providesChildren is built covers both spellings
at once, and the single-definition path is unaffected because a raw
attrset carries none of these keys.
@vic vic added merge-approved Approved, merge yourself when ready ai-assisted labels Sep 3, 2026
@vic
vic enabled auto-merge (squash) September 3, 2026 20:56
@vic
vic merged commit 36c8ba5 into main Sep 3, 2026
15 checks passed
@vic
vic deleted the fix/670-same-named-sub-aspects branch September 3, 2026 21:03
sini added a commit that referenced this pull request Sep 4, 2026
Closes #674

## Summary

`mergeableType` — the freeform type route nesting evaluates modules
under — deep-merges attrsets, concatenates lists, and throws on
everything else. Derivations are deliberately excluded from the attrset
branch so they stay opaque, so two aspects producing the same package
fell to the conflict throw even though the definitions agreed. Same for
the reported `enable = true` shape, where deep-merging descends to a
scalar leaf with two definitions.

- Accepts all-equal definitions before the conflict throw, matching
NixOS's `mergeEqualOption`. Checked last, so it forces values only on
the path that already threw.

#574 introduced the type claiming NixOS module semantics, but
`mergeEqualOption` accepts agreeing definitions and only throws on
disagreement — so the divergence is latent from #574, and #671 made the
path reachable, which is the reporter's own reading of the bisect. This
is the same collapse-agreeing-definitions rule #671 applied to
`meta.provider`, now on the route freeform type.

Testing:

- Adds the reported case verbatim (two aspects, one shared derivation),
the reported symptom shape (a scalar leaf reached by deep-merging an
`apps` attrset defined by two aspects), and a negative control pinning
that disagreeing definitions still throw.

## Validation

- `nix develop -c just ci`: 1118/1118, exit 0, zero failures and zero
errors. `nix develop -c just fmt` a no-op at the final rev.
- Reproduced first on an unfixed `origin/main` worktree at 36c8ba5: the
issue's test verbatim fails with `den: the option 'shared' has
conflicting definitions from multiple aspects`, so the fix is measured
against a red baseline rather than a green assumption.
- Probed the negative control rather than trusting its pass. Forcing the
value without `tryEval` prints that same conflict error, so it fails for
the guard's reason and not incidentally.
- Confirmed the new suite is discovered by the CI harness with `just ci
deadbugs-issue-674` (3/3). The background `just ci` log truncates to 42
lines, so the suite's absence from that log is not evidence either way
and was not read as such.

## Known limitation

The equality check cannot reach function-valued leaves. Measured against
this nixpkgs:

| expression | result |
| --- | --- |
| `(x: x) == (x: x)` | `false` |
| `let f = x: x; in f == f` | `false` |
| `{ a = 1; f = x: x; } == { a = 1; f = x: x; }` | `false` |

A lambda never compares equal, not even to itself, and one lambda
anywhere in an attrset makes the whole comparison false. So two aspects
delivering the same *function* at one option still conflict, and no
`==`-level rule can fix that — it needs identity carried alongside the
value. Derivations are unaffected because Nix short-circuits their
comparison on `outPath`, which is why the reported case is fully fixable
here.
amanako pushed a commit to amanako/nix-config that referenced this pull request Sep 17, 2026
- Remove old TODO referencing bundled `._` includes since it was fixed
  in denful/den#671 (commit 36c8ba5) - aspects under same parent resolve
  to distincts nodes carrying leaf identity when accessed via `._`
- Add a little comment explaining the situation in user part of the
  settings generator
- Simplify comment in lunar-scar aspect
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted allow-ci allow all CI integration tests merge-approved Approved, merge yourself when ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants