fix: resolve identity by definition value and split the provenance field - #678
Draft
sini wants to merge 56 commits into
Draft
fix: resolve identity by definition value and split the provenance field#678sini wants to merge 56 commits into
sini wants to merge 56 commits into
Conversation
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.
…-bound aspect claims one identity"
…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.
…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.
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.
#670
Summary
Identity:
providerType.merge, so two owners writing their own inline aspect of one name stay distinct while a sharedlet-bound value included twice keeps one identity.__walkStampedthrough the parametric-resolve round-trip, so a walk-stamped child does not have its position encoded a second time.Policy:
mkPolicy "tools"records in different aspects both fire while a sharedden.policies.fooincluded from two aspects fires once.excludesby 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:
_-to-providesfold through one shared helper instead of constructing it separately per site, asmergeFunctions' battery branch was dropping a writtenfn._on the floor rather than folding it.mkAliasOptionModuleout 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.Provenance:
providerPrefixintooriginfor the container seed andchainfor 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:
insecureandunfreebatteries keep their identities on a stock configuration.Structural keys:
__-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.normalizeRoot's functor whitelist with the same structural carry-forwardmkParametricBaseuses, as that branch was dropping a functor-shaped root'sexcludesoutright.aspectKeyType.mergeshare 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:
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.Excludes:
excludesso a list-wrapped policy reference excludes, becauseproviderTypenames a list of policy records as valid while the reader had no list arm and reduced it to<anon>.Provides:
Performance:
Tooling:
ci.bashand reports a dead evaluator separately from a test failure, becausetotalis computed aspass + failover whatever reached the JSON stream, so a run that stopped early read as a cleanN/Nwith the diagnostic discarded.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:
mkPolicyrecords sharing a bare name in different aspects both fire, where one previously displaced the other.excludesand other structural siblings, whichnormalizeRootwas dropping outright.includesacross the resolve round-trip.._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.nameoutranks its nested position when the two disagree.__-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:
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.bashreports a dead evaluator as💥 EVALUATOR FAILEDwith the evaluator's stderr, rather than discarding it and printing a tally over whatever the run reached.Renamed:
meta.provideris nowmeta.aspect-chain, and an absent chain is distinguished from a root one (nullagainst[ ]).metaTypeis freeform, so a stalemeta.provideris 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 theresolve.test-chain-100canary and the entity and fleet cells this branch added.ci.bashcomputestotalaspass + fail, which makesN/Nan identity that holds on a truncated run.ci.bashchange 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.originagainst gen's own source rather than by name: seeding it makes a nested chain read additively through the subtree, matching gen'sorigin ++ path.excludeslist-element drop and the provides-forward site disagreement.