Policy Evaluation Flow

Overview

The Policy Evaluation Flow describes how Stella Ops applies the 8-state reachability lattice to vulnerability findings, incorporating VEX statements, reachability analysis, and confidence scoring to produce deterministic verdicts. This is the core decision-making flow that determines whether a container image meets security requirements.

The Policy Engine evaluates a compiled policy (authored in the stella-dsl@1 DSL) deterministically against advisory/VEX/SBOM inputs. Each finding receives a status (affected, not_affected, suppressed, ignored, deferred, warned, or blocked), an optional 0.0–1.0 confidence score with a five-factor breakdown, and — when the Evidence-Weighted Score enricher is enabled — a 0–100 EWS score and triage bucket (ActNow, ScheduleNext, Investigate, Watchlist).

Business Value: Consistent, explainable security verdicts with full audit trail for compliance and governance.

Source: src/Policy/StellaOps.Policy.Engine/Evaluation/PolicyEvaluator.cs, src/Policy/__Libraries/StellaOps.Policy/Confidence/Services/ConfidenceCalculator.cs, src/__Libraries/StellaOps.Reachability.Core/LatticeState.cs.

Actors

ActorTypeRole
Caller (CI/CD, Release Console, Scanner)ClientSubmits advisory/VEX/SBOM tuples or snapshots for evaluation
Policy EngineServiceCompiles and evaluates the active policy pack
VexLensServiceProduces VEX consensus/issuer trust (joined into the evaluation context upstream)
Signals / ReachGraphServiceProduces reachability facts (joined into the evaluation context upstream)
Policy pack storeComponentStores compiled stella-dsl@1 policy packs

The Policy Engine evaluator does not call VexLens or ReachGraph synchronously during evaluation. VEX statements and reachability facts are joined into the PolicyEvaluationContext before the evaluator runs (see ReachabilityFacts/* and Vex/* in StellaOps.Policy.Engine). The sequence diagram below is a conceptual, not wire-accurate, view of those dependencies.

Prerequisites

Reachability Lattice Model

Stella Ops uses an 8-state reachability lattice for vulnerability reachability (LatticeState in src/__Libraries/StellaOps.Reachability.Core/LatticeState.cs).

Note: This reachability lattice is distinct from the “K4” four-valued (Belnap) trust lattice used by the Trust Algebra engine (src/Policy/__Libraries/StellaOps.Policy/TrustLattice/K4Lattice.cs), whose values are Unknown/True/False/Conflict. Earlier drafts of this flow conflated the two; the reachability progression below is the relevant model for policy verdicts.

States are ordered by evidence strength, progressing from Unknown toward confirmed states as static and runtime evidence accumulate. A conflict between static and runtime evidence escalates to Contested, which requires manual review.

                        X (Contested)
                       /             \
              CR (Confirmed          CU (Confirmed
                 Reachable)            Unreachable)
                    |    \          /    |
                    |     \        /     |
               RO (Runtime     RU (Runtime
                  Observed)      Unobserved)
                    |                |
                    |                |
               SR (Static       SU (Static
                  Reachable)       Unreachable)
                     \             /
                      \           /
                       U (Unknown)

State Definitions

The codes below match the LatticeState enum and the v1 lattice-state constants used by the policy gate evaluator (src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs).

StateCodeDescription
UnknownUNo analysis performed; initial state for all symbols
StaticReachableSRStatic call-graph analysis shows a path from an entrypoint
StaticUnreachableSUStatic call-graph analysis proves no path exists
RuntimeObservedROSymbol execution observed at runtime within the observation window
RuntimeUnobservedRUObservation window passed with no execution detected
ConfirmedReachableCRStatic + runtime both confirm an execution path exists
ConfirmedUnreachableCUStatic proves no path AND runtime observed no execution
ContestedXConflicting static/runtime evidence (requires manual review)

Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────────┐
│                         Policy Evaluation Flow                                   │
└─────────────────────────────────────────────────────────────────────────────────┘

┌─────────┐   ┌─────────┐   ┌─────────┐   ┌───────────┐   ┌─────────────┐
│ Scanner │   │ Policy  │   │ VexLens │   │ ReachGraph│   │Policy Store │
└────┬────┘   └────┬────┘   └────┬────┘   └─────┬─────┘   └──────┬──────┘
     │             │             │              │                │
     │ Evaluate    │             │              │                │
     │ request     │             │              │                │
     │────────────>│             │              │                │
     │             │             │              │                │
     │             │ Load policy │              │                │
     │             │ set         │              │                │
     │             │─────────────────────────────────────────────>
     │             │             │              │                │
     │             │ Policy      │              │                │
     │             │ rules       │              │                │
     │             │<─────────────────────────────────────────────
     │             │             │              │                │
     │             │ Get VEX     │              │                │
     │             │ consensus   │              │                │
     │             │────────────>│              │                │
     │             │             │              │                │
     │             │             │ Query issuers│              │
     │             │             │──────┐       │                │
     │             │             │      │       │                │
     │             │             │<─────┘       │                │
     │             │             │              │                │
     │             │ VEX status  │              │                │
     │             │ per CVE     │              │                │
     │             │<────────────│              │                │
     │             │             │              │                │
     │             │ Get reach   │              │                │
     │             │ states      │              │                │
     │             │─────────────────────────────>              │
     │             │             │              │                │
     │             │             │              │ Query call     │
     │             │             │              │ graph          │
     │             │             │              │───────┐        │
     │             │             │              │       │        │
     │             │             │              │<──────┘        │
     │             │             │              │                │
     │             │ Reach facts │              │                │
     │             │<─────────────────────────────              │
     │             │             │              │                │
     │             │ Apply rules │              │                │
     │             │──────┐      │              │                │
     │             │      │      │              │                │
     │             │<─────┘      │              │                │
     │             │             │              │                │
     │             │ Compute     │              │                │
     │             │ confidence  │              │                │
     │             │──────┐      │              │                │
     │             │      │      │              │                │
     │             │<─────┘      │              │                │
     │             │             │              │                │
     │ Verdict +   │             │              │                │
     │ explain     │             │              │                │
     │<────────────│             │              │                │
     │             │             │              │                │

Step-by-Step

1. Evaluation Request

Callers (CI/CD pipelines, the release console, or the Scanner) submit advisory/VEX/SBOM tuples for policy evaluation. The Policy Engine exposes the following authenticated HTTP surfaces (all require the policy:read scope). The /policy/decisions endpoints additionally enforce a resolved tenant context (RequireTenantContext(), overriding any request tenantId with the middleware-resolved tenant); /policy/eval/batch is scope-gated and reads tenantId from the request body:

EndpointPurposeSource
POST /policy/decisionsEvaluate and retrieve decisions for component snapshots with source-evidence summariesEndpoints/PolicyDecisionEndpoint.cs
GET /policy/decisions/{snapshotId}Replay recorded decisions for a snapshot (filterable by PURL/advisory)Endpoints/PolicyDecisionEndpoint.cs
POST /policy/eval/batchBatch-evaluate a page of advisory/VEX/SBOM tuples against active packs (cursor pagination, optional budgetMs)Endpoints/BatchEvaluationEndpoint.cs

The exact wire schema is owned by the DTOs in src/Policy/StellaOps.Policy.Engine (BatchEvaluationRequestDto, PolicyDecisionRequest). The JSON below is an illustrative request shape for the conceptual evaluation flow and is not a verbatim copy of any single endpoint contract. In the real batch contract each item carries packId, version, subjectPurl, advisoryId, and structured severity/advisory/vex/sbom/exceptions/reachability objects, and must set evaluationTimestamp (the validator rejects items without it to keep evaluation deterministic). Page size defaults to 100 and is clamped to 1–500.

// Illustrative — see DTOs for authoritative field names
{
  "tenantId": "acme-corp",
  "items": [
    {
      "advisory": { "cve": "CVE-2024-1234", "severity": "critical", "cvss": 9.8 },
      "component": { "purl": "pkg:npm/lodash@4.17.20", "fixedVersion": "4.17.21" }
    },
    {
      "advisory": { "cve": "CVE-2024-5678", "severity": "high", "cvss": 7.5 },
      "component": { "purl": "pkg:npm/express@4.18.0", "fixedVersion": "4.18.2" }
    }
  ]
}

2. Policy Loading

Policy Engine loads and compiles the active policy pack. Policies are authored in the stella-dsl@1 DSL (parsed by src/Policy/StellaOps.PolicyDsl/PolicyParser.cs), not YAML. A policy is a policy { ... } block of priority-ordered rule blocks; each rule has a when condition, a then action sequence, an optional else branch, and a because rationale. Rules are evaluated in ascending priority order, and the first matching rule wins; if no rule matches, the default action applies.

// syntax: stella-dsl@1
policy "production" syntax "stella-dsl@1" {
    metadata {
        version = "1.0.0"
        description = "Production deployment policy"
        author = "StellaOps"
    }

    settings {
        default_action = "warn"
        fail_on_critical = true
    }

    profile standard {
        trust_score = 0.85
    }

    // Critical vulnerabilities with confirmed reachability
    rule critical_reachable priority 100 {
        when finding.severity == "critical" and reachability.state == "reachable"
        then
            severity := "critical"
            annotate finding.priority := "immediate"
            escalate to "security-team" when reachability.confidence > 0.9
        because "Critical vulnerabilities with confirmed reachability require immediate action"
    }

    // Low severity unreachable can be ignored
    rule low_unreachable priority 50 {
        when finding.severity == "low" and reachability.state == "unreachable"
        then
            ignore until "next-scan" because "Low severity unreachable code"
        because "Low severity unreachable vulnerabilities can be safely deferred"
    }
}

The example above is the default.dsl fixture (src/Policy/__Tests/StellaOps.PolicyDsl.Tests/TestData/default.dsl). Reference-surface gotcha: the finding.* references are valid in the standalone StellaOps.PolicyDsl engine (PolicyEngineFactory.cs), which binds an arbitrary finding object into its SignalContext. The runtime evaluator that backs the HTTP endpoints (PolicyExpressionEvaluator in StellaOps.Policy.Engine) does not expose a finding root — its condition roots are severity, vex, advisory, sbom, reachability, entropy, env, score, and now. Under the runtime evaluator, finding severity is read as severity.normalized (or severity.score), not finding.severity. Reachability members (reachability.state, reachability.confidence, reachability.has_runtime_evidence) are identical across both engines.

Rule actions map to evaluation outcomes rather than literal PASS/WARN/FAIL keywords. The supported actions are: status/severity assignment (status := ..., severity := ...), annotate, warn, escalate, require vex, ignore, and defer. The resulting finding status is one of affected, not_affected, suppressed, ignored, deferred, warned, or blocked (see Evaluation/PolicyEvaluator.cs). Whether a given status fails a release is decided downstream by the CI/CD gate (see CI/CD Gate Flow).

3. VEX Consensus Query

VEX statements reach the evaluator as part of the evaluation context (PolicyEvaluationVexEvidence / PolicyEvaluationVexStatement in Evaluation/PolicyEvaluationContext.cs); the engine does not fetch them via the synchronous call shown below. VexLens consensus and issuer trust are produced upstream and joined into the context before evaluation. Inside the evaluator, each VEX statement is mapped to a status (affected, not_affected, fixed, under_investigation) and assigned a trust score derived from the issuer (vendor/distro issuers default to 0.85, others to 0.70, with bonuses for justification and statement-id presence) — see BuildVexEvidence / MapVexStatus / ComputeVexTrustScore in Evaluation/PolicyEvaluator.cs.

The request/response below is an illustrative consensus shape; /internal/vex/consensus is not a Policy Engine endpoint. Treat it as a conceptual view of the VexLens output that feeds the evaluation context.

// Illustrative consensus shape (produced upstream by VexLens)
{
  "statements": [
    {
      "vulnerability": "CVE-2024-1234",
      "status": "affected",
      "issuers": [
        {"name": "vendor-psirt", "trust": 0.95, "status": "affected"},
        {"name": "osv", "trust": 0.7, "status": "affected"}
      ],
      "consensus": "affected",
      "confidence": 0.92
    },
    {
      "vulnerability": "CVE-2024-5678",
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "issuers": [
        {"name": "vendor-psirt", "trust": 0.95, "status": "not_affected"}
      ],
      "consensus": "not_affected",
      "confidence": 0.95
    }
  ]
}

4. Reachability State Query

Reachability facts are likewise joined into the evaluation context rather than fetched synchronously by the evaluator. The Policy Engine consumes reachability facts from Signals/ReachGraph through ReachabilityFacts/* (e.g. SignalsBackedReachabilityFactsStore, ReachabilityCoreBridge) and exposes them to rules as PolicyEvaluationReachability (IsReachable / IsUnreachable / IsUnknown / HasRuntimeEvidence / Confidence / EvidenceRef). In DSL conditions the state surfaces as reachability.state ("reachable" / "unreachable") plus reachability.confidence and reachability.has_runtime_evidence.

The shape below is illustrative; /internal/reachability/states is not a Policy Engine endpoint. The lattice state values map to the codes in the State Definitions table.

// Illustrative reachability facts (produced upstream by Signals/ReachGraph)
{
  "states": [
    {
      "package": "pkg:npm/lodash@4.17.20",
      "state": "StaticReachable",
      "evidence": {
        "static": {"call_paths": 3, "entry_points": ["src/api/handler.js:45"]},
        "runtime": null
      }
    },
    {
      "package": "pkg:npm/express@4.18.0",
      "state": "RuntimeObserved",
      "evidence": {
        "static": {"call_paths": 12},
        "runtime": {"invocations": 1547, "last_seen": "2024-12-29T09:00:00Z"}
      }
    }
  ]
}

5. Rule Evaluation

Policy Engine evaluates rules in ascending priority order; the first rule whose when condition holds applies its then actions and stops further matching. If no rule matches, the default outcome applies.

Finding: CVE-2024-1234 in pkg:npm/lodash@4.17.20
  - finding.severity: critical
  - reachability.state: reachable   (lattice: StaticReachable / SR)
  - reachability.confidence: 0.70
  - vex status: affected

Rule: critical_reachable (priority 100)
  - when: finding.severity == "critical" and reachability.state == "reachable"
  - evaluation: "critical" == "critical" ✓ AND "reachable" == "reachable" ✓
  - then: severity := "critical"; annotate finding.priority := "immediate"
  - result: MATCH → status "affected", severity "critical"

The evaluator’s status is the policy verdict for the finding. It does not emit a literal PASS/WARN/FAIL; release pass/fail is decided by the CI/CD gate, which also applies lattice-state, VEX-trust, uncertainty-tier, and confidence gates with allow / warn / block outcomes (Gates/PolicyGateEvaluator.cs).

6. Confidence Scoring

Policy Engine computes a 0.0–1.0 confidence score as a weighted sum of five factors. Default weights come from ConfidenceWeightOptions (src/Policy/__Libraries/StellaOps.Policy/Confidence/Configuration/ConfidenceWeightOptions.cs) and must sum to 1.0; if a configured override does not, the calculator normalizes them.

FactorWeightDescription
Reachability0.30Lattice-state certainty × analysis confidence
Runtime0.20Runtime corroboration (with 24h recency bonus)
VEX0.25Best VEX statement status × issuer trust
Provenance0.15Provenance/SLSA level + SBOM completeness
Policy0.10Rule match strength

Each factor produces a raw value in [0,1] (see ConfidenceCalculator). Notable mappings: reachability base values are ConfirmedUnreachable 1.0, StaticUnreachable 0.85, Unknown 0.5, StaticReachable 0.3, ConfirmedReachable 0.1 — multiplied by analysis confidence. VEX raw value is NotAffected → trust, Fixed → trust × 0.9, UnderInvestigation → 0.4, Affected → 0.1. A missing factor degrades to a neutral default (0.5 for reachability/runtime/VEX/policy, 0.3 for provenance) rather than 0.

Confidence = Σ(weight × raw_value), then clamped to [0,1]

For CVE-2024-1234 (StaticReachable @ analysis confidence 0.70, no runtime,
                    best VEX = affected, unsigned SBOM, rule matched):
  - Reachability: 0.30 × (0.3 × 0.70 = 0.21) = 0.063
  - Runtime:      0.20 × 0.50 (no observations → neutral) = 0.100
  - VEX:          0.25 × 0.10 (affected) = 0.025
  - Provenance:   0.15 × 0.30 (unsigned) = 0.045
  - Policy:       0.10 × 0.90 (matched rule) = 0.090

Total Confidence ≈ 0.32  → Tier: Low

The score carries a Tier (VeryLow < 0.3, Low < 0.5, Medium < 0.7, High < 0.9, VeryHigh ≥ 0.9), a per-factor breakdown with human-readable reasons, and a ranked list of Improvements (e.g. “Deploy runtime sensor”, “Obtain VEX statement from vendor”). The exact arithmetic above is illustrative; consult ConfidenceCalculator.Calculate for the authoritative per-factor logic.

Evidence-Weighted Score (EWS)

When the EWS enricher is enabled (IFindingScoreEnricher), evaluation additionally attaches a separate 0–100 Evidence-Weighted Score and a triage bucket (ScoreBucket enum — see src/Signals/StellaOps.Signals/EvidenceWeightedScore/EvidenceWeightedScoreCalculator.cs). The default bucket thresholds are ActNow (90–100), ScheduleNext (70–89), Investigate (40–69), and Watchlist (0–39); the cut-points are configurable via BucketThresholds (ActNowMin, ScheduleNextMin, InvestigateMin).

The EWS is exposed to DSL rules through the score condition root (see the ScoreScope in Evaluation/PolicyExpressionEvaluator.cs): score >= 80, score.value, score.bucket == "ActNow", score.is_act_now, dimension accessors (score.rch / score.rts / score.bkp / score.xpl / score.src / score.mit), and score.has_flag(...) / score.flags. The pre-computed score is also written to the result annotations as ews.score (formatted F2) and ews.bucket (ApplyEvidenceWeightedScore in PolicyEvaluator.cs) — those annotation keys are outputs on the result, distinct from the score DSL accessor used inside when conditions. EWS is distinct from the 0.0–1.0 confidence score and is computed by the Signals module, not by the confidence calculator.

7. Verdict Assembly

The batch endpoint (POST /policy/eval/batch) returns one result per input item. Each result carries the per-finding status (not a PASS/WARN/FAIL verdict), plus rule attribution, confidence, annotations, applied exception, and cache indicators. The authoritative per-item shape is BatchEvaluationResultDto (src/Policy/StellaOps.Policy.Engine/BatchEvaluation):

// Per-item result (BatchEvaluationResultDto) — field names are authoritative
{
  "results": [
    {
      "packId": "production",
      "version": 7,
      "policyDigest": "sha256:...",
      "status": "affected",                 // affected | not_affected | suppressed | ignored | deferred | warned | blocked
      "severity": "critical",
      "ruleName": "critical_reachable",
      "priority": 100,
      "annotations": {
        "priority": "immediate",
        "ews.score": "92.00",
        "ews.bucket": "ActNow"
      },
      "warnings": [],
      "appliedException": null,
      "confidence": {
        "value": 0.32,
        "tier": "Low",
        "explanation": "Confidence is low (32%), primarily driven by runtime and policy."
      },
      "correlationId": "...",
      "cached": false,
      "cacheSource": "None",        // CacheSource enum: None | InMemory | Redis (non-nullable)
      "evaluationDurationMs": 3
    },
    {
      "packId": "production",
      "version": 7,
      "status": "not_affected",
      "severity": "high",
      "ruleName": "vex_not_affected",
      "appliedException": null,
      "confidence": { "value": 0.81, "tier": "High" },
      "cached": true,
      "cacheSource": "InMemory"
    }
  ],
  "nextPageToken": null,
  "total": 2,
  "returned": 2,
  "cacheHits": 1,
  "cacheMisses": 1,
  "durationMs": 5,
  "budgetRemainingMs": null
}

The aggregate fields (total, returned, cacheHits, cacheMisses, durationMs, budgetRemainingMs, nextPageToken) are page-level batch metadata, not a release verdict. Release pass/fail (allow/warn/block) is computed by the CI/CD gate; see CI/CD Gate Flow.

Data Contracts

Policy Rule (compiled IR) Schema

Rules are authored in stella-dsl@1 and compiled to PolicyIrRule (src/Policy/StellaOps.PolicyDsl/PolicyIr.cs). A rule has a name, a numeric priority, a when condition expression, ordered then actions, optional else actions, and a because rationale (the rationale is mandatory at parse time).

// Shape mirrors PolicyIrRule
interface PolicyIrRule {
  name: string;
  priority: number;        // lower runs first; first match wins
  when: PolicyExpression;  // boolean condition
  thenActions: PolicyIrAction[];
  elseActions: PolicyIrAction[];
  because: string;         // required rationale
}

// PolicyIrAction is one of:
//   assignment (status:= / severity:= / annotation), annotate, warn,
//   escalate, require vex, ignore, defer

Exceptions are not embedded in the rule; they are applied during evaluation from the context (PolicyEvaluationExceptions) with effect types Suppress, Defer, Downgrade, and RequireControl (Evaluation/PolicyEvaluator.cs, Domain/ExceptionContracts.cs).

Per-Finding Result Schema

There is no single PolicyVerdict envelope with PASS/WARN/FAIL. Each evaluated finding yields a result (BatchEvaluationResultDto) whose status is the verdict:

// Shape mirrors BatchEvaluationResultDto
interface PolicyEvaluationResultDto {
  packId: string;
  version: number;
  policyDigest: string;
  status: 'affected' | 'not_affected' | 'suppressed' | 'ignored'
        | 'deferred' | 'warned' | 'blocked';
  severity?: string;
  ruleName?: string;
  priority?: number;
  annotations: Record<string, string>;   // includes ews.score / ews.bucket when EWS enabled
  warnings: string[];
  appliedException?: PolicyExceptionApplication;
  confidence?: { value: number; tier: ConfidenceTier; explanation?: string };
  correlationId: string;
  cached: boolean;
  cacheSource: 'None' | 'InMemory' | 'Redis';   // CacheSource enum (non-nullable)
  evaluationDurationMs: number;
}

// ConfidenceTier = 'VeryLow' | 'Low' | 'Medium' | 'High' | 'VeryHigh'
// AppliedException carries the PolicyExceptionApplication record
// (exceptionId, effectId, effectType, original/applied status+severity, metadata).

Error Handling

ErrorRecovery
Policy pack not found / not active404 (KeyNotFoundException) from the decision endpoint
Invalid request body400 (ArgumentException); batch validation returns a ProblemDetails
Batch item missing evaluationTimestamp400 ProblemDetails — every batch item must supply evaluationTimestamp to keep evaluation deterministic (BatchEvaluationValidator)
Batch pageSize out of range / bad pageToken400 ProblemDetailspageSize must be 1–500; pageToken must be a non-negative integer offset
Missing VEX evidenceConfidence VEX factor degrades to the neutral default (0.5); evaluation continues
Missing reachability factsReachability treated as Unknown; reachability factor uses the neutral default
EWS enrichment failureCaught and swallowed; result returned without an EWS score (never fails evaluation)
Unhandled DSL actionRecorded as a warning on the result; evaluation continues
Multiple matching rulesFirst match in (priority asc, source order) wins; remaining rules are skipped
Time budget exhausted (batch)Stops early; returns processed page with nextPageToken for continuation

Observability

Metrics

Emitted by PolicyEngineTelemetry (meter StellaOps.Policy.Engine). Names and labels below match src/Policy/StellaOps.Policy.Engine/Telemetry/PolicyEngineTelemetry.cs.

MetricTypeLabels
policy_evaluations_totalCountertenant, policy, mode
policy_evaluation_latency_secondsHistogramtenant, policy
policy_run_secondsHistogrammode, tenant, policy
policy_rules_fired_totalCounterpolicy, rule
policy_vex_overrides_totalCounterpolicy, vendor
policy_findings_materialized_totalCountertenant, policy
policy_evaluation_failures_totalCountertenant, policy, reason
policy_errors_totalCountertype, tenant
policy_concurrent_evaluationsGaugetenant

(Additional compilation, simulation, entropy, and API metrics exist in the same file.)

Trace Context

Traces are emitted under the StellaOps.Policy.Engine activity source (PolicyEngineTelemetry.ActivitySource). The actual span names are dotted, e.g. policy.evaluate, policy.evaluate_batch, policy.select, policy.materialize, policy.compile, and policy.simulate (see Telemetry/PolicyEngineTelemetry.cs and Services/PolicyRuntimeEvaluationService.cs).

The phase tree below is a conceptual view of what happens inside a single policy.evaluate span — the rule/exception/budget/confidence/EWS steps run inline in PolicyEvaluator.Evaluate and are not emitted as separate child spans today:

policy.evaluate                       (actual span)
├── policy load (compile / cache lookup)         — conceptual phase
├── rule evaluation (priority asc; first match)  — conceptual phase
├── exception application                        — conceptual phase
├── unknown budget                               — conceptual phase
├── confidence scoring                           — conceptual phase
└── evidence-weighted-score (when EWS enabled)    — conceptual phase