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:
- 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. - 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.
- 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.
- 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. - 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):
- Fact identity:
fact_id = sha256(issuer_id | source_statement_id | vulnerability_id | product_key | applicability_hash).source_statement_id= stable upstream statement identity (OpenVEX statement id / CSAF tracking id / advisory edge id, deterministic fallback as in the currentResolveSourceStatementId). Disjoint applicability ranges are distinct facts. Revision hash is a value, not identity. - Issue identity (aggregation):
(canonical vulnerability_id, canonical product_key)— no tenant dimension in the hub.
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)
| Schema | Tables (essence) | Justifying reader / recovery |
|---|---|---|
ingest | content-addressed raw documents + DTOs (advisory/VEX/distro), connector source state + cursors, quarantine | Replay/audit source truth; connectors resume; mirror export reads raw |
facts | fact_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 stores | Lookup APIs, consensus builder, mirror export, verification display. Aliases/references stored once here, not per derived row |
consensus | issue_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 |
dist | distribution/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 / runtime | transactional outbox (fact changes + generation markers), projector inbox/checkpoints, leases + fencing tokens, reconciliation audit | Control-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)
| Table | Contents | Fed by |
|---|---|---|
policy.vuln_gate_current | per-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 stored | explain endpoint |
policy.vuln_tenant_overlay | inbox 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 + lease | durable inbox for the hub stream | P6 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)
- One source transaction commits fact revision/tombstone + ordered outbox row; provider runs write generation open/close markers; only a closed generation authorizes absence-tombstones.
- 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.
- 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. - Replay is physically idempotent: zero hot-row versions, bounded checkpoint/audit writes.
- 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.
- 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).
| Step | Content | Gate |
|---|---|---|
| B0 | Runtime freeze (approval): disable VexRetentionSweepService; old plane keeps serving as-is | approved change window |
| B1 | Consolidation + 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 lists | build green; retained plugins load and map on a scratch stack; no removed-connector references remain |
| B2 | New 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 class | fresh-DB convergence test |
| B3 | Ingestion 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 behavior | fixture generations complete; rehearsal report |
| B4 | Consensus + distribution projections built by the leased projector; lookup/export APIs serving | no-op replay proof; API forcing checks |
| B5 | Router 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 states | route tests; four-persona gate on scratch |
| B6 | Policy-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 |
| B7 | Execution 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 |
| B8 | Production re-ingest (days): Tier-0 + mirror generations complete; surfaces fill; program close-out report | per-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):
- 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). - 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. - Update the OpenAPI aggregation (
Services/OpenApiAggregator.cs) source list; regenerate the published API surface (src/Apigovernance checks). - Add/adjust route-config tests (pattern:
ConcelierJobsRouteConfigTests.cs) for the new group and the aliases; remove alias tests when B7 removes the aliases. - 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):
- 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. - 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.
- Connector-management UI: remove tiles/config for deleted connectors (D13); add the tier model (Tier-1 regional plugins shown as optional).
- 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.
- 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. - 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)
| Store | Basis | Budget |
|---|---|---|
ingest raw + DTO | today’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 window | measured (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/audit | bounded | < 1 GB |
| Hub steady state | ≈ 25–30 GB (vs 89 GB today) | |
| Ingest WAL/temp headroom | batch-bounded; max_wal_size 8 GB during bulk ingest | +15–20 GB |
| Volume provisioning | 100 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.
| # | Scenario | Expected behavior | Held by |
|---|---|---|---|
| T1 | Normal ingest: connector streams a document with 3 statements, 2 for the same (vuln, product) with disjoint ranges | 1 raw doc, 3 facts (disjoint ranges distinct), 3+ provenance rows, outbox entries, linksets updated once | D4 identity incl. applicability_hash; §6.1 atomicity |
| T2 | Same document replayed (connector retry, mirror re-import) | zero new facts/revisions/outbox rows; zero hot-row versions | content-addressed raw + deterministic identity + content-guarded upserts |
| T3 | Vendor republishes: same statement id, changed status | new revision on the same fact (identity unchanged), outbox change event, linkset/gate re-derived; old revision in history | revision hash as value, not identity |
| T4 | Vendor withdraws a statement; generation completes without it | fact tombstoned only after generation close; linkset rebuilt in the same txn; the disjoint-range sibling untouched | §6.1/§6.5 generation-close tombstoning |
| T5 | Projector crashes mid-batch | checkpoint not advanced; restart reprocesses the batch; result identical (T2 idempotency) | single destination transaction incl. checkpoint |
| T6 | Two 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 |
| T7 | Excititor-origin statement + independent external statement with identical text | 1 fact with 2 provenance for the former; separate fact for the latter | issuer in fact identity; derived = provenance |
| T8 | Policy projector lags during bulk re-ingest | hub unaffected; gates serve last-applied state; lag metric alarms; catch-up is ordered replay | outbox ordering + durable inbox checkpoint |
| T9 | Air-gap estate: mirror bundle import on an empty hub | full hub reconstructed from bundle (T2 makes repeat imports no-ops) — this is also the DR path after B8 | mirror import = a Tier-0 source on the same contract |
| T10 | UI/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 |
| T11 | Defect found after the B7 window | fix forward; recovery is re-ingest — there is no old plane to return to (owner-accepted alpha posture, D8/D9) | fresh-rebuild recovery story |
| T12 | Post-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 |
| T13 | A removed connector (e.g. KEV) is needed again for one estate | ship as Tier-1 plugin; exploited-in-the-wild attribute already in the model; no hub migration | connectors-architecture §1.1/§1.3 |
| T14 | Capacity budget breached during ingest (rogue source) | shared guard pauses all hub writers before the floor with operator-visible reason; serving APIs unaffected | P7 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):
| Proof | Test |
|---|---|
| Disjoint ranges survive ingest/replay/reconcile; deleting one keeps the other | DisjointRangeSurvivalTests |
| Claim + derived distribution = one fact/two provenance; external submission independent | SingleFactLineageTests |
| Tombstones reconcile facts/linksets transactionally; injected failure rolls back checkpoint | TransactionalReconcileTests |
| Failed batch never advances checkpoint; restart resumes | CheckpointRestartTests |
| Replay physically idempotent (zero hot-row versions via pg_stat/xmax on isolated DB) | PhysicalIdempotencyTests |
| Two projectors cannot both commit; expired holder fenced | LeaseFencingTests |
| Fresh-DB convergence of baseline migrations; no tenant columns in hub schemas | HubSchemaConventionTests |
| Ingest coverage gates: per-source completion accounting is accurate | IngestGenerationAccountingTests |
| Hub emits events Policy projector consumes exactly-once into gates | GateProjectionContractTests (Policy.Persistence.Tests) |
| No service resolves a foreign DB; persistence naming standard | DatabaseOwnershipConformanceTests (architecture suite) |
| Mirror export→import round-trip rebuilds an equivalent hub | MirrorRoundTripTests |
| Gateway: new route group + aliases resolve, envelope/Bearer intact, alias hits metered | route-config tests in StellaOps.Gateway.WebService.Tests |
| EPSS relocation: hub serves current scores + bounded change window; scanner/risk readers consume the hub projection | EpssRelocationContractTests |
| Console: migrated pages green per-spec + layout guards + four-persona live gate | vitest 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).
