Skip to content

fix: resolve identity by definition value and split the provenance field - #678

Draft
sini wants to merge 56 commits into
mainfrom
refactor/aspect-chain-rename
Draft

fix: resolve identity by definition value and split the provenance field#678
sini wants to merge 56 commits into
mainfrom
refactor/aspect-chain-rename

Conversation

@sini

@sini sini commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

#670

Summary

Identity:

  • Resolves aspect identity by comparing raw definition values at providerType.merge, so two owners writing their own inline aspect of one name stay distinct while a shared let-bound value included twice keeps one identity.
  • Compares the whole raw value with no projection, because a projected comparison merges two values that differ only in the stripped part.
  • Keeps a list of claims per definition position rather than a single claim, as one slot lets a differing sibling hold a position permanently and makes the outcome depend on walk order.
  • Carries __walkStamped through the parametric-resolve round-trip, so a walk-stamped child does not have its position encoded a second time.

Policy:

  • Buckets policy identity claims by scope and bare name, so two mkPolicy "tools" records in different aspects both fire while a shared den.policies.foo included from two aspects fires once.
  • Resolves excludes by raw claim value rather than by bare name, as a displaced claimant was otherwise unaddressable and an exclude suppressed a different policy than the one named.

Alias:

  • Routes the _-to-provides fold through one shared helper instead of constructing it separately per site, as mergeFunctions' battery branch was dropping a written fn._ on the floor rather than folding it.
  • Leaves root's mkAliasOptionModule out of that helper deliberately and records why, because the helper is a // overwrite and wiring it into root would replace root's conflict detection with spelling-priority.
  • Pins that root and nested deliberately differ on a conflicting definition, so the divergence is asserted rather than discovered.

Provenance:

  • Splits providerPrefix into origin for the container seed and chain for the threaded definition path, because one field carried two facts that coincide at a container root and so conflated without any test showing it.

Batteries:

  • States each shipped predicate battery's own chain, so the insecure and unfree batteries keep their identities on a stock configuration.

Structural keys:

  • Derives the __-prefixed half of the structural-key registry by rule instead of listing twelve markers by hand, so a new marker is structural the moment it exists rather than when someone remembers to register it.
  • Replaces normalizeRoot's functor whitelist with the same structural carry-forward mkParametricBase uses, as that branch was dropping a functor-shaped root's excludes outright.
  • Makes the key classifier and aspectKeyType.merge share one predicate, because they previously disagreed about an unlisted __ key, one treating it as a nested key and the other wrapping it as content.

Delivery:

  • Records bind's misplaced-entity-arg verdict as pipeline state so the post-assembly drain's residue guard covers it, since that verdict is silent and correct everywhere except a terminal position where no later scope can deliver.
  • Leaves the double-cover verdict unrecorded and says why in the code, as the descendant does receive that content and no test can catch the mistake of recording it.

Excludes:

  • Flattens an aspect's excludes so a list-wrapped policy reference excludes, because providerType names a list of policy records as valid while the reader had no list arm and reduced it to <anon>.
  • Warns when a raw-ref exclude matches nothing anywhere in the run, and only then, so a genuine typo is reported while an exclude that legitimately matches in one scope and not another stays quiet.

Provides:

  • Reserves structural names from the provides forward at both raw construction sites, so a provides child named after an aspect option behaves the same at all three sites instead of landing in the aspect's own option position at two of them.

Performance:

  • Resolves rawRef excludes once per dispatch rather than once per candidate policy name, as neither the registry flatten nor the claim resolution depends on the name being tested.

Tooling:

  • Surfaces the evaluator's stderr in ci.bash and reports a dead evaluator separately from a test failure, because total is computed as pass + fail over whatever reached the JSON stream, so a run that stopped early read as a clean N/N with the diagnostic discarded.
  • Corrects nine stale claims left by this branch and its predecessors, including a public reference documenting a field that metaType's freeform now absorbs silently and a live test citing a coverage suite that no longer exists.

Behaviour changes

Configurations that were silently losing content now deliver it, so a rebuild can emit modules it did not emit before:

  • Two owners writing their own inline aspect under one name stay distinct instead of collapsing onto one identity, which is the defect this branch exists to close.
  • Two mkPolicy records sharing a bare name in different aspects both fire, where one previously displaced the other.
  • A functor-shaped root aspect keeps its excludes and other structural siblings, which normalizeRoot was dropping outright.
  • A parametric wrapper keeps its sibling includes across the resolve round-trip.
  • A provides child whose name collides with an aspect option survives, and is reachable through ._ at all three construction sites rather than two of them landing it in the aspect's own option position.

Input that was previously accepted and ignored is now rejected or honoured, which can change an existing configuration:

  • excludes = [ "some-name" ] is a type error. It previously reduced to <anon>, matched nothing, and excluded nothing in silence.
  • excludes = [ [ policy ] ] now excludes. The same value was admitted by the type and then discarded, so a configuration that relied on the no-op will start suppressing the policy it names.
  • An exclude resolves against the raw claim value rather than a bare name, so it suppresses the policy actually named instead of a different one that happens to share its name.
  • An aliased aspect's authored name outranks its nested position when the two disagree.
  • Any __-prefixed key is treated as a pipeline internal by rule rather than by appearing on a list of twelve. A __-prefixed key an aspect defines is no longer dispatched as class or nested content.

New diagnostics, where the previous behaviour was silence:

  • A pipe-arg-deferred include whose registered effects cannot be delivered throws, naming the aspect and the scope. It previously delivered the direct half and dropped the policy half with nothing said.
  • An exclude naming a policy that never registered anywhere in a resolution emits a den: warning. It does not change what is excluded. A fleet-level exclude warns once per host that does not include the policy, since a resolution is one entity.
  • ci.bash reports a dead evaluator as 💥 EVALUATOR FAILED with the evaluator's stderr, rather than discarding it and printing a tally over whatever the run reached.

Renamed:

  • meta.provider is now meta.aspect-chain, and an absent chain is distinguished from a root one (null against [ ]). metaType is freeform, so a stale meta.provider is absorbed without error rather than rejected.

Validation

  • nix develop -c just ci: 1194/1194, exit 0, zero failures and zero errors, summary line present and the collected count agreeing with the numerator.
  • nix develop -c just ci performance: 31/31, exit 0, including the resolve.test-chain-100 canary and the entity and fleet cells this branch added.
  • Compared each run against the expected baseline rather than against the printed total, because ci.bash computes total as pass + fail, which makes N/N an identity that holds on a truncated run.
  • Verified the ci.bash change by forcing a real parse error: the evaluator's message with file, line and column now reaches stderr, where the identical run previously produced zero bytes and no summary.
  • Falsified every new cell against the unfixed tree and read its actual red output rather than trusting the green.
  • Checked the identity change in both directions, because over-splitting double-emits visibly while over-merging drops a definition silently, and no single cell discriminates both.
  • Measured origin against gen's own source rather than by name: seeding it makes a nested chain read additively through the subtree, matching gen's origin ++ path.
  • Reviewed the whole branch as one diff after the per-task reviews, which is what surfaced the excludes list-element drop and the provides-forward site disagreement.

sini added 30 commits September 8, 2026 13:08
den spelled three unrelated things "provider": the type of an `includes`
element, the `provides` delivery namespace, and the ancestor chain that
forms identity. Both bugs fixed on this branch were a function reading
the wrong one, and a reviewer scanning for that has to disambiguate by
hand every time.

The chain takes gen-aspects' name for it, which is not a new minting:

  # gen-aspects/lib/identity.nix:4
  aspectPath = a: (a.meta.aspect-chain or [ ]) ++ [ (a.name or "<anon>") ];

Identical formula, identical ancestors-only convention. `meta.provider`
is `internal = true; visible = false`, so no user-facing surface moves.
The internal marker becomes `__aspectChain`, following den's camelCase
convention for `__`-prefixed markers rather than the meta field's
spelling.

`providerType` and `providerPrefix` are deliberately untouched: the
first is a different concept, and the second is a false friend that a
later step splits into `origin`. den's `providerPrefix` accumulates per
level while gen's is fixed per container, so they are not the same thing
despite the spelling, and mapping them by name would miswire silently.

The published capture field renames too, which breaks den-diagram; that
consumer is updated in the same change set. It reads the field through
`or [ ]` defaults at four sites, so leaving it stale would not error, it
would make every node read as a root in every diagram.
It asserts on `meta.aspect-chain`, which is `internal = true; visible =
false` and therefore not a seam. den already separates white-box tests
from behaviour tests by directory; this one was in neither.

The rename in e6608408 breaking it is the tell: a test at a seam does not
notice an internal rename.
meta.aspect-chain defaulted to typeCfg.providerPrefix or [ ], so a root
aspect and an inline includes literal both landed on [ ] and nothing
downstream could tell them apart. The option is now nullOr (listOf str)
with no default; a declared aspect sets its own chain via aspectMeta's
mkDefault instead of inheriting one, so the value travels with the aspect
through re-inclusion the way meta.loc does not.

Audit every reader: identity.aspectPath/baseKey, compile-static's
chainIdentity, provide's self-provide meta, and trace's entry/isProvider/
provPath all build ON TOP of the chain (own identity, a child's prefix, or
display) rather than testing for root, so they now coalesce null and root
alike via a shared identity.ownChain helper instead of the stale `or [ ]`
that stopped catching a present-but-null key.
types.nix's parametric-fn branch now writes meta.aspect-chain instead of
the dead meta.provider key, activating a previously-inert widening of
identity for bare parametric fns under `provides`. Nothing in the suite
discriminated the two arms; add a cell that does.
removeAttrs stripped "provider", a key name the aspect-chain rename left
behind as a stranded string literal. It never matched the live
"aspect-chain" key, so a parametric wrapper carrying its own meta leaked
its chain past the filter and won over the freshly computed one.
nameIndexed and constraint/trace consumers key on scopedIncludesChain
as rendered strings; a future fill of an absent meta.aspect-chain
needs the same parent position as a segment list, not a string to
split back apart. chain-push/chain-pop now maintain
scopedIncludesChainSegments in lockstep with the existing string
chain, fed by the segment list compile-static already builds before
rendering it to chainIdentity.
chainWrap took the rendered string and the segment list as two
independent parameters, so a third call site (or a change to
identity.key) could push a string and a list naming different
positions, read by disjoint consumers. chainWrap now takes the
segment list alone and renders the string itself via identity.pathKey,
and both producers (compile-static, compile-conditional) push
identity.aspectPath so the derivation is sound at every call site.
meta.aspect-chain reads null both for an aspect genuinely written inline
at an includes site and for one that reaches resolution through some
other path never touched by the module system's meta injection: den's
own top-level default (a bare submodule option, not an attrsOf element),
raw fixtures fed straight to fxFullResolve/capture bypassing the module
system, and hand-built closures used as hasAspect/includeIf/exclude
guard targets. Filling absent chains from walk position (a later,
separate change) can only be sound once null means exactly one thing,
so each of these states its own chain explicitly instead.
The acceptance cell for a raw fixture stating its own chain hardcoded
the chain into the fixture itself, so deleting defaults.nix's
den.default stamp left it green — nothing exercised the actual
construction site. Add a cell that reads den.default's chain
directly, and give the fixture's own root an explicit chain instead
of leaving it another absent-chain node.
An inline `includes = [ { name = ...; ... } ]` literal never gets a
declared chain, so two owners each writing their own inline aspect of
the same name both read as root and collide at the gate. Fill from the
walk's current parent position in compile-static, where this node's
own meta is already forced — but only when the chain is absent, never
overwriting a declared aspect's chain, so a shared reference included
from two sites keeps one identity. Exclude names children.nix has
already walk-stamped (nameIndexed/nameAnon): those already encode their
ancestor position in the name itself, and filling their chain too would
double-encode that position, compounding exponentially at every further
level of nesting.
insecure-predicate-builder.nix and unfree-predicate-builder.nix ship
their parent aspect as a raw attrset (no meta) under
den.default.includes, so the fill in compile-static gave it
den.default's own position instead of staying a root. Their children
carried author-written names that already encoded the parent's path
("insecure-predicate/os"), so filling the parent's now-inherited chain
onto them doubled it.

State every one of these aspects' own chains explicitly, splitting the
"/"-named children into a real chain segment rather than leaving the
path baked into the name string as a single segment.
isWalkStampedName tested a name's shape (".*:[0-9]+(/.*)?") to infer
whether the walk had invented it, which also matches an author-written
name in that shape ("gcc:14") and wrongly excludes it from the
inline-chain fill.

Set __walkStamped on the nameAnon/nameIndexed branches in children.nix,
where the stamped name is actually produced, and have compile-static
test that marker instead of pattern-matching the name. Add __walkStamped
to key-classification.nix's structural keys so it doesn't reach
classification as a class key.
mkParametricBase rebuilt a parametric-resolved aspect from an explicit
carry-forward whitelist (name, meta, into, provides) instead of merging
onto the original, silently dropping __walkStamped for any walk-stamped
child that also compiled as parametric (including a plain static aspect
promoted into parametric shape by compile.nix's descendant-arg router).
The chain fill then re-fired on re-entry, double-encoding the segment
already embedded in the walk-stamped name — live on the stock igloo/tux
fixture with no user aspects at all.
…spect claims one identity

An inline includes literal with no declared chain reads as root, same as any
other unfilled null — so a value written once and included from two owners
was indistinguishable from two separately-authored literals, and each
inclusion site filled its own chain and split the value into two nodes that
double-emitted (types.lines content, so the duplication reached delivered
config, not just a diagnostic identity string).

providerType.merge (types.nix) now stamps each raw def with its author-
written definition position, read before wrapperToAspect rewrites `name`.
compile-static's chain fill keys a per-scope registry on that position: the
first node to fill at a position claims the walk's current chain, and every
later node at the same position reuses the claimed chain instead of filling
its own, so both render one identity and gate dedup collapses them to one
emission. A node with no definition position (declared aspects, parametric
wrappers) behaves exactly as before.
…on alone

Stamp meta.__defPos/__defValue at providerType.merge (the one point a
definition is still raw) and register the first chain claimed per
def-position in pipeline state. A second sighting at the same position
reuses that chain only when its raw value is `==` the first's; a factory
or `base // {...}` specialization producing a different value at the same
token position claims its own chain instead of silently colliding with the
first (#670 for that input class, the defect the reverted 38e1f725 mechanism
reintroduced for a different class).

Reverts 38e1f725 (its own commit, restoring shared-raw-include-splits.nix
which the plain revert would have deleted) and replaces it with this sixth,
value-equality-keyed mechanism.
…sition

Two fix-round-1 findings on the value-equality guard (b049398e):

- The claim comparison projected `meta` off both sides before `==`. Any two
  raw values differing only in `meta` therefore compared equal, merged, and
  dropped one owner's node -- the exact silent-drop class this task exists
  to remove, and meta is not comparison-inert (metaType's handleWith/
  collisionPolicy are real fields). `__defValue` is captured pre-stamp, so
  there was nothing of the guard's own bookkeeping for the projection to
  strip. Fixed by comparing the values whole.

- `chainByDefPos.<pos>` held a single claim, registered only once. A
  differing sibling from the same factory walked first permanently occupies
  the slot, so a genuinely shared value's own two sightings are compared
  against the wrong claimant forever and never find each other -- the
  mechanism becomes a silent no-op for that walk order. Fixed by keeping a
  list of `{ chain; value; }` claims per position and matching against
  whichever one equals.

Adds three cells to shared-raw-include-splits.nix covering both findings
(the sibling-then-shared case in both walk orders). Both-baseline and full
suite re-verified unchanged (d61e1f45 4/7, 38e1f725 5/7 on the original set);
just ci 1140/1140, just ci performance 29/29.
…t values

The C2 fix (a560f3b4) turned a single claim per definition-position into a
list, and findFirst walks the whole list on every arrival -- K(K-1)/2 in the
count of distinct raw values sharing one position, not the linear bound
recorded before the list existed.
Two policy records sharing a bare name (two mkPolicy "tools" owned by
different aspects, or two aspects each declaring their own
policies.tools) silently overwrote each other in scopedAspectPolicies,
whose registry key was always the bare name with no way to tell them
apart.

registerPolicy now claims a name within the current scope on first
sight and keeps it bare; a second, distinct raw value claiming that
same name in the same scope gets displaced to a chain-qualified
identity instead of overwriting the first. A shared den.policies.foo
referenced twice compares == to its own earlier claim and reuses its
identity unchanged, so it still fires once.

Bucketing is by (scope, name), not definition position: traced
empirically, two aspects' own same-named policies.<name> merge at
different option paths (each submodule eval bakes its own aspect name
into loc), so a def-position bucket would put them in separate buckets
and miss the very collision this exists to catch, even though both
land in one scope's scopedAspectPolicies.
…estor scope

children.nix's registerPolicy displaced-identity path qualified only by
parent chain, which is constant across every claimant sharing one chain —
distinguishing claimant #1 from the rest but not #2 from #3 at N>=3. Qualify
with the claim's own index within its bucket instead.

The same-scope collision check also used e.scope == scope, but
policy/schema.nix's emitLateForSibling merges an ancestor scope's
registrations with a descendant sibling's by this same ownerIdentity
(allAspectPolicies = scopedAspectPolicies.${parentScope} //
scopedAspectPolicies.${sib.scopeId}). Widen the filter to self-or-ancestor
via the shared foldScopeAncestors walk already used by the constraint
registry, so a host-scope and descendant-scope policy sharing a bare name
are recognized as distinct claimants there too.

Covers both with dedicated regression cells: a three-claimant factory shape
for the index qualification, and an ancestor/descendant scope pair (with a
second sibling as control) for the late-dispatch merge.
excludeIdentity resolved a policy exclude to its bare registration name,
but same-named claimants no longer all share that name -- only whichever
claimant registers first keeps it (children.nix registerPolicy); a later
claimant gets a chain-qualified identity instead. `excludes = [ betaTools ]`
names a specific record, so an exclude authored against it must resolve
to whichever identity betaTools itself ends up holding, not to whatever
currently occupies the bare name.

Raw-value equality against the claim registry is comparable at dispatch
time (confirmed empirically), but not at register-constraint time: an
aspect's own excludes register before its own includes walk, so the
claim registry a raw lookup needs is still empty then. Constraint entries
now carry the raw policy reference (rawRef) alongside the bare-name
fallback key; resolution happens later, at dispatch, via the shared
isPolicyExcluded (constraint.nix) -- the single entry point for both the
initial per-scope dispatch (dispatch-policies.nix) and the late-sibling
re-dispatch (policy/schema.nix emitLateForSibling), which previously ran
its own bare-name-only exclude check and let a rawRef-excluded claimant
fire anyway.
…yExcluded

An exclude naming a record that never registered must exclude nothing,
not fall back to a bare-name match that kills an unrelated policy
sharing that name. Also records isPolicyExcluded's per-call cost now
that it flattens the scoped registry instead of one lookup.
The prior bound omitted C, the size of the claim bucket resolveClaim
scans at each visited ancestor scope — and that bucket is fleet-wide
(policyClaimsByName), not per-entity, so the R×D×C term is linear in
fleet size once multiple entities declare a same-named excluded
policy. Comment only; no behavior change.
E is bounded by how many excludes/handleWith one aspect tree declares
— a count, not the include-nesting depth. Comment only.
nix/lib/aspects/types.nix built the `_`-as-provides alias three separate
ways: root via mkAliasOptionModule (merges, errors on conflict), nested
via a `//` fold (silently overwrites, spelling-priority not order-priority
so reordering never recovered the dropped definition), and mergeFunctions'
battery branch (only ever read fn.provides, ignored fn._ entirely).

Add foldUnderscoreIntoProvides, a single helper that folds a `_` write into
`provides` before flatDefs/mergeFunctions see it, so both spellings of one
provides key become defs of the same key and merge through the existing
per-site machinery instead of one silently dropping the other. Root is
unchanged: mkAliasOptionModule already normalizes `_` before
mergeWithAspectMeta runs, so calling the helper there would be a no-op.

This unifies spelling only (O8, the weak reading), not root's and nested's
differing conflict semantics — root still errors on a genuine scalar
conflict, nested still last-wins. test-q4-nested-conflict-is-error guards
that boundary and is expected to stay red.
test-q4-nested-conflict-is-error asserted nested's genuine scalar conflict
IS an error and was designed to stay red — correct during review, but
wrong to land: a collected cell that's expected to stay red breaks the
"clean just ci means nothing's broken" property every other check here
depends on.

Replace it with test-q4-root-nested-conflict-diverges: a green cell that
pins the same fact (root errors on conflict, nested last-wins) as an
assertion instead of a permanent failure. Targets different NixOS options
per position (networking.hostName vs time.timeZone) so root's raw
conflicting defs can't also poison nested's already-collapsed value in the
same host evaluation.
Land a structural probe (providerType.merge called directly, no host
eval) for the fix that lets mergeFunctions' battery branch forward a
_ write on a __functor-carrying def. Falsified: reverting that one
line drops direct/viaUnderscore/viaProvides all to false while the
rest of the suite still passes, so nothing else pinned it.

Correct foldUnderscoreIntoProvides' docstring: leaving root untouched
isn't a no-op (measured false — helper-at-root flips the root conflict
test from "ERROR" to "hB"), it's necessary because the helper is a //
overwrite and would replace root's priority-preserving,
conflict-detecting alias with the same spelling-priority behaviour
this fold exists to fix at nested.
Add test-rvb-consumer-battery-underscore alongside the existing
direct providerType.merge probe: same three fields, declared via
den.aspects.battHolder.provides.batt instead of a hand-minted def,
so the pin survives a change in how a declaration reaches merge, not
just the merge signature itself. Falsified against the same one-line
revert — real red on both cells together.

Also fixes a stray triple-space before the colon in the
foldUnderscoreIntoProvides docstring (nixfmt does not reformat
comment prose, so it survived the prior round's fmt pass unnoticed).
…tion)

typeCfg.providerPrefix conflated a container's fixed seed (den.aspects []
den.ful [name], den.batteries ["den" "batteries"]) with the per-level
accumulated definition chain. The two coincide at a container root, which
made the conflation invisible anywhere anyone would test it.

Split into `origin` (list, stated once per container, never accumulated)
and `chain` (list | null, threaded definition chain; null keeps its
present meaning of "absent, fill from the walk"). Consumers read
`typeCfg.chain or typeCfg.origin` — `or` fires on a missing key, not a
present null, so the null sentinel used by options.includes is preserved.

`origin` deliberately mirrors gen-aspects' own `providerPrefix` field
(bound to `origin` there), easing the eventual den-on-gen rewrite; `chain`
names the accumulating half gen has no equivalent for. Pure key rename at
the three declaration sites and two CI fixture files — zero value changes.
No cell in the corpus asserted a battery or namespace meta.aspect-chain
literal, so O4/O5 of the origin/chain split rested on suite totals alone.
Add two cells in the shape of this file's existing direct meta.aspect-chain
read: a battery root's chain is ["den" "batteries"], a namespace aspect's
chain is [<ns>]. Green today, so a regression in either origin seed shows
up as a real red instead of a passing total.
@github-actions github-actions Bot added the allow-ci allow all CI integration tests label Sep 8, 2026
sini added 26 commits September 8, 2026 14:07
…rry-forward

The whitelist (name, meta, into, provides, __walkStamped) has to be
hand-extended for every new structural marker or it silently vanishes
mid-pipeline; __walkStamped was dropped this way earlier this week. Carry
forward every structural key present on the aspect except the ones the
parametric round-trip itself re-derives (name/meta handled explicitly here;
__fn/__args/__scopeHandlers/__ctxId/__parametricResolvedArgs rebuilt by
mkParametricNext/tagParametricResult; __functor/__functionArgs already
normalized away; __aspectChain/__providesForwarded re-derived by the walk
on re-entry).

This also fixes a live silent drop: a parametric wrapper's sibling
`includes` (preserved by normalize.nix's wrapFunctorChild) were rebuilt away
by mkParametricBase whenever the invoked function's result didn't carry its
own `includes`. Pinned by the new deadbugs cell.
The Task-17/18.2 equivalence proof between materializeUnified and the old
phase2(provides)∘phase3(routes) pipeline is finished. Delete the oracle arm:
unifiedEdges, legacyEdgeTrace, materializeEquiv (resolve.nix), edges/parity.nix,
and the five suites that forced them (fx-materialize-unified, fx-unified-edges,
fx-edge-parity, fx-edge-unification-gate, fx-oracle-production-differential).

A retired v1 phase-fold that still executes would otherwise still appear in
den's live trace results the gen rewrite is built against.
The performance suite declared zero hosts across all 8 sites, so a green
perf run certified only bare aspect-resolution walks, never entity or
fleet building. Adds one entity-building cell (host + host-nested user +
standalone home, each forcing a real nixosConfiguration/home-manager
build) and one fleet-scale cell (N=5 real hosts, each forced
individually via length∘filter to avoid builtins.all's short-circuit,
with an independent registration count as a control).
The synthetic `_`/`provides` aspect was built three times in
types.nix (root submodule, functor-carrying battery, nested
freeform key), and the three had drifted apart on three axes with
nothing in CI pinning any of the divergence.

mkUnderscore own path replaces all three: `own` supplies both the
child-key domain and the provides source, `path` names the
synthetic aspect. Ruling: a key held both as a provides child and a
direct key is included via its direct value — `_` is a total alias
for `provides`, not a filtered view of it. Pinned at all three
sites by a new deadbugs cell.
`excludes` typed as `listOf unspecified` accepted any value, including a
plain string — which `identity.key` then reduces to "<anon>", matching no
policy and silently excluding nothing. `includes` already rejects a bad
value via `providerType`'s check (attrs, function, or a list of policy
records); route `excludes` through the same type so both error alike.
…valence-oracle retirement

dec6133 deleted unifiedEdges/legacyEdgeTrace/materializeEquiv but left resolve.nix
importing routeEdges, extractEdgeTrace, assembleSubtree, applyProvidesEdges,
dedupProvides, and providesEdges without calling any of them, plus a dead local
applyRoutes wrapper. Removing those wrappers orphaned their own single callers:
edges/route.nix's applyRoutes fold and edges/provides.nix's applyProvidesEdges
fold (both superseded by materializeUnified's interleaved dispatch) and
edge-trace.nix's extractEdgeTrace (the differential suite it served was already
deleted). Verified each by repo-wide git grep -w before removal.
…e pipeline

mkDrained's post-assembly drain flat-lifted keys off the deferred child's
unapplied parametric shape, which is always empty — its content lives inside
__fn until bind resolves it. Walk each drainable child through mkPipeline
instead, mirroring scope-widen.nix's in-pipeline drain, and fold its
scopedClassImports into the drain scope. A walk that fans into more than one
scope throws rather than silently collapsing the fan (D1 §4.2, arm B).

Lands the three D1 oracle suites (d1probe, d1matrix, d1cycle) as deadbugs.
Per D1 §4.1, d1probe's arm 5 (pipe-deferred nested aspect key) ships as an
invariance cell paired with a plain non-deferred twin (arm 5b) rather than a
claimed fix — a nested aspect key never auto-walks, deferred or not.
`den.schema.<kind>.excludes` accepted a bare string and silently
excluded nothing — identity.key reduces a string to "<anon>", which
matches no policy. Same defect as den.aspects.*.excludes before
3c5b522, one tier up: that fix routed the aspect-tier field through
providerType, but gen-schema's freeform collections have no
per-collection type to route this through, so it reached every
schema kind unchecked. Validate in the collection's own merge
instead, at declaration time like the aspect-tier fix.
wrapperToAspect stamped both name and meta.aspect-chain onto every content
wrapper it produced, including one whose value it does not own -- an aspect
assigned to a nested key from another aspect's own name+chain (den.aspects.
group.key = den.aspects.other;). That discarded the author's own name and
identity for the position it happened to be written at, so hasAspect and
.identity disagreed with each other for a value that is present and
emitting, with no diagnostic.

Fill only the components the value does not already carry: skip the name
when it is present and meaningful (isMeaningfulName, matching normalize.nix
and has-aspect.nix's isNamed), and skip the chain when meta.aspect-chain is
already set. Guarding the name alone is insufficient -- it leaves the chain
null, handing the identity to the inclusion walk instead of the authoring
position, so the same value included by two owners lands under two
prefixes. Den's own sentinel names ("<anon>", ...) are not an author's
name and stay overridable by position.

Extends test-alias-merged-aspect to assert identity, not only delivery, and
adds three new deadbugs cells covering both wrapperToAspect attribution
sites (depth-1 provider, depth-2 annotateChildren), the sentinel-name
carve-out, and a regression fence over raw-value equality (compile-static's
chain fill is unaffected since the mechanism always leaves meta.aspect-chain
non-null).
…ect option

mkUnderscore filtered a provides child's NAME through structuralKeysSet — the
set that classifies an aspect's OWN top-level keys for content dispatch. A
provides child named after an ordinary aspect option (description, meta,
name, includes, excludes, provides, policies, into, classes) was silently
dropped: no error, absent from .provides/._, and the option's own default
read back in its place.

The only genuine machinery at this seam is the __-prefix pipeline-internal
convention and the single key `_` (a multi-def nested key's content wrapper
injects a literal `_` alongside its real children — see
multidef-provides-internals.nix). Every other structural-key name now
survives; the aspect's own declared option keeps top-level priority via its
always-present default (mergeWithAspectMeta's existing merged-shadow check).
…uction site

mkUnderscore's three construction sites all compute unshadowedProvides, but
mergeFunctions' attrset-with-__functor branch (site B — the shape a
functor-carrying battery like import-tree/forward takes) never published it
as __providesForwarded. classifyKeys reads that marker to skip a forwarded
provides child during classification; missing it at B meant a battery's
forwarded provides child was classified as the battery's own content where
sites A and C both correctly skip it.
extractEdgeTrace (the retired full-union oracle) was removed in 1dba9f1,
leaving edge-trace.nix's own `synthesize` import unused — synthesize edges
now arrive pre-built through routeEdges rather than being constructed
directly here — and the top-level `rec` with nothing left to self-reference.
`synthesize` itself stays alive and load-bearing in edges/edge.nix, route.nix,
and toposort.nix; only this file's unused import of it is removed.
Mirrors the sibling excludes fix (7be5768): a bare string (or other
non-aspect value) in den.schema.<kind>.includes used to reach
children.nix's aspect walk unchecked and crash with a raw Nix
"expected a set but found a string" from propagateScope's `//`. This
freeform gen-schema collection has no per-collection type to route the
value through first, so validate at merge time instead, recursing into
nested lists the same way processInclude walks them. Message quality
only — it already errored, and keeps erroring on exactly the same
inputs, now with a den:-prefixed message.
…e scope-fork count

D1 F1: the walkedScopeIds > 1 throw landed for §4.2 was dead code — push-scope
can only fire from inside policy dispatch, which this walk never runs
(installPolicies gates on __entityKind, attached only via the resolve-entity
effect, never reached here), so the walk can never fan into more than one
scope. Worse, the fix as landed introduced a new silent-drop mode: a
pipe-arg-deferred child carrying both direct class content and a policy
effect (route/instantiate/provide/aspect-policy) or a still-deferred nested
include delivered the direct half and dropped the other with no diagnostic,
since this walk registers those effects without ever dispatching them.

Guard on that residue instead: throw when any of the five scoped-effect
state maps (scopedAspectPolicies, scopedRoutes, scopedInstantiates,
scopedProvides, scopedDeferredIncludes) hold something for the walk, rather
than deliver scopedClassImports alone and lose the rest quietly.

Known gap, not closed here: a deferred child whose own includes fans over an
entity arg leaves no residue in any of these maps and still loses content
silently — that needs bind's entity-arg fan classification, a different
position entirely (D1 F1 arm C).
ci.bash's --select marked every expectedError cell as passing without
forcing its expr, so a cell asserting an error read green even when the
code stopped throwing. Force it through tryEval instead. Only the
binary throw/no-throw crosses the nix-eval-jobs worker boundary, so
type and message verification still needs nix-unit directly; the header
now says so rather than claiming these cells are ignored.
The expectedError cell alongside this one discriminates only under raw
nix-unit. Assert the same throw through tryEval so the gate sees it.
…name

`isPolicyExcluded` resolved every rawRef exclude entry to its target identity
and flattened the whole scoped constraint registry, neither of which depends on
the candidate name being tested. Both were inside the four-argument call the two
callers made from within a `filterAttrs` lambda, so the entire O(E + R × D × C)
term was recomputed for each of P policy names.

Curry the function so `name` is the last argument and hoist the partial
application out of both lambdas. The rawRef arm now resolves every entry rather
than stopping at the first match: which entries got resolved previously depended
on the order candidate names arrived in, so an error reachable through
resolveClaim surfaced for some dispatch orders and not others.
…forward

normalizeRoot's needsWrap branch rebuilt a functor-shaped ROOT aspect from
name/meta/includes/__scopeHandlers, dropping excludes, provides, policies,
into, classes and every __ marker. Carry every structural key forward
except the ones the unwrap itself re-derives, matching mkParametricBase.

The branch is reached whenever an aspect carrying a user-written __functor
is resolved as a root (the synthetic resolveAspectWith is a non-pattern
lambda and reports no functionArgs, so ordinary aspects never enter it).
The new cell pins a dropped sibling `excludes`; its non-functor twin is the
control that excludes are otherwise live at that root.
builtinStructuralKeys enumerated its twelve __-prefixed entries, so a new
pipeline marker was structural only once someone remembered to list it
here; until then it was dispatched as class or nested-aspect content.
CLAUDE.md already states the convention, and types.nix's providesChildren
already applies it as a live predicate.

structuralKeysSet becomes isStructuralKey, a predicate — every reader was
a membership test, none iterated the set, so no reader loses a list. The
eleven non-__ names and den.reservedKeys stay listed and unchanged.
isChildKey drops its now-redundant hasPrefix term.
deferHandler's stub and deferConditional's stub read like carry-forward
whitelists and are not: the real aspect is queued intact and resolves in
full at the drain, while the stub only registers a deferred identity.
Verified before writing — the three resolve-complete handlers in the tree
(identity.collectPathsHandler, trace.nix's two) read name, meta,
__entityKind and __ctxId, none reads includes; resolvedNodes' only
consumer (has-aspect.nix's augment) reads name and meta.aspect-chain.
`fx.aspect` re-exported the key predicate with no reader in or out of the
module. The dead export predates the rule refactor — `structuralKeysSet` was
re-exported here and equally unread — so it was renamed rather than introduced.
Leaving it tells the next reader the seam is load-bearing when nothing crosses it.
…l drain

bind rules an aspect inert when an entity arg is neither in-ctx nor a
descendant. That silence is correct in the main pipeline — the aspect is
delivered at another scope — but the post-assembly drain is terminal, so
there is no later scope and the content is gone with no trace in any of
the five scoped-effect maps the drain guard scans.

Record the verdict where it is made (bind) and read it where terminality
is known (the drain's residueKinds), rather than inferring a loss from an
absent scopedClassImports, which is indistinguishable from an aspect that
legitimately delivers nothing. Only the misplaced-entity-arg verdict is
recorded: zero-children has no target to deliver to, and the
shared-with-descendant verdict is double-cover avoidance, where the
descendant does receive the content.
…excludes

`providerType`'s check names a list of policy records as a valid element, so
`excludes = [ [ policy ] ]` type-checked and then excluded nothing: with no list
arm in `excludeIdentity`, `identity.key` reduced the list to "<anon>" and matched
no policy. `includes` flattens the same value and delivers, so the two disagreed
over a shape their shared type calls valid.

This is the silent no-op 3c5b522 closed for bare strings, still open over the
shape that commit's own type admits. Its message claimed routing `excludes`
through `providerType` made "both error alike"; they did not.

Also record why bind's shared-with-descendant verdict is not reported to the
terminal drain. Double-cover avoidance is not a vanished delivery, so recording
it would throw on correct behaviour, and no cell can catch that mistake: the
drain walk starts a fresh pipeline where scopeKind is null, which makes recording
all three inert sites observationally identical to recording one.
…ites

mkUnderscore's shadow comment held only at the declared-submodule site.
The two raw sites — providerType.merge's battery attrset and
aspectContentType's nested key — have no submodule and no option
defaults to win the providesChildren fold, so a provides child named
name/description/meta/includes landed in the aspect's own option
position and was read structurally from there. provides.name at a raw
site became the aspect's own name and died coercing a set to a string.

Reserve those names from the forward only; .provides/._ still reach
every child, so _ stays a total alias for provides.
…e claims

ci.bash sent nix-eval-jobs' stderr to /dev/null, so a run the evaluator
could not finish produced EXIT 1, no summary, zero failures tallied and
nothing saying why. Route it to a file, read PIPESTATUS rather than
letting set -e abort the summary, and print it.

The rest are comments and docs that a commit made false:
the residue guard's 'any of the scoped-effect maps' (it is six of
fourteen, and the three exclusions are derived, not measured); the two
deferral stubs' 'name/meta and nothing else' (also __entityKind and
__ctxId); the record-inert handler rule, recorded at the fx.send site
where the next author will meet it; meta.provider in the public
reference, renamed to meta.aspect-chain and silently absorbed by meta's
freeform type if written; and five citations to deleted identifiers and
retired suites, one of which claimed coverage from a suite that no
longer exists.
…lution

An exclude naming a policy record den never found resolved to null, was
filtered out, and suppressed nothing with no diagnostic.

The check is global, not per-scope: resolveRawRefIdentity returning null
for one scope is correct behaviour — a host-scope exclude reaches every
descendant scope and the policy it names is typically claimed in only
some of them — so warning from isPolicyExcluded would fire on every
legitimate multi-scope exclude. It reads the terminal state at
fxResolveFull's post-assembly position instead, where both the constraint
registry and policyClaimsByName are complete.
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.

1 participant