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.

Shared scope contract

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

ParameterMeaning
regionComma-delimited region ids. Values are trimmed, lower-cased, deduplicated, and ordinally sorted.
environmentComma-delimited environment ids with the same normalization.
artifactExact, case-insensitive artifact/release reference. It matches releaseName, or releaseId when that projection field is populated.
digestExact 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.

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:

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 CVE exposure verdict

GET /api/v2/security/findings/cve-exposure requires findings:read, the authenticated tenant, and a valid exact cveId (CVE-YYYY-NNNN...). It accepts optional region, environment, and artifact scope plus limit. The response is the standard SecurityItemResponse; its item contains:

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-CVE vulnerability detail

GET /api/v2/security/vulnerabilities/{cve} is the per-CVE detail read-model the Console vulnerability-detail page (/security/vulnerabilities/:cveId, reached from advisory-search / KEV / By-CVE chips) targets. It requires findings:read, the authenticated tenant, and a valid exact cve (CVE-YYYY-NNNN...); 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:

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.

Routing note: the gateway routes ^/api/v2/security(.*) to findings-security, so this endpoint is served by StellaOps.Findings.Security.WebService (not the findings-ledger compatibility 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:

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:

PivotProjection scalarNotes
cvecveIdDisplay is canonical upper-case; matching is normalized ordinal.
packagepackageNameOne scalar membership per finding.
componentcomponentNameOne scalar membership per finding.
releasenon-empty releaseId, otherwise releaseNamePreserves 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.
environmentenvironmentSecondary drill-down does not replace the global environment scope.
imagereleaseNameThis 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:

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:

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 dedicated confidential client stellaops-findings-security-internal to mint a client_credentials bearer for the selected Authority-allow-listed tenant. Authority permits exactly policy:read and projects exactly api://policy-engine; 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 consumer; scope and audience are fixed in source/YAML and are 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

Findings.Security owns the artifact-triage read contract:

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 fieldAuthority and visibility
artifactReference, name, versionThe 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.readinessLatest 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.
findingsThe existing digest-scoped Findings projector plus the enforced disposition overlay. Severity, reachable, actionable, and disposition counts therefore match /api/v2/security/findings?digest=....
evidencescanner.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.
attestationsCount 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.
workflowAlways { 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.

Determinism and compatibility