Advisory Drift Re-scan Flow

Ingestion contract reconciled (PCD-307, 2026-10-05). The hub ingest loop, fact payload and read scope were verified against 438677d46f in src/Vulnerabilities and the canonical StellaOpsScopes.cs catalog. Re-verify the source anchors below with rg -n 'FactUpsertedEventType|ReadHubEvents|SectionName' src/Vulnerabilities. The downstream sections cite JobEngine, Scanner and Notify contracts separately. Where a sample is illustrative rather than a wire-faithful contract it is labelled (illustrative).

Overview

The Advisory Drift Re-scan Flow describes how StellaOps re-evaluates previously scanned images when new vulnerability advisories are published or existing advisories are updated, without re-running the original image analysis. This keeps security verdicts current as new vulnerabilities are disclosed.

Audience: platform operators tuning rescan cadence and impact targeting, and engineers integrating with the scheduler.rescan.delta / scanner.report.ready events the re-evaluation paths emit.

Business Value: Continuous security-posture updates as new vulnerabilities are disclosed, catching newly-vulnerable images before they are exploited.

Re-evaluation runs through one path, Scheduler-driven impact targeting (JobEngine). The Scheduler’s advisory trigger follows the Vulnerabilities hub’s vuln.facts stream, keeps the changed packages that match an indexed image, and creates Advisory runs that resolve those images from its impact index and queue re-evaluation. The materialised result is the scheduler.rescan.delta@1 event consumed by Notify. (src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker)

Note. The path is gated behind configuration. The advisory trigger (Scheduler:Worker:VulnerabilityHub:Trigger) and the Scanner image projection that fills the impact index (Scheduler:Worker:ScannerProjection) both default to disabled, and the trigger plans nothing while the index is empty. Verify the deployment configuration before assuming the path is live.

Actors

ActorTypeRole
Vulnerabilities hubServiceOwns advisory facts; publishes the vuln.facts stream the advisory trigger follows
Scheduler (JobEngine)ServiceResolves impacted images via the impact index and queues re-evaluation runs; emits scheduler.rescan.delta@1
ScannerServiceServes the reports (and, for content-refresh schedules, the rescans) the runner requests, and Smart-Diff comparisons
Policy EngineServiceMaterialises effective findings / verdicts that the re-evaluation compares against
NotifyServiceDelivers scanner.report.ready and scheduler.rescan.delta events to channels
SbomServiceServiceStores/serves the SBOM documents that re-evaluation reuses

Prerequisites

Advisory Sources

The Vulnerabilities worker runs the configured source keys in Vulnerabilities:Ingestion:Scheduler:EnabledSources. Scheduling is opt-in through Vulnerabilities:Ingestion:Scheduler:Enabled; the configured interval applies to complete cycles. IngestScheduler resolves the registered IVulnSourceConnector implementations, passes documents through IngestPipeline, and invokes NormalizationPump for advisory facts. Its optional EPSS snapshot is a separate cycle input. Binary corpus sources write corpus records rather than advisory facts.

Source: src/Vulnerabilities/__Libraries/StellaOps.Vulnerabilities.Ingestion/Ingest/IngestScheduler.cs and src/Vulnerabilities/StellaOps.Vulnerabilities.Worker/Program.cs.

Flow Diagram (Scheduler-driven path)

sequenceDiagram
    participant Source as Advisory source
    participant Hub as Vulnerabilities hub
    participant Jobs as Scheduler (JobEngine)
    participant Scanner
    participant Notify
    Source->>Hub: Connector ingestion and normalization
    Hub->>Hub: Fact changes and vuln.facts outbox in one transaction
    Jobs->>Hub: Authenticated event read from durable checkpoint
    Hub-->>Jobs: Ordered fact envelopes and stream epoch
    Jobs->>Jobs: Debounce changed packages; resolve indexed images
    Jobs->>Scanner: Request re-evaluation reports
    Scanner-->>Jobs: Materialized report changes
    Jobs->>Notify: scheduler.rescan.delta@1

Step-by-Step (Scheduler-driven path)

1. Advisory ingestion (Vulnerabilities hub)

The worker’s IngestSchedulerWorker hosts the configured ingest cycle. IngestPipeline stores source documents and NormalizationPump derives facts and provenance. FactWriter updates current facts, appends changed revisions, and writes one outbox event per changed fact in the caller’s transaction. Unchanged fact content creates no revision or vuln.facts event. Connector failures are reported per source so one source does not starve the cycle.

2. Fact change stream (Vulnerabilities hub)

FactWriter.Stream is vuln.facts; its changed-fact event type is vuln.fact.upserted, version 1. This path uses the hub’s PostgreSQL outbox and authenticated pull API, GET /api/vulnerabilities/v1/events/vuln.facts. It does not require a Concelier publisher or a NATS subject.

The event payload uses these exact names (FactWriter.WriteBatchAsync):

interface FactUpsertedPayload {
  fact_id: string;
  vulnerability_id: string;
  product_key: string;
  revision_hash: string;
}

The event-read response supplies streamEpoch, headSeq, retention information and ordered envelopes. Each envelope has eventId, streamEpoch, seq, type, v, occurredAt and payload. The read requires the hub’s AdvisoryRead policy (advisory:read). Source: StellaOps.Vulnerabilities.WebService/Program.cs, ReadHubEvents. Consumers must handle a retained-window gap rather than treating a partial page as complete history.

3. Trigger into the Scheduler

The Scheduler models the run trigger as the RunTrigger enum: Cron | Conselier | Excitor | Manual | Advisory (src/JobEngine/.../StellaOps.Scheduler.Models/Enums.cs). Advisory-driven runs carry RunTrigger.Advisory; Conselier and Excitor belonged to the retired Concelier/Excititor export webhooks and remain only so stored runs still read.

The advisory trigger (Scheduler:Worker:VulnerabilityHub:Trigger, default off) reads the Vulnerabilities hub’s global vuln.facts stream (GET /api/vulnerabilities/v1/events/vuln.facts) per scheduler tenant:

Notify still defines the retired upstream completion events conselier.export.completed and excitor.export.completed (src/Notify/.../NotifyEventKinds.cs); nothing in JobEngine consumes them.

4. Impact targeting (Scheduler)

Rather than ad-hoc SQL against an scanner.scans table, the Scheduler resolves the affected image set through the impact index (IImpactIndex / RoaringImpactIndex, src/JobEngine/.../StellaOps.Scheduler.ImpactIndex/). The targeting service (ImpactTargetingService) exposes:

usageOnly drops only matches whose component is known not to be used in that image; components on an entrypoint path and components whose usage was never analysed are kept, because skipping a rescan for lack of usage data would be silent. Analysis-only schedules pass usageOnly = true; content-refresh schedules pass false. The Selector scopes the query by tenant / digest / namespace / repository / tag glob / labels (SelectorScope: AllImages | ByNamespace | ByRepository | ByDigest | ByLabels). The index keys images by tenant and digest, so a tenant-scoped query returns each image once.

5. Re-evaluation (Scanner + Policy Engine)

For each image in a queued run segment, the runner asks Scanner for a fresh report (POST /api/v1/reports with the image digest, HttpScannerReportClient), and Scanner obtains the verdict from the Policy Engine’s preview of that image. Analysis-only schedules re-evaluate the stored image this way without re-analysing it; content-refresh schedules first submit a new scan of the image’s indexed location (registry/repository@digest, POST /api/v1/scans), and skip the rescan of an image whose location the index does not know. The runner builds the image’s DeltaSummary from the report, and Smart-Diff (below) classifies whether a state change is material.

Score replay (POST /api/v1/scans/{scanId}/score/replay, ScoreReplayService) is not part of this flow: it reproduces a scan’s original score from the frozen inputs in its manifest, so it cannot reflect a new advisory.

6. Delta summary and the rescan-delta event

For each impacted image the run produces a DeltaSummary (src/JobEngine/.../StellaOps.Scheduler.Models/Run.cs):

{
 "imageDigest": "sha256:…",
 "newFindings": 4,
 "newCriticals": 0,
 "newHigh": 1,
 "newMedium": 2,
 "newLow": 1,
 "kevHits": ["CVE-2024-1234"],
 "topFindings": [ { "purl": "…", "vulnerabilityId": "CVE-2024-1234", "severity": "Critical", "link": "…" } ],
 "reportUrl": "…",
 "attestation": { "uuid": "…", "verified": true },
 "detectedAt": "2025-10-18T15:40:11Z"
}

When any deltas exist, SchedulerEventPublisher.PublishRescanDeltaAsync publishes a Notify event of kind scheduler.rescan.delta(version 1, NotifyEventKinds.SchedulerRescanDelta). The wire-faithful payload (see the canonical sample at docs/modules/signals/events/samples/scheduler.rescan.delta@1.sample.json, which the RescanDeltaEventSampleTests assert against) is:

{
 "eventId": "51d0ef8d-3a17-4af3-b2d7-4ad3db3d9d2c",
 "kind": "scheduler.rescan.delta",
 "tenant": "tenant-acme-solar",
 "ts": "2025-10-18T15:40:11+00:00",
 "payload": {
 "scheduleId": "rescan-weekly-critical",
 "impactedDigests": [
 "sha256:0f0a8de5c1f93d6716b7249f6f4ea3a8db451dc3f3c3ff823f53c9cbde5d5e8a",
 "sha256:ab921f9679dd8d0832f3710a4df75dbadbd58c2d95f26a4d4efb2fa8c3d9b4ce"
 ],
 "summary": { "newCritical": 0, "newHigh": 1, "total": 4 },
 "reason": "policy-change:scoring/v2"
 },
 "attributes": {}
}

The producer (BuildRescanPayload) emits scheduleId, impactedDigests[], summary { newCritical, newHigh, total }, and an optional reason (manual:… / conselier:… / excitor:…). There is no scan.verdict_changed event kind in the platform; verdict changes are surfaced through scheduler.rescan.delta (and, for individual report completion, scanner.report.ready).

A divergent sample exists at docs/modules/notify/resources/samples/scheduler.rescan.delta@1.sample.json with deltaId / imagesAffected / newVulnerabilities fields. That sample does not match the Scheduler producer or the asserted contract; the signals sample above is authoritative.

Withdrawn: Scanner feed-change rescore

Scanner has no feed-change rescore job. FeedChangeRescoreJob, a Scanner WebService background job that polled Concelier, Excititor and Policy snapshot hashes and replayed scoring for recent scans, was never registered by a host, and its snapshot source had no implementation. It is withdrawn, together with its FeedChangeRescore options and its StellaOps.Scanner.FeedChangeRescore meter. Advisory changes reach images only through the Scheduler path above.

Material Risk Detection (Smart-Diff)

Material-change detection is implemented by MaterialRiskChangeDetector (src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/Detection/). The rules R1–R4 are not a verdict/finding/KEV/VEX set. The actual rules are:

RuleID (DetectionRule)TriggerChange types
Reachability flipR1reachable flips true↔false (skipped if either side unknown)reachability_flip
VEX status flipR2Meaningful VEX transition (e.g. not_affected→affected, affected→fixed)vex_flip
Affected range boundaryR3Component version enters/exits the affected version rangerange_boundary
Intelligence / policy flipR4KEV add/remove, EPSS crosses threshold (default 0.10), or policy decision flip (Allow/Warn/Block)kev_added, kev_removed, epss_threshold, policy_flip

Each detected change carries a direction (increased / decreased / neutral) and a configurable weight (MaterialRiskChangeOptions). The detector also computes a priorityScore (per the Smart-Diff advisory A9): +1000 if currently KEV, +500 if reachable, +200 for a range flip into the affected range, +150 for a VEX flip to affected, +0..100 from EPSS (epss × 100), and +300/+100 for a Block/Warn policy decision.

The result record (MaterialRiskChangeResult) exposes findingKey, hasMaterialChange, changes[], priorityScore, and the previous/current state hashes.

Re-scan Policies (illustrative)

Not source-verified config. The YAML blocks below describe a desired triggering model and are retained as design intent. They do not map to a shipped configuration schema. The shipped knobs are the advisory trigger (Scheduler:Worker:VulnerabilityHub:Trigger), the Scanner image projection (Scheduler:Worker:ScannerProjection), and the Scheduler schedule definitions.

# (illustrative) immediate re-evaluation on critical advisories
advisory_drift:
 trigger: immediate
 severity_threshold: critical
 batch_size: 100
 parallelism: 10
# (illustrative) scheduled re-evaluation
advisory_drift:
 trigger: scheduled
 schedule: "0 */4 * * *" # every 4 hours
 severity_threshold: high
 include_new_vex: true

Data Contracts

Hub fact event (vuln.facts / vuln.fact.upserted@1)

The payload and ordered read envelope are defined in step 2. product_key identifies the changed product used by the Scheduler’s impact targeting; fact_id and revision_hash identify the fact and its changed revision.

Rescan delta event (scheduler.rescan.delta@1)

interface SchedulerRescanDeltaPayload {
 scheduleId: string;
 impactedDigests: string[]; // "sha256:…"
 summary: { newCritical: number; newHigh: number; total: number };
 reason?: string; // "manual:…" | "conselier:…" | "excitor:…"
}

Delta summary (per image)

interface DeltaSummary {
 imageDigest: string; // "sha256:…"
 newFindings: number;
 newCriticals: number;
 newHigh: number;
 newMedium: number;
 newLow: number;
 kevHits: string[];
 topFindings: { purl: string; vulnerabilityId: string; severity: SeverityRank; link?: string }[];
 reportUrl?: string;
 attestation?: { uuid?: string; verified?: boolean };
 detectedAt?: string;
}

Authorization Scopes

Scopes referenced by this flow (canonical catalog StellaOpsScopes.cs):

Scope constantValueUsed for
AdvisoryReadadvisory:readRead Vulnerabilities hub facts and ordered event envelopes
VulnSubmitvuln:submitSubmit first-party issuer facts to the Vulnerabilities hub
SchedulerReadscheduler:readRead Scheduler run state / history
SchedulerOperatescheduler:operatePause / resume / trigger Scheduler jobs
ScannerReadscanner:readRead scan results / score replays
ScannerScanscanner:scanTrigger scan / rescore operations
FindingsReadfindings:readRead effective findings materialised by Policy Engine
NotifyViewernotify.viewerView notifier delivery history

ASP.NET policy names may use colon form even where a scope constant differs; verify the policy registration and the scope constant together when wiring endpoints.

Performance Optimizations

OptimizationDescriptionSource anchor
Report reuseAnalysis-only runs request a fresh report for the stored image instead of rescanning itHttpScannerReportClient
Impact-index targetingRoaring-bitmap lookup by purl / vuln id instead of scanning all imagesRoaringImpactIndex
Usage-only filteringRestrict to runtime/entrypoint components (usageOnly)ImpactTargetingService
Deterministic dedupImpact set deduplicated by image digestImpactTargetingService

Error Handling

ErrorRecoverySource anchor
Transient Scanner report failureRetry with exponential backoff (SchedulerWorkerOptions.Runner.Scanner.MaxRetryAttempts, default 3)HttpScannerReportClient
Content-refresh submission failureLogged as a warning; the runner still requests the reportHttpScannerReportClient
Event publish failure (Scheduler)Logged; run is not failed by a publish errorSchedulerEventPublisher.PublishAsync
Local publisher in useOne-time warning that Notify events are not publishedLocalSchedulerEventPublisher

Observability

Metrics

Scheduler worker (meter StellaOps.Scheduler.Worker, SchedulerWorkerMetrics), grouped by schedule mode:

MetricType
scheduler_planner_runs_totalCounter
scheduler_runner_images_totalCounter
scheduler_runner_delta_critical_total, scheduler_runner_delta_high_total, scheduler_runner_delta_total, scheduler_runner_delta_kev_totalCounter

Key log events (representative)

EventLevelFields
Scanner report attempt failed; retryingWARNdigest, attempt, delay
Failed to publish scheduler eventERROReventKind, runId