Scanner scan.completed producer contract (DC-15)

Status: payload v2 + tenant-partitioned producer transport IMPLEMENTED (2026-08-28). Q-22 embeds both consumer-read collections and makes the event self-verifying. The approved Q-23 redesign maps the stable logical scanner.scans identity to one injective physical stream, epoch, sequence, retention floor and remote-consumer cursor per exact tenant. Findings and Integrations consume the owner feed into their own databases; Policy uses the bounded, authenticated digest-detail API. The former service-global stream/scope design is superseded and retained below only as history. Owner: Scanner. Contract authored by SCN-2 (finding D-SCN1-7); producer by SCN-2E; v2 by FND-X18-7’s Q-22 slice. Binding design: DC-15, envelope DC-06, transport DC-07.

Source of truth for the shapes: src/Scanner/__Libraries/StellaOps.Scanner.Contracts/Events/ScanCompletedContract.cs. Wire shape pinned by ScannerContracts.Tests/ScanCompletedContractTests; the type-string lockstep with the consumer-side constant by StellaOps.Architecture.Contracts.Tests/ScannerConsolidationConformanceTests.


1. Why this exists

X11–X13 were three direct scanner.* SQL seams. The active replacement is one producer contract, two consumption styles: a tenant-partitioned retained event feed for rebuildable Findings and Integrations projections, and a bounded digest-scoped owner API for Policy’s on-demand evaluation read. Consumers persist only their own projections and cursors; none needs a Scanner database connection.

D-SCN1-7 recorded that all three consumer programs were blocked on the same missing artefact — SPRINT_20260722_024’s D-INT4-1 declined to invent the payload precisely because this contract did not exist — which made it Scanner’s critical path rather than a downstream detail.

2. Identity

FieldValue
Envelope typescanner.scan.completed
Envelope v2 (Q-22 full-detail-event; v1 is pre-cutover residue, skip-and-count)
Logical streamscanner.scans
Physical streamscanner.scans.tenant.<lowercase UTF-8 hex of the exact tenant id> (ScanCompletedContract.TenantStream)
Catch-up routeGET /api/v1/scans/completed/events (afterSeq, limit ≤ 5000, streamEpoch, maxBytes ≤ 8 MiB / default 2 MiB)
Consumer cursor routePOST /api/v1/scans/completed/consumers/{consumerId} (empty epoch/seq pair registers or resets; a complete pair reports committed progress)
Detail routeGET /api/v1/scans/by-digest/{imageDigest}/detail

Both completion handlers inject IStellaOpsTenantAccessor, retain the tenant endpoint filter, derive the tenant only from the authenticated request context and echo it as tenantId; callers cannot select a tenant in the query or request body. The Authority tenant resolver trims and lowercases the authenticated identity before creating StellaOpsTenantContext. Consumer configuration must name that canonical resolved id, not the caller’s original spelling. Scanner then compares that id with StringComparison.Ordinal. The physical-key helper does not normalize again: it is injective over its input because it hex-encodes the exact UTF-8 bytes, not a slug or lowercased copy. This does not promise case-distinct identities at the HTTP boundary.

The type string is not new. The estate already carried three spellings and this evolves the one a consumer already binds, rather than minting a fourth:

The DC-06 envelope is not redeclared in StellaOps.Scanner.Contracts. Envelope ownership belongs to the outbox lineage (StellaOps.Eventing, per DC-29’s evolve-don’t-fork rule), and the contracts project must stay a dependency-free leaf so it remains the conformance-verified closed seam D-INT4-2 justified for integrations-web and signals. What this project owns is the payload and the type/v constants.

3. Why these fields, and no others

Every field was derived by reading the consumers’ SQL, not by speculation. Mapping:

RegisterConsumerReads todayReplaced by
X11Findings security read modelformer scan_runtime_state, scan_evidence_projections and artifact_boms joinsauthenticated tenant feed; v2 payload is verified, then projection + inbox/checkpoint commit in stellaops_findings; producer cursor is reported only after that commit
X12Integrations registry-image scan planeformer latest-attempt/SBOM/reachability readsauthenticated tenant feed; v2 payload is verified, then integrations.registry_image_scan_projections + inbox/checkpoint commit in stellaops_integrations; producer cursor is reported only after that commit
X13Policy.Engine/Services/PostgresFindingsLookup.csformer scan_runtime_state and scan_evidence_projections readstenant-scoped GET /api/v1/scans/by-digest/{imageDigest}/detail; bounded response, Authority scanner:read, exact tenant/digest and set-hash verification

Two shapes deserve explicit notice because getting them wrong would be silent:

4. The collections ARE in the event (payload v2, Q-22)

Current contract (v2, shipped 2026-08-26; ruled 2026-08-25, settlement receipt: SPRINT_20260722_010 Decisions & Risks). The payload embeds the two collections consumers actually read — componentPurls and reachabilityFindings — beside the summaries and set digests it always carried:

The payload is content-addressed and self-verifying: a consumer recomputes purlSetSha256 and findingSetSha256 from the embedded collections and admits only on match — a tampered or truncated payload refuses itself, whatever transported it. Event identity is (tenant, imageDigest, purlSetSha256, findingSetSha256, canonicalBomSha256, projectionUpdatedAt); scanId is provenance only and no consumer decision may key on it. The set digests still make this a projection feed rather than a change notification: an unchanged digest means an unchanged generation, and a consumer skips the apply.

The ground for embedding: v1’s second detail fetch was the stream’s one environment-bound reference, and the exact mechanism that made from-zero replay fail closed forever — the latest-anchored detail route can never serve a superseded generation to a replayed older envelope. DC-06’s register entry carries an on-record per-stream amendment scoped to exactly these two collections; its own justification is “narrow AND replayable”, and the fetch leg broke replayability. Two mandatory companions shipped with the cut:

  1. Declared tenant-stream-family retention window (RemotelyConsumed class). scanner-web declares the exact scanner.scans.tenant. prefix and hosts the windowed pruner; the old exact global physical stream remains in the same window only to retire pre-cut rows. The window is deployment-supplied and activation is fail-closed — source ships no number, and an incomplete declaration refuses startup (ScannerScansRetentionActivation; env keys documented in devops/compose/docker-compose.scanner.yml). An installation with no declaration keeps the pre-Q-22 unbounded behavior, visibly.
  2. Byte-bounded catch-up page. Each page is bounded by cumulative payload bytes (maxBytes, default 2 MiB, max 8 MiB — constants on ScanCompletedContract) in addition to the envelope count; a truncated page stays an ordered gapless prefix, and one envelope is always served so an oversized event can never stall replay.

The version cut is v2 clean per §2.11 (zero external package consumers at the bump; the current repository package is pinned in src/Directory.Packages.props and verified against its source by ContractPackageMirrorConformanceTests). Consumers skip-and-count v1 envelopes and still advance their checkpoints; a digest whose last success predates the cutover is cured by rescan or the recorded bootstrap — named residue, not machinery. The digest-detail route SURVIVES for Policy’s on-demand reads (latest-anchored is correct there); it stops being the event’s second half, and the rejected exact-scanId selector was never built.

History — the pre-ruling v1 rationale (superseded 2026-08-25)

Until the Q-22 ruling, DC-06’s payload rule — ids + hashes, never full documents — kept both collections out of the event: it carried only the counts and set digests above, and a consumer that needed the list fetched the digest-detail route. The set digests let a consumer skip the fetch when nothing moved, and both digests were repeated in the detail response so a caller could verify it fetched the generation it was notified about. The 2026-08-25 blast-radius measurement (F-R0825-X18-1) showed the fetch leg breaking replay: the producer emits each scan id’s first successful transition, the detail query anchors ORDER BY updated_at DESC, scan_id LIMIT 1, and a delayed older envelope therefore fetches the newer generation and is correctly rejected — forever. The full-detail-event ruling replaced the fetch with the embedded, self-verified collections; exact-scanId and latest-only alternatives were rejected.

Digest definitions (producer-side, so consumers can verify):

DigestComputation
purlSetSha256SHA-256 over the lowercased, ordinal-sorted, newline-joined purl set
findingSetSha256SHA-256 over the ordinal-sorted {vulnerabilityId}|{componentPurl}|{isReachable} triples, newline-joined
canonicalBomSha256scanner.artifact_boms.canonical_bom_sha256 verbatim

Three properties a re-implementer needs, all pinned by frozen fixtures in StellaOps.Scanner.Events.Tests (expectations computed outside the implementation, so they assert the contract rather than the code): both digests are a function of the SET, so input order and duplicates do not move them; isReachable is spelled true/false lowercase ordinal; and the empty set hashes the empty string (e3b0c442…b855) rather than a sentinel — a scan that found nothing is a real generation, and a magic value would make “no components” and “not measured” indistinguishable.

5. The producer, as built (SCN-2E, 2026-08-11)

src/Scanner/__Libraries/StellaOps.Scanner.Events/ — see its AGENTS.md for the binding rules.

Emission point: the terminal transition, not the projection stage. The event claims status = succeeded, and the only moment that becomes true is IScanRuntimeStateWriter.MarkSucceededAsync. ScanEvidenceProjectionStageExecutor runs earlier in the same job, so the projection is already durable by then — which is what lets the payload be read back from the database rather than carried through the pipeline in memory. Reading it back is also what makes the event and the detail route describe the same rows.

One transaction. The status UPDATE, the projection and artifact_boms reads, and IOutboxWriter.AppendAsync share one caller-owned transaction on Scanner’s own database (DC-29 design §6.1). Proven by rollback, not by observing two successes: with eventing.outbox dropped the append fails and the status does not move.

One digest implementation. The BCL-only public ScanFactsDigest in StellaOps.Scanner.Contracts computes and verifies purlSetSha256 / findingSetSha256 for the emitter, detail reader and package consumers. That makes “the event and the route agree” true by construction without importing Scanner implementation projects or forking the algorithm.

Emitted at most once per transition. The status write happens on every delivery (the queue is at-least-once and the read-model must converge); the envelope is appended only on the FIRST transition into succeeded, decided by the previous status the UPDATE returns. Consumers additionally dedupe on (stream, epoch, seq).

Withheld rather than fabricated. A scan with no resolved target_digest commits its status and publishes nothing: every consumer keys on the digest, so such an envelope is unroutable and the alternative is a consumer projection keyed on the empty string.

severityCounts is now optional, and is emitted null. Measured during SCN-2E: Scanner’s own database has no severity column anywhere — the consolidated baseline’s 90 tables carry none, scan_findings holds EPSS columns and no severity and has no writer in src/ at all, and the worker’s in-memory VulnerabilityMatch.Severity is never persisted. X12’s counts come from joining Scanner’s component purls against vuln.advisory_affected, which is the vulnerability plane’s data. DC-15’s sketch assigned this field to the wrong owner: severity changes without Scanner re-scanning anything, while the purl set is Scanner’s fact. Payload v2 therefore carries the PURLs themselves with componentStats.purlSetSha256; a consumer that owns a severity calculation combines them with its own advisory projection. The current Integrations projection implementation preserves a null Scanner severity as unknown and does not restore a vulnerability-database join. null means not measured; a consumer must never read absence as zero.

Homing. scanner-web converges the eventing schema in stellaops_scanner and serves the detail route; scanner-worker emits with the migration host surgically removed and the removal counted. One family, one migration authority — the SCN-4 invariant, applied to a second schema.

The route needed no gateway config change. Scanner rides the Router microservice bridge (AddRouterMicroservice / TryRefreshStellaRouterEndpoints), and AspNetCoreEndpointDiscoveryProvider enumerates the ASP.NET EndpointDataSource at HELLO — so a mapped endpoint is the declared, publishable set. There is no reverse-proxy route table to serialize on, which is why DC-19’s one-program-per-window constraint does not bind here. It would bind for a service the gateway front-doors by prefix.

Payload v2, as built (Q-22 slice, 2026-08-26). The emitter embeds componentPurls and reachabilityFindings read in the SAME transaction snapshot as the digests, so the payload is self-verifying by construction, not by convention (producer cost is serialization-only — the emitter already read both collections). The retained reader trims each page to the requested byte budget after capturing the head, always serving at least one envelope; scanner-web declares the tenant physical-stream prefix scanner.scans.tenant. as RemotelyConsumed and hosts the windowed pruner behind the fail-closed deployment activation (Services/ScannerScansRetention.cs). The Findings consumer (ScannerSecurityProjectionConsumer) recomputes both set hashes from the embedded payload before inbox admission and no longer takes a detail argument; its staged digest-detail client method and expectation seam are deleted. Byte-bound, tamper-refusal and fail-closed-activation behavior are each red/green-proven in StellaOps.Scanner.Events.Tests, StellaOps.Findings.Persistence.Tests and StellaOps.Scanner.WebService.Tests.

Q-23 redesign (approved and implemented 2026-08-28)

The feed requires the dedicated tenant-bound machine scope scanner:projection:read; ordinary scanner:read and the superseded cross-tenant scanner:projection:read-all do not admit it. The catch-up and consumer-cursor endpoints also require an authenticated tenant and pass that exact identity to Scanner’s reader/registry. Findings and Integrations each use a named confidential client, an exact configured token tenant, an independent physical stream/cursor, and a projection in their own database. Policy is deliberately not a projection client: it receives only scanner:read and calls the digest-detail route per evaluation.

Each projection consumer registers/resets its producer-side cursor before reading an empty local projection. It then commits projection replacement, inbox admission and local checkpoint under its destination fence. Only after that transaction commits does it report (streamEpoch, seq) to Scanner. A report failure retains too much and is retried from local truth; it cannot make Scanner prune uncommitted consumer state. A tenant epoch change or retention gap affects only that tenant.

The digest-detail response is independently anchored:

Scanner producer, Integrations X12 and Policy X13 source/package work is complete; the X12 forcing test proves a tampered PURL or finding hash fails before inbox/projection mutation. Deployment activation, integrated gates, credential provisioning and live catch-up/lag proof remain separate work where their owning sprint says so; this document does not claim those live steps occurred.

History — superseded service-global Q-23 ruling (2026-08-25/26)

The first Q-23 settlement kept one global physical scanner.scans sequence behind scanner:projection:read-all, with consumers expected to partition payloads after receipt. It explicitly rejected tenant streams as cursor/retention complexity. The 2026-08-28 redesign superseded that choice because a cross-tenant bearer and global retention floor did not satisfy the exact-tenant boundary: physical stream identity, epoch, cursor and retention are now per tenant, and the old scope is removed from the named clients.

6. Consumer rules (DC-06, restated because they are load-bearing here)