Scan Submission Flow

Reconciliation note (doc ↔ code). This flow was reconciled against the Scanner WebService and Worker source in src/Scanner/. Several earlier sketches did not match the implementation and have been corrected here: the submit/status request and response contracts, the status enum, the queue-backed dispatch path (there is no separate “Scheduler” service in the submit path), the worker stage pipeline (the /internal/* orchestration endpoints never existed), the analyzer set, the storage table names, and the completion-notification mechanism. Ground truth: src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs, Contracts/ScanSubmitRequest.cs, Contracts/ScanSubmitResponse.cs, Contracts/ScanStatusResponse.cs, Domain/ScanStatus.cs, src/Scanner/StellaOps.Scanner.Worker/Processing/ScanStageNames.cs.

Overview

The Scan Submission Flow describes the lifecycle of a container image scan from initial submission through SBOM generation, vulnerability matching, VEX gating, verdict/attestation publication, and result storage. This is the core workflow that produces security evidence for container images.

Business Value: Automated, deterministic scanning ensures every container image is evaluated against the latest advisories and policies before deployment.

Actors

ActorTypeRole
Developer/CIHuman/SystemSubmits scan request
CLI / API ClientSystemInitiates scan via API
GatewayServiceRoutes and authenticates
Scanner WebServiceServiceAccepts submission, persists snapshot, enqueues job
Scan queue (Valkey)InfrastructureBuffers enqueued scan jobs
Scanner WorkerServiceLeases job, analyzes image, runs the stage pipeline
ConcelierServiceProvides advisory data / matcher ingestion (sbom-match stage)
VEX (VexLens / VexHub)ServiceVEX statements consumed by the vex-gate stage
Policy EngineServiceMaterialises effective findings / policy verdicts
Signer / AttestorServiceSigns the verdict DSSE envelope (push-verdict / generate-poe stages)
JobEngine / NotifierServiceReceives scan events and fans out notifications

Note on “Scheduler”. Earlier drafts named a standalone Scheduler service in the submit path. The Scanner WebService submit endpoint enqueues directly onto a Valkey-backed scan queue (QueuePublishingScanCoordinator); the Scanner Worker leases jobs from that queue (QueueBackedScanJobSource). The Scheduler module now lives inside JobEngine (see repo §1.4 module map) and is not invoked per-submission. Scheduled/recurring scans are a separate concern.

Prerequisites

Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────────┐
│                           Scan Submission Flow                                   │
└─────────────────────────────────────────────────────────────────────────────────┘

┌───────┐   ┌─────────┐   ┌───────────────┐   ┌────────┐   ┌─────────────────┐
│  CLI  │   │ Gateway │   │ Scanner       │   │ Scan   │   │ Scanner Worker  │
│  /API │   │         │   │ WebService    │   │ queue  │   │ (stage pipeline)│
└───┬───┘   └────┬────┘   └───────┬───────┘   └───┬────┘   └────────┬────────┘
    │            │                │               │                 │
    │ POST /api/v1/scans          │               │                 │
    │ {image:{reference,digest}}  │               │                 │
    │───────────>│                │               │                 │
    │            │ authn + scope  │               │                 │
    │            │ check          │               │                 │
    │            │───────────────>│               │                 │
    │            │                │ persist       │                 │
    │            │                │ snapshot +    │                 │
    │            │                │ enqueue       │                 │
    │            │                │──────────────>│                 │
    │ 202 Accepted                │               │                 │
    │ {scanId,status:"Pending",   │               │                 │
    │  location,created}          │               │                 │
    │<───────────│<───────────────│               │                 │
    │            │                │               │ lease job       │
    │            │                │               │────────────────>│
    │            │                │               │                 │ resolve-image
    │            │                │               │                 │ pull-layers
    │            │                │               │                 │ build-filesystem
    │            │                │               │                 │ execute-analyzers
    │            │                │               │                 │ sbom-match ──> Concelier
    │            │                │               │                 │ reachability-analysis
    │            │                │               │                 │ epss-enrichment
    │            │                │               │                 │ vex-gate ──> VEX evidence
    │            │                │               │                 │ publish-evidence
    │            │                │               │                 │ compose-artifacts (SBOM)
    │            │                │               │                 │ emit-reports
    │            │                │               │                 │ generate-poe ──> Signer
    │            │                │               │                 │ push-verdict ──> OCI registry
    │            │                │               │                 │
    │ GET /api/v1/scans/{scanId}  │               │                 │ (status polled
    │───────────>│───────────────>│               │                 │  or SSE stream)
    │ 200 {status,image,...}      │               │                 │
    │<───────────│<───────────────│               │                 │
    │            │                │               │                 │
    │            │  JobEngine event scanner.event.scan.completed     │
    │            │  (carries `notifier` metadata) ──> Notifier        │
    │            │                │               │                 │

The named stages above are the canonical worker pipeline from ScanStageNames.Ordered. Stages with no registered executor for a given deployment are silently skipped (ScanJobProcessor), so not every scan runs every stage.

Step-by-Step

1. Scan Request Submission

CLI: The CLI scan command group does not expose a single stellaops scan <image> one-shot submit. Local execution uses stellaops scan run (runs a scanner bundle against a local target), and stellaops scan image inspect|layers inspects an OCI image. Server-side submission is done by API clients / CI integrations against the endpoint below. (Verify against src/Cli/StellaOps.Cli/Commands/CommandFactory.cs, BuildScanCommand.)

API Request (POST /api/v1/scans, route group base /api/v1, segment scans):

POST /api/v1/scans HTTP/1.1
Host: gateway.stellaops.local
Authorization: Bearer {token-with-scanner:scan-or-scanner:write}
X-StellaOps-TenantId: acme-corp
Content-Type: application/json

{
  "image": {
    "reference": "docker.io/library/nginx:1.25",
    "digest": "sha256:abc123..."
  },
  "force": false,
  "clientRequestId": "ci-build-4821",
  "metadata": {
    "ci.pipeline": "build-and-scan"
  }
}

Request contract (ScanSubmitRequest):

There is no options object on the submit request. Analyzer selection, SBOM format, policy set, and attestation are not per-request fields — they are deployment/worker configuration. Earlier drafts that showed options.{analyzers,sbom_format,policy_set,attestation} were inaccurate.

2. Gateway Processing

3. Submission + Enqueue (Scanner WebService)

Response (ScanSubmitResponse, 202 Accepted):

{
  "scanId": "9f3a9b2c1234abcd...",
  "status": "Pending",
  "location": "/api/v1/scans/9f3a9b2c1234abcd...",
  "created": true
}

status is the live snapshot status (Pending on a fresh submit), serialised from the ScanStatus enum. There is no estimated_wait field, and the identifier is a 40-char hex scanId, not a scan-<uuid> string.

4. Worker Dispatch

The Scanner Worker leases the enqueued job (QueueBackedScanJobSource) and runs ScanJobProcessor.ExecuteAsync, which iterates the canonical stage pipeline (ScanStageNames.Ordered). There is no separate Scheduler service handing work to the worker, and no scheduler.jobs table is written during submit.

5. Stage Pipeline (Image Analysis → Verdict)

The worker executes stages in this fixed order (ScanStageNames.Ordered); stages without a registered executor are skipped:

Stage (StageName)Purpose
ingest-replayMount a sealed replay bundle when replaying a prior scan
resolve-imageResolve the image manifest / digest
pull-layersFetch image layers
build-filesystemMaterialise the layered filesystem
checkout-source(Source-scan path) check out source
execute-analyzersRun the OS + language analyzers (see note below)
prepare-reachability-inputsStage inputs for reachability
sbom-matchForward component PURLs + image digest to Concelier matcher
service-security, crypto-analysis, ai-ml-security, build-provenanceOptional deeper analyses
scan-secrets, binary-lookupSecret detection and binary symbol lookup
epss-enrichmentAttach EPSS scores to findings
reachability-analysis, synthetic-analyzer-coverage-reachabilityReachability signals
vex-gateApply VEX evidence and compute the gate (pass / warn / block)
publish-evidenceProject scan evidence
compose-artifactsAssemble the unified SBOM (CycloneDX / SPDX)
entropyLayer entropy / opacity analysis
emit-reportsEmit the report document
generate-poeGenerate the proof-of-evidence / DSSE material (Signer)
push-verdictPublish the verdict DSSE envelope as an OCI referrer artifact

Analyzers. The repo ships 14+ language analyzers (StellaOps.Scanner.Analyzers.Lang.*: C/C++, Swift, Dart, Elixir, Go, Rust, Bun, PHP, Java, .NET, Python, Node, Deno, Ruby) plus many OS package analyzers (Analyzers.OS.*: apk, dpkg, rpm, pacman, portage, homebrew, and Windows Chocolatey/MSI/WinSxS, macOS pkgutil/bundle). The earlier fixed list of exactly 11 named analyzers was outdated, and analyzer selection is not a submit-request input.

6. Vulnerability Matching (sbom-match stage)

After analyzers emit components, SbomMatchStageExecutor forwards the scan’s image digest and component PURLs to Concelier’s matcher (IConcelierLearnClient.ForwardAsync, keyed by the scan’s image digest, under the scan’s tenant). The resulting matches land in vuln.sbom_canonical_match and surface via the findings APIs. A matcher-forward failure is best-effort and does not fail an otherwise-good scan.

There is no Scanner POST /internal/match endpoint. Matching is an outbound worker→Concelier call, not an inbound HTTP API on the Scanner.

7. VEX Application (vex-gate stage)

VexGateStageExecutor consumes available VEX evidence and computes a gate result summarised in the scan-completed event’s vexGate block (totalFindings, passed, warned, blocked, bypassed, policyVersion). VEX status (e.g. not_affected, fixed) modifies whether a finding is gated.

There is no Scanner POST /internal/vex/apply endpoint.

8. Policy / Verdict

Effective findings and policy verdicts are materialised by the Policy Engine (it holds the effective:write service scope and writes effective findings). The Scanner Worker’s push-verdict stage publishes the resulting verdict as a signed DSSE envelope OCI referrer artifact (VerdictPushStageExecutorIVerdictOciPublisher), carrying the SBOM digest, feeds digest, policy digest, and decision.

There is no Scanner POST /internal/evaluate endpoint, and the rich {verdict, confidence, violations[]} body shown previously is not a real Scanner API response. The verdict is surfaced via the OCI referrer artifact and the scan-completed event payload.

9. Attestation (generate-poe / push-verdict stages)

DSSE signing is performed through the Signer service (HttpSignerPoEDsseSigningService for proof-of-evidence; the verdict envelope is signed and pushed by the verdict-push stage). There is no Scanner POST /internal/attest endpoint; the doc previously showed a fabricated request shape.

10. Result Storage

Scanner stores results:

StoreData
PostgreSQL scanner.scans (scans table)Scan metadata / snapshot
PostgreSQL scanner.scan_findingsPer-finding rows (scan_id, tenant_id, vuln_id, package_purl, …)
RustFS object storeSBOM documents, attestations, and other artifacts (RustFsArtifactObjectStore)
ValkeyScan queue + progress/event streaming (not a long-term result cache)

The findings table is scan_findings, not findings. Artifact blobs are stored via the configured IArtifactObjectStore (RustFS by default; an S3-compatible store also exists) rather than fixed blobs//attestations/ path conventions.

11. Notification

The Scanner does not call a Notify service directly, and there is no per-submit “Scheduler triggers Notify” step. On completion the worker / web service emits JobEngine events whose kinds are defined in JobEngineEventKinds (scanner.event.scan.completed, .started, .failed, scanner.event.report.ready, scanner.event.sbom.generated, scanner.event.vulnerability.detected). The scan-completed event carries a notifier metadata block (severityThresholdMet, notificationChannels, digestEligible, immediateDispatch, priority) which the Notifier consumes to decide fan-out.

Illustrative scanner.event.scan.completed envelope (JobEngineEvent + ScanCompletedEventPayload):

{
  "eventId": "…",
  "kind": "scanner.event.scan.completed",
  "version": 1,
  "tenant": "acme-corp",
  "occurredAt": "2026-05-30T10:30:00Z",
  "scope": { "repo": "library/nginx", "digest": "sha256:abc123..." },
  "payload": {
    "scanId": "9f3a9b2c…",
    "imageDigest": "sha256:abc123...",
    "verdict": "fail",
    "summary": { },
    "vexGate": { "totalFindings": 12, "passed": 9, "warned": 1, "blocked": 2 }
  },
  "notifier": { "severityThresholdMet": true, "immediateDispatch": true }
}

Data Contracts

Scan Submit Request (ScanSubmitRequest)

interface ScanSubmitRequest {
  image?: {                 // ScanImageDescriptor
    reference?: string;     // registry/repo:tag or @digest
    digest?: string;        // sha256:...
  };
  artifactRef?: string;     // flat alias for image.reference
  artifactDigest?: string;  // flat alias for image.digest
  force?: boolean;          // re-scan even if a snapshot exists
  clientRequestId?: string; // caller correlation / idempotency hint
  metadata?: Record<string, string>; // free-form string→string map
}
// At least one of reference/digest (in either form) is required; otherwise 400.

Scan Submit Response (ScanSubmitResponse, 202 Accepted)

interface ScanSubmitResponse {
  scanId: string;     // 40-char hex identifier
  status: string;     // ScanStatus: "Pending" | "Running" | "Succeeded" | "Failed" | "Cancelled"
  location?: string;  // status URL (also echoed in the Location header)
  created: boolean;   // false if an existing snapshot was reused
}

Scan Status Response (ScanStatusResponse, GET /api/v1/scans/{scanId})

interface ScanStatusResponse {
  scanId: string;
  status: string;          // "Pending" | "Running" | "Succeeded" | "Failed" | "Cancelled"
  image: { reference?: string; digest?: string }; // ScanStatusTarget
  createdAt: string;       // ISO-8601
  updatedAt: string;       // ISO-8601
  failureReason?: string;
  entropy?: {              // EntropyStatusDto, when computed
    imageOpaqueRatio: number;
    layers: { layerDigest: string; opaqueRatio: number; opaqueBytes: number; totalBytes: number }[];
  };
  surface?: object;        // SurfacePointersDto, when a digest resolves
  replay?: {               // ReplayStatusDto, when artifacts exist
    manifestHash: string;
    bundles: { type: string; digest: string; casUri: string; sizeBytes: number }[];
  };
}

The status response does not contain verdict, confidence, a severity summary, or sbom_url / attestation_url / findings_url. The verdict and finding summary are delivered out-of-band via the scanner.event.scan.completed JobEngine event (and the OCI verdict referrer artifact), not on the scan-status endpoint. The status enum is Pending | Running | Succeeded | Failed | Cancelled (ScanStatus, Domain/ScanStatus.cs) — note Succeeded (not completed) and the absence of a queued value.

Error Handling

Submit-time errors (returned synchronously from POST /api/v1/scans):

ErrorHTTP StatusSource
Missing/empty body, or neither reference nor digest supplied400 (problem+json)HandleSubmitAsync validation
Digest supplied but not a valid algo:hex digest400 (problem+json)ArtifactDigestValidator
Tenant could not be resolved / tenant conflict400 (problem+json)ScannerRequestContextResolver
Missing or insufficient scope (scanner:scan / scanner:write)401 / 403Authorization policy scanner.api
Duplicate / conflicting submission409Declared on the submit endpoint

Runtime errors (surfaced asynchronously, not on the submit response): registry pull/auth failures, analyzer failures, and signing failures are reported through the scan snapshot status/failureReason and the scanner.event.scan.failed event (ScanFailedEventPayload carries a structured error with code, message, and a recoverable flag). The earlier table conflated synchronous submit errors with downstream runtime failures, and listed a “Policy set not found” case that does not apply (policy set is not a submit input).

Observability

Metrics

Worker metrics (ScannerWorkerMetrics, meter ScannerWorkerInstrumentation.Meter):

MetricType
scanner_worker_queue_latency_msHistogram
scanner_worker_job_duration_msHistogram
scanner_worker_stage_duration_msHistogram
scanner_worker_jobs_completed_totalCounter
scanner_worker_jobs_failed_totalCounter
scanner_worker_language_cache_hits_total / ..._misses_totalCounter
scanner_worker_os_cache_hits_total / ..._misses_totalCounter
scanner_worker_surface_manifests_published_total (and _skipped / _failed)Counter

The earlier scan_submitted_total / scan_completed_total / scan_duration_seconds names were not found in the worker metrics source; the emitted names are scanner_worker_* as above. WebService-side counters (if any) should be confirmed separately.

Trace Context

scan-submission
├── gateway-auth
├── scanner-webservice-submit (persist snapshot + enqueue)
└── scanner-worker-execute (ScanStageNames.Ordered)
    ├── resolve-image
    ├── pull-layers
    ├── build-filesystem
    ├── execute-analyzers
    ├── sbom-match            (→ Concelier matcher)
    ├── reachability-analysis
    ├── epss-enrichment
    ├── vex-gate
    ├── publish-evidence
    ├── compose-artifacts     (SBOM assembly)
    ├── emit-reports
    ├── generate-poe          (→ Signer)
    └── push-verdict          (→ OCI registry referrer)

Authorization Scopes

Canonical scope constants live in src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs. Scopes relevant to this flow:

Scope (claim value)ConstantUsed for
scanner:scanScannerScanTrigger a scan (submit)
scanner:writeScannerWriteAlso accepted for submit; entropy attach
scanner:readScannerReadRead scan status, events, SBOM, reports
scanner:exportScannerExportExport SBOM / reports
effective:writeEffectiveWritePolicy Engine service identity writing effective findings
signer:signSignerSignDSSE signing of verdict / PoE material

ASP.NET policy names in ScannerPolicies (e.g. scanner.api, scanner.scans.read) are distinct from the scope claim values above. The submit policy (scanner.api) is an any-of over scanner:scan, scanner:write, and the legacy Authority scopes scanner.scans.enqueue / scanner.scans.write (Program.cs, Sprint 20260517_020).