| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Core.Rules
Description
The policy rules engine.
A rule set is evaluated against a single PackageDetails snapshot to produce a
Decision. The model is deny by default; the boot order decides: the configured
rules are arranged once, at boot, into a single total order (bootOrder) -- highest
precedence first, then rule name ascending -- and evaluation walks that order and
takes the first decisive result. A result is decisive iff the rule returned a
decisive verdict (Allow, Deny, or a fail-closed CannotVet), or the harness
resolved a faulted evaluation fail-closed (); a
non-decisive verdict (Unavailable _ FailDeny _NoDecision, a fail-open CannotVet) or a fail-open fault
() is a non-decisive no-op whose reason is
collected, in boot order, for the deny-by-default audit trail. If no rule is decisive
the package is Unavailable _ FailNoDecision _BlockedByDefault.
A rule is evaluation-agnostic data; how it is evaluated is a separate concern. The
closed built-in vocabulary (Rule) says what a rule is;
evalRule is the single dispatch that says how each built-in rule decides, closing
over the boot-bound capabilities in RuleDeps. The engine's runtime structure is the
PreparedRule: it pairs a rule's boot-order identity (precedence and name) with the
raw per-version evaluator and an optional Resilience policy. prepare builds one
per configured rule; the pure built-ins carry no Resilience and run directly, while
the effectful CVE rules carry a Resilience (a per-attempt timeout, bounded retry
with backoff, and a per-source Breaker) applied by the harness
runEffectfulRule. The order is the tiebreak: there is no runtime
comparison of results.
The evaluator on a PreparedRule is not reachable from config: prepare only
ever binds evalRule over closed Rule data, so untrusted config can express only
the built-in vocabulary. Supplying an arbitrary evaluator is a code-layer capability
(the engine's own tests today; a rule DSL or plugin later), never a config surface.
evalRules may evaluate effectful rules speculatively in parallel, but the result is
always as-if sequential by boot order: the winner is the earliest-in-order
decisive rule, never the first to return in wall-clock time, and once the winner is
known every still-running strictly-later evaluation is cancelled. The cheap pure
prefix is evaluated directly, so no IO an earlier decisive result would moot is ever
launched. Evaluation is IO-typed (a rule's evaluator may do IO), so there is no pure
entry point. The rule data types live in Ecluse.Core.Rules.Types; the resilience
harness lives in Ecluse.Core.Rules.Effectful.
Synopsis
- data RuleDeps = RuleDeps {
- rdWithCveLookup :: forall a. (Maybe CveLookup -> IO a) -> IO a
- rdCurrentAdvisoryEtag :: IO (Maybe DbEtag)
- rdBreakerReporter :: BreakerReporter
- rdFaultReporter :: FaultReporter
- evalRule :: RuleDeps -> EvalContext -> Rule -> PackageDetails -> IO RuleVerdict
- data PreparedRule = PreparedRule {
- prepName :: Text
- prepPrecedence :: Int
- prepResilience :: Maybe Resilience
- prepEval :: EvalContext -> PackageDetails -> IO RuleVerdict
- data Resilience = Resilience {}
- prepare :: RuleDeps -> [PrecededRule] -> IO [PreparedRule]
- bootOrder :: [PreparedRule] -> [PreparedRule]
- renderBootOrder :: [PreparedRule] -> [Text]
- evalRules :: EvalContext -> [PreparedRule] -> PackageDetails -> IO Decision
- renderDecision :: PackageDetails -> Decision -> Text
- renderDuration :: NominalDiffTime -> Text
- cveIdsInReason :: Text -> [Text]
- runEffectfulRule :: EvalContext -> PreparedRule -> PackageDetails -> IO RuleEvaluation
- data EffectfulConfig = EffectfulConfig {}
- defaultEffectfulConfig :: EffectfulConfig
- backoffPolicy :: [Int] -> RetryPolicyM IO
- data Breaker
- newBreaker :: IO (TVar Breaker)
- newtype BreakerReporter = BreakerReporter (Breaker -> IO ())
- noBreakerReporter :: BreakerReporter
- newtype FaultReporter = FaultReporter (Text -> Text -> IO ())
The boot-bound rule capabilities
The boot-bound capabilities a rule's evaluation may consult, injected once at
the composition root and closed into the prepared rules by prepare. This is the
capability counterpart of EvalContext: the context carries per-evaluation ambient
data (the clock instant), while these are process-lifetime capabilities.
rdWithCveLookup is acquisition-bracketed rather than a bare read so its provider
can pin the advisory database generation for exactly one rule evaluation: the
background sync's atomic shadow-swap closes and prunes a superseded artifact only
once no evaluation still holds it. Nothing means no advisory database is loaded
(none configured, or the first sync has not landed); the CVE rule abstains.
Constructors
| RuleDeps | |
Fields
| |
The built-in rule dispatch
evalRule :: RuleDeps -> EvalContext -> Rule -> PackageDetails -> IO RuleVerdict Source #
Evaluate a single built-in rule against a single package version -- the one place
"how a rule decides" lives. The dispatch over the closed Rule data: the pure
constructors reason over the PackageDetails alone and pure their RuleVerdict;
AllowIfRemediatesCve and DenyIfCve read the advisory database through the
boot-bound RuleDeps and do IO. A rule returns only a verdict -- it never
manufactures an Unavailable; a genuine lookup fault surfaces as an exception, which
the Resilience harness (attached by prepare) catches and resolves.
IO-typed so the dispatch is uniform across the pure and effectful arms. The pure
arms are total -- a malformed rule or package yields a verdict, never an exception, so
hostile metadata cannot crash the gate.
The engine's prepared rule
data PreparedRule Source #
A rule prepared for the engine to evaluate: its boot-order identity (precedence
and name), an optional Resilience policy, and the raw per-version evaluator the
engine runs. This is the engine's one runtime structure -- and its only injection
point.
For a configured rule prepare builds it: the name from the rule data (ruleName),
the evaluator from evalRule, and (today) no Resilience. Because the evaluator is a
plain function field -- not a closed Rule -- it is also where an arbitrary evaluator
can be supplied without widening the closed Rule vocabulary: the engine's own tests
build a PreparedRule directly with a fake evaluator (one that throws, hangs, or
returns a chosen RuleVerdict) and a chosen name to exercise the resilience harness
and the parallel walk. That escape hatch is a code-layer capability; config only ever
reaches the closed data path through prepare, so it cannot supply one.
It declares no allow/deny "direction": admit vs block is simply what prepEval
returns. With the rule runs directly; with a
prepResilience = NothingResilience it is wrapped by runEffectfulRule.
Constructors
| PreparedRule | |
Fields
| |
data Resilience Source #
The resilience policy wrapped around an effectful rule's IO: the timeout/retry/
breaker knobs, the per-source circuit-breaker state, its observer, and the
failure alignment an exhausted evaluation resolves to (fail-closed FailDeny or
fail-open FailNoDecision). The alignment rides on the prepared rule, folding away the
separate failure-policy the two-tier design once carried.
Constructors
| Resilience | |
Fields
| |
prepare :: RuleDeps -> [PrecededRule] -> IO [PreparedRule] Source #
Prepare a resolved policy (PrecededRules) into the engine's runtime rules: each
rule's name comes from its data (ruleName), its evaluator from evalRule closed
over the boot-bound RuleDeps, and its Resilience from whether the rule needs one.
The pure built-ins carry no Resilience () and run
directly; prepResilience = NothingAllowIfRemediatesCve is prepared with a fail-open Resilience
(FailNoDecision), so a lookup that fails or hangs abstains -- the version falls back
to the ordinary quarantine -- and never admits on an unconfirmable claim.
IO-typed because preparing a resilient rule allocates its per-source breaker
(newBreaker) -- once, at the composition root, shared across evaluations.
Boot-time ordering
bootOrder :: [PreparedRule] -> [PreparedRule] Source #
Arrange a rule set into the single total order evaluation walks: __highest
precedence first, then rule name ascending__ as the deterministic tiebreak. A pure
function of the rules' precedences and names, independent of the order they were
configured in -- so shuffling the configured set yields the same order and hence the
same Decision. The order is the tiebreak; there is no runtime comparison of
results.
renderBootOrder :: [PreparedRule] -> [Text] Source #
Render the boot order as one diagnostic line per rule, in evaluation order, so an operator sees at boot exactly how their policy will resolve. Empty for an empty rule set.
Evaluation
evalRules :: EvalContext -> [PreparedRule] -> PackageDetails -> IO Decision Source #
Evaluate a package version against a rule set in IO: walk the boot order and
take the first decisive result, else BlockedByDefault with every non-decisive
reason gathered in boot order.
The engine evaluates effectful rules speculatively in parallel but the decision is
always as-if sequential by boot order -- the earliest-in-order decisive rule wins,
never the first to return in wall-clock time. A rule with no Resilience is evaluated
directly; a contiguous run of resilient rules is launched concurrently, then awaited
in boot order, and the moment the earliest decisive one is known every still-running
strictly-later evaluation is cancelled. No IO an earlier decisive result would moot is
ever launched, because a resilient run is started only once every rule before it is
known non-decisive.
Never throws. An effectful rule's faults are absorbed by its resilience
harness (runEffectfulRule); a direct rule that throws anyway -- an invariant
break, since a direct rule declares no effects -- is absorbed here as a
fail-closed Undecidable naming the rule. Either way one request's evaluation
resolves to a Decision, never a serve-path escape.
renderDecision :: PackageDetails -> Decision -> Text Source #
A human-readable summary of a decision, suitable for logs and the denial response body.
renderDuration :: NominalDiffTime -> Text Source #
Render a duration as an approximate, human-friendly string for a decision
message: its two most-significant non-zero units, so a value just short of a
threshold reads differently from the threshold itself (89s is "1 minute 29
seconds", not the bare "1 minute" a 90s minimum also rendered to). A long
duration stays compact, since its lesser units are zero and dropped. Always
non-negative.
>>>renderDuration 604800"7 days"
>>>renderDuration 90"1 minute 30 seconds"
cveIdsInReason :: Text -> [Text] Source #
Recover the advisory ids a DenyIfCve denial named, from the rendered decision
message the denial audit line carries. The deny reason denyVerdict builds embeds the
ids between "affected by " and " (CVSS"; this reads them back so the audit line can
name the CVE without threading a structured field through the pure decision path (the
Ecluse.Core.Server.Pipeline.Internal Metadata contract adds audit data at that
layer). A message carrying no such segment (a non-CVE denial) yields []. Kept beside
denyVerdict so the two move together; RulesSpec round-trips one against
the other so a reword of either fails the build.
The resilience harness
runEffectfulRule :: EvalContext -> PreparedRule -> PackageDetails -> IO RuleEvaluation Source #
Run one prepared rule through its resilience policy. A rule with no Resilience
() runs directly, its verdict wrapped prepResilience = NothingDecided. A
resilient rule's IO runs under its circuit-breaker gate, a per-attempt timeout, and
bounded retry with backoff: any RuleVerdict the rule returns -- a deterministic
CannotVet included -- resets the breaker and is returned Decided, taken at face
value and never retried; only a fault the harness observes (a timeout, an
exception, or the breaker already open) advances the breaker and resolves to
, the alignment from the rule's Unavailable transience alignment reasonResilience
(fail-closed or fail-open).
The breaker timing reads the injected resilience clock (resClock), read fresh at each
breaker decision, so it is deterministic under test and independent of the request
snapshot ctxNow the age rules hold constant. Reading it again after the retry run means
a tripped breaker's cooldown starts when the failure commits, not when the run began.
Total -- it never throws; a rule failure becomes a result.
data EffectfulConfig Source #
The resilience knobs around an effectful rule's IO: a per-attempt timeout,
how many retries to make on failure with the backoff before each, and the breaker
threshold and cooldown. The breaker's timing reads the injected resilience clock
(resClock) fresh at failure commit, not the request snapshot ctxNow.
Constructors
| EffectfulConfig | |
Fields
| |
defaultEffectfulConfig :: EffectfulConfig Source #
Sensible defaults for the resilience knobs: a 2-second per-attempt timeout, two retries at 100ms then 250ms, and a breaker tripping after 5 consecutive failures and cooling for 30 seconds. The caller supplies the rule's IO; the knobs are policy with these defaults.
backoffPolicy :: [Int] -> RetryPolicyM IO Source #
An ecBackoff schedule compiled to a Control.Retry policy: the retry at
iteration n waits the n-th delay (microseconds) before it, and the policy stops
(yields Nothing) once the schedule is exhausted -- so the list's length is the retry
budget. [] admits no retry (a single attempt); [a, b] admits up to two. Inspect
the resulting delays without sleeping with simulatePolicy.
The breaker's state, gating whether the guarded operation may be attempted.
A Closed breaker is healthy and counts consecutive failures towards the trip
threshold; an Open breaker fast-fails until its instant passes; a HalfOpen
breaker has admitted one recovery probe and is waiting on its outcome.
newBreaker :: IO (TVar Breaker) Source #
A fresh, healthy breaker (no failures recorded) in a new TVar.
newtype BreakerReporter Source #
An observer of breaker state changes: invoked with the breaker's new state after a transition commits, so a layer that cares (a state gauge) can record it.
Deliberately telemetry-agnostic -- it is just a callback, so
the breaker and its callers (Ecluse.Core.Rules, the credential refresher) stay
free of any metric dependency; the composition root supplies the bridge to the
instruments. Breaker -> IO ()noBreakerReporter is the inert default: a breaker observed by it records
nothing, which is also how a breaker constructed before the telemetry substrate exists
behaves until the live observer is installed.
Constructors
| BreakerReporter (Breaker -> IO ()) |
noBreakerReporter :: BreakerReporter Source #
The inert reporter: discards the state, recording nothing.
newtype FaultReporter Source #
The observer an exhausted effectful evaluation reports its fault detail to: the
deciding rule's name and the rendered fault (an exception's displayException, or a
timeout). A telemetry-agnostic callback in the shape of BreakerReporter,
so the pure rules engine names no logger; the composition root closes a katip line over
it. Fires once per exhausted evaluation, never on a verdict or a still-cooling breaker.
Constructors
| FaultReporter (Text -> Text -> IO ()) |