Findings — architecture

Status. This is the module dossier for the consolidated Findings service (ADR-039 D14), live on the local estate since the FND-9 cutover window (2026-08-26). It supersedes findings-ledger/architecture.md, which now describes predecessor hosts — two of which still exist, three of which do not.

Verified against commit ea728e78d1 (2026-08-27). Re-verify with:

# composition and route surface
rg -n "^app\.Map" src/Findings/StellaOps.Findings.WebService/Program.cs
rg -n "ConsolidatedRoot|LegacyPrefix" src/Findings/StellaOps.Findings.WebService/Endpoints/FindingsConsolidatedRouteAliases.cs
# the family's own database, and only its own
rg -n "ConnectionEnvironmentVariable" src/Findings/__Libraries/StellaOps.Findings.Persistence/Extensions/ConsolidatedFindingsPersistenceExtensions.cs
# the conformance pack that pins all of the above
pwsh ./tools/scripts/test-targeted-xunit.ps1 \
  -Project src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/StellaOps.Architecture.Contracts.Tests.csproj \
  -Class "*FindingsConsolidationConformanceTests*"

The SCN-7 X11 addendum is verified against the source tree at this document’s commit. Re-verify active-host composition with rg -n "AddActiveGenerationSecurityReadModel|RemoveAll<ISecurityFindingProjectionStore>" src/Findings, and run the targeted ScannerSecurityProjectionPassTests, ScannerSecurityProjectionConsumerPostgresTests and ActiveGenerationScannerProjectionAcceptanceTests classes.

1. One family, two roles

src/Findings/ ships one deployable family with two replica roles. They are not two services: they share one database, one migration set, and one source graph.

RoleSourceImageServes
findings-websrc/Findings/StellaOps.Findings.WebService/stellaops/findings-web:devthe whole HTTP surface below
findings-workersrc/Findings/StellaOps.Findings.Worker/stellaops/findings-worker:devno domain route — projections and scoring only

Both call AddConsolidatedFindingsPersistence and both converge the schema on startup; the migration runner serializes through a PostgreSQL advisory lock, so a simultaneous boot is safe. Single-writer coordination for the projectors comes from the shared P6 fenced lease — this family owns no private lease table and no private outbox lineage.

Since SPRINT_20260911_001 TLC-1 (2026-09-14) findings-web is also a P6 producer. FindingDispositionPublisher appends finding.disposition.changed to the shared P6 outbox (eventing.outbox) in this family’s own database, on the per-tenant physical stream findings.dispositions.tenant.<lowercase-utf8-hex> of logical stream findings.dispositions. That is still the shared P6 lineage, not a second one: before the cutover the publisher wrote into another service’s timeline.events table, which is the cross-service write that ruling severed. The retained feed that serves those envelopes is described in §4.

findings-worker builds a web host despite serving no domain route. That is deliberate: the hardened runtime base declares an HTTP HEALTHCHECK, so a worker with no HTTP surface reports UNHEALTHY forever. AddWorkerHealthChecks() answers /health/liveness and /health/readiness, and those are the only HTTP endpoints the worker role exposes.

Compose home: devops/compose/docker-compose.findings.yml, composed explicitly rather than included by docker-compose.stella-ops.yml. That is the same shape the live Vulnerabilities hub keeps after its own cutover — a completed consolidation lives in its own overlay here, it does not migrate into docker-compose.stella-services.yml. ConsolidatedOverlay_StaysTheFamilyHome_AndTheRetiredKeysStayGone pins both directions.

2. The database boundary

The family owns stellaops_findingsand nothing else (CoC §8.2, ADR-039). The single operator-facing knob is STELLAOPS_POSTGRES_FINDINGS_CONNECTION (ConsolidatedFindingsPersistenceExtensions.ConnectionEnvironmentVariable); both roles fail closed without it.

There is deliberately no second way in. LedgerServiceOptions.Database.ConnectionString is overridden rather than bound, because binding it would let findings:ledger:… point the ledger plane at a different database — the exact cross-database read the consolidation exists to remove. Both application layers are wired with includeLegacyMigrations: false, so the predecessor migration sets can never converge a competing history against this database.

Schemas, created by Migrations/001_findings_consolidated_baseline.sql (StellaOps.Findings.Persistence) and extended by the numbered migrations beside it:

SchemaCarries
findingsthe ledger, projections, VEX decisions, evidence, runtime traces
findings_securitythe security read-model and the advisory-generation lifecycle
riskenginerisk_score_results — the kept risk-scoring capability
analyticsthe vuln-correlation ingestion tables
findings_ledger_appstructural only: the RLS tenant-context helper
findings_archivestructural only: where SCR-4 detaches offboarded tenant partitions

FindingsSchemaTopology is the machine-readable statement of two facts operational code needs: which findings parents are LIST-partitioned by tenant_id (the unit FND-9’s Merkle-preserving move copies and the unit tenant offboarding archives), and which tables carry FORCE row-level security behind findings_ledger_app.require_current_tenant(). It mirrors the baseline SQL and is deliberately not shared with the schema tests — the tests keep their own literals so the two lists are independent pins on each other.

This family is the named ledger-class override for tenant offboarding (SCR-4): on tenant.deleted the Merkle partitions are archived and detached, never deleted. The drain itself stays opt-in and report-only until an operator flips Catalog:Replication:TenantLifecycle:{Enabled,DryRun}.

3. The canonical HTTP surface

Everything the family serves is reachable under /api/findings/v1/*. That surface is published as real ASP.NET endpoints by FindingsConsolidatedRouteAliases, not as a middleware rewrite — Router-dispatched requests bypass middleware, so a rewrite would work over direct HTTP and silently fail through the gateway.

The host also exposes an anonymous generated OpenAPI document at /openapi/v1.json. It is derived from the same ASP.NET EndpointDataSource as Router HELLO rather than maintained as a second hand-written route catalog. The adjacent contract test compares every canonical (method, path) operation in that document with the HELLO discovery set. This is source-level parity; removing the remaining static Findings hint still requires a rebuilt host and a live aggregate proof that no other connected service publishes an equivalent canonical template.

Carried pathCanonical path
/api/v1/findings/api/findings/v1/ledger
/vuln/ledger/events/api/findings/v1/ledger/events
/api/v1/scoring/api/findings/v1/scoring
/api/v1/capabilities/api/findings/v1/capabilities
/v1/alerts/api/findings/v1/alerts
/v1/vex-decisions/api/findings/v1/vex-decisions
/v1/evidence-subgraph/api/findings/v1/evidence-subgraph
/api/v2/security/api/findings/v1/security
/api/risk/aggregated-status/api/findings/v1/risk/aggregated-status

The endpoint groups arrive from the family’s own application libraries (StellaOps.Findings.Ledger.Application, StellaOps.Findings.Security.Application, StellaOps.Findings.Operations), which is what lets this host compile no predecessor deployable.

Authorization: /health/ready is anonymous; the doctor surface requires findings:health:read; the recovery surface requires findings:recovery; the retained disposition feed requires the machine-only findings:projection:read and an authenticated tenant. The lifted endpoint groups keep their own policy names as private consts, so the values are the contract — FindingsHostCompositionTests pins that every mapped endpoint’s policy actually resolves, which is what keeps the summary in FindingsPolicies honest rather than decorative.

Retired with the predecessors

/risk-scores/* and /exploit-maturity/* are gone (owner ruling Q-2, 2026-08-25). They had zero gateway routes and zero callers in src/. The risk-scoring capability is kept and folds here per D14; a read surface for it will be designed on /api/findings/v1/* when a real consumer exists. /api/vuln-explorer/* is likewise retired (Q-1): the real capability serves /v1/* and is reached through the canonical prefix above.

4. Projection seams

Three inbound seams feed the family. All three are default-off and fail DI closed rather than manufacturing a credential.

Advisory corpus (AddAdvisoryCorpusProjection). Consumes the Vulnerabilities hub’s corpus.generation.completed publication and materializes a generation into findings_security.advisory_projection_generation plus its section imports. Generations move staging → active, with the previous one held retained as the rollback target; a stranded staging generation records an explicit abandonment_code so a reader can tell a dead generation from a pending one without inferring it from state. Budgets are validated at startup — a positive generation budget, a retained budget at least as large, and one or two retained generations.

Advisory/SBOM matching (AddAdvisorySbomProjection, X18-6). The direct SbomService owner client, the fixed-fence projection coordinator and the active/staging match materializer share one activation gate. With client credentials enabled the corpus registration supplies the Authority token client; otherwise enabling the lane fails closed rather than falling back to a tenant bearer.

Scanner security projection (AddScannerSecurityProjection, X18-7). Gated by Findings:ScannerSecurityOwner:Enabled. Enabling it registers a dedicated Authority token client for the confidential stellaops-findings-scanner-projection identity (secret env FINDINGS_SCANNER_PROJECTION_CLIENT_SECRET, scope scanner:projection:read only) — it never rides a sibling lane’s credentials, and missing deployment values refuse startup naming the key. The token’s exact tenant selects one Scanner-owned physical stream, epoch, retention floor and remote cursor; the owner response must echo that tenant exactly. Findings keeps a tenant-distinct local lease/checkpoint identity, verifies both producer set hashes, and commits the v2 projection plus checkpoint in stellaops_findings before reporting the durable cursor to Scanner. A new tenant or an epoch reset cannot disturb another tenant’s local projection.

The consolidated web host calls AddActiveGenerationSecurityReadModel, which removes every earlier registration of the five security read contracts before registering the Findings-owned active-generation store. FND-26 (2026-08-29) closed the retirement that note used to defer: the predecessor concrete PostgresSecurityFindingProjectionStore, with its vuln.*/scanner.* SQL, is frozen at src/__Obsoleted/Findings/__Libraries/StellaOps.Findings.Security.Persistence/Stores/, no longer compiles into any deployable, and is no longer registered by AddFindingsSecurityApplication. The projection gate remains default-off; this source state is not evidence that credentials were provisioned or the feed was activated in a live estate.

FindingsScoringWorker is a deliberate placeholder, not an oversight: the broader consolidated scoring loop is staged. When it lands it writes to riskengine.risk_score_results in this family’s own database — not by resurrecting the retired surface.

The retained findings.dispositions feed (DC-29)

Everything above consumes someone else’s feed. This one is the mirror image: Findings is the producer, and Timeline is the remote consumer (SPRINT_20260911_001 TLC-1, 2026-09-14). The seam is Scanner’s scanner.scans shape, not a new design — PostgresFindingDispositionStreamReader and PostgresFindingDispositionStreamConsumerRegistry mirror PostgresScanCompletedStreamReader and PostgresScanCompletedStreamConsumerRegistry.

Logical streamfindings.dispositions, event type finding.disposition.changed, v = 1
Physical streamfindings.dispositions.tenant.<lowercase-utf8-hex> — one epoch, sequence, retention floor and consumer cursor per tenant
Catch-up routeGET /api/findings/v1/ledger/dispositions/events
Consumer cursor routePOST /api/findings/v1/ledger/dispositions/consumers/{consumerId}
Scopefindings:projection:read (machine-only) plus RequireTenant()

Both routes take the tenant only from the validated bearer claim, never from a parameter, so a caller cannot read across partitions. Ordinary findings:read is refused; the scope’s single declared carrier is the confidential stellaops-timeline-web client (allowedAudiences: stellaops, client_credentials, secret env TIMELINE_AUTHORITY_CLIENT_SECRET), and the Standard descriptor tests pin both that grant and the forbid everywhere else.

Page semantics. afterSeq (default 0) is the consumer’s durable cursor and limit (default 500, maximum 5000) bounds the count. The head is captured before envelopes are exposed, so a page can never advertise an older head than the rows it carries. Every page is additionally bounded by maxBytes (default 2 MiB, maximum 8 MiB): the trim keeps the page’s ordered gapless prefix, which makes a truncated page indistinguishable from a smaller limit — the cursor advances and the next page continues. One envelope is always served, even when it alone exceeds the budget, because an oversized event that could not be delivered would stall replay forever. The response carries streamEpoch, headSeq, retentionHorizonSeq, epochChanged and requiresBootstrap; a consumer whose cursor has fallen below the retention horizon, or whose remembered epoch no longer matches, is told to bootstrap rather than allowed to continue over a gap. Bad cursor input is 400, never a silent clamp.

Consumer cursors. Posting an empty body registers or resets a consumer; posting a complete {streamEpoch, seq} pair reports committed progress. A cursor whose epoch is not the producer’s current epoch, or whose sequence is ahead of the producer’s head, is 409 — the producer refuses to record a position it cannot have produced. Supplying only one of the pair is 400. Timeline checkpoints in its own database; this registry is the producer-side retention floor, not the consumer’s checkpoint.

Retention (DC-36). AddFindingDispositionOutboxRetention is opt-in and default off: without Findings:DispositionOutbox:Enabled no pruner is registered and nothing is deleted, so a newer build never starts pruning in a host that declared no window. Enabling it demands Findings:DispositionOutbox:RemoteConsumerLease and all seven Eventing:OutboxRetention:* keys explicitly; a missing or contradictory key refuses startup naming the key. The stream is declared RemotelyConsumed with MatchStreamPrefix, because the consumer’s checkpoint lives in another database and no local checkpoint can ever appear here. RequirePublished must be false — ordered pull catch-up has no transport publisher watermark.

5. The Graph asset-registry feed

HttpGraphAssetRegistryEventSource (in StellaOps.Findings.Ledger.Application/Services/GraphAssetRegistry/) is the consumer half of the Findings→Graph asset-registry seam. It reads Graph’s asset-registry event feed over its typed StellaOps.Graph.Contracts.AssetRegistry contract and cursors through PostgresAssetRegistryFeedStateStore (findings.asset_registry_projection_offsets, migration 008_findings_graph_asset_registry_feed_state.sql). The projector on the other side is AssetRegistryLedgerProjectionWorker. Graph is reached as a producer over its published contract — there is no cross-database read.

6. Advisory-generation recovery (Q-21)

AddAdvisoryGenerationRecoveryAdapter registers database-backed materializers and the recovery coordinator, not the corpus downloader, projector, or filesystem object store. The web role therefore does not configure or mount the worker’s advisory-corpus cache; that writable store belongs to findings-worker. AppRootWritabilityConformanceTests pins this distinction against the composition and Compose configuration.

AdvisoryGenerationRecoveryEndpoints is the one authorized public surface over the internal recovery coordinator, rooted at /api/findings/v1/security/advisory-generations/recovery:

OperationEffect
POST /holdpause activation
POST /rollbackestablish the durable, non-expiring activation hold
POST /restoreadmitted only by the exact witness/checkpoint test
POST /resumeseparate audited operation, admitted only while active/staging witnesses are current

Every transition requires a non-empty reason plus an incident or change reference. The server derives subject, time and operation id itself, and the coordinator appends the audit row (findings_security.advisory_generation_recovery_audit) in the same transaction as the state change, so an audited transition cannot half-happen.

There is deliberately no expiry knob and no bypass parameter. The gate is the global opt-in findings:recovery scope, which no base persona and no service client carries by default. The runbook’s prohibition on hand-written recovery SQL holds — this surface exists so that prohibition is enforceable rather than aspirational.

7. Predecessors

PredecessorState
riskengine-webdeleted at FND-10 (2026-08-27), with its /risk-scores/* surface
riskengine-workerdeleted at FND-10
findings-vulncorrelationdeleted at FND-10 — it carried no API surface and all three of its background services logged disabled at startup
findings-ledger-websource still present; see findings-ledger/architecture.md
findings-security-websource still present

The last two survived FND-10 for a measured reason rather than an oversight: deleting them would orphan 49 test files that cover kept library capability, and no WebApplicationFactory over the consolidated host exists to repoint them at. Building one is its own task.

The predecessors’ old schemas (findings, findings_security, analytics, riskengine) still sit in stellaops_platform. Dropping them is a separate destructive approval with its own window and has not been taken; the measured inputs for that window are recorded in the FND-10 execution log of SPRINT_20260722_010.