Findings — architecture

Verified against commit 942a18727c on 2026-09-26 (UTC). Re-verify: git grep -n "app\.Map" -- src/Findings/StellaOps.Findings.WebService/Program.cs and git grep -n "findings" -- devops/release/bundle/docker-compose.yml devops/compose/docker-compose.findings.yml.

This is the module dossier for the consolidated Findings service (ADR-039 D14). The Findings Ledger, the security read-model, vulnerability correlation and the kept risk-scoring capability are libraries of this one family; their design records are under ledger/. The dossier of the withdrawn findings-ledger-web host is archived at docs-archive/modules/findings-ledger/.

Architecture-pack pins for the statements below: FindingsConsolidationConformanceTests and AppRootWritabilityConformanceTests in src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/.

0. Deployment shape

ItemValueSource
Compose homedevops/compose/docker-compose.findings.yml (services findings-web, findings-worker; network alias findings.stella-ops.local)compose file
Release bundleNot deployed. devops/release/bundle/docker-compose.yml has no findings-web or findings-worker service, although authority, platform and timeline-web in that file are configured with http://findings.stella-ops.local base addressesbundle compose
Imagesstellaops/findings-web, stellaops/findings-worker (tag FINDINGS_IMAGE_TAG, default dev)compose file
Databasestellaops_findings via STELLAOPS_POSTGRES_FINDINGS_CONNECTION, fail-closedConsolidatedFindingsPersistenceExtensions.ConnectionEnvironmentVariable
Migrationssrc/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/ (numbered, embedded, converged on startup by both roles)persistence project
Required database rolefindings_ledger_admin (NOLOGIN BYPASSRLS), created by the install path as the superuser and asserted before any migration; startup fails with a named Migration preflight message when it is absent. No migration creates or comments on it (ADR-004 exception, PCD-64); the owner role needs no CREATEROLEFindingsDatabaseRoles, FindingsMigrationChecksumPins
Owner role posturefindings, LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS (ADR-039). Neither migrations nor the runtime need BYPASSRLS or membership in findings_ledger_admin: every read of a FORCE-RLS table binds one tenant (section 2, SPRINT_20260930_006 PCD-241)LedgerProjectionUnderNoBypassRlsOwnerTests, FindingsLedgerAdminRoleColdStartTests
Doctor checksdoctor.findings.ledger.tenant-partition-coverage, doctor.findings.ledger.rls-posture, doctor.findings.capacity.budget, served by MapServiceDoctorEndpoints under findings:health:readStellaOps.Findings.Operations/Doctor/FindingsDoctorChecks.cs, FindingsCapacityBudgetCheck.cs
Capacity probefindings-web runs one leased probe at startup and then daily. It writes findings.capacity_probe_receipt (migration 027) against the data-identity register’s budgets, including the matcher generation line (DIP-14: rows of advisory_projection_generation in every state, an index-only count; budget 3, warn at 3, fail at 4). The capacity check fails when the newest receipt is absent, older than 36 hours or at a stop line, and warns at an alarm line. findings-worker’s corpus staging import pauses on the same receipt (see Capacity pause under section 4; rules)AddFindingsDoctorChecks(), AddAdvisoryCorpusProjection(), FindingsDuplicationLines.cs
OpenAPIgenerated mirror openapi/v1.json and api-reference.mdtools/scripts/docs/regen-from-openapi.ps1

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.

PCD-827: Scanner completion v2 findings with reachabilityStatus: unknown and isReachable: false are admitted as unknown and stored with is_reachable = NULL. They retain their vulnerability/component identity and source scan provenance. The security read model returns unknown, and component reachability remains incomplete. Reachable claims without an entrypoint, vulnerable symbol and non-empty path remain malformed and are stepped over with the existing explicit warning. The optional marker retains the v2 wire shape and Boolean set-hash identity.

Source verification: commit 85a783cd8f (implementation; coordinated consumer deployment pending). Re-verify from the repository root: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj -Method "*Apply_Unknown*" -BuildProjectReferences.

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. The security application layer is wired with includeLegacyMigrations: false and the ledger layer registers no migrations (the StellaOps.Findings.Ledger chain was withdrawn by SPRINT_20260928_004 PCD-149), so no predecessor migration set can 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 and the tenant-registry trigger function (migration 018)
findings_archivestructural only: where SCR-4 detaches offboarded tenant partitions

FindingsSchemaTopology is the machine-readable statement of the 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), which tables carry FORCE row-level security behind findings_ledger_app.require_current_tenant(), and the names of the projector’s tenant registry and its trigger. It mirrors the 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.

Row-level security posture

Owner ruling 2026-10-02 (SPRINT_20260930_006 PCD-241). The owner role is NOBYPASSRLS and twelve findings tables (fourteen until migration 028 dropped the two write-only projections) are ENABLE + FORCE with a <table>_tenant_isolation policy whose resolver raises (P0001) when app.current_tenant is unset. No session reads tenant data without being bound to one tenant:

Row-level security here guards against application mistakes. It is not a boundary against hostile SQL running as the owner role, which can read the leaf partitions (they carry no policy of their own) and lift FORCE on the tables it owns. Pins: LedgerProjectionUnderNoBypassRlsOwnerTests and FindingsDoctorChecksTests (StellaOps.Findings.Operations.Tests), ConsolidatedFindingsSchemaTests (StellaOps.Findings.Persistence.Tests).

Ledger writes are bound to the authenticated tenant (SPRINT_20261017_002 PCD-840, owner ruling S-1 of 2026-10-17). FORCE RLS binds a session to the tenant the service names, so it cannot stop the service from naming the wrong one. The write paths take the tenant only from the authenticated principal, through the shared tenant resolver (the stellaops:tenant claim; for a global-admin principal, the canonical X-StellaOps-TenantId header):

Pins: LedgerWriteTenantBindingTests and DecisionPathAnswersTests (StellaOps.Findings.Operations.Tests: PostgreSQL under the NOBYPASSRLS owner, the real host routes), DecisionServiceLedgerAppendTests and AdvancedAssuranceGoldenWriteThroughServiceTests (StellaOps.Findings.Ledger.Tests).

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}.

Retention and custody per table are recorded in retention-classification.md(migrations 013_findings_retention_class_vocabulary.sql and 016_findings_retention_classes.sql). The retention programme’s schema-identity row RFC-16 (2026-09-23) resolved the two Findings declarations its detector could not place, vex_decisions and vex_override_envelopes in the legacy StellaOps.Findings.Ledger/migrations/001_v1_findings_ledger_baseline.sql, to the already classified findings.* rows through that file’s SET search_path TO findings; no schema changed. That legacy chain was withdrawn on 2026-09-30 (PCD-149, frozen under src/__Obsoleted/Findings/StellaOps.Findings.Ledger/PCD-149/); every table it declared is created by the consolidated chain, which is now each table’s only creator.

3. The canonical HTTP surface

Distribution fix evidence reads the current tenant’s active-generation match and SBOM component through GET /api/findings/v1/security/findings/{findingId}/backport (findings:read). The finding- plus 64-hex identity is the same identity the Findings list returns. The read requires current, complete generation provenance before checking the Vulnerabilities owner’s retained source evidence and comparing the installed version with the distributor threshold. The current Findings evidence rail exposes this as Distributor fix. See the distribution evidence contract.

The dormant ledger backport and patch paths are withdrawn. No unsupported ledger event types or per-tenant copy of the global advisory payload are added. Missing coverage stays unknown; source unavailability and integrity failures stay visible errors.

Everything the family serves through the gateway is reachable under /api/findings/v1/*. Each endpoint maps its /api/findings/v1/* template directly, so the surface is published as real ASP.NET endpoints, 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 only paths outside that root are the service-side /v1/vulns, /v1/vulns/{id}, /v1/fix-verifications and /v1/audit-bundles, which their consumers call on the host directly.

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.

The endpoints were carried from the predecessor hosts under the paths in the left column, and until 2026-09-30 the host also served each one there as a rollback alias. Those paths are no longer mapped: a request to one reaches no Findings endpoint. Use the right column.

Retired 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 mapping is a prefix substitution, so a carried path that already began with ledger keeps both segments: the ledger list is GET /api/findings/v1/ledger/ledger and chain verification is GET /api/findings/v1/ledger/ledger/{ledgerId}/chain-verify.

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 and the VEX decision overlay feed require 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; the summary lives in FindingsPolicies.

Doctor registration is separately opt-in through Doctor:Registration:Enabled. The registrar uses the configured Doctor client (the estate declares stellaops-findings-doctor) for platform:doctor:register, including its configured tenant and explicit token endpoint. Findings owns an isolated auth container and token cache for this named HTTP client, so the later Policy-exception client cannot replace its credentials. It reuses the shared authentication pipeline and the host’s logging, time and egress policy; it does not add a scope to another client. Blank Authority, ClientId or ClientSecret settings refuse an enabled startup. A disabled registrar needs none of those credentials.

A healthy Findings host does not prove registration succeeded. Verify the registered findings-web catalog includes doctor.findings.capacity.budget and its heartbeat advances after deployment. The registrar’s retry loop keeps a registration failure separate from the service’s own readiness.

Signed VEX overrides. POST /v1/vex-decisions with attestationOptions.createAttestation signs the override as a vex-override@v1 DSSE envelope (ES256) only when the operator supplies a signing key at runtime: findings:ledger:VexOverrideAttestation:PrivateKeyPem (a PKCS#8 P-256 PEM; wins when set) or findings:ledger:VexOverrideAttestation:KeySeed (a secret seed from which a stable P-256 key is derived). Both are key material: supply them from the environment or a mounted secret, never from committed configuration. Neither has a default. Without one, the override is persisted UNSIGNED: 201 with X-Vex-Attestation-Status: pending, X-Vex-Attestation-Detail: attestation_pending_signer_wiring and no signatureInfo. Until 2026-09-30 the seed defaulted to a string committed in the source (SPRINT_20260930_002 PCD-199), so any envelope signed before an operator key was configured used a key anyone can derive; treat such signatures as unverifiable.

Effective VEX status. Each security finding carries vexStatus and vexJustification from Policy’s gate projection (SPRINT_20260930_002 PCD-196): the read model batches the findings’ (vulnerabilityId, productKey) pairs to Policy’s POST /api/policy/vex/effective-status (HttpPolicyVexStatusReader, the tenant-bound policy:read bearer of stellaops-findings-security-internal, configured under FindingsSecurity:PolicyExceptions). The projection applies the shared consensus evaluator to the hub’s statements with the tenant’s VEX decisions as overlays (see the next paragraph). A pair Policy holds no statement for reads none; when Policy cannot answer, findings read unknown. The evidence bridge’s score cap reads the same source through PolicyVexConsensusLookup (only the EvidenceWeightedScore:TrustConsensusVexByDefault mode can cap from it: Policy’s issuer tiers are not the curated tiers TrustCuratedIssuersCap requires).

VEX decisions reach gates (SPRINT_20260930_002 PCD-197). Every decision write appends a vex.decision.changed event to this host’s own P6 outbox in the same transaction, on a per-tenant findings.vex-decisions stream. findings-web serves the stream at GET /api/findings/v1/ledger/vex-decision-overlays/events and a fenced bootstrap snapshot at GET /api/findings/v1/ledger/vex-decision-overlays/snapshot; Policy’s gate projection pulls both per tenant and applies the decisions as status overlays, which moves the promotion outcome and the effective status above. A decision on a component applies to that package at every version for the tenant; decisions scoped to environments, projects or an SBOM node, image subjects and UNDER_INVESTIGATION are stored but withdrawn from gates with a reason. Case-trust overrides at /api/findings/v1/ledger/vex-trust-overrides now use this v2 feed: a false row applies a case-untrust hold at under_investigation; restoring trust or deleting the row withdraws it. The optional source is audit context. The combined snapshot uses an overlay-id cursor and the consumer bootstraps its v2 checkpoint from decisions plus trust rows. Contract and composition rules: vex-decision-overlay-contract.md.

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.

The VexLens consensus paths were withdrawn on 2026-10-04 (SPRINT_20260930_002 PCD-196, to src/__Obsoleted/Findings/__Libraries/StellaOps.Findings.Ledger.Application/PCD-196/): the findings:ledger:vexlens lookup client, the consensus-event subscriber, the consensus materialization job and an in-memory consensus service. VexLens is retired; the effective consensus comes from Policy as described above.

PCD-445 uses the shared neutral generic comparator and preserves exclusive introducedExclusive alongside inclusive introduced/last-affected and exclusive fixed endpoints. Matcher version findings-corpus-v4 validates every endpoint before an outside verdict. Forward migration 026 adds nullable introduced_exclusive to the owned matcher and materialized-match projections; binary COPY, ordinary INSERT and paged materialization retain it. Existing projection identity, generation activation, tenant fence and reclamation remain in force. Compatible Findings startup must apply this migration before a producer publishes matcher format 5. A separately coordinated live receipt must prove the new corpus/component boundary; library tests do not certify rollout. Unsupported generic bounds or missing component versions are a per-version state, not a manifest refusal (SPRINT_20261005_002 PCD-822): the pair is not matched, the SBOM version is recorded match-incomplete for that manifest (advisory_sbom_match_incomplete, migration 030), and the materialization keeps every evaluable pair and publishes its witness. Until PCD-822 one such row refused the whole manifest, so the match fence stopped for every tenant (measured live 2026-10-10: 16 legacy Packagist generic-exact rows with a fix bound on pkg:composer/symfony/yaml). Existing bounded generic-exact OSV rows still need the separately owned source-shape cleanup (PCD-823), since matcher rows cannot recover the discarded upstream event kind from their scheme alone; until then the affected versions read match-incomplete.

Match-incomplete contract. The record is keyed by tenant, SBOM version and corpus generation; it carries sorted reason codes, the exact unevaluable pair and distinct-row counts and the ordinal-first 256 row keys, and it is replaced in the same fenced transaction as the manifest’s matches. Every read that serves match or finding results from the active generation carries the tenant’s count in X-StellaOps-Match-Incomplete-Versions and X-StellaOps-Match-State: complete|incomplete; the readiness probe reports the all-tenant count and degrades. The state is recomputed, not sticky: a new generation has no witness, so the SBOM pass materializes it against every live version before the projector can activate it, and a version that only the old rows left unevaluable reads complete once that generation is active. Contract detail: security-read-model.md.

PCD-822 source verified against b5bd07e5d4 (2026-10-11 UTC). Re-verify: pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj -Method '*MatchIncomplete*' -BuildProjectReferences.

PCD-445 source verified against 56357b8682799c029045a161491eabab422373e8 (2026-10-08 UTC). Re-verify: pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.AdvisoryMatching.Tests/StellaOps.Findings.AdvisoryMatching.Tests.csproj -All -BuildProjectReferences and repeat with src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj.

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.

Import batches use PostgreSQL binary COPY into an unindexed transaction-local staging table, then merge into the selected section projection in one statement. The merge and durable line cursor commit together under the existing lease fence; rollback removes the temporary table and does not advance progress. Lease renewal also refreshes the owner consumer registration with the last locally committed checkpoint. Staging rows never advance the reported event cursor.

A verified cached section keeps its completed import state while activation waits for the manifest-specific SBOM fence. Repeated passes do not parse or import that completed section again. Local stored-object hashing, decoded identity verification, and resumed line reading renew the writer lease at the configured heartbeat interval, including reads that skip already imported lines. Losing the lease stops the read before a destination write can continue.

Superseded generations are reclaimed by AdvisoryCorpusReclamationPass in bounded batches after activation. Activation does not run a large cascading delete. The active generation, configured rollback generations, recovery holds and SBOM match fences remain protected.

Matcher rows are stored once per distinct canonical line and shared by the generations that contain them (migration 029, SPRINT_20261007_062 DIP-9): advisory_matcher_projection holds the rows and advisory_matcher_generation_membership records each generation’s lines. A generation import inserts only the lines no generation already holds (consecutive generations share about 90-97% of their lines), and reclaiming a generation deletes its match rows, then its membership with the shared rows that membership was the last reference to, then its metadata and exploit rows. The import and the reclamation pass serialize per batch on a transaction-scoped advisory lock, so a row cannot be deleted between the import finding it and its membership committing. Migration 029 does not copy rows: it empties the corpus projection family and deletes the corpus consumer checkpoint, and the projector rebuilds from the owner’s current publication. Until that generation is imported, matched and activated, the security read routes answer 503 security_read_model_source_unavailable. PCD-350 measured the complete owner HTTP download, verification, COPY and activation path for 100,000 matcher rows at 7,824.5 and 8,729.5 rows/s in real PostgreSQL acceptance fixtures; these are local fixture measurements, not deployed-estate throughput. This import and registration slice is verified against source 458a2b76ad (2026-10-05). Re-verify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj -Method '*AdvisoryCorpus*' -BuildProjectReferences -Restore.

Reclamation runs separately from activation under a durable generation claim. Its default Findings:AdvisoryCorpus:ReclaimBatchRows is 1,000 rows per DELETE (PCD-338), reduced from 5,000 after two loaded-estate statements exhausted the 30-second driver timeout. The default pass budget is 2 seconds and the lease remains 90 seconds. A pass checks its budget between committed batches; an in-flight statement can exceed the pass budget. Each drain command has a 5-second timeout (ReclaimCommandSeconds), further limited by a shorter connection timeout and half the lease heartbeat interval. Migration 032 adds advisory_reclamation_progress, keyed by generation id and drain table. A batch reads its durable key, deletes children, removes any newly unreferenced shared matcher rows, and writes its new key in one reclamation-lease-fenced transaction. A restart or another replica reads the last committed key, including completed-table witnesses; an aborted batch advances neither deletion nor progress. No full-table count precedes deletion: logs report committed rows, the shared matcher rows deleted as unreferenced and the number of generations still draining, without claiming an exact remaining-row total. A membership batch and its unreferenced-row delete are drain commands with the same timeout. Waiting for the matcher row gate uses the smaller of DatabaseLockWaitSeconds and the drain command budget. The gate is released when each batch commits, so a cooldown never holds an import.

After all four large child ranges have committed empty-range witnesses, generation removal uses a separate ReclaimGenerationCommandSeconds upper bound (default 60, valid 1…300 seconds). The effective bound is at most LeaseSeconds - 2 * DatabaseLockWaitSeconds - 1 seconds, after renewing and fencing the lease. PostgreSQL’s statement timeout enforces it and the driver’s bound is one second longer so a server refusal can be observed. Native foreign keys remain enabled: their final cascade checks can still traverse deleted index entries. Final removal logs its budget and preserves all completed progress on timeout, rollback or cancellation; a later replica retries the final operation without re-draining completed tables. Progress rows cascade with their generation, and active/retained selection and recovery holds retain their existing guards. Existing claims at the 032 upgrade have no historical cursor and begin at the head once; the migration does not guess a cursor or reclaim live rows. Migration 032 and a rebuilt Findings worker were deployed on 2026-10-18. After the worker restarted on 2026-10-19, the next reclamation drained and removed its generation with no timeout, and the durable cursor advances during live drains. No live restart has yet interrupted a drain, so a live mid-drain resume has not been observed; source tests cover it. Keep-set acquisition (the eligibility SELECT … FOR UPDATE in the claim phase) can still hit the driver timeout outside a drain and has separate PCD-830 work.

Reclamation source verification: afbe290f7073f30ae208520776cb76a054a043b5 (2026-10-13), isolated PostgreSQL qualification only. Re-verify from the repository root: pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Findings/__Tests/StellaOps.Findings.Persistence.Tests/StellaOps.Findings.Persistence.Tests.csproj -All -BuildProjectReferences.

Findings:AdvisoryCorpus:ReclaimDutyCyclePercent defaults to 20 (valid range 1…50). After each pass, including a failed pass, the worker closes its drain connection and waits at least four times that pass’s elapsed duration while holding the shared reclamation lease. Long cooldowns renew that lease, so replicas cannot alternate to multiply the work budget. Shutdown cancels the wait and releases the lease; committed batches survive. Idle and failed passes also observe the 30-second ReclaimPollSeconds minimum. A 100 ms minimum prevents a sub-clock-resolution pass spinning. This bounds the leased reclamation work’s wall-time duty cycle, not instantaneous CPU, autovacuum, or other database work. Operators can tune duty and batch bounds to their measured CPU and cleanup throughput; reducing duty extends cleanup time. Check that committed deletions keep up with incoming generations and retain the capacity writer gate below so a slow drain cannot consume unbounded disk. Active and retained generations remain protected by the existing eligibility and activation fence. Increasing batch size increases statement work and latency.

Capacity pause (SPRINT_20261007_062 DIP-13, ADR-039 P7). Before it imports a generation whose rows are not imported yet (a new, failed or partly imported generation), on the event path and on the bootstrap path, the projector consults the Findings capacity writer gate. The gate reads the newest findings.capacity_probe_receipt, which findings-web’s probe writes. It is closed while that receipt shows stellaops_findings at its database stop line, while the receipt is older than Capacity:WriterGate:StaleAfter (48 hours by default), and while no receipt exists once Capacity:WriterGate:UnmeasuredGrace (20 minutes by default) has passed since findings-worker started. A missing receipt table counts as no receipt, so a findings-worker deployed before findings-web pauses after the grace with a capacity unmeasured reason. While the gate is closed:

The gate logs one line when it closes or opens, not one per poll. The stop line is the database line of the receipt; the matcher projection’s table line at its pin fails the Doctor check without pausing the import.

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 kept StellaOps.RiskEngine.Infrastructure store library embeds no migrations. EST-9 froze its unregistered predecessor SQL unchanged; its database tests now apply the StellaOps.Findings.Persistence owner chain before exercising the kept store. This removes a competing migration resource without withdrawing the scoring capability.

PCD-450 withdrew the unregistered FixChainAttestationClient, its HTTP DTOs and the provider’s optional GetFixStatusAsync lookup. Neither registered FixChain lookup route has an Evidence owner endpoint. The retained FixChainRiskProvider scores verification data supplied by its caller; it does not fetch attestations or infer verification from an unavailable provider. The former client and its in-memory workflow tests are frozen outside the build graph.

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). The eventing outbox and inbox pruners run in every host that embeds the eventing chain, with a seven-day library default window that a host may lengthen and cannot disable (SPRINT_20260916_001 RFC-14, owner ruling Q-42, 2026-09-23). AddFindingDispositionOutboxRetention decides only whether this host declares the disposition stream prunable: without Findings:DispositionOutbox:Enabled the stream is undeclared, measured every pass and never reclaimed. Declaring it demands Findings:DispositionOutbox:RemoteConsumerLease and all six 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-web, riskengine-workerdeleted at FND-10 (2026-08-27), with the /risk-scores/* and /exploit-maturity/* surface (ruling Q-2)
findings-vulncorrelationdeleted at FND-10; it carried no API surface
findings-ledger-webwithdrawn to src/__Obsoleted/Findings/StellaOps.Findings.Ledger.WebService/ on 2026-08-28 (FND-20, owner ruling D1); the /v1/ledger/*, /v1/vex-consensus/*, /ledger/export/* and /internal/ledger/* families were not carried
findings-security-webwithdrawn to src/__Obsoleted/Findings/StellaOps.Findings.Security.WebService/ on 2026-08-28 (FND-20); nothing withdrawn
VulnExplorer hostdecommissioned; its endpoints are mapped by findings-web from StellaOps.Findings.Ledger.Application/Endpoints/VulnExplorerEndpoints.cs; /api/vuln-explorer/* is retired (ruling Q-1)

The per-route reconciliation of the two withdrawn hosts is frozen evidence at docs/implplan/_evidence/20260828_fnd20_host_route_reconciliation/. The legacy migration chains of StellaOps.Findings.Security.Persistence and StellaOps.Findings.VulnCorrelation.Persistence remain in the kept libraries but are not selected by the consolidated hosts (includeLegacyMigrations: false for Security; VulnCorrelation is wired into no service). The StellaOps.Findings.Ledger chain, which still created the findings_ledger_admin role, was withdrawn on 2026-09-30 (PCD-149); that library embeds no migrations. The ledger snapshot and time-travel services behind the withdrawn /v1/ledger/snapshots and time-travel routes had no other caller and were withdrawn on 2026-10-01 (PCD-242, frozen under src/__Obsoleted/Findings/StellaOps.Findings.Ledger/PCD-242/); findings.ledger_snapshots stays in the forward-only chain with no reader or writer. The ledger library’s vulnerability-detail endpoint (GET /api/findings/v1/security/vulnerabilities/{identifier}), which only the withdrawn ledger host mapped, was withdrawn on 2026-10-01 (PCD-230, frozen under src/__Obsoleted/Findings/__Libraries/StellaOps.Findings.Ledger.Application/PCD-230/); the security read model serves that path. Dropping any predecessor schema copies in stellaops_platform is a separate destructive approval that the FND-10 record in SPRINT_20260722_010 did not take; the live state of that database is not verified by this document.