StellaOps.Vulnerabilities hub — detailed design (v2)

Status: Accepted design baseline (revision 3, 2026-07-22 — tracks ADR-039 rev-4 owner approvals: database stellaops_vuln, rebuild-from-scratch method, early decommission before ingest, no parity vs v1, registry-based doctor model. Execution windows still gate runtime work.) Evidence: investigation (all counts/widths measured 2026-07-22). Sprint: docs/implplan/SPRINT_20260722_001_Concelier_vulnerability_data_plane_separation.md.

1. Why the current store is 91 GB — the simple version

The actual knowledge is small. All raw source documents together are ~6 GB. The rest is multiplication:

  1. The same VEX statement is stored ~6 times. Raw document → normalized claim (vex.claims) → a derived VexHub copy (vexhub.statements) → one observation row for the claim → a second observation row for the VexHub copy → an evidence-ref row per observation. 3.5M statements become 14M+ rows before aggregation.
  2. Every copy repeats fat JSON (aliases, references, “facts”) instead of pointing at shared rows. An observation averages 1,400 B — two thirds of it JSON duplicated from elsewhere.
  3. Every (CVE, product) gate decision stores a ~3.5 kB JSON essay explaining itself — 5M rows × 3.5 kB ≈ 17 GB of explanations, while its actual readers (Policy, CLI) read five short columns.
  4. Indexes double everything. vexhub.statements: 1.9 GB of data under 4.0 GB of indexes (13 of them). The four projection tables carry 20+ GB of indexes.
  5. Churn, not just size: sweeps rewrite unchanged rows on every pass (dead tuples + WAL), and the retention worker deletes the wrong rows and leaves aggregates stale — so the store grows and degrades at the same time.

Net effect: ~600 bytes of real fact costs ~15 kB stored. The v2 hub stores each fact once, keeps provenance as narrow references, and derives everything else reproducibly.

2. The hub in one paragraph

StellaOps.Vulnerabilities (merged Concelier + Excititor + VexHub; deployables vulnerabilities-web + vulnerabilities-worker) owns the single database stellaops_vuln on the dedicated stellaops-postgres-vulndata cluster. It is a tenant-free global knowledge hub: source-connector plugins stream advisories/VEX/distro data in; mirror plugins import/export offline bundles; lookup APIs answer “what is known about this CVE/purl?”; an outbox feeds control-plane projections. Per-tenant effective decisions (the successor of vuln.issue_gate_decisions) move to Policy’s own database, built from hub events plus tenant overlays. Source policy (tiers, removals — Cisco/MSRC/GHSA obsoleted; KEV/ACSC/CERT-In dropped as region-locked; Tier 0 credential-free) is defined in connectors-architecture.md. EPSS relocates into the hub from the Scanner schema (P14 — consolidation review §3).

3. Data model

Identities (ADR-039 D4):

Rules: a claim and its derived distribution statement are one fact with two provenance records; independent issuers stay independent facts; tombstones follow completed source generations, never age; raw documents are immutable and content-addressed; the hub never stores tenant data (P12).

Hard cases are pre-resolved in the design-challenges register: canonical product identity + applicability model (DC-01), applicability-hash canonicalization (DC-02), CSAF product-tree fan-out control (DC-03), alias graph + issue re-keying without touching fact identity (DC-04), and bytea(32) hash keys (DC-05). Tenant-authored VEX is a control-plane overlay with read composition in Policy’s API (DC-10); release gates never synchronously depend on this database (DC-11 / ADR-039 P15).

3.1 Schemas inside stellaops_vuln (one owner, so internal schemas are fine)

SchemaTables (essence)Justifying reader / recovery
ingestcontent-addressed raw documents + DTOs (advisory/VEX/distro), connector source state + cursors, quarantineReplay/audit source truth; connectors resume; mirror export reads raw
factsfact_current (identity + status/justification/severity/fixed_version + normalized applicability + revision hash), fact_provenance (origin kind/ref, document digest, signature verification, key fingerprint), fact_revision (append-only, partitioned), alias + product-identity dedup storesLookup APIs, consensus builder, mirror export, verification display. Aliases/references stored once here, not per derived row
consensusissue_linkset (global, narrow: counts, status/severity summaries, content hash), rebuild queue“Sources & consensus” UI + lookup APIs + the event stream to Policy; fully rebuildable from facts
distdistribution/verification view (former VexHub statements — rebuildable lineage over facts), mirror import/export state, future external submissions (those are source facts)VexHub-compatible distribution APIs; mirror bundle production
outbox / runtimetransactional outbox (fact changes + generation markers), projector inbox/checkpoints, leases + fencing tokens, reconciliation auditControl-plane projections (Policy), internal projections; durable resume; single-writer proof

Deliberately absent from the hub: tenant columns/RLS, per-tenant gate decisions, verbose gate explanation JSON (DC-31: derived on demand, nowhere stored), the observation/evidence-ref double layer (facts + provenance replace it), rebuild timestamps in hot-row equality — and (round 15) sbom_registry / sbom_canonical_match: SBOM↔advisory match results are artifact-scoped consumer data, owned by the Findings family in the control plane (computed there on the corpus artifact from sbom.uploaded events; X18/X19). The exploited-in-the-wild attribute (kev-class flags) is a hub fact attribute delivered via corpus artifact + events — its identified consumer is Findings’ risk aggregation.

3.2 Control-plane side (Policy-owned, in stellaops_policy)

TableContentsFed by
policy.vuln_gate_currentper-tenant effective status/severity/range/title/summary + winner source keys (explicit columns) + package/ecosystem + input/settings/decision hashes + corpus generation stamp (DC-30 replay)hub events × corpus artifact × tenant overlays × policy settings
(no explanation table — DC-31)verbose explanations are re-derived on demand (deterministic: generation + linkset content + settings version); nothing storedexplain endpoint
policy.vuln_tenant_overlayinbox materialization (not a system of record — DC-32): tenant VEX decisions + trust overrides live in the Findings ledger (signed, Merkle-anchored) and arrive as events; policy-rule exceptions live in policy.exceptions (evolved, DC-10)Findings-ledger events + Policy/Console APIs
policy.vuln_projection_checkpoint + leasedurable inbox for the hub streamP6 standard

Matcher input: Policy consumes the generation-stamped compact corpus artifact (DC-30) via the VulnDb.Compact reader with atomic A/B generation swap — no advisory-row tables in stellaops_policy; online and offline matching share one code path.

CLI compact vuln-db export reads the hub’s global corpus API (it exports global knowledge; the current implementation reading tenant-scoped gates via SQL was a category error).

4. ERD (hub)

erDiagram
    RAW_DOCUMENT ||--o{ FACT_PROVENANCE : "digest referenced by"
    FACT_CURRENT ||--o{ FACT_PROVENANCE : "1..n origins"
    FACT_CURRENT ||--o{ FACT_REVISION : "history"
    FACT_CURRENT }o--|| ISSUE_LINKSET : "aggregates into"
    ISSUE_LINKSET ||--o{ OUTBOX : "change events"
    FACT_CURRENT ||--o{ DIST_STATEMENT : "rebuildable distribution view"

    FACT_CURRENT {
        text fact_id PK
        text issuer_id
        text source_statement_id
        text vulnerability_id
        text product_key
        text applicability_hash
        jsonb applicability
        text status
        text justification
        text severity
        text fixed_version
        text revision_hash
        timestamptz first_seen
        timestamptz last_seen
        timestamptz tombstoned_at
    }
    FACT_PROVENANCE {
        text fact_id FK
        text origin_kind "connector_claim | derived_distribution | external_submission | advisory_edge | distro"
        text origin_ref
        text document_digest
        text verification_status
        text signing_key_fingerprint
    }
    ISSUE_LINKSET {
        text vulnerability_id PK
        text product_key PK
        bigint fact_count
        jsonb status_summary
        jsonb severity_summary
        text content_hash
    }

5. Data flow

flowchart LR
    SRC["source connector plugins\n(advisories, VEX, distros)"] -->|stream| ING["ingest: raw + DTO"]
    MIRIN["mirror import plugin"] --> ING
    ING --> NORM["normalize → facts + provenance\n(one txn: fact + outbox)"]
    NORM --> CONS["consensus linksets\n(leased projector, no-op guarded)"]
    NORM --> DIST["distribution view (rebuildable)"]
    DIST --> MIROUT["mirror export plugin"]
    CONS --> API["lookup APIs: vuln / VEX / consensus / corpus export"]
    API --> CLI["stella vuln-db export"]
    API --> UI["Console: sources & consensus"]
    CONS -->|outbox events| POL["Policy projector (own DB):\ntenant gates + overlays + explanations"]
    POL --> GATES["release gates / findings"]

6. Reconciliation protocol (unchanged in substance, now hub-internal + hub→Policy)

  1. One source transaction commits fact revision/tombstone + ordered outbox row; provider runs write generation open/close markers; only a closed generation authorizes absence-tombstones.
  2. Each projection stream (consensus, distribution, Policy’s gate projector) has one leased writer with a fencing token; the token is checked inside the destination transaction.
  3. One destination transaction applies content-guarded upserts (… DO UPDATE … WHERE target.revision_hash IS DISTINCT FROM excluded.revision_hash OR tombstone changed), rebuilds exactly the affected linksets, and advances the checkpoint. Failure rolls back all of it.
  4. Replay is physically idempotent: zero hot-row versions, bounded checkpoint/audit writes.
  5. Deletes: removing one provenance origin keeps the fact while another live origin remains; removing the last tombstones the fact and rebuilds its linkset in the same transaction; disjoint ranges never touch each other. There is no separate retention sweep in v2.
  6. Restart/failover: resume from the durable checkpoint; a second instance blocks on the lease; an expired holder is fenced by its stale token.

The event contract (envelope + versioning), transport + inbox dedup, corpus-readiness contract, and linkset rebuild backpressure are specified in DC-06…DC-09 of the design-challenges register.

7. Build & cutover plan (fresh start — replaces side-by-side backfill)

One step per sprint-sized unit; the program deliberately spans many sprints (SPRINT_20260722_001/003–008).

StepContentGate
B0Runtime freeze (approval): disable VexRetentionSweepService; old plane keeps serving as-isapproved change window
B1Consolidation + rename: StellaOps.Vulnerabilities.* namespaces/projects, StellaOps.Vulnerabilities.Persistence DAL, plugin manifests/bundles re-signed, compose keys/images. Connector pruning (D13): delete Cisco/MSRC (advisory + Excititor CSAF), GHSA, KEV, ACSC, CERT-In — code, tests, fixtures, devops/etc/plugins/concelier/connectors/* config, bundle entries. Traps: cross-ALC plugin identity, embedded Migrations/** resource names change with assembly rename (rebuild owning lib), bundle prune listsbuild green; retained plugins load and map on a scratch stack; no removed-connector references remain
B2New cluster stellaops-postgres-vulndata (own volume, ~100 GB) + stellaops_vuln DB/role; fresh baseline migrations 001_* per schema (no legacy migration history carried); every table carries its P13 retention classfresh-DB convergence test
B3Ingestion machinery proven end-to-end on fixtures + a scratch-stack full re-ingest rehearsal (Tier-0 connectors — all credential-free — EPSS, mirror import); rehearsal measures wall-clock/WAL/rate-limit behaviorfixture generations complete; rehearsal report
B4Consensus + distribution projections built by the leased projector; lookup/export APIs servingno-op replay proof; API forcing checks
B5Router route swap + Console migration built and gated on a scratch stack (§8): new /api/vulnerabilities/v1/* group, UI on the API base-map switch, rebuild-progress empty statesroute tests; four-persona gate on scratch
B6Policy-side gate projection from hub events + overlays; readers repointed in code (Policy internal, CLI → hub corpus API, mirror export → hub plugin, Scanner/risk EPSS → hub projection)live forcing functions on scratch
B7Execution window (owner-approved, destructive — D8/D9): drop vuln/vex/vexhub/concelier from the platform DB and the EPSS tables from scanner (frees ~91 GB before ingest); deploy the vulndata cluster + hub services; swap gateway routes and readers in the same window. Vulnerability surfaces enter the documented degraded/rebuilding mode (empty, filling)signed destructive approval; window checklist
B8Production re-ingest (days): Tier-0 + mirror generations complete; surfaces fill; program close-out reportper-source completion vs upstream inventories

There is no old-vs-new parity or sanity comparison: the old data is deleted before ingest (D8) and the estate is alpha — the owner explicitly waived canonicalization-drift concerns. Coverage gates compare against upstream source inventories only. After B7 there is no old plane to roll back to; recovery is fix-forward + re-ingest (desk-check T11/T12).

8. Router/gateway and Console migration (explicit steps)

Gateway (src/Router/StellaOps.Gateway.WebService/appsettings.json route config + the route-config test suite):

  1. Add route group /api/vulnerabilities/v1/*vulnerabilities-web, with the identity-envelope and scope requirements carried over (advisory:read, vex:read, vuln:* families unchanged — scopes name capabilities, not services, so the catalog does not change).
  2. Old prefixes (Concelier jobs/LNM /api/v1/lnm/*, /jobs/*, Excititor, VexHub) are replaced in the B7 execution window — their upstreams cease to exist with the old plane, so there is no dual-run or alias-metric program (owner D8). A thin alias survives only where an external contract (older CLI builds, mirror consumers) demands it, each with a named owner and sunset.
  3. Update the OpenAPI aggregation (Services/OpenApiAggregator.cs) source list; regenerate the published API surface (src/Api governance checks).
  4. Add/adjust route-config tests (pattern: ConcelierJobsRouteConfigTests.cs) for the new group and the aliases; remove alias tests when B7 removes the aliases.
  5. Known trap: regex routes participate in the identity envelope — verify Bearer forwarding on the new group (past incident: regex route stripped Authorization).

Console (src/Web/StellaOps.Web):

  1. Repoint the API client layer (the generated/typed clients for concelier/excititor/vexhub) to the /api/vulnerabilities/v1/* group behind one switch (API base map), not per-component edits.
  2. Affected surfaces: Sources & consensus (LNM linksets/observations views), VulnCatalog / Vuln DB pages, advisory/VEX source-catalog pages, dashboard badges/counters, evidence drill-downs that deep-link raw documents.
  3. Connector-management UI: remove tiles/config for deleted connectors (D13); add the tier model (Tier-1 regional plugins shown as optional).
  4. Empty-state honesty (no-mocks rule): during the post-decommission ingest window (B7→B8) every vulnerability surface must show an explicit “corpus rebuilding — N of M sources complete” state driven by the hub’s generation accounting — never fake data, never raw errors.
  5. Tests: per-page vitest specs updated alongside (Rule A), layout-guard runs, and the four-persona live gate (tools/scripts/run-console-four-role-live.ps1) as the UI acceptance bar.
  6. Sequencing: routes + UI + readers swap together in the B7 window; the four-persona gate runs on the scratch stack before the window and against production after it.

9. Capacity budget (measured basis)

StoreBasisBudget
ingest raw + DTOtoday’s raw ≈ 6 GB (current-state re-ingest likely similar or smaller; Tier-3 removals reduce it)≈ 6 GB
EPSS (relocated from scanner, P14): current scores 350k rows + bounded change-history windowmeasured (today: 181 MB current + ~1.9 GB/month unbounded partitions — the unbounded pattern is what the retention class fixes)≈ 1–3 GB
facts (≈ 3.5M VEX + ~2.7M advisory-edge + distro facts; 602 B avg claim, narrower fact rows; provenance ~8.6M × ~200 B; dedup stores ~1.5 GB)measured widths≈ 12–16 GB
consensus (≈ 5M global linksets, narrow + 1–2 indexes)measured≈ 3 GB
dist (rebuildable view, pruned indexes — vs 5.9 GB today with 13 indexes)measured≈ 2–3 GB
outbox/runtime/auditbounded< 1 GB
Hub steady state≈ 25–30 GB (vs 89 GB today)
Ingest WAL/temp headroombatch-bounded; max_wal_size 8 GB during bulk ingest+15–20 GB
Volume provisioning100 GB dedicated volume (owner confirms availability, 2026-07-22); separate physical disk recommended; the B7 old-plane deletion additionally frees ~89 GB on the control-plane volume before ingest
Policy-side gates (control-plane cluster)5M narrow rows ≈ 4 GB; explanations are not stored (DC-31: deterministic re-derivation) + one active corpus artifact ≈ 2–3 GB local≈ 6–7 GB on the control-plane volume (the former 10–17 GB explanation question is dissolved)

Per-path metrics (P7): rows/bytes per schema, attempted-vs-actual writes, WAL/batch, outbox lag, checkpoint age, lease health, per-source quotas, volume + host headroom; shared guard pauses all writers before the floor.

10. Theoretical validation (desk-check of the plan)

Each scenario walked against the design; the invariant column names the mechanism that holds it. These become the acceptance walkthroughs of the implementation sprints.

#ScenarioExpected behaviorHeld by
T1Normal ingest: connector streams a document with 3 statements, 2 for the same (vuln, product) with disjoint ranges1 raw doc, 3 facts (disjoint ranges distinct), 3+ provenance rows, outbox entries, linksets updated onceD4 identity incl. applicability_hash; §6.1 atomicity
T2Same document replayed (connector retry, mirror re-import)zero new facts/revisions/outbox rows; zero hot-row versionscontent-addressed raw + deterministic identity + content-guarded upserts
T3Vendor republishes: same statement id, changed statusnew revision on the same fact (identity unchanged), outbox change event, linkset/gate re-derived; old revision in historyrevision hash as value, not identity
T4Vendor withdraws a statement; generation completes without itfact tombstoned only after generation close; linkset rebuilt in the same txn; the disjoint-range sibling untouched§6.1/§6.5 generation-close tombstoning
T5Projector crashes mid-batchcheckpoint not advanced; restart reprocesses the batch; result identical (T2 idempotency)single destination transaction incl. checkpoint
T6Two workers race (deploy overlap, split brain)second blocks on lease; expired holder’s commit rejected by fencing-token check inside the txn§6.2 lease + fencing
T7Excititor-origin statement + independent external statement with identical text1 fact with 2 provenance for the former; separate fact for the latterissuer in fact identity; derived = provenance
T8Policy projector lags during bulk re-ingesthub unaffected; gates serve last-applied state; lag metric alarms; catch-up is ordered replayoutbox ordering + durable inbox checkpoint
T9Air-gap estate: mirror bundle import on an empty hubfull hub reconstructed from bundle (T2 makes repeat imports no-ops) — this is also the DR path after B8mirror import = a Tier-0 source on the same contract
T10UI/gates during the rebuild window (B7→B8)vulnerability surfaces show explicit “corpus rebuilding — N of M sources” states from generation accounting; release-gate behavior in the empty window follows the recorded degraded-mode decision§8 empty-state rule + D8
T11Defect found after the B7 windowfix forward; recovery is re-ingest — there is no old plane to return to (owner-accepted alpha posture, D8/D9)fresh-rebuild recovery story
T12Post-B8 disaster (hub volume lost)re-provision volume, auto-migrate empty DB, re-ingest Tier-0 + mirror (days); tenant decisions unaffected (they live in Policy)tenant-free hub + P9 recovery story
T13A removed connector (e.g. KEV) is needed again for one estateship as Tier-1 plugin; exploited-in-the-wild attribute already in the model; no hub migrationconnectors-architecture §1.1/§1.3
T14Capacity budget breached during ingest (rogue source)shared guard pauses all hub writers before the floor with operator-visible reason; serving APIs unaffectedP7 single capacity contract

Residual risks the desk-check cannot discharge (tracked in sprints): re-ingest wall-clock on upstream rate limits (measured by the B3 scratch rehearsal before the production window), and the release-gate posture while the projection is empty (fail-open-with-warning vs fail-closed — an owner decision recorded in SPRINT_20260722_008 before the B7 window). Purl-drift parity was explicitly waived by the owner (alpha estate, fresh ingest).

11. Test plan

src/Vulnerabilities/__Tests/StellaOps.Vulnerabilities.Persistence.Tests/ (isolated PostgreSQL, frozen fixtures, targeted-run wrapper with ran-vs-suite counts):

ProofTest
Disjoint ranges survive ingest/replay/reconcile; deleting one keeps the otherDisjointRangeSurvivalTests
Claim + derived distribution = one fact/two provenance; external submission independentSingleFactLineageTests
Tombstones reconcile facts/linksets transactionally; injected failure rolls back checkpointTransactionalReconcileTests
Failed batch never advances checkpoint; restart resumesCheckpointRestartTests
Replay physically idempotent (zero hot-row versions via pg_stat/xmax on isolated DB)PhysicalIdempotencyTests
Two projectors cannot both commit; expired holder fencedLeaseFencingTests
Fresh-DB convergence of baseline migrations; no tenant columns in hub schemasHubSchemaConventionTests
Ingest coverage gates: per-source completion accounting is accurateIngestGenerationAccountingTests
Hub emits events Policy projector consumes exactly-once into gatesGateProjectionContractTests (Policy.Persistence.Tests)
No service resolves a foreign DB; persistence naming standardDatabaseOwnershipConformanceTests (architecture suite)
Mirror export→import round-trip rebuilds an equivalent hubMirrorRoundTripTests
Gateway: new route group + aliases resolve, envelope/Bearer intact, alias hits meteredroute-config tests in StellaOps.Gateway.WebService.Tests
EPSS relocation: hub serves current scores + bounded change window; scanner/risk readers consume the hub projectionEpssRelocationContractTests
Console: migrated pages green per-spec + layout guards + four-persona live gatevitest specs + run-console-four-role-live.ps1 (UI acceptance bar)

Superseded: the rev-1 backfill-parity suite and the 424/424 lineage-repair fixtures (v1 data is not migrated, so its damage is discarded, not repaired).