Findings.Security exposure read model
StellaOps.Findings.Security.WebService owns the Console exposure projection and serves it through Router. It is a read-model aggregator over canonical matcher/advisory/SBOM/release sources plus the Findings disposition overlay; it does not invent findings when sources are empty.
Currency / implementation boundary (2026-08-24). The source descriptions below document the legacy derive contract, not a source that still exists. The Vulnerabilities G1 cutover dropped the
vulnrelations that contract queried; the X18 honesty guard therefore returns503 security_read_model_source_unavailableinstead of presenting a false green. Owner-register item 21(b) now places the replacement in Findings as an event-fed, local advisory projection over hub corpus artifacts. The build-ready target is specified in X18 advisory projection and local matcher target below. The producer, projection schema, tenant matching and consolidated active-generation read adapter are source-complete, including handler-owned response provenance. The predecessor/live route swap, remaining owner projections and runtime acceptance remain.
Shared scope contract
GET /api/v2/security/findings and GET /api/risk/aggregated-status accept the same optional scope parameters:
| Parameter | Meaning |
|---|---|
region | Comma-delimited region ids. Values are trimmed, lower-cased, deduplicated, and ordinally sorted. |
environment | Comma-delimited environment ids with the same normalization. |
artifact | Exact, case-insensitive artifact/release reference. It matches releaseName, or releaseId when that projection field is populated. |
digest | Exact normalized immutable SBOM digest. Digest reads use the matcher/SBOM path because the tenant projection table does not store a digest. |
The resolved response scope contains tenantId, regions[], environments[], artifact, and digest. Tenant identity comes only from the authenticated Stella tenant accessor; clients cannot widen it with a query/header value. Findings.Security applies tenant, region, environment, artifact, and digest predicates in PostgresSecurityFindingProjectionStore before the read model reaches endpoint aggregation.
Console callers must preserve the set contract: multi-select region and environment context is serialized as comma-delimited values, never reduced to the first selected item. The Findings Explorer deliberately compares a scoped read with a tenant-wide read so a scope-empty result can be explained and widened by the operator. The Exposure action lens deliberately omits these selectors because it must retain tenant-wide and BYOS findings. This difference is a UI workflow decision; both surfaces consume the same owned projection and neither changes tenant authorization.
The route group requires findings:read for every authenticated principal. An admin-family role does not bypass that scope requirement. Conversely, an admin token that carries findings:read is not rejected for lacking developer service ownership claims. The current findings list remains tenant-wide after the documented query predicates; W7’s separate “just my services” requirement still needs an authoritative service-identity projection and server-side service claim predicate rather than a role bypass or client-only filter.
Developer service-scope blocker
W7’s Findings.Security half is BLOCKED by FINDINGS-SERVICE-IDENTITY-CONTRACT-001. The owned SecurityFindingProjection, SecurityExposureScope, request context, and findings_security.security_finding_projection schema carry no serviceId, repositoryId, or imageNamespace. The canonical derive query reads vuln.sbom_registry.digest as sbom_digest, but the projection upsert discards that digest and writes a null release_id. componentName, releaseName, and artifact-reference text are display/source labels, not ownership identities, and must never be matched to developer claims.
Authority remains the issuer of the developer serviceIds, repositoryIds, and imageNamespaces claim families. To unblock this read, ReleaseOrchestrator must publish a versioned, tenant-bound composition contract that maps an immutable artifact digest to one or more stable service identities and their repository/image-namespace identities. That producer contract must define shared-digest many-to-many semantics, normalization, rename/deletion behavior, and freshness. Findings.Security must then retain the immutable digest through its startup-migrated owned projection (or consume an equally bounded canonical digest query) and compose against that authoritative mapping.
The eventual filter is server-side and fail closed: it applies tenant plus the normalized ownership claims before rows, totals, facets, pivots, actionability, or pagination are computed. A developer principal with no usable ownership claims cannot fall back to tenant-wide data; an absent or stale digest mapping cannot be treated as owned. The product contract must explicitly preserve or change the current admin/non-developer tenant-wide behavior. Web may only project the bounded result or an explicit missing_ownership_claims/mapping absence state; it may not perform a local ownership filter.
Both responses add nullable asOf, defined as the maximum updatedAt in the complete scoped Findings.Security projection after the enforced disposition overlay. null means the scope is honestly empty. The older asOfUtc (findings) and computedAt (aggregate) remain response-generation timestamps for compatibility; they are not data-currency claims.
Freshness and live reconciliation contract
Findings.Security owns one serialized two-lane disposition coordinator. Before its initial authoritative pull, it establishes a StreamPosition.End subscription to orchestrator:events. Domain-agnostic finding.disposition.changed payloads are queued while a pull is running and applied after the complete replace, so an event received during reconciliation cannot be pruned by that reconciliation. Source updatedAt is monotonic in both the in-memory and PostgreSQL cache indexes; an older or equal event cannot overwrite newer enforced state, including when it is the first event seen after a process restart.
The event delivery target is 30 seconds (the Valkey push lane uses that poll interval as its missed- notification safety bound). A complete tenant snapshot is pulled at startup and every 60 seconds, so a missed, raced, or trimmed event is authoritatively repaired within 60 seconds after Findings Ledger is reachable. Only a successful, complete 200 snapshot may call ReplaceTenant; HTTP, transport, invalid-row, foreign-tenant, null-body, or partial-page failure leaves the current cache intact. A successful empty list is authoritative and prunes withdrawn caps.
GET /api/v2/security/freshness requires findings:read and the authenticated tenant. It returns only that tenant’s event/reconcile timestamps, counters, source lag, authoritative-repair lag, the 30/60-second targets, and the Scanner contract. /readyz runs a freshness health check. The exact 60-second target is reported immediately when exceeded, while readiness uses a 150-second grace to avoid scheduler-jitter probe flapping. The event stream may be reported as degraded while the authoritative repair lane remains current.
Runtime options are under FindingsSecurity:FindingDisposition:Freshness: Enabled, StreamName, EventPollTargetSeconds, ReconcileIntervalSeconds, HealthFailureAfterSeconds, and Tenants. Enabled configuration is validated before startup; an omitted tenant list inherits the existing FindingDisposition:Reconcile:Tenants list for compatibility.
Scanner freshness is not a replicated cache. Each artifact-triage request reads the latest exact- tenant attempt and content-addressed BOM success directly from PostgreSQL. A committed Scanner timestamp is therefore visible on the next request. The readiness boundary remains exact: 14 days is ready; any age greater than 14 days is stale.
Findings response
The findings response retains its existing top-level fields and adds:
scopeandasOfas defined above;pagination: { limit, offset, cursor, nextCursor, hasMore }while retaining top-levellimitandoffset;pivotBuckets[]with additive stablekeyandisUnknownfields plusdrillDown: { filter, value };actionability: { scopedTotal, reachableTotal, unfixedTotal, actionableTotal, remainingTotal, remainingReasons, definition }computed before pagination;sort, the normalized server ordering token (severityoractionable).
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.
Findings resolves the latest successful Scanner projection before considering job progress. A persisted per-CVE verdict therefore remains visible while a later scan is pending or running. When no persisted verdict exists, a pending/running latest scan updated within the last six hours projects computing; an older transient row is stale and falls back to the matcher fact (reachable:proven for the legacy true claim, otherwise not-observed). Missing or malformed explicit evidence never becomes reachable proof. Computation status remains orthogonal to the evidence lattice and is not persisted as a lattice enum value.
total, facets, and pivot buckets are computed over the complete filtered set before pagination. For example, a 574-row result requested with limit=200 returns 200 items, total=574, and pagination.nextCursor="o:200".
Query-specific vulnerability exposure verdict
GET /api/v2/security/findings/vulnerability-exposure requires findings:read, the authenticated tenant, and a valid exact vulnerabilityId (see Accepted identifier space). It accepts optional region, environment, and artifact scope plus limit. The response is the standard SecurityItemResponse; its item contains:
verdict:exposed,not_exposed, orunknown;reasonState:reachable_evidence_recorded,all_matches_not_present,no_matching_findings, orinsufficient_reachability_evidence;- exact
matchingFindingsTotalandaffectedFindingsTotal, a maximum of 50 deterministicaffectedFindings, andtruncated; - normalized
scopeand materialized-projectionasOfcurrency; - stored finding, release, artifact-reference, component, package, environment, region, and reachability identities for each reachable item.
The verdict is deliberately fail-closed. Any reachable:proven or reachable:likely exact match makes it exposed. It is not_exposed only when at least one exact match exists and every exact match is explicitly not-present. Empty results, not-observed, computing, unknown, and any mixture of negative and incomplete evidence remain unknown; absence is never folded into safety.
The store executes the CVE, tenant, region, environment, and artifact predicates in PostgreSQL against the owned findings_security.security_finding_projection. PostgreSQL computes the exact counts and currency; only the capped, deterministically ordered reachable rows cross the process boundary. This endpoint therefore does not load the complete tenant projection or duplicate the general findings search/pivot implementation.
The bounded entries are finding rows, not deduplicated artifacts or services: multiple component findings may carry the same artifact reference. The current materialized projection stores that artifact reference but not an immutable digest, authoritative service identity, or owner assignment. Consequently this endpoint intentionally has no digest parameter. artifactIdentityState is recorded only for a stored artifact reference; service.state and ownership.state are not_recorded, with null service identity and an empty ownerIds array. Web must render those absence states literally, not infer service/owner from the artifact name, release name, requester, or CVE. A successor must either add digest to the owned projection or implement an equally bounded exact-CVE matcher/SBOM query, then compose authoritative ReleaseOrchestrator/Authority service and owner data without creating a Findings-owned copy.
Like the persistent-shell summary below, this query-specific answer reads materialized truth and does not trigger the canonical derive/upsert refresh. Consumers must display asOf; opening the full findings surface remains the canonical-refresh path.
Per-vulnerability detail
GET /api/v2/security/vulnerabilities/{vulnerabilityId} is the per-vulnerability detail read-model the Console vulnerability-detail page (/security/vulnerabilities/:vulnerabilityId, reached from advisory-search / KEV / By-CVE chips) targets. It requires findings:read, the authenticated tenant, and a valid exact vulnerabilityId (see Accepted identifier space); it accepts optional region, environment, and artifact scope. The response is the bare detail object (the Console unwraps res.item ?? res), matching the sibling contract shape.
It joins two real sources on a single tenant connection:
- Canonical advisory metadata aggregated across the (one-per-
affects_key)vuln.advisory_canonicalrows for the CVE:description(summary/title), worstseverity,epss(epss_score),exploitedInWild(exploit_known),fixedVersions/affectedVersions(fromversion_range), and CWEweaknesses. The authoritativecvss,cvssVector, andcvssVersioncome from the deterministic primary/highest-score row invuln.advisory_cvssjoined through an activevuln.advisoriesCVE identity. Absence stays null; reachability score/confidence is never converted into CVSS.referencesare deterministic navigational links, never fabricated values: the canonical NVD link for a CVE, the canonical GitHub advisory link for a GHSA, MITRE CWE links for recorded weaknesses, and nothing else. A scheme with no landing page we can name canonically gets no advisory link, because NVD hosts CVE only and annvd.nist.govlink for a non-CVE identifier is a guaranteed 404. - Affected findings (bounded, all reachability states) for the CVE from the owned
findings_security.security_finding_projection— the SAME store the findings list serves, with the ledger-enforced disposition overlay applied (finding-id then correlation-key precedence). These driveseverity(worst), aggregatereachabilityState(strongest witness),reachabilityMethodologyandwitnessPath(from the selected strongest finding’s stored Scanner evidence),effectiveDisposition(most-actionable),deployedEnvironments,affectedFindings, andaffectedFindingsTotal.
Because every projection finding is derived by joining vuln.advisory_canonical, any CVE a user can see in the findings list is guaranteed to carry advisory metadata here, so the detail resolves for it. The endpoint returns 404 only when the CVE has neither a canonical advisory row nor any affected finding in scope — an honest not-found, never a fabricated shell. Fields with no backing data (including an absent advisory CVSS row, gate impacts, signed score, VEX provenance, reachability methodology, or witness path) are emitted as null / empty / omitted, never invented.
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 any identifier scheme the advisory corpus carries, not CVE alone. Admission is shape-based rather than an enumerated allowlist, so a newly added advisory source resolves without a code change.
An identifier is accepted when, after trimming outer whitespace, it is at most 64 characters and consists of ASCII alphanumeric segments joined by single -, . or _ separators, with no empty, leading or trailing segment. The scheme (text before the first -) is upper-cased; the entry is upper-cased by default, and lower-cased for schemes whose issuing database publishes a lower-case entry alphabet. Two schemes additionally carry the canonical form their issuer publishes:
| Scheme | Canonical form | Why it is pinned |
|---|---|---|
CVE | CVE-<4 digits>-<4+ digits> | Rejects a non-numeric sequence, e.g. CVE-2026-291i67, which the generic shape gate would admit. |
GHSA | GHSA-<4>-<4>-<4> over [0-9a-z] | The entry is lower-case base32; an upper-case fold would match no stored row. |
Anything else is refused with HTTP 400 (security.vulnerability.invalid_vulnerability_id / security.vulnerability_exposure.invalid_vulnerability_id) before any query runs — including internal whitespace, any byte outside printable ASCII, and trailing punctuation. A well-formed identifier that the corpus does not hold is an honest 404, not a validation error.
Measured on the active generation 2026-08-28 (669,877 advisory_metadata_projection rows): 25 distinct schemes, of which 45.9% of rows are not CVE — CVE 362,662, MAL 236,132, UBUNTU 56,795, USN 7,907, GHSA 3,918, RUSTSEC 686, TEMP 645, GO 395, PYSEC 339, DRUPAL 252, and fifteen smaller families down to single rows. Identifiers are compared to the projection with = rather than upper() because folding case in SQL loses the index seek, so the accepted form above is exactly the stored form.
Routing note: the gateway routes
^/api/v2/security(.*)tofindings-security, so this endpoint is served byStellaOps.Findings.Security.WebService(not thefindings-ledgercompatibility handler, which is unreachable via the gateway).
Persistent-shell summary
GET /api/v2/security/findings/summary is the bounded tenant-wide count surface for persistent Console chrome. It requires findings:read and the authenticated tenant, and returns:
{
"tenantId": "default",
"actorId": "operator-123",
"criticalNeedingTriage": 12,
"asOf": "2026-07-18T04:16:49.569013Z"
}
criticalNeedingTriage counts critical rows whose resolved enforced disposition is action_required or review_required. accepted_risk is already reviewed and mitigated is resolved, so neither contributes. The older UI-only token unreviewed is not a persisted disposition and must not be sent to the full findings endpoint.
The summary reads the owned findings_security.security_finding_projection directly and applies the durable disposition overlay with the same strict precedence as the full read model: finding-id cache, correlation-key cache, then the projection fallback. asOf is the maximum resolved row timestamp and is null for an honestly empty projection. The endpoint does not derive matcher/advisory rows, acquire the projection-refresh advisory lock, build facets/pivots, or call Policy; it is therefore appropriate for a shell badge. It is a materialized indicator, while opening the full Findings surface remains the canonical-refresh path.
Projection refresh hot path
Tenant-wide reads still derive the complete canonical matcher/advisory set before pagination so totals, facets, pivots, the flat list, and the aggregate cannot drift. The persistence refresh is set-based: one unnest-backed PostgreSQL statement carries the complete derived row set, filters rows that already match the owned projection, and upserts the remainder atomically. A transaction-scoped advisory lock serializes only refreshes for the same tenant; different tenants remain independent. This prevents simultaneous Console count/list/aggregate reads from issuing one database command per finding or contending row-by-row on the same projection keys.
Scanner reachability_findings evidence is parsed and indexed once per digest for each refresh, then looked up by the exact CVE + component-PURL pair. This prevents a stronger record for the same CVE on another package from donating its state, methodology, or witness path to the wrong finding. Duplicate records for one pair resolve deterministically by verdict strength, witness length, method presence/name, and path. It is not reparsed for every finding in that digest. Neither optimization adds a completed-result cache: outside an overlapping in-flight refresh, every request starts a new read of the current canonical sources and applies the current enforced-disposition overlay.
Identical normalized tenant/scope reads that overlap in flight share that one source refresh and result. This bounds Console list, count, and aggregate fan-out without making the result a cache: the operation is removed as soon as it completes, and the next request starts a new canonical read. Caller cancellation stops only that caller while another waiter remains. If every waiter leaves, the operation atomically stops accepting waiters and leaves the coalescing map before its database work is cancelled; an immediate replacement caller therefore starts or joins a fresh refresh and cannot inherit the abandoned operation’s cancellation. Region, environment, artifact, and digest are part of the coalescing key, so differently scoped reads remain independent.
Offset and cursor are two representations of the same zero-based position. A cursor is the deterministic token o:<offset>. If both are supplied they must resolve to the same position; malformed or conflicting values return 400 with security.findings.invalid_cursor. Reuse a cursor with the same scope, filters, pivot, and ordering inputs. The final page returns nextCursor=null and hasMore=false.
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:
key: opaque deterministic identity<pivot>:<escaped-normalized-value>;pivotValue: deterministic display value, orUnknownfor a missing/blank scalar;isUnknown: whether the source scalar was missing/blank;findingCount,criticalCount, andreachableCountfrom real rows in that bucket;drillDown.filteranddrillDown.value, the canonical pair accepted by the same endpoint.
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/v2/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:
| Pivot | Projection scalar | Notes |
|---|---|---|
cve | vulnerabilityId | Display 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). |
package | packageName | One scalar membership per finding. |
component | componentName | One scalar membership per finding. |
release | non-empty releaseId, otherwise releaseName | Preserves current rows whose release id is not materialized. The FET-1 provisional artifact drill-down token is superseded by canonical release; artifact remains a compatible global-scope query. |
environment | environment | Secondary drill-down does not replace the global environment scope. |
image | releaseName | This 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/v2/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:
reachableTotalcounts rows whose canonical projection hasreachable=true;unfixedTotalcounts rows whose effective disposition is notmitigated. The current projection has no independent fixed-version field;mitigatedis the sole enforced VEX/consensus cap token and therefore the only honest fixed/mitigated state at this boundary;actionableTotalcounts rows that are reachable, unfixed, and have effective dispositionaction_required;remainingTotal = scopedTotal - actionableTotal.
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/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:
not_configuredhas null values, verdict, and evaluation time. This is the production state while Policy has no mapped risk-budget source; Findings does not infer a budget or verdict from finding totals and does not call documented-but-unmapped routes.never_evaluatedmay carry a real configured limit, but consumption, remaining, verdict, and evaluation time are null.unavailablehas null values, verdict, and evaluation time; it means a configured source could not be authoritatively read, not zero consumption.evaluatedrequires a real post-epochevaluatedAtand a typed verdictwithin_budgetorexceeded. An evaluatedconsumed=0is preserved as numeric zero and is distinct from every null state.
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:
- vulnerability id is an exact trimmed, case-insensitive CVE match;
- environment membership is exact trimmed, case-insensitive (
[]means every environment); - artifact digest is exact after the existing scope trim/lower-case normalization and can match only when the request itself is digest-scoped;
- an exception carrying
purlPatternorpolicyRuleIdis deliberately not reported as a match because the currentSecurityFindingProjectioncarries neither canonical PURL nor rule id.
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
Findings.Security owns the artifact-triage read contract:
GET /api/v2/security/artifact-triage?limit=<n>&offset=<n>|cursor=o:<n>lists artifacts;GET /api/v2/security/artifact-triage/detail?digest=sha256:<hex>reads one artifact through the same query and projector used by the list.
The immutable normalized digest is both artifactKey and digest; neither endpoint derives identity from display text. Ordering is deterministic: critical finding count descending, high count descending, total finding count descending, then digest ordinal. Cursors use the same o:<offset> contract as findings. The list’s evaluatedAtUtc is captured once per query for all readiness calculations; it is not presented as source-data currency.
Field provenance is explicit:
| Response field | Authority and visibility |
|---|---|
artifactReference, name, version | The tenant-visible vuln.sbom_registry row, with latest tenant-visible sbom.ledger_versions.artifact_ref. Exact-tenant rows take precedence; null/default rows are shared catalog visibility. |
deployments[] | Exact-tenant release_orchestrator.deployed_estate rows with status='deployed', keyed by the artifact digest. Each item returns environmentId, releaseId, deploymentId, estateStatus, deployedAt, and updatedAt. Multiple components carrying the same digest in one environment/deployment collapse deterministically to one item; deployed time is the minimum and update time the maximum across those component rows. Order is environment, deployment, then release id. Release JSON current/target fields are planning context and are never exposed as current deployed-environment truth. |
scan.latestAttempt* | Exact-tenant scanner.scan_runtime_state. Status is allowlisted and normalized to pending, running, succeeded, failed, or cancelled; raw failure text is not exposed. |
scan.lastSuccessfulScanAt, scan.readiness | Latest content-addressed scanner.artifact_boms.inserted_at by digest. The BOM itself is durable success, including upload paths without runtime state. Readiness is absent, ready, or stale; ready includes exactly 14 days and stale begins after 14 days. A failed latest attempt does not erase a prior durable BOM. BOM visibility follows the already tenant-gated digest and is content-addressed/shared. |
findings | The existing digest-scoped Findings projector plus the enforced disposition overlay. Severity, reachable, actionable, and disposition counts therefore match /api/v2/security/findings?digest=.... |
evidence | scanner.scan_evidence_projections for the durable BOM build id only when that scan/evidence row belongs to the exact tenant. evidenceKindCount is precisely the number of persisted evidence-kind array entries; it is not a count of files, findings, or bundles. score is the Scanner BOM coverage/evidence score and is never used as a count. null source is not_recorded; a persisted empty array is recorded with count zero. |
attestations | Count of persisted entries in the durable BOM attestations array, plus one when the separate canonical dsse_envelope_ref column is populated. Merged VEX references and evidence scores are not attestations. Raw CAS/DSSE references are not exposed. not_recorded has a nullable count rather than a fabricated zero. |
workflow | Always { status: "unsupported", scope: "none", lane: null, reason: "no_persisted_workflow_state" } until a tenant/user-isolated server authority exists. The current browser-local lane map is not shared product state and is never projected as such. |
Queue and detail contain the same item shape and invoke ArtifactTriageService.QueryAsync; detail only supplies the digest and takes the single result. The response envelope may identify the authenticated actor, but no personal lane state exists, so one user’s browser-local assignments cannot appear in another user’s server response.
X18 advisory projection and local matcher target (owner item 21(b); IN PROGRESS)
Status (2026-08-24): the Vulnerabilities producer predecessor, Findings projection schema, dark retained-corpus consumer, pure matcher, manifest witness, SBOM state repository, authenticated SbomService owner client, component parser, projection coordinator, active/staging materializer and findings-worker scheduling are source-complete. The worker composition is default off and the staged overlay supplies no invented control tenant. The consolidated findings-web source graph now selects a tenant-exact active-generation adapter for findings list/detail/count/facet/match, summary, CVE exposure and image context. The predecessor host remains on its carried adapter until the atomic window; owner-data routes not represented by X18 fail typed rather than fabricating empty/false data. SbomService’s frozen source seam remains inactive pending its separately owned setup/window; the Findings-owned Scanner projection schema/apply primitive and local read joins are now staged, but its transport is not host-wired. The predecessor swap, remaining Release projection, transport/activation, rollback and live acceptance remain FND-X18-7.
This section is the implementation contract for SPRINT 010’s X18 re-home. The Vulnerabilities producer predecessor (FND-X18-1/2) is source-complete and FND-X18-4 has landed the additive local schema. The default-off worker can now project and materialize in the background, but this is source readiness rather than runtime truth. Nothing in this status makes the current Findings read model ready or authorizes it to call either owner on a request path.
Ownership and invariants
- Vulnerabilities remains the tenant-free aggregation owner. It publishes immutable corpus generations and small publication envelopes; it does not accept an SBOM, compute a tenant match, or answer a Findings read/gate synchronously.
- Findings owns the local advisory projection, version matching, per-tenant match results, and the existing
findings_securityConsole read models in physical databasestellaops_findings. - SbomService remains the SBOM system of record. Findings holds a rebuildable component projection, never raw SBOM authority.
- Advisory/corpus rows are global and have no
tenant_id. SBOM, component, match, and derived security rows are tenant keyed. A global advisory row must never be copied once per tenant, and a tenant SBOM/match row must never be made global to simplify a join. - A Console read or release gate makes zero hub or SbomService calls. Owner calls occur only in the background projection lanes. Once a generation is active, temporary owner unavailability cannot erase it or turn an empty response into a clean bill of health.
What exists now, and what does not
The source-verified input inventory is deliberately narrow:
| Current source | Current contract | X18 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 descriptors | The closed producer wire is frozen and is the only DTO/parser seam Findings may compile against. |
CorpusArtifactProducer + production scheduler caller | Retained v2 publication streams all five sections from one repeatable-read snapshot after completed-generation normalization; CAS readability precedes the atomic catalogue/current-pointer + outbox transaction | This is now a real producer seam. The legacy direct ProduceAsync v1 path remains compatibility-only and is not a bootstrap authority. |
| hub retained publication API | GET /api/vulnerabilities/v1/corpus/publications/current, then manifest/named-section routes by opaque artifactRef; digest/ETag and byte ranges are supported | Valid owner bootstrap/download seam. The legacy /corpus/export stays unstamped and cannot replace it. |
vuln.fact.upserted / consensus.changed | fact id + vulnerability/product/revision hash; issue key + content hash/count/state | Neither event can reconstruct advisory display metadata. They are invalid substitutes for the missing metadata section. |
| exploit corpus/event | KEV-class attributes and EPSS probability | Sufficient for exploited/EPSS projection after it is generation-addressed; not sufficient for title/CVSS/date. |
SbomService SbomVersionCreatedEvent | snapshot id, tenant id, projection hash/schema, created time; emitted when a projection is read | Legacy 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 API | Transactional sbom.uploaded/sbom.version.retired, captured-head catch-up, fixed-fence bounded snapshot, remote-consumer checkpoint and exact-version document route | Source-complete producer seam, still default off. 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 route | GET /api/v1/sbom/subject/{subjectRef}/document?version={versionId}; authenticated tenant; stored media type; exact ledger version | The 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 retention | Version removal and sbom.version.retired are one owner transaction | The 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:
- A format-v2-only
advisory-metadatacorpus section. Rows are strictly increasing by lexicographic UTF-8vulnerabilityIdbytes, matching PostgreSQL UTF8COLLATE "C"(no case folding or Unicode normalization); ill-formed surrogate input is refused rather than replacement-encoded. Each canonical row carries:vulnerabilityId, nullabletitle,summary,description,publishedAt,modifiedAt,severity, nullable atomiccvss { score, vector, version }, sorted distinctweaknesses[]andreferences[],metadataRevision,selectionVersion, and sortedorigins[] { 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 matchingCVSS:<version>/vector prefix. corpus.generation.completedv2. Its envelope is small and containsartifactRef,manifestDigest,sourceKey,sourceGeneration, andsections[] { name, formatVersion, digest, bytes }. Ordering uses the envelope’s realstreamEpochandseq; bulk rows are never in the event.artifactRefis 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 knownCorpusSections.Allname appears exactly once; sorted distinct unknown extras are permitted.advisory-metadatais 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 current v1 event and unstamped /corpus/export remain during rollout, but they 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:
sbom.uploadedv1 carries the non-empty, trimmed authenticated tenant claim as opaquetenantId, plusversionId,chainId,sequenceNumber,artifactRef,documentDigest,format, andformatVersion;sbom.version.retiredv1 carriestenantId,versionId,chainId,sequenceNumber,artifactRef,documentDigest, andreason.
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 is a new producer endpoint/task, not a claim about /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:
| Table | Key and purpose |
|---|---|
findings_security.advisory_projection_generation | Global, 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. A partial unique index permits at most one active generation; cold state has none. |
findings_security.advisory_corpus_section_import | Global, 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_projection | Global, PK (manifest_digest, row_key) where row_key is SHA-256 of the canonical matcher line; vulnerability id, product key, status, severity, scheme, introduced/fixed, EPSS. Indexed by (manifest_digest, product_key). |
findings_security.advisory_metadata_projection | Global, PK (manifest_digest, vulnerability_id); every metadata field above, metadata_revision, selection_version, origins JSON, section digest/format. |
findings_security.advisory_exploit_projection | Global, 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_fence | Global, 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. |
findings_security.advisory_sbom_version_projection | Tenant, 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 digest, 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_stage | Ephemeral, 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_tombstone | Tenant, 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_projection | Tenant, PK (tenant_id, version_id, component_key); BOM ref, canonical PURL, PURL-without-version product_key, ecosystem, name and source-exact version. 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_projection | Tenant, 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. |
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 1.4/1.5/1.6 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; product keys remove only the version while preserving qualifiers/subpath. 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.
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. 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:
- Acquire the fenced corpus lease; fetch the publication envelope/manifest by owner handle.
- Reject unsupported section versions, a missing declared section, byte-length/digest mismatch, or unsorted/invalid NDJSON. Keep the old
activerow untouched. - Download each part to the worker-owned cache using exact byte-range resume, hash the complete file, then import bounded transactional batches into rows keyed by the staging manifest. 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.
- Match every live SBOM version against the staging matcher rows, then catch the
sbom.versionsstream 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. - In one fenced transaction mark the old generation
retained, the staging generationactive, 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. - 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.
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 deliberately remains closed until 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-side stream activation, live database forcing and X18-7’s active-generation read cutover/rollback remain deliberately open; source composition does not claim a production fence has been written.
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-v1 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. Applicable source status is retained on the match row; only affected materializes a positive security finding. fixed/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; FND-X18-7 still owns predecessor cutover and runtime 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 P6 apply primitive admits only Scanner’s closed scanner.scan.completed v1 event plus matching digest detail, recomputes both declared set hashes, and atomically replaces projection rows with its inbox/checkpoint under the projection lease. An epoch reset atomically clears that rebuildable state and resets the cursor before replay.
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.
This is not yet a running Scanner projection: retained-stream pagination, exact-detail transport, worker registration/default-off configuration and live checkpoint/lag acceptance remain. The local generation also cannot truthfully fill Release-related images or artifact-triage Release/Scanner facts; those methods keep typed capability states instead of empty results. The predecessor store and its foreign-schema manifest remain isolated to the predecessor host until the live atomic cutover; their presence in source is not a fallback from the consolidated host.
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 and X-StellaOps-Corpus-Digest headers. An unpinned response carries none of those headers. Vulnerability detail keeps advisory, bounded findings, total and this identity in one snapshot. The states are:
unavailable: no verified active generation, corrupt/unsupported active artifact, or no complete SBOM bootstrap. Dependent routes keep the typed 503 and readiness is unhealthy.current: the active local generation’s manifest witness equals the durable SBOM checkpoint and no staging generation is present. Routes answer from local state and readiness is healthy.stale: a valid active generation exists but an owner is unreachable, a newer publication/event is pending, or a rebuild is in progress. The read probe directly measures checkpoint/witness drift and any staging work, including incomplete download/import; owner-unreachable state remains worker-health evidence. Routes continue to answer the last good snapshot; readiness is degraded. Stale is never converted to empty/current.
Cutover order is fixed:
- Ship the Vulnerabilities closed contract, real metadata/artifact publisher/catch-up, and the SbomService closed contract + upload/retire/bootstrap/document seams.
- 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.
- 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. - Use FND-5’s native canonical endpoint publication and run direct + Router authenticated/unauthenticated forcing calls. The external Console prefixes do not change.
- 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.
This is deliberately an internal source primitive, not an operator endpoint. Product/Security must still decide who may trigger a global selection, mandatory reason/audit evidence, and live hold/ resume authority. Every recovery failure carries the procedure path in findings-service-deploy.md § FND-X18 advisory generation rollback and catch-up. Database migrations remain forward-only: schema rollback uses ADR-004 snapshot restore, not a down migration. The route swap can be reversed separately while both Findings hosts read the same local generation.
Required proof before X18 closes
- Contract tests pin canonical metadata/publication/event bytes, unknown additive fields, n/n-1 behavior, artifact handle non-inline shape, and digest/range corruption refusal.
- Producer integration tests prove a real completed generation invokes publishing and that upload/ retention transactions emit created/retired events; a test-only direct call is a red proof.
- FND-X18-4 complete: fresh-database convergence, second-run idempotence, zero-demo-seed, no foreign schema/connection, executable-drift refusal, comment-only reconciliation, exact schema, active-generation uniqueness, and tenant/compiled-SQL isolation tests pass for migration 002.
- FND-X18-5 complete in source: a real loopback owner plus PostgreSQL proof covers byte-range resume, exact three-section admission, full digest/ETag validation, bounded byte/line checkpoints, duplicate content no-op, epoch/below-horizon bootstrap, verified absence, current+previous prune, owner-outage/corrupt health, all named download/import/activation crash boundaries, and production hosted-service/direct-owner DI. No live deployment or read-path cutover is claimed.
- A/B crash tests fail at every phase without moving the active pointer; duplicates, out-of-order pages, epoch reset, below-horizon bootstrap, byte-range resume, tombstone/no-resurrection, and owner outage with last-good data are covered.
- Cross-tenant tests attempt same version/component ids in two tenants and prove no list/detail/ count/facet/match leakage. Global projection tests prove one advisory copy serves both without a tenant column.
- Matcher goldens cover all nine schemes and are red-proved by boundary flips. A known live-sized stream rehearsal records bytes, rows, time-to-first-row/activation, peak memory/disk, event and byte/line cursor resume, and consensus-section non-download.
findings-webandfindings-workerpass isolated clean publish. Build-boundary conformance proves only Findings-family projects, classified neutral foundations, and closed producer contracts enter the graph;StellaOps.VulnMatch.Coreand producer implementation projects are absent.- Live acceptance records active digest/checkpoints, zero
42P01, typed cold-start 503, stale last-good behavior, direct and Router route calls, four personas, one upload/match/detail/aggregate chain, retirement removal, rollback to the retained generation, and catch-up back to current.
Determinism and compatibility
- Default row ordering remains severity rank, CVE, package, component, then finding id. Actionable-first ordering is explicit and uses the same deterministic tie-break chain.
- Scope arrays, facets, pivot bucket keys/order, and cursor positions serialize deterministically.
- Existing response properties and query forms remain valid; FET-1 through FET-3 response fields are additive. The only stabilized metadata correction is the FET-1 provisional release drill-down token from
artifacttorelease;artifactremains the unchanged global scope query. - Although the X18 corpus consumer, tenant matcher and consolidated local adapter are implemented in source, Findings.Security still does not expose or claim KEV/EPSS-hot ordering. The predecessor route swap, runtime activation and live acceptance remain FND-X18-7 work.
- The already-shipped FET changes above require no schema migration. The dark X18 consumer uses the already-shipped forward migration
002_findings_advisory_projection.sql; do not read this legacy compatibility note as permission to put its state in a predecessor schema. - Disposition freshness reuses the existing
findings_security.finding_disposition_cache; FET-6 adds no schema migration.
