| 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 it is Allow, Deny,
or an (a fail-closed uncomputable check); Unavailable _ FailDeny _NoDecision
and are non-decisive no-ops whose reasons are
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 AllowIfRemediatesCve carries 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.
Synopsis
- data RuleDeps = RuleDeps {
- rdWithCveLookup :: forall a. (Maybe CveLookup -> IO a) -> IO a
- rdBreakerReporter :: BreakerReporter
- inertRuleDeps :: RuleDeps
- evalRule :: RuleDeps -> EvalContext -> Rule -> PackageDetails -> IO RuleResult
- data PreparedRule = PreparedRule {
- prepName :: Text
- prepPrecedence :: Int
- prepResilience :: Maybe Resilience
- prepEval :: EvalContext -> PackageDetails -> IO RuleResult
- 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
- runEffectfulRule :: EvalContext -> PreparedRule -> PackageDetails -> IO RuleResult
- data EffectfulConfig = EffectfulConfig {}
- defaultEffectfulConfig :: EffectfulConfig
- backoffPolicy :: [Int] -> RetryPolicyM IO
- data Breaker
- newBreaker :: IO (TVar Breaker)
- newtype BreakerReporter = BreakerReporter (Breaker -> IO ())
- noBreakerReporter :: BreakerReporter
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
| |
inertRuleDeps :: RuleDeps Source #
Rule capabilities with no advisory database and no breaker observer: the composition value before a CVE sync is configured, and the pure tests' default.
The built-in rule dispatch
evalRule :: RuleDeps -> EvalContext -> Rule -> PackageDetails -> IO RuleResult 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 result, never
yielding Unavailable; AllowIfRemediatesCve reads the advisory database through
the boot-bound RuleDeps and does IO, relying on its Resilience harness (attached
by prepare) to resolve a failing lookup to a fail-open Unavailable.
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 result, 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 RuleResult) 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.
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 use in decision messages. Always non-negative.
>>>renderDuration 604800"7 days"
>>>renderDuration 90"1 minute"
The resilience harness
runEffectfulRule :: EvalContext -> PreparedRule -> PackageDetails -> IO RuleResult Source #
Run one prepared rule through its resilience policy. A rule with no Resilience
() runs directly. A resilient rule's IO runs under its
circuit-breaker gate, a per-attempt timeout, and bounded retry with backoff: a clean
verdict (prepResilience = NothingAllow/Deny/NoDecision) resets the breaker and is returned; an
exhausted rule (timeout, exception, the breaker open, or the rule self-reporting
Unavailable on every attempt) advances the breaker and resolves to -- the alignment from the rule's Unavailable
transience alignment reasonResilience (fail-closed
or fail-open), the transience from the last failing attempt.
The breaker timing reads the EvalContext clock, so it is deterministic under test.
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 clock is the EvalContext.
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.Effectful, 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.