Findings security exposure read model

StellaOps.Findings.WebService (findings-web) owns the Console exposure read model. It selects PostgresActiveGenerationSecurityReadModelStore from StellaOps.Findings.Persistence and reads Findings-owned local projections in stellaops_findings. It never queries a sibling database or invents findings from missing input.

Currency / implementation boundary (2026-10-06, PCD-408). Source verified against ed5055eabe; proof pointers and re-verification commands follow below. The predecessor host and foreign-SQL store are frozen. The old derive/upsert and pending-cutover descriptions are historical. PCD-404 separately proves the isolated owner-envelope-to-HTTP image-identity path; that receipt does not establish an estate deployment or real producer session. The runtime observation below measures only container identity and health.

Source authority and re-verification

Paths below are repository-relative. Execute the commands from the repository root.

ClaimCurrent source proof
Host and selected adaptersrc/Findings/StellaOps.Findings.WebService/Program.cs, AddActiveGenerationSecurityReadModel; src/Findings/__Libraries/StellaOps.Findings.Persistence/ActiveGenerationSecurityReadModel.cs, registration replacement and all carried read interfaces
Live interface descriptionssrc/Findings/__Libraries/StellaOps.Findings.Security.Persistence/Stores/SecurityFindingProjectionContracts.cs documents the same local-generation reads, bounded exposure sample and explicit missing-capability refusals; PCD-409 reconciles comment text without changing signatures or behavior
Tenant, scope and image predicatesThe same adapter’s FindingsSql, FindingCountSql, ImageContextSql, ImageSbomComponentsSql and AddScopeParameters
Document bytes versus OCI imagesrc/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/021_findings_sbom_image_identity.sql and forward correction 022_findings_sbom_reference_kind.sql
Console passes image identitysrc/Web/StellaOps.Web/src/app/features/image-security/image-security-data.service.ts, loadFindingsByDigest, loadImageContext, loadImageSbom
HTTP admission and provenancesrc/Findings/__Libraries/StellaOps.Findings.Security.Application/Endpoints/SecurityReadModelEndpoints.cs; sibling SecurityReadModelSourceEndpointFilter.cs; adapter ReadGenerationProvenanceAsync
Service-identity absencesrc/Findings/__Tests/StellaOps.Findings.Security.Tests/DeveloperServiceScopeContractAbsenceTests.cs; current projection/scope and request-context types
Frozen predecessorsrc/__Obsoleted/Findings/StellaOps.Findings.Security.WebService/AGENTS.md (FND-20); src/__Obsoleted/Findings/__Libraries/StellaOps.Findings.Security.Persistence/AGENTS.md (FND-26)
Scanner worker compositionsrc/Findings/StellaOps.Findings.Worker/Program.cs, AddScannerSecurityProjection; its default-off owner registration and tenant-partitioned v2 consumer
Finding relation fields (CCP-058)The adapter’s FindingsSql (version.image_digest, reachability.source_scan_id) and ReadFindingsAsync; SecurityReadModels.cs (SecurityFindingProjection, SecurityDispositionProjection); tests FindingRelationReadContractTests, FindingRelationOpenApiTests and the ActiveGenerationReadAdapter_* / SbomImageIdentity_NonImageReferenceKeepsUnknownReachability cases
git rev-parse HEAD
rg -n "AddActiveGenerationSecurityReadModel|MapSecurityReadModelEndpoints" src/Findings/StellaOps.Findings.WebService/Program.cs
rg -n "image_digest|tenant_key|document_state|CapabilityUnavailable|RemoveAll" src/Findings/__Libraries/StellaOps.Findings.Persistence/ActiveGenerationSecurityReadModel.cs
rg -n "document|image_digest|GENERATED|substring" src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/021_findings_sbom_image_identity.sql src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/022_findings_sbom_reference_kind.sql
rg -n "loadFindingsByDigest|loadImageContext|loadImageSbom|set\('digest'" src/Web/StellaOps.Web/src/app/features/image-security/image-security-data.service.ts
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj -Method "*SbomImageIdentity*" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Operations.Tests/StellaOps.Findings.Operations.Tests.csproj -Class "*SbomImageIdentityOwnerPathTests*" -BuildProjectReferences

The last two commands rerun the selected identity and actual-host cases; they are not full-suite checks. The accepted PCD-404 receipt (pruned evidence docs/implplan/_evidence/20261006-scanner-jobengine-carry/PCD-404.md at a7f0f3539e) records implementation 6b9cd563b5 and its exact red/green controls. Its post-merge receipt (pruned evidence docs/implplan/_evidence/20261006-scanner-jobengine-carry/POST-MERGE.md at db05532c96) records the later owner-path check. PCD-408 reverified source and documentation; it did not rerun those product tests.

Runtime and blocker currency

Older statementReconciliation and authority
The predecessor still serves the Console; the consolidated route swap remains pendingSuperseded in source by Program.cs and the FND-20/FND-26 freeze records. The deployment runbook records the completed 2026-08-26 window. Read-only Docker inspection on 2026-10-06 independently found stellaops-findings-web and stellaops-findings-worker running and healthy. This does not re-prove Router routing, auth or feature acceptance.
Dropped vuln.* relations imply every current read returns 503Historical predecessor failure. The current probe checks a local active generation and its SBOM fence. An unavailable generation still returns 503 security_read_model_source_unavailable; source inspection does not establish the current estate response.
Scanner transport is not host-wiredSuperseded in source: the worker calls AddScannerSecurityProjection, which composes the default-off tenant-partitioned v2 consumer. Credentials, live cursor/lag and real producer delivery require separate measurement.
W7 developer service scope is blockedStill a source gap, pinned by DeveloperServiceScopeContractAbsenceTests: projection/scope/request context lack authoritative service/repository/image-namespace ownership. The old digest-discarding derive/upsert explanation is obsolete. Authority, ReleaseOrchestrator and Findings retain their respective claim, composition and filtering ownership.
Artifact triage and image-related releases can return old foreign-schema factsUnsupported in the consolidated adapter. It throws security_artifact_triage_projection_unavailable and security_release_projection_unavailable; an empty queue/list must not replace either refusal.
Recovery is only an internal primitive awaiting operator-authority designSuperseded by the deployed-service runbook’s recovery-authority section and current host’s recovery endpoints. Follow that scoped authorization/audit/hold procedure; this dossier grants no recovery or deploy authority.
August corpus sizes, row counts and timing establish today’s capacityHistorical sizing/acceptance only. They do not measure the current corpus, producer availability or deployment.

The measured image labels identify findings-web source f5a684f1e11ef6bacf0d4e14c511f0156b02c8a3 and findings-worker source d4fffd1ca8e196a82d974cf91248f7efb23ba7dc. These are dated image-label observations, not a comparison of their complete source inputs. The accepted PCD-404 source is newer; deployment of its correction was not verified here. PCD-398 owns accepted-source binding, coordinated Findings image selection and live validation. The deployment runbook owns runtime operations.

Recheck runtime identity without displaying environment values:

docker ps --format "{{.Names}} {{.Image}}"
docker inspect --format "{{.Name}} {{.Image}} {{.State.Status}} {{.State.StartedAt}}" stellaops-findings-web stellaops-findings-worker
docker image inspect --format '{{.Id}} {{index .Config.Labels "org.opencontainers.image.revision"}}' <recorded-image-id>

Shared scope contract

GET /api/findings/v1/security/findings and GET /api/findings/v1/risk/aggregated-status accept the same optional scope parameters.

ParameterCurrent predicate
regionTrimmed, lower-case, distinct, ordinally sorted comma-delimited ids. The local adapter binds the source region byos; a nonmatching selector excludes these rows.
environmentThe same set normalization; the local adapter binds source environment byos. Release deployment geography is not inferred.
artifactNormalized exact, case-insensitive version.artifact_ref comparison. Display names and absent Release identities do not create a composition mapping.
digestExact normalized immutable OCI image digest, compared with version.image_digest. It is never an alias for document_digest.

The response scope contains tenantId, regions[], environments[], artifact and digest. Tenant identity comes from the authenticated Stella tenant accessor. SQL enforces exact tenant_key, complete active manifest, non-retired available owner document, positive matcher status and every supplied scope predicate before endpoint aggregation. The document digest remains the integrity identity for fetched SBOM bytes. The generated image identity requires a complete digest-pinned OCI registry/repository reference. Tag-only, malformed, PURL and transport-URL references have no image identity. Image context and inventory select an available retained version by owner occurrence time descending, then version id; retirement permits another available version to be selected. See consolidation design section 7.1.

Console multi-select context preserves the whole comma-delimited region/environment set. Findings Explorer compares scoped and tenant-wide reads; the Exposure action lens preserves tenant-wide/BYOS context. These UI choices do not change tenant authorization. The route group requires findings:read; an admin-family role does not bypass that scope.

Developer service-scope blocker

FINDINGS-SERVICE-IDENTITY-CONTRACT-001 remains a source contract gap. The current SecurityFindingProjection, SecurityExposureScope and SecurityRequestContext carry no authoritative developer ownership identities. An OCI digest predicate does not implement W7’s “just my services” requirement.

Authority owns serviceIds, repositoryIds and imageNamespaces claims. ReleaseOrchestrator must provide an authoritative tenant-bound digest-to-service composition; Findings must apply that mapping and usable ownership claims before counts, facets and pagination. The producer contract must define shared-digest many-to-many semantics, normalization, rename/deletion behavior and freshness. Names, artifact-reference text and package labels cannot substitute for these identities. A future developer filter must fail closed on missing claims or stale/absent mappings, with an explicit product decision for admin/non-developer visibility.

Both list and aggregate asOf values report the maximum scoped row observation after disposition overlay, or null for no rows. asOfUtc and computedAt report response time. The separately pinned corpus headers identify the generation that produced the body.

Freshness and live reconciliation contract

Request-scoped disposition reads (FCP-1)

The read model requests one disposition snapshot after the effective VEX overlay and before building the complete scoped result. Both the shared snapshot path and vulnerability-detail path use the same bulk cache contract. Filters, counts, facets, pivots, ordering and pagination retain their existing order; a page limit does not reduce the scope used for those aggregates.

The cache deduplicates natural finding IDs and correlation keys independently within the authenticated tenant. Tenant and finding IDs remain trimmed and case-sensitive; correlation keys are trimmed and lower-case. A natural-ID result wins over a correlation result regardless of their relative timestamps. Durable JSON must match the requested tenant and, for natural-ID rows, the requested finding key. An invalid row is logged and ignored independently.

Hot positive hits need no SQL. A cold read uses one asynchronous connection and sequential commands with at most 1,024 missing IDs and 1,024 missing correlations per command. With F distinct missing IDs and C distinct missing correlations, the command budget is ceil(max(F, C) / 1024), including zero for an empty or fully hot request. PostgreSQL index seek counts can exceed the command count because an array predicate may seek many keys.

Absence is retained only in the request’s immutable result. A later request retries missing or failed durable keys, so cross-process writes become visible without a negative-cache TTL. Valid durable hits hydrate only their returned index and preserve its monotonic hot winner. An authoritative tenant replacement fences hydration across its hot prune and durable transaction. A read may return a positive it observed before withdrawal, but cannot repopulate that withdrawn entry into the hot cache after a successful replacement. The fence holds no lock during SQL or the existing hot-index scan.

The existing availability contract remains: a non-cancellation durable error is logged; already observed hot or valid results remain available and unresolved keys use the matcher fallback for that request. There is no per-key SQL retry after a failed batch. Caller cancellation reaches connection opening, execution and row reads. This snapshot does not claim atomicity across concurrent event updates and reconciliation.

Source verified against 2632617d082e191e13647a2422cb39d9244a8494 on 2026-10-20. The unchanged implementation also passed the complete 316-case adjacent Security project at accepted-source union c60471821df98d50009c1f69f4667e05f2adb74a on 2026-10-20. Re-verify the command budget and freshness/identity rules with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Security.Tests/StellaOps.Findings.Security.Tests.csproj -Class "*FindingDisposition*" -BuildProjectReferences. The owning sprint records source, test and local live acceptance separately; this contract does not establish the current deployed image or a measured whole-host CPU budget.

Event and pull coordination

The disposition coordinator’s event/pull design is separate from corpus and Scanner freshness. Its source subscribes before an authoritative replace, preserves queued events and monotonic updates, and only replaces tenant state after a complete successful snapshot. The configured default event/pull targets are 30/60 seconds; these are targets, not measured estate lag. GET /api/findings/v1/security/freshness reports the authenticated tenant’s disposition telemetry. Configuration and health behavior are owned by FindingDispositionFreshnessOptions, FindingDispositionFreshnessCoordinator and FindingDispositionFreshnessHealthCheck in the Security Application library; recheck these implementations before changing an operational threshold.

Current Scanner reads join Findings-owned tenant/image/CVE/PURL projections. The former direct scanner.* database query and artifact-triage 14-day readiness explanation are historical. They do not describe a supported current triage path.

Findings response

The findings response retains its existing top-level fields and adds:

Each finding also carries additive reachabilityState while retaining the legacy reachable boolean. The explicit state uses the public verdict vocabulary reachable:proven, reachable:likely, not-observed, not-present, and unknown, plus transient computing. The boolean compatibility fold is true for both reachable verdict tiers and false for every other state. The Console uses the explicit field to expose its narrower nullable view (null for unknown and computing) rather than inferring those states from the compatibility boolean.

total, facets and pivots describe the complete filtered set before page slicing. Cursor o:<offset> and an explicit offset must agree; malformed/conflicting inputs return 400 security.findings.invalid_cursor. Overlapping normalized scope reads share an in-flight operation, not a retained result cache. The next request reads the local active generation again; there is no canonical foreign-SQL refresh/upsert.

Findings carry explicit reachabilityState alongside the boolean fold. The local adapter maps a present Scanner true verdict to reachable:proven, false to not-observed, and missing evidence to unknown. It never derives not-present from Scanner false. The predecessor’s pending-scan computing fallback is not the current local adapter algorithm. The application preserves explicit states rather than turning an unknown result into reachable proof.

Each finding row, and each row of GET /api/findings/v1/security/disposition and GET /api/findings/v1/security/disposition/{findingId}, also names its artifact and evidence relations (SPRINT_20261007_006 CCP-058). An absent relation is null, never an empty string.

Query-specific vulnerability exposure verdict

GET /api/findings/v1/security/findings/vulnerability-exposure requires findings:read, the authenticated tenant and an accepted exact vulnerabilityId. Its endpoint accepts region/environment/artifact and a bounded limit, with no digest query parameter. The standard SecurityItemResponse item retains:

The current adapter reads exact scoped local matches, counts them and returns at most 50 reachable rows. It does not execute the predecessor’s bounded-count SQL against security_finding_projection.

Any reachable exact match yields exposed. not_exposed requires actual not-present evidence for every match; the local binary Scanner projection supplies none, so false/missing verdicts remain unknown. Service/owner absence remains explicit; artifact text does not supply ownership.

Per-vulnerability detail

GET /api/findings/v1/security/vulnerabilities/{vulnerabilityId} reads local advisory_metadata_projection and advisory_exploit_projection, bounded tenant findings and their exact total in one repeatable-read snapshot. It does not join vuln.advisory_canonical, vuln.advisory_cvss or the frozen predecessor table. Metadata comes from the producer’s retained corpus, with null/empty values preserved. The endpoint returns 404 when neither advisory nor scoped finding exists. affectedFindings is capped at 200; total/limit/truncation fields disclose the sample. The response preserves field meaning: componentName is not a package version, releaseName is not a release version, and projection updatedAt is not a deployment or lifecycle-first-seen timestamp. releaseVersion, deployedAt, and firstSeen are therefore null until an authoritative source supplies them. asOf is the latest advisory/projection observation. scope repeats the normalized tenant/region/environment/artifact contract. affectedFindings is a severity-ranked sample capped at 200; each affected row carries its own stored methodology/path, while the top-level methodology/path is selected deterministically from findings in the aggregate reachability state, preferring a longer path and then a recorded method. affectedFindingsTotal, affectedFindingsSampleLimit, and affectedFindingsTruncated disclose that bound, so consumers cannot claim sample minima as global history.

Accepted identifier space

Both routes accept the advisory corpus’s identifier schemes through the shared StellaOps.VulnerabilityIdentifiers normalizer. After trimming, the identifier has at most 64 characters and ASCII alphanumeric segments separated by single hyphens, dots or underscores. CVE requires CVE-<4 digits>-<4+ digits>; GHSA preserves its canonical lower-case entry. Malformed values return the route-specific invalid-vulnerability-id 400 before querying. A valid identifier absent from the active corpus is a 404. The 2026-08-28 counts in the frozen X18 receipt are historical, not current corpus measurements.

The ledger library’s same-shaped detail handler was withdrawn under PCD-230. The consolidated host maps this Security Application endpoint; the ledger detail service still serves GET /v1/vulns/{id}.

Persistent-shell summary

GET /api/findings/v1/security/findings/summary requires findings:read and the authenticated tenant. SummarySql counts critical active-generation matches whose resolved disposition is action_required or review_required. Disposition precedence is finding-id cache, correlation-key cache, then review_required. accepted_risk and mitigated do not contribute. asOf is the maximum resolved row observation, null for no rows. Summary reads the same local-generation family as the list; neither path derives/upserts foreign sources.

Projection refresh hot path

Projection writes occur in fenced background owner consumers and the manifest match materializer. List/detail/summary requests read those local rows. The retired unnest-based predecessor derive/upsert and per-tenant refresh advisory lock are historical. Read coalescing does not activate or refresh a corpus generation.

Pivot and drill-down contract

Supported pivot tokens are cve, package, component, release, environment, and image. The server computes buckets over the complete selected result before pagination. Each bucket returns:

Apply a bucket with both drillDownFilter=<bucket.drillDown.filter> and drillDownValue=<bucket.drillDown.value>. Supplying only one member or an unsupported filter returns 400 security.findings.invalid_drill_down. Drill-down is a secondary server filter applied after the normalized FET-1 tenant/region/environment/artifact/digest store scope, so it cannot widen or replace that scope. The returned scope remains the original normalized global scope. FET-2 actionability metadata describes the complete drilled subset before the optional actionable selector, and rows/pivots/facets/pagination describe the selected result.

Example: if a CVE bucket returns drillDown: { "filter": "cve", "value": "cve-2026-1001" }, open it with GET /api/findings/v1/security/findings?pivot=cve&drillDownFilter=cve&drillDownValue=cve-2026-1001 while retaining the caller’s existing scope, sort, and actionable parameters.

Dimension sources are explicit:

PivotProjection scalarNotes
cvevulnerabilityIdDisplay and matching both use the identifier’s own canonical form, which is not always upper-case: GHSA entry bodies are lower-case. The pivot TOKEN is still cvewhile the projection scalar it pivots on is vulnerabilityId (FND-25 renamed the field, not the pivot vocabulary — that token has its own facet keys and Console selector and is recorded as follow-up work).
packagepackageNameOne scalar membership per finding.
componentcomponentNameOne scalar membership per finding.
releasenon-empty releaseId, otherwise artifactReferencePreserves current rows whose release id is not materialized (the active-generation store materializes none, so this bucket equals image). The FET-1 provisional artifact drill-down token is superseded by canonical release; artifact remains a compatible global-scope query.
environmentenvironmentSecondary drill-down does not replace the global environment scope.
imageartifactReferenceThis is the artifact/image reference carried by the current projection. It is not relabeled as an immutable digest. scope.digest remains the only digest authority.

Within one pivot response, the projection is scalar: every selected finding belongs to exactly one bucket, including the explicit unknown bucket, so sum(pivotBuckets[].findingCount) == total. Across different pivot dimensions, the same finding appears once in each dimension; totals from different pivot responses are not additive. An honestly empty result has total=0 and no buckets. A row whose selected scalar is blank is not dropped: it belongs to <pivot>:__unknown__, retains its real severity/reachability counts, and is drillable with value __unknown__. Unknown never means zero risk.

Actionability contract

GET /api/findings/v1/security/findings accepts optional actionable=true|false. Omitted returns all rows selected by the existing scope/severity/disposition/search filters. true returns only actionable rows; false returns only the remaining rows. Top-level total, facets, pivots, items, and pagination describe that selected result. actionability is always computed over the complete result after the existing filters but before the actionable selector, so it does not collapse to zero when an actionable-only page is empty.

The stable definition token is reachable_unfixed_action_required:

accepted_risk and review_required remain unfixed, but they are not action_required: the former has an explicit operator acceptance and the latter still needs analyst review. remainingReasons is mutually exclusive and deterministically ordered. The first applicable reason wins: not_reachable, mitigated, accepted_risk, review_required, then disposition_not_actionable for an unknown/non-actionable token. Counts always sum to remainingTotal. Thus an actionable-only response can truthfully say zero actionable findings while preserving that, for example, 574 findings remain in the selected scope and why.

sort=severity is the default and preserves the FET-1 order: severity rank, CVE, package, component, finding id. sort=actionable places actionable rows first, then uses that same severity and identity tie-break chain. Unknown sort values normalize to severity for backward compatibility. Cursor positions are stable under repeated execution with identical scope, filters, drill-down pair, actionable selector, and sort; clients must restart pagination if any of those inputs change.

Aggregate response

GET /api/findings/v1/risk/aggregated-status retains tenantId, computedAt, severity/category totals, top risks, score, trend, and trace id. It adds the same scope and asOf, plus total, criticalOpen, budget, and exceptions. criticalOpen counts critical findings whose enforced disposition is not mitigated; accepted and review-required risk remains visible until the VEX/consensus cap is actually applied. Its risk source is one SecurityReadModelService.ListScopedProjectionAsync(scope) result, so enforced disposition currency and store predicates are identical to the flat response and sum(bySeverity.*) == total for the same scope/as-of.

topRisks is a ranked package-group sample capped at 25, not a pageable complete asset catalog. topRisksTotal reports the complete package-group count and topRisksLimit reports the cap. Each profile carries the distinct sorted vulnIds used to build it. The Console may deep-link to /security/vulnerabilities/{cve} only when exactly one valid CVE is authoritative for the group; multi-CVE or unknown groups route to /security/findings?q={package}. Search/filter on the risk overview applies only to the returned ranked sample and must say so.

Scoped aggregate reads do not write into the tenant-wide historical trend series. Only an unfiltered tenant aggregate records and compares findings_security.security_risk_snapshot; otherwise a scoped response would corrupt the tenant-wide trend baseline.

Budget state

budget.state is one of not_configured, never_evaluated, unavailable, or evaluated. The value fields are limit, consumed, remaining, verdict, and evaluatedAt, plus ownerRoute (/ops/policy/governance, the canonical default Risk Budget tab). The invariants are:

The non-production state constructors are contract-ready and tested, but Findings does not emit an evaluated state until a real Policy budget adapter exists.

Policy exception state

Findings reads the real tenant-bound GET /api/policy/exceptions?status=active&limit=100&offset=... surface over the internal service network. Direct Policy HTTP is JWT-only: before any Policy request, Findings uses the confidential outbound client stellaops-findings-security-internal to mint a client_credentials bearer for the selected Authority-allow-listed tenant. This call requests only policy:read; the identity also carries the exact retained-corpus and SBOM owner grants used by the consolidated Findings worker, documented in the Authority dossier. Findings sends that bearer on every bounded page and sends no operator bearer, raw tenant header, or Gateway identity envelope.

Tokens use the in-repo Auth.Client cache with a key containing the fixed client, audience, scope, and normalized tenant. A 30-second safe-expiry margin and per-key single-flight gate prevent stale reuse and Authority stampedes without serializing different tenants. Missing/partial identity configuration, token rejection, non-caller timeout, an unusable/broader-scope token, or cache/Authority failure suppresses the Policy request and produces exceptions.state=unavailable; caller cancellation still propagates. Reads are bounded to 100 pages and become authoritative only on a short terminal page; Policy non-success, invalid response shape, duplicate ids, or reaching the page cap also produces unavailable.

Runtime configuration lives under FindingsSecurity:PolicyExceptions:ServiceIdentity (Enabled, Authority, optional MetadataAddress/TokenEndpoint, exact ClientId, ClientSecret, and HttpTimeoutSeconds). When enabled, startup rejects a missing secret, a client id other than stellaops-findings-security-internal, a non-HTTP(S) Authority URL, or an invalid timeout. Compose supplies the same ignored/Vault-backed FINDINGS_SECURITY_AUTHORITY_CLIENT_SECRET to Authority bootstrap and the Findings consumers; each call’s scope is fixed in source and the identity’s exact combined scope/audience allow-list is fixed in canonical Authority YAML, not operator-configurable. Rotate by updating the shared secret source and restarting Authority and Findings in that order.

Policy source failure does not erase risk truth: the aggregate remains 200 with the same risk totals and budget state while matchedExceptionCount, matchedFindingCount, sourceUpdatedAt, and items are all null. A successful source with no matching exceptions is instead state=available, counts 0, sourceUpdatedAt=null, and items=[].

The server rechecks status=active and expiresAt > now using its injected TimeProvider, then applies Policy’s AND scope semantics to the exact finding set used by the risk aggregate:

Matched items are ordered by exceptionId. matchedExceptionCount counts distinct returned exceptions, matchedFindingCount counts the union of matched finding ids, and sourceUpdatedAt is the maximum real Policy updatedAt among matches. The existing per-finding SecurityExceptionState projection is not used as Policy exception authority for this aggregate.

Artifact triage projection

The list and detail routes remain mapped, with exact digest admission for detail. Their current ActiveGenerationArtifactTriageProjectionStore throws 503 security_artifact_triage_projection_unavailable. Former vuln.sbom_registry, scanner.*, sbom.* and Release database field provenance is historical; the consolidated host cannot read those owner databases. Release-related image reads likewise retain security_release_projection_unavailable. Required owner projections remain with their source owners. Missing facts cannot be replaced with empty deployments, ready scan status, fabricated attestations or browser-local workflow assignments.

X18 advisory projection and local matcher protocol (owner item 21(b))

Currency (2026-10-06). The consolidated host selects the local active-generation adapter; the predecessor host/store are frozen. The worker composes the retained corpus/SBOM consumers and default-off Scanner v2 consumer. The protocol below retains the X18 design and historical acceptance requirements; it is not an assertion that its August rollout steps remain open. The source/runtime reconciliation and current proof pointers are above. SPRINT 010 is the frozen historical acceptance record; PCD-398 owns the current estate image follow-up. Owner reads remain in background lanes, with no request-path sibling database fallback.

Ownership and invariants

Producer protocol inputs

The retained X18 protocol input inventory is deliberately narrow. The dated rollout descriptions below do not establish current producer configuration or estate delivery:

Producer sourceProtocol contractX18 usability
StellaOps.Vulnerabilities.Contracts/Corpus/*Existing legacy matcher/consensus/exploit/reachability shapes keep their v1/v2 window; the FND-X18-1 advisory-metadata row is format-v2-only, and corpus.generation.completed v2 carries all known descriptorsThe closed producer wire is frozen and is the only DTO/parser seam Findings may compile against.
CorpusArtifactProducer + production scheduler callerRetained v2 publication streams all five sections from one repeatable-read snapshot after completed-generation normalization; CAS readability precedes the atomic catalogue/current-pointer + outbox transactionThe retained v2 seam is the protocol authority. The August direct ProduceAsync v1 description is historical, not a current bootstrap instruction.
hub retained publication APIGET /api/vulnerabilities/v1/corpus/publications/current, then manifest/named-section routes by opaque artifactRef; digest/ETag and byte ranges are supportedValid owner bootstrap/download seam. The historical unstamped /corpus/export is not a replacement authority.
vuln.fact.upserted / consensus.changedfact id + vulnerability/product/revision hash; issue key + content hash/count/stateNeither event can reconstruct advisory display metadata. They are invalid substitutes for the missing metadata section.
exploit corpus/eventKEV-class attributes and EPSS probabilitySufficient for exploited/EPSS projection after it is generation-addressed; not sufficient for title/CVSS/date.
SbomService SbomVersionCreatedEventsnapshot id, tenant id, projection hash/schema, created time; emitted when a projection is readLegacy non-authority signal: not an upload event, not ordered catch-up, and not tied to a ledger version or raw document. X18 does not consume it.
StellaOps.SbomService.Contracts + sbom.versions owner APITransactional sbom.uploaded/sbom.version.retired, captured-head catch-up, fixed-fence bounded snapshot, remote-consumer checkpoint and exact-version document routeProducer protocol seam; source defaults do not establish the deployment flag. Findings compiles only the closed contracts; its default-off worker registers the authenticated direct-owner client, coordinator and scheduler only when the single owner gate is enabled.
SbomService raw-document routeGET /api/v1/sbom/subject/{subjectRef}/document?version={versionId}; authenticated tenant; stored media type; exact ledger versionThe Findings worker requests the exact opaque tenant/version, verifies media type, raw-byte digest and JSON, parses supported component rows, and persists unavailable/invalid state with typed failure rather than clean zero.
SbomService retentionVersion removal and sbom.version.retired are one owner transactionThe worker applies the explicit event as a retired version plus durable tenant tombstone, rematerializes affected manifests, and same-epoch upload replay cannot resurrect it. Absence is never inferred as retirement.

No consumer may paper over those gaps with polling by time, an invented generation number, a scan of another service’s database, or a new cross-service source reference.

PREDECESSOR: generation publication and advisory metadata

Status: COMPLETE in source (FND-X18-1/2).

FND-X18-1 gave the producer-owned closed StellaOps.Vulnerabilities.Contracts SDK two wire contracts before Findings adds a reader; FND-X18-2 connected those bytes to the retained runtime producer:

  1. A format-v2-only advisory-metadata corpus section. Rows are strictly increasing by lexicographic UTF-8 vulnerabilityId bytes, matching PostgreSQL UTF8 COLLATE "C" (no case folding or Unicode normalization); ill-formed surrogate input is refused rather than replacement-encoded. Each canonical row carries: vulnerabilityId, nullable title, summary, description, publishedAt, modifiedAt, severity, nullable atomic cvss { score, vector, version }, sorted distinct weaknesses[] and references[], metadataRevision, selectionVersion, and sorted origins[] { sourceKey, sourceRecordId, documentDigest }. Weakness/reference arrays and each origin string tie-break use that same strict UTF-8 byte order. Null and empty mean the producer did not have a value; they are never filled from the CVE id or a navigation URL. Any populated optional metadata requires at least one origin; the all-null/empty row is explicitly valid. Timestamps are exact UTC round-trip strings. CVSS is finite, 0–10 at one-decimal precision, uses version 2.0/3.0/3.1/4.0, and has the matching CVSS:<version>/ vector prefix.
  2. corpus.generation.completed v2. Its envelope is small and contains artifactRef, manifestDigest, sourceKey, sourceGeneration, and sections[] { name, formatVersion, digest, bytes }. Ordering uses the envelope’s real streamEpoch and seq; bulk rows are never in the event. artifactRef is an opaque owner API handle: corpus-publication: plus 8–128 ASCII alphanumeric/underscore/hyphen token characters, never a URL, path, bucket/key, query, or fragment. Every known CorpusSections.All name appears exactly once; sorted distinct unknown extras are permitted. advisory-metadata is format 2, while the four legacy sections retain their implemented v1/v2 window.

The dependency-free contract implementations are CorpusAdvisoryMetadata.cs and CorpusGenerationPublication.cs; their contract tests pin full/null bytes, v2-only metadata, revision vectors (including raw Unicode/case), streaming row order, provenance requirements, CVSS and timestamp rejection, all-known-section completeness, opaque handle grammar, forward-compatible unknown descriptors, canonical order, and the no-inline publication shape. They add no host, persistence, worker, or runtime caller to the SDK. The named-section reader, raw row parser, and streaming row validator all consume the attached descriptor format explicitly; none silently treats a v1 metadata attachment as v2.

metadataRevision is SHA-256 over every resolved field defined by the v2 row profile, its selection version, and ordered origins. Unknown v2 properties are non-semantic extension metadata only; any new resolved/revision-bearing field requires a section-format bump and is refused by the v2 reader. The hub’s field selection is metadata-selection/v1: ignore invalid/blank candidates; rank candidates by the hub’s explicit issuer/source authority tier, then source-supplied modifiedAt descending, then sourceKey, sourceRecordId, and document digest in strict lexicographic UTF-8 byte order (PostgreSQL UTF8 COLLATE "C"). The CVSS score/vector/version moves as one tuple (highest valid base score first, the same deterministic tie-break chain); arrays are the sorted distinct union of accepted authoritative candidates. No ingestion clock participates in the row or digest. A later selection algorithm uses a new selectionVersion and therefore a new metadataRevision.

FND-X18-2 now retains normalized metadata candidates with exact raw-document provenance in the hub-owned tenant-free database. Normalization commits them with facts/checkpoints; the selector admits only candidates still linked to a live non-tombstoned fact. The production scheduler calls the publisher after a completed non-binary generation drains, and the publisher streams all five parts under one repeatable-read snapshot and fixed writer lease. It verifies durable CAS objects before the catalogue/current pointer and v2 outbox envelope become visible in one transaction.

The owner API resolves the opaque handle to a manifest and named range-readable parts with digest and ETag verification. A below-horizon vuln.corpus catch-up names and embeds the active bootstrap. Exactly current + previous are retained; the 12 GiB/publication and 24 GiB/retained P16 bounds cover the dated 3.99 GB matcher + 5.73 GB consensus measurement and fail closed on overrun. Generation building retains one bounded streamed row with its pooled UTF-8 bytes and fixed file/hash buffers, plus a bounded current-vulnerability candidate group, not a multi-gigabyte section list. Focused integration proof drives the real scheduler caller, resolves/verifies all five parts, exercises range resume and below-horizon bootstrap, and proves a pre-readability failure leaves no handle/catalogue/v2 state.

The historical v1 event and unstamped /corpus/export cannot mark the Findings projection ready. FND-X18-5’s dark worker now consumes the retained v2 seam and fetches only its declared matcher, metadata, and evidence sections — never consensus-inputs. It does not expose the staging generation until FND-X18-6 supplies the real SBOM match fence.

PREDECESSOR: SbomService upload/retire stream

SbomService adds a closed, conformance-classified StellaOps.SbomService.Contracts project with no host/persistence reference, and a P6 catch-up stream sbom.versions:

For both, ordering and owner occurrence time come from the standard P6 envelope (streamEpoch, seq, occurredAt); the payload does not duplicate them.

The upload outbox append is in the same owner-database transaction that makes the ledger version visible, after the raw document has been persisted. The retirement append and version removal are one transaction. The existing read-triggered version_events table is not repurposed. Ordered catch-up returns streamEpoch, headSeq, retentionHorizonSeq, and events after afterSeq; the producer accepts a remote-consumer registration so its retention floor includes Findings.

Bootstrap/rebuild also needs a bounded, keyset-paged owner snapshot of all non-retired, tenant-bearing ledger versions with a snapshot epoch/head fence and the same metadata fields. This uses the implemented fixed-fence owner snapshot protocol; it is not /internal/sbom/events. Legacy rows with null tenant ids are reported as withheld; an opaque non-GUID tenant is valid and must not be withheld or rewritten. It never becomes a global/default tenant. After snapshot import, Findings catches up strictly through the recorded head. The existing tenant-scoped raw-document route is then called by exact (tenantId, artifactRef, versionId) to obtain the immutable document. A 404/invalid digest is document_unavailable, not an empty SBOM.

Findings schema and activation protocol

Forward migration 002_findings_advisory_projection.sql is embedded in StellaOps.Findings.Persistence and auto-applied by both consolidated hosts. It adds no seed and does not modify baseline 001. The migration shipped under FND-X18-4 with exact catalog, key/check/ index, fresh/second-start, zero-row, checksum-normalization, cross-tenant, and compiled-SQL proofs. The table contract is:

TableKey and purpose
findings_security.advisory_projection_generationGlobal, PK manifest_digest; artifact_ref, source_key, source_generation, publication epoch/seq, section descriptor JSON, SBOM fence epoch/seq, state (staging,active,retained,failed), started/completed/activated times and failure code, and the unique identity generation_id that matcher membership rows carry (migration 029). A partial unique index permits at most one active generation; cold state has none.
findings_security.advisory_corpus_section_importGlobal, PK (manifest_digest, section_name); format version, expected digest/bytes, downloaded bytes, imported line count, import state/error. This is the resumable byte/row cursor, not corpus authority.
findings_security.advisory_matcher_projectionGlobal, one row per distinct canonical matcher line, shared by every generation that contains it (migration 029). PK row_id (identity); unique row_key, the SHA-256 of the canonical matcher line; vulnerability id, product key, status, severity, scheme, introduced/fixed, EPSS, and the distro release the row applies to (platform, matcher-rows v3; null for a row without one). Indexed by (product_key, row_key) and (vulnerability_id).
findings_security.advisory_matcher_generation_membershipGlobal, PK (generation_id, row_id); which generations contain which shared matcher rows. FK generation_id to the generation, ON DELETE CASCADE. There is deliberately no FK to the shared row (each check would lock the referenced tuple, about 25M tuple locks per import): the import holds pg_advisory_xact_lock_shared(hashtextextended('findings:advisory-matcher-rows', 0)) per batch and the reclamation pass holds it exclusively while it deletes membership and the shared rows no generation still references. Every read of a generation’s matcher rows joins this table.
findings_security.advisory_metadata_projectionGlobal, PK (manifest_digest, vulnerability_id); every metadata field above, metadata_revision, selection_version, origins JSON, section digest/format.
findings_security.advisory_exploit_projectionGlobal, PK (manifest_digest, evidence_key); vulnerability id, kind/source/added/due/refs or EPSS score/percentile/model date. evidence_key hashes the canonical exploit line.
findings_security.advisory_sbom_match_fenceGlobal, PK/FK manifest_digest; exact sbom.versions epoch/seq plus live-version and matched/unmatched component proof counts. This is a manifest completion witness, not a second stream checkpoint; activation accepts it only while it exactly equals the locked P6 checkpoint. A snapshot commit deletes every fence: replacing the SBOM versions deletes every match through the cascade, so no fence still witnesses its manifest, and each generation is materialized again even when the snapshot lands on the head its fence named.
findings_security.advisory_sbom_version_projectionTenant, PK (tenant_id UUID, version_id UUID); tenant_key TEXT preserves the exact normalized claim, while tenant_id is derived by the already-shipped TenantStorageKey.ParseTenantGuid rule (parse a GUID, otherwise deterministic SHA-256 storage UUID). Carries chain/sequence, artifact ref, document byte digest, nullable generated OCI image_digest (021/022), format/version, owner event epoch/seq/time, component count, document state/failure code, retired flag/reason. Unique (tenant_id, chain_id, sequence_number).
findings_security.advisory_sbom_snapshot_stage_session + advisory_sbom_snapshot_version_stageEphemeral, crash-safe fixed (consumer, epoch, head) bootstrap stage. Exact row replay is a no-op; drift under the same fence fails snapshot_replay_conflict. A nonzero owner-withheld count fails commit with snapshot_withheld_legacy_versions; repair the owner row identity/digest and restart bootstrap rather than accepting a valid subset.
findings_security.advisory_sbom_version_tombstoneTenant, PK (tenant_id, version_id); durable exact retirement identity and owner cursor. It survives component/match deletion and blocks same-epoch upload replay from resurrecting the version.
findings_security.advisory_sbom_component_projectionTenant, PK (tenant_id, version_id, component_key); BOM ref, canonical PURL, the hub’s ProductKeyV1 lookup key as product_key (null for a pkg:generic PURL, which no hub key names), ecosystem, name, source-exact version, and the release the component was installed from (platform, migration 020). component_key hashes the canonical identity tuple. A missing version is retained as unmatched input and never guessed; a missing/malformed/ambiguous PURL or component identity is rejected before persistence.
findings_security.advisory_match_projectionTenant, PK (tenant_id, version_id, component_key, manifest_digest, matcher_row_key); deterministic match id, vulnerability/status/severity/range, matcher_version, source event epoch/seq, and corpus/section digests. Reads join only the active generation. Since migration 029 manifest_digest references the generation and matcher_row_key the shared matcher row’s row_key, both ON DELETE CASCADE. (manifest_digest, matcher_row_key) (migration 025) serves the generation reference and the reclamation drain; (matcher_row_key) (029) serves the row reference.
findings_security.advisory_sbom_match_incompleteTenant, PK (tenant_id, version_id, generation_id) (migration 030, SPRINT_20261005_002 PCD-822); one row per SBOM version whose declared applicability the generation’s matcher rows could not evaluate: sorted distinct reason_codes (invalid_bounds, invalid_version_or_bound, missing_component_version), exact unevaluable_pair_count (component x row) and unevaluable_row_count (distinct matcher rows), the ordinal-first 256 unevaluable_row_keys, matcher_version, matcher section digest, the SBOM epoch/seq it was computed at and recorded_at. FKs to the version ((tenant_id, version_id)) and the generation (generation_id, the identity the DIP-9 matcher membership also references), both ON DELETE CASCADE; (generation_id) serves the generation reference and the per-generation replacement. Written and replaced with the manifest’s matches in the same fenced transaction; never carried forward.

Forward migration 003_findings_advisory_sbom_match_fence.sql adds only the manifest witness above; it inserts no rows and does not fork P6 state. Its writer locks the canonical checkpoint and refuses any witness whose epoch/seq differs. P6 epoch is an opaque reset identity: sequence is compared only within the same epoch, and a non-monotonic numeric epoch replacement is valid only when the locked checkpoint carries that exact identity.

Forward migration 004_findings_advisory_sbom_projection_state.sql adds explicit document failure detail, the two fixed-fence snapshot-stage tables, and the tenant/version retirement tombstone. The dark PostgresAdvisorySbomProjectionStore uses the existing P6 lease/inbox/checkpoint transaction: snapshot replacement and the exact (epoch,head) checkpoint commit together; incremental events must be contiguous within the same epoch; epochs are never numerically ordered. It adds no private cursor/lease lineage, owner database reach, seed, host registration, or activation publication. Forward migration 005_findings_advisory_match_zero_head.sql leaves applied migration 002 byte- unchanged and replaces only its named match-source-cursor check so exact snapshot head 0 is valid while negative sequence remains rejected. SbomService deliberately permits a pre-stream snapshot with live versions at head zero; writing 1 into match provenance would fabricate an owner event.

AddAdvisorySbomOwnerClient is the dark network prerequisite. It registers nothing while disabled. Enabling requires an explicit direct owner origin, explicit trimmed opaque control tenant and the host-supplied Authority token client. Control calls use exact sbom:operate; exact-document calls use sbom:read with the event tenant preserved byte-for-byte. The client disables redirects, bounds success bodies, never buffers error bodies, binds snapshot continuations to the requested epoch/head and PostgreSQL UUID keyset order, accepts non-monotonic epoch reset as identity change, and fails typed on authentication/HTTP/transport/timeout, malformed wire/payload, document 404, media-type, digest or JSON error. It has no Concelier/Router/database fallback. findings-worker registers it only under the same default-off gate that registers the projection pass; enabled startup still requires the host-supplied Authority token client and a real non-empty opaque control tenant.

AdvisorySbomDocumentParser is the database-free deterministic seam after exact document fetch. It binds the verified digest/format back to the snapshot/event identity, preserves opaque tenant and artifact bytes, and accepts only exact CycloneDX JSON 1.4/1.5/1.6/1.7 top-level components or the current SbomService SPDX 2.3/3.0 top-level packages upload shape. The separate producer-recognized SPDX 3 JSON-LD @context + @graph package shape remains explicitly unsupported: it is not fabricated into the version stream’s packages model. Every source array entry must produce exactly one component; missing/malformed PURLs, conflicting versions, repeated identity properties, duplicate BOM refs/ canonical PURLs/tuples, and projected-count drift fail with stable document codes. PURL scheme/type and qualifier order are canonicalized without folding case-sensitive package path data. The product key is the hub’s lookup key for the canonical PURL (ProductKeyV1.TryDeriveLookupKey): the source package for deb and apk (pkg:deb/debian/openssl for pkg:deb/debian/libssl3@…?origin=openssl), the binary with arch and non-zero epoch for rpm, none for pkg:generic, and the PURL without its version for every other type. The platform is the release the Scanner stamped on a CycloneDX OS package as the property stellaops.os.platform, else the PURL distro qualifier (DistroPlatformV1.ComponentPlatform). Components projected before this identity are re-projected once: without the checkpoint findings-advisory-sbom-v1#component-identity-v2, which every snapshot writes, the stored checkpoint reads as absent and the next pass takes a snapshot. Component keys hash the length-prefixed canonical identity tuple under a versioned domain and output is ordinal by that key. The parser remains database-free; the registered coordinator owns its persistence calls.

AdvisorySbomMatchMaterializer is the active/staging seam after that durable component projection. One invocation receives the existing SBOM P6 lease, locks its fencing row, the canonical checkpoint and the exact complete active or staging corpus generation plus its complete matcher-rows section, then replaces only that manifest’s match rows. Candidate pairs are UUID/text-keyset paged with the same explicit PostgreSQL COLLATE "C" in both seek and order; the family-owned pure matcher decides applicability. Before publishing the existing manifest witness in that same transaction, the store recounts live versions, declared and actual components, persisted applicable rows, distinct matched components, and the exact unmatched complement, then rechecks the checkpoint/generation/count fence. Any mismatch rolls the target replacement back, so the other active/staging manifest and its witness remain untouched. There is no Concelier/corpus fallback. The worker renews immediately before each atomic materialization; its FOR SHARE fencing-row lock then prevents a successor takeover from interleaving until commit. Discovery returns only complete active/staging manifests whose witness is missing or differs from the canonical checkpoint, so an idle poll does not rebuild a multi-GB match set.

Match-incomplete versions (migration 030, SPRINT_20261005_002 PCD-822). A matcher row whose declared applicability cannot be evaluated for a component (scheme generic-exact or generic-range with matcher reason invalid_bounds, invalid_version_or_bound or missing_component_version) no longer refuses the whole manifest. The pair produces no match; the store records the SBOM version in advisory_sbom_match_incomplete for that manifest with its reason codes, exact pair and row counts and ordered row keys, keeps every evaluable pair of that and every other version, and publishes the witness, so the fence advances. Ordered range schemes keep answering a plain non-match for the same reasons (measured live 2026-10-11: no active-generation pair took that path). Lease, generation, checkpoint, count and matcher-contract faults still fail the materialization and roll it back. The record is recomputed on every materialization of the manifest and belongs to it: a new generation (for example after the hub re-normalizes the rows to an ordered scheme) has no witness, is materialized against every live version before it can activate, and records only what its own rows cannot evaluate. Before PCD-822 one such row stopped the fence for every tenant, and every later SBOM version read as zero matches. The worker logs each manifest that records match-incomplete versions at Warning, with counts and reason codes and no tenant identity.

The existing P6 eventing.consumer_checkpoints, eventing.inbox, and eventing.leases tables own stream position, dedupe, and fencing; migration 002 does not fork them. Consumer ids are stable (findings-advisory-corpus-v1, findings-advisory-sbom-v1). Each checkpoint advances in the same destination transaction as the domain write. The tenant tables follow the current findings_security explicit-scope posture: every repository method requires a UUID tenant and every PK/index begins with it, except ix_advisory_match_projection_matcher_row and ix_advisory_match_projection_matcher_row_key, which serve only the tenant-free references to the global generation and matcher rows (and the reclamation drain) and are never used by a read. There is no all-tenant read API. Cross-tenant negative tests and compiled-SQL guards reject a missing tenant predicate; global tables are tested to contain no tenant column.

The frozen SbomService snapshot item has no per-item event epoch/sequence/occurrence time. A version imported from that fenced snapshot therefore stores those three owner_event_* fields as one all-null group; an event-derived row stores all three. The snapshot response’s epoch/head stays in the P6 checkpoint and is never copied into a row as though it were that version’s source event.

Generation activation is an A/B protocol:

  1. Acquire the fenced corpus lease; fetch the publication envelope/manifest by owner handle.
  2. Reject unsupported section versions, a missing declared section, byte-length/digest mismatch, or unsorted/invalid NDJSON. Keep the old active row untouched.
  3. Download each part to the worker-owned cache using exact byte-range resume, hash the complete file, then import bounded transactional batches. Metadata and exploit rows are keyed by the staging manifest; a matcher line is stored once by row_key and the staging generation records membership of it, so a line an earlier generation already holds costs one membership row (migration 029). Persist download byte offset and imported line count after each durable batch. Repeating a batch is a content-key no-op; parsing resumes from the beginning only to revalidate strict whole-section row order, while writes resume after the durable line cursor.
  4. Match every live SBOM version against the staging matcher rows, then catch the sbom.versions stream up through a recorded head fence. Events arriving during the rebuild are applied to the active generation and queued/applied to staging before the fence closes. In the same fenced destination transaction as the P6 checkpoint, publish the exact staging manifest’s match witness.
  5. In one fenced transaction mark the old generation retained, the staging generation active, and advance the corpus checkpoint, but only after locking the SBOM checkpoint and exact manifest witness and proving their epoch/seq are equal. A reader therefore sees all-old or all-new corpus/ metadata/matches, never a mixed generation.
  6. Retain the previous compatible generation for rollback, then prune older generation-keyed rows under the declared Findings disk budget. Pruning never touches the active row. For the matcher projection, pruning a generation deletes its membership and only the shared rows that no remaining generation references.

FND-X18-5 implements the direct owner catch-up/bootstrap, strict three-section download, digest/ETag verification, resumable import, health state, and fenced activation transaction. Its production activation gate remains closed unless the shared P6 findings-advisory-sbom-v1 / sbom.versions checkpoint exists; FND-X18-6 owns producing that fence only after staging matches are complete. Until then the imported generation stays staging and readiness is cold or rebuilding stale rather than falsely current. FND-X18-6 now composes the owner projection coordinator and atomic active/staging materializer in findings-worker. One default-off gate registers the owner client and hosted loop together; each pass holds the existing P6 lease, heartbeats between bounded snapshot/event transactions, renews before each stale-manifest rebuild, and publishes that manifest’s exact witness. Source composition does not claim a production fence has been written. The August rollout’s activation/cutover/rollback evidence is historical; current deployment requires its own receipt.

The dated 2026-08-14 X18 evidence in SPRINT 010 measured the matcher stream at 3.99 GB/122 seconds (34.9-second gateway TTFB) and consensus-inputs at 5.73 GB/138 seconds. Those figures are point-in-time sizing evidence, not architecture constants; the implementation lane re-measures them. The durable rule is that Findings fetches only its declared sections, uses direct authenticated owner-service streaming rather than Router/UI request budgets, and keeps memory O(batch), not O(section). The three cursors are distinct: event (epoch,seq), artifact download byte offset, and database import line count. None is inferred from a timestamp.

On duplicate/out-of-order delivery, P6 inbox admission and source seq make the second/older write a no-op. A producer epoch change discards that stream’s checkpoint and starts owner bootstrap while the last active Findings generation remains readable. A corpus withdrawal is absence from the next fully verified generation; it becomes visible only at activation. An SBOM retirement marks exactly (tenant,version) retired with component_count=0, persists its tombstone, and excludes its retained component history from the next active/staging match replacement. Same-epoch upload replay cannot resurrect it; the other tenant’s identical version/component ids remain untouched.

Version matcher home and semantics

The matcher is a Findings-family library at src/Findings/__Libraries/StellaOps.Findings.AdvisoryMatching/. It may reference the neutral StellaOps.VersionComparison foundation and the producer’s closed contracts SDK; it must not reference the separate Vulnerabilities-owned DC-30 artifact-consumer SDK StellaOps.VulnMatch.Core, any Vulnerabilities/SbomService host, persistence, worker, or domain implementation. That SDK is now a legal closed seam, but Findings intentionally owns its tenant-local matcher semantics rather than inheriting a sibling consumer’s policy.

matcher_version=findings-corpus-v2 evaluates the corpus registry exactly: semver, evr, deb, apk, pep440, maven, generic-exact, generic-range, and product-level. Range schemes use source-exact half-open [introduced,fixed) bounds; null opens that side. generic-exact matches only the exact declared version. product-level matches the product identity without fabricating a version range. Unknown schemes and missing component versions produce an explicit unmatched reason, never affected/not-affected. A row that names a distro release applies only to a component installed from it (DistroPlatformV1.Applies; an unknown component release keeps every row); another release’s row is unmatched with platform_out_of_scope. Applicable source status is retained on the match row; affected materializes a positive security finding, and so does fixed with a fix bound (reason matched_below_fix: the component matched only because it is below that fix, Red Hat CSAF’s shape), the rule Policy’s release gate applies. fixed without a bound, not_affected and under_investigation remain non-positive evidence (the last is an honest review/unknown state); none cancels an independently applicable affected row inside the matcher. Consensus and the signed Findings overlay resolve status/trust outside version comparison, never by selecting whichever row arrived last.

The deterministic match id hashes tenant, SBOM version, component key, vulnerability id, matcher-row key, manifest digest, and matcher version. Informational processing time is excluded. Golden tests cover every scheme, both bounds, prerelease/revision rules, product-level facts, conflict statuses, and exact product-key/PURL normalization; legacy VersionRuleEvaluator’s SemVer-only behavior is not accepted as parity evidence.

As of 2026-08-23 the pure matcher, manifest-specific durable fence, Findings-owned dark SBOM state repository, authenticated owner client and pure component parser prerequisites exist. The registered projection coordinator composes the last three for one fixed snapshot fence and incremental catch-up only through the first observed owner head. It stages every snapshot item, records 404 as Unavailable and other immutable-document defects as Invalid with the exact typed failure code, and never turns either into an available clean-zero SBOM. Owner transport/configuration failures abort rather than create rows. Epoch identity changes and retention gaps request a new snapshot without ordering epoch numbers or consuming a retained tail; exact P6 order and durable tombstones are rechecked by the store before it advances the checkpoint. Local snapshot/event commit precedes the remote consumer report: a report failure propagates but cannot roll local state back, and only a later caller invocation reports that durable position again. findings-worker schedules the coordinator and stale active/staging match materialization behind the one default-off owner flag, with manifest-witness publication in the atomic replacement transaction. Focused PostgreSQL proof pins identical ids in two tenants, one global advisory row, isolated list/detail/count/facet/match answers, create/retire/out-of-order/epoch and tombstone-no-resurrection. The same proof crosses current -> last-good stale -> current by advancing the canonical checkpoint before rematerializing its manifest witness. A second forcing step inserts an incomplete staging generation (stale), then activates it between the advisory and finding phases of a detail read: repeatable-read returns a wholly prior-generation answer and the next request returns a wholly new-generation answer. No reader or host may treat source readiness as evidence that live tenant matches exist. Recheck the current estate window and producer cursors independently of historical FND-X18-7 acceptance.

Read path, health, and cutover

The consolidated adapter materializes the carried API projections directly from one active local generation; it does not refresh the frozen predecessor security_finding_projection. Detail reads advisory_metadata_projection + advisory_exploit_projection; finding rows and image context join tenant SBOM/component/match tables with exact tenant_key, retirement/document-state predicates and the active manifest. Migration 006 adds Findings-owned scanner_scan_projection and scanner_reachability_projection: one current successful scan head per opaque tenant/image and its exact CVE/PURL verdict details. The current Scanner consumer admits the closed tenant-partitioned v2 publication plus exact matching detail, recomputes both declared set hashes, and atomically replaces local rows with its inbox/checkpoint under the projection lease. Epoch reset rebuilds only that tenant. See the consolidated Scanner protocol for its retained-feed and lease contract.

Finding, CVE-exposure and image-component reads join this local state by exact tenant, digest, CVE and component PURL. Missing finding verdicts remain unknown; Scanner false maps to not-observed, never not-present. Because the image-component compatibility shape has only a boolean, an affected component without a matching Scanner verdict returns typed security_reachability_projection_unavailable rather than a false negative. The consolidated source manifest names only Findings-owned tables plus P6 state. Compiled conformance forbids foreign vuln.*, scanner.* and cross-database sbom.* relations in the adapter.

Scanner retained pagination, exact-detail transport and worker composition exist in source behind the default-off gate; a real producer session and live checkpoint/lag are separate evidence. Release-related images and artifact triage still keep their typed capability refusals. The predecessor host/store and foreign-schema manifest are frozen audit material, never a fallback.

The read-path probe reports the active manifest digest, activation time and exact active-witness vs durable-SBOM-checkpoint position. Existing worker health remains the authority for section formats, owner head/horizon, import measurements and owner availability. The route filter checks the probe before executing, so cold state cannot expose the frozen summary as an empty/current 200. Preflight never supplies success provenance because an activation can occur between probe and handler. Each supported local store read instead resolves state, activation time and manifest digest inside the same repeatable-read PostgreSQL snapshot as its body, including an empty result, and pins that identity into the scoped request accessor. One pin or repeated value-identical pins are accepted; a second different identity fails closed as 503 security_read_model_generation_provenance_conflict. Only after the handler returns successfully does the route filter replace any caller/handler-supplied values with the pinned X-StellaOps-Source-State, X-StellaOps-Source-As-Of, X-StellaOps-Corpus-Digest, X-StellaOps-Match-State and X-StellaOps-Match-Incomplete-Versions headers. An unpinned response carries none of those headers. The match pair (PCD-822) is read in the same snapshot as the body: X-StellaOps-Match-Incomplete-Versions counts the requesting tenant’s live, available SBOM versions that the served generation recorded match-incomplete, and X-StellaOps-Match-State is incomplete when that count is above 0, else complete. An incomplete answer still carries every evaluable match; for the unevaluable pairs it is unknown, never a clean zero. The generated OpenAPI document lists all five headers on every 2xx response of the /api/findings/v1/security/* and /api/findings/v1/risk/aggregated-status routes this filter guards. Vulnerability detail keeps advisory, bounded findings, total and this identity in one snapshot. The states are:

Match completeness is reported beside the source state, not folded into it. When the active generation holds match-incomplete versions, the readiness probe adds the all-tenant count as source.match_incomplete_versions (always present for an active generation), its description says how many versions are match-incomplete, and readiness is degraded even when the state is current.

Historical cutover sequence (SPRINT 010; not a current open-task list):

  1. Ship the Vulnerabilities closed contract, real metadata/artifact publisher/catch-up, and the SbomService closed contract + upload/retire/bootstrap/document seams.
  2. With migration 002 now shipped, run the FND-X18-5 retained-corpus consumer dark (complete in source); FND-X18-6 then bootstraps SBOM inventory, builds matches in shadow, and proves active-generation/tenant parity before opening the activation fence.
  3. Switch legacy and consolidated Findings read adapters to the local active generation, then remove the foreign-relation entries from the honesty manifest. Disable the best-effort Concelier _learnForwarder; do not delete it before the durable SBOM event is observed end to end.
  4. Use FND-5’s native canonical endpoint publication and run direct + Router authenticated/unauthenticated forcing calls. The external Console prefixes do not change.
  5. Run the four-persona Console gate and the known SBOM upload -> local match -> advisory detail -> aggregate forcing function. Only then may FND-9 move data/revoke grants or FND-10 remove hosts.

Rollback never points back to dropped vuln relations. Stop activation, then the internal IAdvisoryCorpusGenerationRecovery seam deterministically chooses the newest retained generation below the expected active generation for the same source. It explicitly refreshes that retained manifest’s match witness at the canonical SBOM checkpoint, takes the existing corpus fencing lease, and commits active -> staging plus retained -> active in one PostgreSQL transaction. The former current generation is not deleted: staging is its durable recovery marker, so the ordinary active/staging materialization pass keeps both generations caught up as owner SBOM events advance. An explicit restore transaction admits that former current generation only when its witness exactly equals the locked checkpoint, then commits rollback-active -> retained plus caught-up staging -> active. Existing repeatable-read request snapshots therefore see all-old or all-new metadata and matches, never a mixed generation. The real-PostgreSQL acceptance forces rollback between detail subqueries, advances the actual owner-event projection, proves a stale-witness restore fails without a partial flip, catches up both manifests, and restores current with no generation/corpus deletion and no vuln schema.

The internal recovery primitive now has authenticated hold/rollback/restore/resume endpoints in StellaOps.Findings.WebService/Endpoints/AdvisoryGenerationRecoveryEndpoints.cs. Use the scoped recovery-authority, audit and exit-guard procedure in findings-service-deploy.md § FND-X18 advisory generation rollback and catch-up. Source existence does not prove a successful estate recovery. Database migrations remain forward-only; schema recovery follows ADR-004 rather than a down migration. Frozen predecessor hosts are not an operator rollback target.

Historical X18 acceptance requirements

Determinism and compatibility