ecluse:ecluse-core
Safe HaskellNone
LanguageGHC2021

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 (Unavailable _ FailDeny _); a non-decisive verdict (NoDecision, a fail-open CannotVet) or a fail-open fault (Unavailable _ FailNoDecision _) 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 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

The boot-bound rule capabilities

data RuleDeps Source #

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 prepResilience = Nothing the rule runs directly; with a Resilience 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

  • resConfig :: EffectfulConfig

    The per-attempt timeout, retry budget/backoff, and breaker threshold/cooldown.

  • resAlignment :: FailureAlignment

    Whether an exhausted evaluation fails closed (FailDeny) or open (FailNoDecision).

  • resBreaker :: TVar Breaker

    This rule's per-source circuit-breaker state, shared across evaluations.

  • resBreakerReporter :: BreakerReporter

    The observer this rule's breaker reports its state transitions to (ecluse.rule.breaker.state). Inert (noBreakerReporter) for an unobserved rule; the composition root installs the live one.

  • resClock :: IO UTCTime

    The injected wall clock the breaker reads for its admission gate and its cooldown arithmetic. getCurrentTime in production, overridable under test for deterministic breaker timing. Deliberately separate from the request snapshot ctxNow (which the age rules hold constant across a packument): the breaker is a wall-clock device, and reading it fresh at the point a failure commits is what makes the cooldown start when the failure is recorded, not when the retry run began.

  • resFaultReporter :: FaultReporter

    The observer an exhausted evaluation reports its fault detail to (the rendered exception, or a timeout), so a live-database query fault is diagnosable from the operator log rather than collapsing to a bare Unavailable. Inert (noFaultReporter) for an unobserved rule; the composition root installs the live one. It never reaches the client-facing decision message.

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 (prepResilience = Nothing) and run directly; AllowIfRemediatesCve 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 (prepResilience = Nothing) runs directly, its verdict wrapped Decided. 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 Unavailable transience alignment reason, the alignment from the rule's Resilience (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

  • ecTimeout :: Int

    The per-attempt timeout in microseconds. An attempt that does not return within it is treated as a failure (a transient, retryable cause).

  • ecBackoff :: [Int]

    The backoff delays in microseconds, one per retry, applied before the corresponding retry attempt. Its length is the retry budget: [] means the single initial attempt only, [100, 200] means up to two retries after it.

  • ecBreakerThreshold :: Int

    Consecutive exhausted-rule failures that trip the breaker.

  • ecBreakerCooldown :: NominalDiffTime

    How long the breaker stays open (fast-failing the rule) before a single half-open probe is allowed to test recovery.

  • ecRetryAfter :: Maybe RetryAfter

    The Retry-After delay to suggest to a client when this rule's unavailability surfaces on a concrete-artifact request; Nothing suggests none.

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.

data Breaker Source #

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.

Constructors

Closed Int

Healthy: the consecutive-failure count so far, up to the trip threshold.

Open UTCTime

Tripped until the given instant: attempts fast-fail until then.

HalfOpen

Cooldown elapsed: one probe attempt is admitted to test recovery.

Instances

Instances details
Show Breaker Source # 
Instance details

Defined in Ecluse.Core.Breaker

Eq Breaker Source # 
Instance details

Defined in Ecluse.Core.Breaker

Methods

(==) :: Breaker -> Breaker -> Bool #

(/=) :: Breaker -> Breaker -> Bool #

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 Breaker -> IO () 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. 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 ())