I tried the security constraints idea against the repo. A package signals what it needs, the signal carries the full constraint description, and the handler picks a compiler based on that:
# Compiler records with some kind of security feature metadata
gcc-hardened = {
name = "gcc-hardened";
features = { stackProtector = true; fortifySource = true; fips = false; };
};
gcc-fips = {
name = "gcc-fips";
features = { stackProtector = true; fortifySource = true; fips = true; };
};
# Does every required feature match what the compiler provides?
checkConstraints = required: provided:
builtins.all (name:
provided.${name} or false == required.${name}
) (builtins.attrNames required);
# The package declares its security requirements.
# If the compiler in scope satisfies them, use it.
# Otherwise, signal with the full constraint description.
requireCC = constraints:
conditions.withRestart "use-compiler" (cc: pure cc)
(mapM (env:
let cc = env.cc or null;
in if cc != null && checkConstraints constraints (cc.features or {})
then pure cc
else conditions.signal {
type = "security-constraint";
inherit constraints;
provided = if cc != null then cc.features or {} else {};
}
) state.get);
The handler reads the constraint from the signal and picks the right compiler:
# Handler inspects what was required and chooses accordingly
smartHandler = cond:
let needed = cond.constraints;
in conditions.invokeRestart "use-compiler"
(if needed.fips or false then gcc-fips else gcc-hardened);
# No compiler in scope. Signal fires, handler reads fips = true, provides gcc-fips.
runFx (provide {} (
conditions.handle "security-constraint" smartHandler
(requireCC { fips = true; })
))
# => gcc-fips
# Same handler, different requirement. Reads stackProtector + fortifySource, provides gcc-hardened.
runFx (provide {} (
conditions.handle "security-constraint" smartHandler
(requireCC { stackProtector = true; fortifySource = true; })
))
# => gcc-hardened
In my mind, the condition is the effect row here: it describes the security requirement as data, the handler satisfies it. Same negotiation as the DI case, but now the package and handler are negotiating security properties.
stream.flatMap already does constraint search over a compiler pool. Singleton streams per compiler, flatMap with a predicate to filter, done to drop non-matches. Stack two filters and the intersection falls out. observeAll enumerates solutions, observe takes the first. That seems to be the kind of “fill logic-variable-holes via search” you described, i.e., you say what you need and search the space rather than construct through it.
Then there’s the encoding question. In context-passing, requireCC is pending { ability = <lambda> }. You can run it, but you can’t see what constraints it will signal until you do. A freer encoding gives you Request "cc" { fips = true; } (cc -> ...), pattern-matchable without execution. The dependency description is the program, readable before any handler runs. Both encodings express the security constraints fine. The difference is whether you can audit statically (freer) or only dynamically (context-passing). If you want it to be able to show you every security requirement in this package set without building anything, then freer becomes a necessity.
I do keep coming back to CL the more I think about this. callPackage is basically dynamic binding without the language knowing about it. CL special variables have dynamic extent, closer to what rw.local does. Guix inherits that from Scheme. It seems to me that Nix reinvented the pattern without the tradition. We then have the same underyling question: how much does the language let you see about what a computation will do before you run it.
Lots of stuff to explore 