Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
ad5fb84
refactor: rename the provenance chain to aspect-chain
sini Sep 2, 2026
64a6af2
test: move the chain-doubling test to internal-api
sini Sep 2, 2026
2f2afa2
fix: distinguish absent aspect-chain from root
sini Sep 2, 2026
29fd53b
test: pin the identity of a bare parametric fn at a nested provides key
sini Sep 3, 2026
a5374d3
fix: stop a self-provide wrapper's stale meta from overriding its chain
sini Sep 3, 2026
71e27cc
feat: push the walk chain as a segment list alongside its rendering
sini Sep 3, 2026
5936a43
fix: derive the pushed chain identity from its segment list
sini Sep 3, 2026
d652b3d
fix: make every non-inline aspect state its own chain
sini Sep 4, 2026
6e880c7
test: discriminate the raw-fixture chain cell against defaults.nix
sini Sep 4, 2026
c3529de
fix: fill an absent aspect-chain from the walk's current position
sini Sep 4, 2026
c99f8bb
fix: state each shipped predicate battery's own chain segment
sini Sep 5, 2026
a8b5d29
fix: mark a walk-stamped name at the point it is stamped
sini Sep 5, 2026
77be802
fix: carry __walkStamped through the parametric-resolve round-trip
sini Sep 5, 2026
27a5323
fix: key a definition-position chain registry so a shared let-bound a…
sini Sep 5, 2026
f955cb8
Revert "fix: key a definition-position chain registry so a shared let…
sini Sep 5, 2026
5db69fd
fix: key aspect identity by raw definition-value equality, not positi…
sini Sep 5, 2026
8e2c133
fix: compare the whole raw value and keep a list of claims per def-po…
sini Sep 5, 2026
430e16c
docs: record the claim-list guard's true cost, superlinear in distinc…
sini Sep 5, 2026
72590a5
fix: split colliding same-named policy identities per scope
sini Sep 5, 2026
0334b86
fix: qualify displaced policy identity by claim index and self-or-anc…
sini Sep 5, 2026
d2c27ae
fix: resolve policy excludes by raw claim value, not bare name
sini Sep 8, 2026
cebe19e
fix: drop the absence-case exclude fallback, pin the bound in isPolic…
sini Sep 8, 2026
ac67a84
fix: correct isPolicyExcluded's recorded cost bound to O(P×(E+R×D×C))
sini Sep 8, 2026
3ccc828
fix: describe isPolicyExcluded's E bound as a count, not a depth
sini Sep 8, 2026
ac5c50f
fix: fold `_` into `provides` once, before every construction site
sini Sep 8, 2026
8ef892a
test: invert the nested-conflict guard to a green divergence pin
sini Sep 8, 2026
487c54a
fix: pin the mergeFunctions battery fix, correct root's no-op basis
sini Sep 8, 2026
776e3cf
test: pin the battery underscore fold through a real aspect declaration
sini Sep 8, 2026
e544361
refactor: split providerPrefix into origin (seed) and chain (accumula…
sini Sep 8, 2026
4000f44
test: pin battery and namespace root chains directly
sini Sep 8, 2026
d74c4f4
fix: replace mkParametricBase's explicit whitelist with structural ca…
sini Sep 8, 2026
dec6133
fix: retire the delivery-edge equivalence oracle
sini Sep 8, 2026
d11b0e9
test: add entity- and fleet-scale performance cells
sini Sep 8, 2026
bcb84ee
refactor: unify the three `_` constructions behind one mkUnderscore
sini Sep 8, 2026
3c5b522
fix: reject a bare string in an aspect's `excludes`
sini Sep 8, 2026
1dba9f1
fix: remove the resolve.nix edge-oracle bindings orphaned by the equi…
sini Sep 8, 2026
2bedbaa
fix: drain a pipe-arg-deferred aspect-level include by re-entering th…
sini Sep 8, 2026
7be5768
fix: reject a bare string in a schema-tier excludes list
sini Sep 9, 2026
7973c8a
fix: let an aliased aspect's authored name outrank its nested position
sini Sep 9, 2026
f8756cc
fix: let a provides child's name survive when it collides with an asp…
sini Sep 9, 2026
58d5726
fix: emit the forwarded-provides marker at the functor-battery constr…
sini Sep 9, 2026
764507e
chore: remove dead bindings orphaned by the extractEdgeTrace removal
sini Sep 9, 2026
b5af260
fix: prefix den.schema.*.includes bad-element error with den:
sini Sep 9, 2026
00b0af8
fix: guard the drain walk's undeliverable residue, not its unreachabl…
sini Sep 9, 2026
90f47f5
fix: check that an expectedError cell actually throws
sini Sep 9, 2026
b6b2c40
test: pin the residue guard with a gate-visible twin
sini Sep 9, 2026
825b5d2
perf: resolve rawRef excludes once per dispatch, not once per policy …
sini Sep 9, 2026
08b82b3
fix: replace normalizeRoot's functor whitelist with structural carry-…
sini Sep 9, 2026
aad65c8
refactor: close the structural-key registry by rule for __ markers
sini Sep 9, 2026
a206a21
docs: record that the two deferral stubs are minimal on purpose
sini Sep 9, 2026
6bf4d24
refactor: drop the unread isStructuralKey re-export
sini Sep 9, 2026
3549c12
fix: make an inert entity-fan verdict leave a residue for the termina…
sini Sep 9, 2026
6127cbc
fix: flatten an aspect's excludes so a list-wrapped policy reference …
sini Sep 9, 2026
71e6ec1
fix: reserve structural names from the provides forward at both raw s…
sini Sep 9, 2026
8fd5c6b
fix: surface the evaluator's stderr in ci.bash, and correct nine stal…
sini Sep 9, 2026
ce38772
feat: warn when a raw-ref exclude matches no claim anywhere in a reso…
sini Sep 9, 2026
463ae0a
fix: honour includes and excludes written on an entity instance
sini Sep 15, 2026
eb16ea5
fix: make the two schema-tier collections share one element check
sini Sep 15, 2026
cb5439b
docs: correct three claims this branch left stale
sini Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/den-debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ When a bug involves content wrappers, check whether the forwarded (shallow-merge
Look for structural markers that distinguish the working path from the broken path. In den's pipeline, common differentiators:

- `__contentValues` — present on content wrappers from `aspectContentType`, absent on sub-aspects from `emitNestedAspect` and full aspects from `aspectSubmodule`
- `__provider` — tracks the definition path through nested aspects
- `__aspectChain` — tracks the definition path through nested aspects
- `__providesForwarded` — keys forwarded from `provides` onto the aspect
- `__fn` / `__args` — parametric wrappers
- `__scopeHandlers` — context propagation
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ New test files must be `git add`'d before nix can evaluate them. Use `--override
- Idiomatic Nix: use `lib.optional` `lib.optionals` `lib.optionalAttrs` for basic conditionals
- Idiomatic Nix: avoid `with` — prefer `inherit` to bring names into scope. `with` obscures where bindings come from and breaks tooling.
- Error messages: prefix with `den:` for traceability (e.g., `throw "den: multiple __functor definitions at ..."`)
- Internal markers: double-underscore prefixed attrs (`__contentValues`, `__provider`, `__fn`) are pipeline internals. Don't add new ones without understanding the classification and structural key filtering in `key-classification.nix`.
- Internal markers: double-underscore prefixed attrs (`__contentValues`, `__aspectChain`, `__fn`) are pipeline internals. Don't add new ones without understanding the classification and structural key filtering in `key-classification.nix`.
- Commenting: comments should describe why not what, code should be self documenting as to what
- Minimal changes: fix the bug, don't refactor surroundings
- Diagnose before reverting: the fix is usually one targeted change
Expand Down
53 changes: 48 additions & 5 deletions ci.bash
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
#!/usr/bin/env bash
#
# Uses nix-eval-jobs with $(nproc) workers
# NOTE: Ignores tests with expectedError
# NOTE: expectedError cells only verify that expr throws SOMETHING (via
# tryEval, in the --select expression below). They do not verify
# expectedError.type/.msg — nix-eval-jobs runs each job out-of-process, so
# only tryEval's success/failure crosses that boundary, not the caught
# exception's details. Use `nix-unit` directly (`just ci-deep`/`just test`)
# for full type/msg verification.
#
# Redirect stdout to null IF you only want to see failures
set -aeuo pipefail
Expand Down Expand Up @@ -48,13 +53,18 @@ if test -n "$testFilter"; then
fi

results=$(mktemp -t den-test-XXXXX.json)
evalLog=$(mktemp -t den-test-XXXXX.err)

# Cap workers and per-worker memory to prevent OOM from infinite recursion.
# nproc can be very high (32+); limit workers so worst-case memory is bounded.
max_workers=8
mem_per_worker=2048 # MiB
workers=$(( $(nproc) < max_workers ? $(nproc) : max_workers ))

# set +e around the pipeline: under `set -e` a dying evaluator aborts the
# script here, so the summary below never runs and the exit status arrives
# with nothing said. Read PIPESTATUS instead and report it.
set +e
nix-eval-jobs \
--flake ./templates/ci#tests${preSuite} \
--override-input den . \
Expand All @@ -68,8 +78,16 @@ nix-eval-jobs \
let
hasExpected = v ? expected && !(v.expected ? undefined);
hasExpectedError = v ? expectedError && !(v.expectedError ? undefined);
# nix-eval-jobs runs each job in a separate worker; only
# tryEval'\''s success/failure crosses that boundary, not the
# caught exception'\''s type/msg text. So this only proves expr
# throws SOMETHING — closing the class where a fix silently stops
# throwing and the cell still reads green. It does not verify
# expectedError.type/.msg; only `nix-unit` does that (it uses
# the evaluator'\''s C++ API directly to inspect the exception).
pass = if hasExpected then v.expr == v.expected
else if hasExpectedError then true # ignored
else if hasExpectedError then
!(builtins.tryEval (builtins.deepSeq v.expr null)).success
else true;
name = builtins.replaceStrings ["." "'\''"] ["-" "_"] prefix;
in derivation {
Expand All @@ -81,17 +99,34 @@ nix-eval-jobs \
builtins.mapAttrs (k: go (if prefix == "" then k else "${prefix}.${k}")) v
else derivation { name = "SKIP"; system = "${system}"; builder = "/bin/sh"; args = ["-c" "echo > $out"]; };
in builtins.mapAttrs (k: go k) tests' \
"${args[@]}" 2>/dev/null \
"${args[@]}" 2>"$evalLog" \
| tee "$results" \
| jq -r 'if (.name != null and (.name | startswith("PASS-"))) then "✅ '"${postSuite}"'" + .attr else empty end'
evalStatus=${PIPESTATUS[0]}
set -e

# A dead evaluator is not a test failure and must not be tallied as one:
# `total` below is pass+fail over whatever reached the JSON stream, so a run
# that stopped early still reads as a clean N/N with zero failures. The
# evaluator's stderr is the only thing that says which file and line killed
# it, so it is a file now rather than /dev/null.
if [ "$evalStatus" -ne 0 ]; then
echo >&2
echo "💥 EVALUATOR FAILED (nix-eval-jobs exit ${evalStatus})" >&2
echo "The run stopped early — no tally covers what it did not reach." >&2
echo >&2
cat "$evalLog" >&2
rm -f "$evalLog" "$results"
exit "$evalStatus"
fi

pass=$(jq -r 'select(.name != null and (.name | startswith("PASS-"))) | "."' "$results" | wc -l)
fail=$(jq -r 'select(.error != null or (.name != null and (.name | startswith("FAIL-")))) | "."' "$results" | wc -l)
total=$(expr "$pass" + "$fail")

if [ "$fail" -eq "0" ]; then
echo "🎉 ${pass}/${total} successful" >&2
rm "$results" || true
rm -f "$results" "$evalLog" || true
else
echo >&2
echo "💥 FAILURES (${fail}):" >&2
Expand All @@ -101,6 +136,14 @@ else
jq -r 'select(.error != null or (.name != null and (.name | startswith("FAIL-")))) | "❌ '"${postSuite}"'" + .attr' "$results" >&2
echo >&2
echo "😢 ${pass}/${total} successful" >&2
rm "$results" || true
# Only when the evaluator actually said something. An ordinary assertion
# failure leaves nothing here but nix's lock-file warnings, and burying the
# list of failures under those is how a diagnostic stops being read.
if grep -q "^error:" "$evalLog"; then
echo >&2
echo "--- evaluator stderr ---" >&2
cat "$evalLog" >&2
fi
rm -f "$results" "$evalLog" || true
exit 1
fi
32 changes: 22 additions & 10 deletions docs/src/content/docs/reference/aspects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ registered class), *nested aspect keys*, or one of these structural keys:
|-----|------|---------|
| `<class>` | module / config | Config merged into entities of that class |
| `includes` | `listOf provider` | Providers (aspects, sub-aspects, functions) pulled into this aspect |
| `excludes` | `listOf unspecified` | Aspects or policies excluded from this subtree |
| `excludes` | `listOf provider` | Aspects or policies excluded from this subtree |
| `provides` / `_` | submodule | Sub-aspect namespace (`_` is an alias for `provides`) |
| `policies` | policy registry | Named policy functions, activated by placing in `includes` |
| `meta` | submodule | Attached metadata (`handleWith`, `provider`, `collisionPolicy`; `guard`/`aspects` for conditional aspects) |
| `meta` | submodule | Attached metadata (`handleWith`, `aspect-chain`, `collisionPolicy`; `guard`/`aspects` for conditional aspects) |
| `classes` | `lazyAttrsOf raw` | Class schemas declared by this aspect, merged into `den.classes` |
| `name` / `description` | `str` | Aspect name and description |

Expand Down Expand Up @@ -114,17 +114,29 @@ for the full API and examples.
longer exists.
</Aside>

### `meta.provider`
### `meta.aspect-chain`

Type: `listOf str` (internal, read-only). Default: the provider prefix.
Type: `listOf str` or `null` (internal, read-only). No default -- a declared
aspect sets its own chain.

Tracks the structural origin of an aspect as a path. Top-level aspects have
`meta.provider = []`. An aspect provided by `foo` (via `foo.provides.bar` or
its alias `foo._.bar`) has `meta.provider = ["foo"]`. Deeply nested
providers accumulate: `foo._.bar._.baz` has `meta.provider = ["foo" "bar"]`.
Tracks the structural origin of an aspect as a path. A top-level aspect has
`meta.aspect-chain = []`. An aspect provided by `foo` (via `foo.provides.bar`
or its alias `foo._.bar`) has `meta.aspect-chain = ["foo"]`. Deeply nested
providers accumulate: `foo._.bar._.baz` has
`meta.aspect-chain = ["foo" "bar"]`.

The `meta.provider` list distinguishes aspects by origin during pipeline
resolution.
`null` and `[]` are different values. `[]` means "root -- the chain is
empty"; `null` means "no chain set", which is what an inline `includes`
literal carries until something fills it in. Absence, not root.

The chain distinguishes aspects by origin during pipeline resolution.

<Aside type="caution">
This field was called `meta.provider`. `meta` has a freeform type, so a
`meta.provider = [ "foo" ]` written today is absorbed into freeform meta and
silently ignored -- no error, no warning, and the chain stays whatever the
pipeline derives.
</Aside>

### `meta.guard` / `meta.aspects` -- conditional aspects

Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries.nix
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
type = lib.types.submodule {
freeformType = lib.types.attrsOf (
(config.den.lib.aspects.mkAspectsType {
providerPrefix = [
origin = [
"den"
"batteries"
];
Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries/import-tree.nix
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
in
{
name = "import-tree(${baseNameOf rootStr})";
meta.provider = [
meta.aspect-chain = [
"den"
"batteries"
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ let
};
};

# Chain segments state each child's position under "insecure-predicate"
# explicitly rather than letting the name carry it — a name can never
# double-encode an ancestor that's already named as its own chain segment.
osAspect =
{ host }:
{
name = "insecure-predicate/os";
name = "os";
meta.aspect-chain = [ "insecure-predicate" ];
}
# A synthetic host identity (from a `user@host` home with no declared host)
# has no class output, so there is nothing to import into. Guard like
Expand All @@ -42,7 +46,8 @@ let
userAspect =
{ host, user }:
{
name = "insecure-predicate/user";
name = "user";
meta.aspect-chain = [ "insecure-predicate" ];
}
// lib.optionalAttrs (lib.elem "homeManager" user.classes) {
homeManager.imports = [ insecureModule ];
Expand All @@ -51,14 +56,19 @@ let
homeAspect =
{ home }:
{
name = "insecure-predicate/home";
name = "home";
meta.aspect-chain = [ "insecure-predicate" ];
}
// lib.optionalAttrs (home ? class) {
${home.class}.imports = [ insecureModule ];
};

aspect = {
name = "insecure-predicate";
# Stated explicitly, not left to fill from the walk: this is included
# from den.default, and without its own chain it would inherit
# den.default's position instead of staying a root.
meta.aspect-chain = [ ];
inherit description;
includes = [
osAspect
Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries/insecure/insecure.nix
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let

__functor = _self: allowed-names: {
name = "insecure(${builtins.concatStringsSep "," allowed-names})";
meta.provider = [
meta.aspect-chain = [
"den"
"provides"
];
Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries/tty-autologin.nix
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let

__functor = _self: username: {
name = "tty-autologin(${username})";
meta.provider = [
meta.aspect-chain = [
"den"
"provides"
];
Expand Down
16 changes: 13 additions & 3 deletions modules/aspects/batteries/unfree/unfree-predicate-builder.nix
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ let
};
};

# Chain segments state each child's position under "unfree-predicate"
# explicitly rather than letting the name carry it — a name can never
# double-encode an ancestor that's already named as its own chain segment.
osAspect =
{ host }:
{
name = "unfree-predicate/os";
name = "os";
meta.aspect-chain = [ "unfree-predicate" ];
}
# A synthetic host identity (from a `user@host` home with no declared host)
# has no class output, so there is nothing to import into. Guard like
Expand All @@ -42,7 +46,8 @@ let
userAspect =
{ host, user }:
{
name = "unfree-predicate/user";
name = "user";
meta.aspect-chain = [ "unfree-predicate" ];
}
// lib.optionalAttrs (lib.elem "homeManager" user.classes) {
homeManager.imports = [ unfreeModule ];
Expand All @@ -51,14 +56,19 @@ let
homeAspect =
{ home }:
{
name = "unfree-predicate/home";
name = "home";
meta.aspect-chain = [ "unfree-predicate" ];
}
// lib.optionalAttrs (home ? class) {
${home.class}.imports = [ unfreeModule ];
};

aspect = {
name = "unfree-predicate";
# Stated explicitly, not left to fill from the walk: this is included
# from den.default, and without its own chain it would inherit
# den.default's position instead of staying a root.
meta.aspect-chain = [ ];
inherit description;
includes = [
osAspect
Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries/unfree/unfree.nix
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let

__functor = _self: allowed-names: {
name = "unfree(${builtins.concatStringsSep "," allowed-names})";
meta.provider = [
meta.aspect-chain = [
"den"
"provides"
];
Expand Down
2 changes: 1 addition & 1 deletion modules/aspects/batteries/vm-autologin.nix
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let

__functor = _self: username: {
name = "vm-autologin(${username})";
meta.provider = [
meta.aspect-chain = [
"den"
"provides"
];
Expand Down
10 changes: 10 additions & 0 deletions modules/aspects/defaults.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
type = den.lib.aspects.types.aspectType;
};

# aspectType's meta-injecting merge only fires for options built through a
# container that calls the element type's merge directly (den.aspects is
# attrsOf aspectType). den.default is a bare top-level submodule option:
# nixpkgs expands its nested-path definitions (den.default.includes = ...)
# via the type's getSubOptions instead, which never calls the overridden
# merge, so aspectMeta's mkDefault chain never lands and this reads null.
# den.default is unambiguously a root — it is broadcast, never included by
# anyone — so it states its own chain rather than relying on injection.
config.den.default.meta.aspect-chain = lib.mkDefault [ ];

# Inject den.default as a schema include for all entity kinds so
# default aspects are resolved automatically. This replaces the old
# *-to-default policies (host-to-default, user-to-default, home-to-default)
Expand Down
34 changes: 34 additions & 0 deletions modules/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,38 @@ let
# lazily at eval time, so they safely use den.lib.schema.
schemaLib = import ./../nix/lib/schema.nix { inherit inputs lib; };

# Element check for BOTH schema-tier collections. gen-schema has no
# per-collection `type` to route a bad element through, so the check that the
# aspect tier gets from `providerType` has to live here — and it has to be
# ONE check: the two collections take the same references, so a shape that
# excludes at the aspect tier must not throw at the schema tier.
#
# A bare string was the original defect at both. Unchecked, it reached
# children.nix's aspect walk and crashed with a raw Nix `expected a set but
# found a string` from propagateScope's `//` on the includes side, and on the
# excludes side `identity.key` reduced it to "<anon>", which matches no
# policy and so excluded nothing in silence.
#
# Recurses into nested lists because `providerType` names a list of policy
# records as a valid element and children.nix walks nested lists the same way
# at both tiers (`processInclude`, and `lib.flatten` over excludes since
# 6127cbc). Admits a function because a parametric aspect reference is one.
checkCollectionElement =
collection:
let
check =
v:
if builtins.isList v then
map check v
else if builtins.isAttrs v || lib.isFunction v then
v
else
throw "den: den.schema.<kind>.${collection}: expected a policy or aspect reference, got ${
if builtins.isString v then ''"${v}"'' else builtins.typeOf v
}";
in
check;

classSchemaType = lib.types.submodule (
{ ... }:
{
Expand Down Expand Up @@ -74,9 +106,11 @@ in
collections = {
includes = {
default = [ ];
merge = acc: val: acc ++ map (checkCollectionElement "includes") val;
};
excludes = {
default = [ ];
merge = acc: val: acc ++ map (checkCollectionElement "excludes") val;
};
isEntity = {
default = false;
Expand Down
Loading
Loading