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. Historical basis: the predecessor store measured 91 GB
The 2026-07-22 predecessor measurement found about 6 GB of raw documents. That snapshot is not a current hub or customer sizing result. The measured excess came from 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 shared installation PostgreSQL server (stellaops-postgres, alias db.stella-ops.local) — consolidated there 2026-08-14 by SPRINT_20260810_002 PTC-5 per the ADR-039 P1 topology clarification; the dedicated stellaops-postgres-vulndata cluster this document originally specified (§B2 below) is retired. 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)
Current schema note (RFC-6): the original design table below included unused DTO, quarantine, alias/product-identity, rebuild-queue, mirror-state and reconciliation-audit declarations. The forward retirement removes those only when empty; the active normalizer and eventing projector remain. Use the current module schema and custody for implemented storage and disposal behavior. No fact-revision retention change is part of that retirement.
| Schema | Tables (essence) | Justifying reader / recovery |
|---|---|---|
ingest | content-addressed retained raw documents (advisory/VEX/distro), connector source state + cursors | Normalization and retained replay/audit inputs; connectors resume. Mirror seeds project current facts and selected metadata, not provider raw history. |
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 | no table. Migration 012_retire_unused_hub_tables.sql dropped dist.mirror_state; sprint 063 HSR-1 migration 020_retire_dist_statement.sql dropped the stored dist.statement view. The VEX statements (former VexHub statements) are computed on read from facts.fact_current. Future external submissions are source facts | VEX lookup route (HubQueries.GetVexStatementsAsync); mirror bundle production reads facts directly (MirrorExporter) |
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 |
Applicability keeps source endpoint meaning: inclusive introduced, exclusive fixed and inclusive lastAffected, and exclusive introducedExclusive. NVD start/end including/excluding fields retain these four distinct meanings. CVE lessThanOrEqual uses lastAffected and cannot declare a fix. Source-specific retained-document convergence qualifies raw input under the existing normalization fence, updates meaning through revision/provenance transactions, and records a completion checkpoint. An unavailable raw payload leaves its fact intact and produces an explicit residual; legacy collapsed bytes cannot reconstruct the missing boundary type. See DC-01 and DC-02 and the producer contract for identity projection, admission guards and the separate live-adoption gate.
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_global | one consensus outcome per (vulnerability, product, image platform, FIPS install) for every tenant: effective status/severity/range + winner source keys (explicit columns) + package/ecosystem + input/decision hashes + corpus generation stamp (DC-30 replay); no tenant column (SPRINT_20261007_064 PGF-2, DC-38 item 4); a release row that repeats the issue’s unscoped outcome is folded into the unscoped row and read back by policy.vuln_gate_effective (PGF-4) | hub events × corpus artifact |
policy.vuln_gate_current | tenant override rows only, where a tenant’s overlays change the global outcome; readers compose environment row, override and global row through policy.vuln_gate_effective | hub events × corpus artifact × tenant overlays |
| (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 — implemented (POL-F1) as the shared eventing schema auto-migrated into stellaops_policyrather than a Policy-specific copy: StellaOps.Eventing.Reliability already owns inbox/checkpoint/lease/fencing and homes them in the consuming host’s own database (P4) |
Hub→Policy transport (POL-F1). Policy never opens a connection to stellaops_vuln. The hub’s consensus projector appends a consensus.changed envelope to the vuln.consensus stream in the same transaction as the linkset change it describes — and only when the content guard reports the linkset actually moved, so a replay emits zero events. Policy pulls those envelopes from the hub’s DC-07 catch-up endpoint (GET /api/vulnerabilities/v1/events/{stream}, which also returns headSeq so lag is a real number) and applies each one inside a destination transaction in stellaops_policy. The pull runs on the projector’s schedule, never inside a gate decision — P15’s decision-time independence is preserved, and a hub outage stops the projection advancing without stopping gates from serving their last applied state (T8).
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 {
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 --> MIROUT["mirror export plugin"]
CONS --> API["lookup APIs: vuln / VEX / consensus / corpus export"]
NORM -->|"VEX statements computed on read"| API
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, 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 (sprint 063 HSR-1 later retired the stored distribution projection; VEX statements are computed on read); 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. Historical capacity estimates (2026-07-22 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); retired by sprint 063 HSR-1 (migration 020), so the hub stores no dist rows | measured | ≈ 2–3 GB (historical estimate; now 0) |
| outbox/runtime/audit | bounded | < 1 GB |
binary plane (binaries + symbols, folded in by DC-33 — see the re-baseline below) | measured constants + stated corpus scope | ≈ 10–25 GB |
| Hub steady state | ≈ 25–30 GB (vs 89 GB today) — superseded, see below | |
| 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.
The estimates above describe the original design assumptions, not the current estate. They omit repeated upstream document regeneration and must not be used as customer sizing guarantees.
9.1 Re-baseline owed at G1 (2026-08-04)
Raw-document correction (2026-10-07, VRD-1/2). The master estate’s Red Hat raw store reached 77 GB stored across 488,583 rows for 29,676 advisories; latest copies alone were about 1.2 GB. This dated measurement exposed timestamp-only regeneration churn missing from the original model. Unsigned CSAF connector content-change detection and disposal of normalized, superseded, unreferenced copies address that term. Latest, referenced and incomplete inputs remain retained; fact revisions remain retained. Applied migration 013 is superseded through forward migration 019, never rewritten. The raw-document lifecycle and fixed-sample compression decision records the custody rule, ratio/CPU measurement limits and the decision to retain pglz TOAST. The source implementation is verified against 0fcdce5cc7ec52154230886ddb15fc894f97bb70; re-run the raw-ingestion and retention commands. The approved native master sweep completed with the executed retention receipt. Standing Worker adoption remains PCD-337-owned; the source results do not establish that rollout.
Customer sizing boundary (VRD-4). A v3 mirror seed exports current advisory claims and selected metadata. It does not transfer the producer’s raw-document history or fact revisions. Import creates mirror raw records and local fact/provenance/revision and serving projections. Transfer bytes alone therefore cannot size the customer database. Use the sprint’s from-empty import and completed per-table measurement receipt. That receipt measured 44,908,713,663 owner-database bytes on 2026-10-08 for a 4,905,152,865-byte seed, on source that predates HSR-1. The producer’s 77 GB is not a customer unpacking requirement. The current owner data layers are the VRD-4 measurement baseline; the reference-safe raw sweep bounds obsolete mirror records. The owner-approved target is sprint 065 SIF-2: operator-retained digest-verified seed files supply replay, the database keeps current facts and bounded revisions, and seed-native import avoids raw record copies or replaces only the current advisory record on delta. SIF-1 sets that target from the VRD-4 measurement; sprint 063 HSR-1 has retired the stored distribution layer SIF-2 depended on. Sprint 061 does not implement the successor path.
The table above is a pre-DC-33 estimate and it is now measured-challenged. Treat the 25–30 GB steady state and the 100 GB volume as unvalidated until re-measured at the G1 cutover. Two independent movements:
(a) Advisory plane measured higher than estimated (G0 full rehearsal, 2026-08-03, finding F5). The ingest pump is fact-bound at ~320–550 facts/s, and the database grows at ≈ 2–3.1 KB per fact. On those constants a full Red Hat CSAF ingest alone projects +37.5 GB — one Tier-0 source exceeding the whole-hub steady-state line. The facts row in the table (12–16 GB for the entire fact population) is the specific number F5 challenges; it was derived from today’s partial plane, not from a full ingest of every source.
(b) The binary plane did not exist when the table was written. DC-33 folds BinaryIndex’s knowledge plane into stellaops_vuln (SPRINT_20260722_014 BIN-3). Its budget, with the assumptions that produce it — each is a knob, not a constant:
| Component | Assumption | Budget |
|---|---|---|
Binary facts (buildid / fingerprint applicability in facts.*) | one fact per (vulnerable build-id, vulnerability) pair, at the measured 2–3.1 KB/fact | 1M facts ⇒ 2–3 GB; 5M ⇒ 10–15 GB |
binaries identity + package map + fact index | one identity row per indexed binary (not per vulnerable one), ~300 B + ~250 B + indexes | 5M indexed binaries ⇒ ≈ 4 GB |
Fingerprint corpus (binaries.fingerprint) | ~150–250 B/row; O(10³) functions per fingerprinted package; the corpus is curated to CVE-relevant components, never exhaustive | 10k packages ⇒ 2–3 GB |
| Fix index | ~200 B/row over (distro, release, source package, vulnerability, arch) | O(10⁶) rows ⇒ ≈ 0.3 GB |
ingest binary-corpus documents | the connector family stores repository index documents only | < 1 GB |
| Symbols | catalog rows only; blob bytes are object-store (vuln/symbols/, matrix §6.2) | < 100 MB in PG |
| Binary plane total | at the corpus scope above | ≈ 10–25 GB, dominated by binary facts |
The load-bearing invariant: package payloads are never stored in the hub. The corpus connector streams repository indexes; the analysis stage fetches package bytes as transient working data and discards them. If that ever inverts, the plane grows by the size of the distro archives themselves (TB-class), which is why it is enforced in the connector contract and restated in the binaries schema header rather than left to reviewer memory.
Consequence for provisioning. {full advisory ingest} + {binary plane} + WAL/temp headroom no longer fits the 100 GB dedicated volume in ADR-039 P8 with any confidence. The G1 cutover must re-measure the advisory plane at full ingest and re-derive the volume from that measurement plus the binary-plane scope the estate actually configures — the corpus scope knobs above are the lever, and Vulnerabilities:Ingestion:Sources:DistroBinaryCorpus is where an operator sets it.
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).
