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 it is Allow, Deny, or an Unavailable _ FailDeny _ (a fail-closed uncomputable check); NoDecision and Unavailable _ FailNoDecision _ 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 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

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

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 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

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.

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 (prepResilience = Nothing) 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 (Allow/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 Unavailable transience alignment reason -- the alignment from the rule's Resilience (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

  • 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.Effectful, 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.