component_architecture_excititor.md — Stella Ops Excititor (Sprint 22)
Consolidates the VEX ingestion guardrails from Epic 1 with consensus and AI-facing requirements from Epics 7 and 8. This is the authoritative architecture record for Excititor.
Scope. This document specifies the Excititor service: its purpose, trust model, data structures, observation/linkset pipelines, APIs, plug-in contracts, storage schema, performance budgets, testing matrix, and how it integrates with Concelier, Policy Engine, and evidence surfaces. It is implementation-ready. The immutable observation store schema lives in
vex_observations.md.
Module path
All Excititor library projects live under src/Concelier/__Libraries/StellaOps.Excititor.* for historical reasons (Excititor began life as a Concelier submodule). The namespace StellaOps.Excititor.* is the source of truth for module ownership; the filesystem path is path-agnostic to compilers, test runners, NuGet, and plugin discovery. See src/Concelier/__Libraries/README.md for the ownership pointer and docs/architecture/proposal-excititor-module-path.md for the rename-vs-retain decision (Option B, filed under SPRINT_20260512_021 TASK-021-09).
0) Mission & role in the platform
Mission. Convert heterogeneous VEX statements (OpenVEX, CSAF VEX, CycloneDX VEX; vendor/distro/platform sources) into immutable VEX observations, correlate them into linksets that retain provenance/conflicts without precedence, and publish deterministic evidence exports and events that Policy Engine, Console, and CLI use to suppress or explain findings.
Boundaries.
- Excititor does not decide PASS/FAIL. It supplies evidence (statuses + justifications + provenance weights).
- Excititor preserves conflicting observations unchanged; consensus (when enabled) merely annotates how policy might choose, but raw evidence remains exportable.
- VEX consumption is backend-only: Scanner never applies VEX. The backend’s Policy Engine asks Excititor for status evidence and then decides what to show.
0.1 Mounted provider runtime guard
Updated 2026-06-06: Excititor Worker and WebService no longer compile the seven built-in VEX provider implementation projects. StellaOps.Excititor.Worker loads signed, read-only mounted provider bundles through Excititor:Worker:Plugins and VexWorkerPluginCatalogLoader. WebService loads the same provider assemblies through the dedicated Excititor:Providers:Plugins section and registers known IVexConnector providers from mounted assemblies through the runtime provider bridge. Both loaders derive exact entry assembly names from RuntimePluginBundleInventory when no operator search pattern is configured, so dependency DLLs packaged beside a payload are not admitted as plugin entry assemblies. The scoped ProjectReference audit is green for both runtime hosts: zero direct provider implementation references remain.
WebService provider configuration is now host-owned. It keeps scalar persisted configuration definitions, public default URI constants, enum-string validation, and OCI reference-shape validation in Services/VexProviderConfigurationService.cs and Services/VexProviderConfigurationModels.cs instead of referencing concrete provider option types. Provider management uses loaded IVexConnector metadata where a mounted provider is registered: display name, description, kind, runtime source, and sync support come from the registered connector descriptor. The hard-coded catalog remains only as a non-runnable planning/configuration surface for known providers that are not mounted yet. Future work can enrich this with manifest-backed provider metadata from plugin.json, but direct provider project references must not return.
The partition is covered by src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/Architecture/ExcititorRuntimeProviderProjectReferenceGuardTests.cs: Worker and WebService must stay free of direct provider references and compile-time Add*Connector calls. StellaOps.Excititor.Connectors.RedHat.CSAF is the recommended base bundle; Cisco CSAF, MSRC CSAF, OCI OpenVEX attestations, Oracle CSAF, SUSE/Rancher VEX Hub, and Ubuntu CSAF remain optional overlay provider bundles. devops/plugins/excititor/recommended/ carries those six signed optional provider bundles for the recommended compose overlay. The harness overlay mounts devops/plugins/excititor/harness/excititor-harness/, a signed fixture-only IVexConnector that returns deterministic responded probe output through the real Excititor connector contract.
WebService exposes the same plugin diagnostics contract as Concelier at GET /internal/plugins/status and POST /internal/plugins/probe; both routes report the resolved Excititor provider plugin directory and include optional mounted bundles. Worker hosts continue to write the canonical startup report to /var/lib/stellaops/plugin-scratch/excititor/probe-report.json unless the operator overrides Excititor:Worker:Plugins:ScratchDirectory or ProbeReportFileName.
Runtime proof captured on 2026-06-07 in docs/qa/feature-checks/runs/pluginized-compose/coordinator-current-20260607-base-http-expanded-clean-plus-router-export-notify-excititor/ rebuilt both stellaops/excititor-web:dev and stellaops/excititor-worker:dev in mounted-plugin mode, pruned seven provider payload files from each image publish context, passed image audits for both images, and showed required redhat-csaf mounted/discovered/admitted/loaded/ probed through both the WebService HTTP probe and Worker scratch report.
1) Aggregation guardrails (AOC baseline)
Excititor enforces the same ingestion covenant as Concelier, tailored to VEX payloads:
- Immutable
vex_rawrows. Upstream OpenVEX/CSAF/CycloneDX files are stored verbatim (content.raw) with provenance (issuer,statement_id, timestamps, signatures). Revisions append new versions linked bysupersedes. - No derived consensus at ingest time. Fields such as
effective_status,merged_state,severity, or reachability are forbidden. Roslyn analyzers and runtime guards block violations before writes. - Linkset-only joins. Product aliases, CVE keys, SBOM hints, and references live under
linkset; ingestion must never mutate the underlying statement.
Raw VEX endpoints (WebService)
POST /ingest/vex(scope: vex.admin) accepts deterministicVexIngestRequestpayloads. Clients must sendX-StellaOps-TenantId. Optional dependencies (e.g., orchestrators, loggers) are wired through[FromServices] SomeType? service = nullparameters so tests do not need bespoke service registrations.GET /vex/raw,GET /vex/raw/{digest}, andGET /vex/raw/{digest}/provenance(scope: vex.read) expose raw documents, cursored listings, and metadata-only projections.POST /aoc/verifyreplays stored documents through the Aggregation-Only Contract for audits and Grafana alert sources.- To satisfy the AOC rule forbidding derived data, serialized raw responses omit the
statementsarray unless replay tooling explicitly materializes it. - Optional/minor DI dependencies must be declared as
[FromServices] IFoo? foo = nullparameters so host startup (and tests) remain stable when the service is not registered.
- Deterministic canonicalisation. Writers sort JSON keys/arrays, normalize timestamps (UTC ISO‑8601), and hash content for reproducible exports.
- AOC verifier.
StellaOps.AOC.Verifierruns in CI and production, checking schema compliance, provenance completeness, sorted collections, and signature metadata.
1.1 VEX raw document shape
{
"_id": "vex_raw:openvex:VEX-2025-00001:v2",
"source": {
"issuer": "vendor:redhat",
"stream": "openvex",
"api": "https://vendor/api/vex/VEX-2025-00001.json",
"collector_version": "excititor/0.9.4"
},
"upstream": {
"statement_id": "VEX-2025-00001",
"document_version": "2025-08-30T12:00:00Z",
"fetched_at": "2025-08-30T12:05:00Z",
"received_at": "2025-08-30T12:05:01Z",
"content_hash": "sha256:...",
"signature": {
"present": true,
"format": "dsse",
"key_id": "rekor:uuid",
"sig": "base64..."
}
},
"content": {
"format": "openvex",
"spec_version": "1.0",
"raw": { /* upstream statement */ }
},
"identifiers": {
"cve": ["CVE-2025-13579"],
"products": [
{"purl": "pkg:rpm/redhat/openssl@3.0.9", "component": "openssl"}
]
},
"linkset": {
"aliases": ["REDHAT:RHSA-2025:1234"],
"sbom_products": ["pkg:rpm/redhat/openssl@3.0.9"],
"justifications": ["reasonable_worst_case_assumption"],
"references": [
{"type": "advisory", "url": "https://..."}
]
},
"supersedes": "vex_raw:openvex:VEX-2025-00001:v1",
"tenant": "default"
}
1.2 Issuer trust registry
To enable Epic 7’s consensus lens, Excititor maintains vex_issuer_registry documents containing:
issuer_id, canonical name, and allowed domains.trust.tier(critical,high,medium,low),trust.confidence(0–1).productsPURL patterns the issuer is authoritative for.signing_keyswith key IDs and expiry.last_validated_at,revocation_status.
The original registry design targeted rejection of issuers without registry entries or valid signatures. Current boundary (verified 2026-07-19): WebService and Worker ingestion resolve IVexSignatureVerifier to UnifiedVexSignatureVerifier, which validates supported cryptographic material carried by the connector/document and treats Authority IssuerDirectory lookup as optional trust enrichment. A missing or non-active directory key currently omits trust enrichment rather than rejecting the document; callers that require issuer-authorized evidence must require populated trust metadata until an admission gate is implemented.
1.3 Normalised tuple store
Excititor derives vex_normalized tuples (without making decisions) for downstream consumers:
{
"advisory_key": "CVE-2025-13579",
"artifact": "pkg:rpm/redhat/openssl@3.0.9",
"issuer": "vendor:redhat",
"status": "not_affected",
"justification": "component_not_present",
"scope": "runtime_path",
"timestamp": "2025-08-30T12:00:00Z",
"trust": {"tier": "high", "confidence": 0.95},
"statement_id": "VEX-2025-00001:v2",
"content_hash": "sha256:..."
}
These tuples allow VEX Lens to compute deterministic consensus without re-parsing heavy upstream documents.
Excititor workers now hydrate signature metadata with issuer trust data retrieved from the Issuer Directory service. The worker-side IssuerDirectoryClient performs tenant-aware lookups (including global fallbacks) and caches responses offline so attestation verification exposes an effective trust weight alongside the cryptographic details captured on ingest.
For the live WebService/runtime path, enabled VEX signature verification now requires a real VexSignatureVerification:IssuerDirectory:ServiceUrl. The default dependency-injection wiring no longer seeds InMemoryIssuerDirectoryClient or a process-local verification cache when verification is enabled. Operators may optionally enable VexSignatureVerification:Valkey:* to persist verification results in Valkey via StackExchange.Redis; if that configuration is absent, verification executes without a cache instead of silently falling back to in-memory state.
Current registration boundary (verified 2026-07-19): the paragraph above and the disabled-mode contract below describe the separately registered V2 surface. The active WebService and Worker ingestion contract is
IVexSignatureVerifier -> UnifiedVexSignatureVerifier; it does not route through the V2 adapter/profile/cache path.
When VexSignatureVerification:Enabled=false, Excititor runs in an explicit verification-disabled mode for bootstrap, local, or offline ingest continuity. That mode never produces trusted verification success: V2 verification results are verified=false with failureReason=VerificationDisabled, a VERIFICATION_DISABLED warning, and diagnostics marking verification.mode=disabled; the legacy V1 bridge returns advisory null for NoSignature and VerificationDisabled so the connector base class can decide whether AllowUnsigned permits ingestion. Other failures, including invalid signatures, revoked keys, issuer mismatches, and provider errors, still throw VexSignatureRequiredException. /excititor/status reports disabled verification as disabled_unverified, and strict release-gating profiles must treat those observations as insufficient trust rather than as verified VEX.
Sprint 20260501-020 (audit pass-2 finding §E1) hardened the V1 verifier surface into a fail-closed adapter:
- The legacy default
VerificationDisabledVexSignatureVerifier(a no-op that returnednullfor every document) has been deleted. VexSignatureVerifierV1Adapterwas the fail-closed bridge introduced by that sprint. Current WebService startup callsAddUnifiedVexSignatureVerifier()afterAddVexSignatureVerification(), and Worker startup calls the unified registration directly; the unified registration removes the adapter’sIVexSignatureVerifierbinding and installsUnifiedVexSignatureVerifier. The V2 verifier remains separately resolvable in WebService but is not the active ingestion surface.VexConnectorBase.CreateRawDocumentAsync(the new fail-closed factory used by all VEX connectors) verifies the document before computing its digest and returning, stamping the canonicalvex.signature.*metadata keys (status,issuer,issuerId,keyId,method,profile,verifiedAt,transparency.entry,transparency.logId) on success. Sprint 20260501-023’s AOC pre-normalize guard reads these keys to gate normalization.VexConnectorVerificationOptions.AllowUnsignedis the only escape hatch and is explicitly forbidden in production: a hosted startup validator (VexConnectorVerificationStartupValidator) refuses to start the host whenIHostEnvironment.IsProduction()is true andVexSignatureVerification:Connectors:AllowUnsigned=true.
Sprint 20260507-004 (MIRROR-005 closeout) unified the WebService and Worker signature verification surfaces:
- The canonical implementation now lives in
StellaOps.Excititor.Attestation.Signature.UnifiedVexSignatureVerifier(in the sharedStellaOps.Excititor.Attestationlibrary) and is registered in bothStellaOps.Excititor.WebService/Program.csandStellaOps.Excititor.Worker/Program.csviaservices.AddUnifiedVexSignatureVerifier(). - The unified verifier checksums the payload, optionally verifies OCI DSSE attestations through
IVexAttestationVerifier, cryptographically verifies detached OpenPGP signatures from connector-suppliedpgp-signature/pgp-public-key/pgp-keyidmetadata, and (whenIIssuerDirectoryClientis registered) attaches tenant-aware issuer trust enrichment. - The legacy
StellaOps.Excititor.Worker.Signature.WorkerSignatureVerifieris preserved as a thin forwarding shim so existing Worker test classes keep compiling; both surfaces resolve the unified verifier at runtime, so a detached-PGP-signed Red Hat or Cisco CSAF VEX document produces the same trust decision regardless of whether it is admitted through the direct ingest endpoint or a scheduled provider run. - Parity is enforced by
UnifiedSignatureParityTests(inStellaOps.Excititor.Worker.Tests), which verifies the same PGP fixture through both surface registrations and asserts identicalVexSignatureMetadataplus identical fail-closed behaviour on tampered payloads.
1.4 AI-ready citations
GET /v1/vex/statements/{advisory_key} produces sorted JSON responses containing raw statement metadata (issuer, content_hash, signature), normalised tuples, and provenance pointers. Advisory AI consumes this endpoint to build retrieval contexts with explicit citations.
1.5 PostgreSQL raw store
This is the canonical design for the PostgreSQL-backed raw store that powers
/vex/rawand ingestion.
Schema: vex
vex_raw_documents(append-only)digest TEXT PRIMARY KEY—sha256:{hex}of canonical UTF-8 JSON bytes.tenant TEXT NOT NULLprovider_id TEXT NOT NULLformat TEXT NOT NULL CHECK (format IN ('openvex','csaf','cyclonedx','custom'))source_uri TEXT NOT NULL,etag TEXT NULLretrieved_at TIMESTAMPTZ NOT NULL,recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()supersedes_digest TEXT NULL REFERENCES vex_raw_documents(digest)content_json JSONB NOT NULL— canonicalised payload (truncated when blobbed)content_size_bytes INT NOT NULLmetadata_json JSONB NOT NULL— statement_id, issuer, spec_version, content_type, connector version, hashes, quarantine flagsprovenance_json JSONB NOT NULL— DSSE/chain/rekor/trust infoinline_payload BOOLEAN NOT NULL DEFAULT TRUE- Indexes:
(tenant, retrieved_at DESC),(tenant, provider_id, retrieved_at DESC),(tenant, provider_id, source_uri, etag),(tenant, supersedes_digest), GIN onmetadata_json, GIN onprovenance_json.
vex_raw_blobs(large payloads)digest TEXT PRIMARY KEY REFERENCES vex_raw_documents(digest) ON DELETE CASCADEpayload BYTEA NOT NULL(canonical JSON bytes; no compression to preserve determinism)payload_hash TEXT NOT NULL(sha256:{hex}of stored payload bytes)
vex_raw_attachments(optional future)digest TEXT REFERENCES vex_raw_documents(digest) ON DELETE CASCADEname TEXT NOT NULL,media_type TEXT NOT NULLpayload BYTEA NOT NULL,payload_hash TEXT NOT NULL- PRIMARY KEY (
digest,name)
Observations/linksets - use the append-only Postgres linkset schema already defined for
IAppendOnlyLinksetStore(tablesvex_linksets,vex_linkset_observations,vex_linkset_disagreements,vex_linkset_mutations) with indexes on(tenant, vulnerability_id, product_key)andupdated_at.Claims -
vex.claimsstores normalized, queryable claim projections keyed by deterministicclaim_hash, with JSONB product metadata, compact document metadata columns (document_format,document_source_uri,document_revision,document_signature_json), lookup indexes for provider/vulnerability reads, andidx_claims_issue_projection_cursor (tenant, created_at, claim_hash)for Concelier’s bounded keyset replay. The legacy per-claimdocument_jsoncopy was removed; reads reconstructVexClaimDocumentfromdocument_digest, compact columns, and the content-addressedvex_raw_documentsrow. Worker-hosted mirror runs also project successfully appended, AOC-checked claim batches intovexhub.statementsso VexHub exports can distribute the same verified connector output without a second upstream fetch.Product-key aliases -
vex.product_key_aliasesmaps a tenant’s scanner/legacy identifier (alias_key) to one canonical distro product key (canonical_key), withkey_type, source, and first-seen provenance. Migration008_vex_product_key_aliases.sqlowns the table,(tenant, alias_key)primary key, reverse(tenant, canonical_key)index, and tenant RLS where the current baseline’svex_appguard exists.VexProductKeyAliasWriterderives and idempotently writes aliases on the live worker/OCI ingest path; alias-index failures are logged but do not fail canonical claim ingest.Attestations -
vex.attestationsstores durable DSSE/VEX attestation envelopes keyed by(tenant, attestation_id)with manifest lookup and attested-at indexes. The active squashed baseline001_v1_excititor_baseline.sqlcreates the table in the runtime schema so isolated test schemas and the defaultvexschema use the same contract.Evidence links -
vex.evidence_linksstores durable VEX-to-evidence associations keyed by deterministiclink_id, with the evidence envelope metadata in JSONB plus lookup indexes onvex_entry_idandenvelope_digest. Live runtime wiring resolvesIVexEvidenceLinkStorefromStellaOps.Excititor.Persistence;AddVexEvidenceLinking(...)no longer injects an in-memory fallback when no real Excititor PostgreSQL connection is configured.Graph overlays - materialized cache table
vex.graph_overlays(tenant, purl, advisory_id, source) storing JSONB payloads that followdocs/modules/excititor/schemas/vex_overlay.schema.json(schemaVersion 1.0.0). Live WebService runtime resolvesIGraphOverlayStoreto the Postgres-backed store; there is no in-memory production fallback or production test-double implementation.Connector runtime state and provider settings -
vex.connector_statesandvex.provider_settingsare tenant-owned runtime tables keyed by(tenant_id, connector_id)and(tenant_id, provider_id). Repository reads, lists, and upserts resolve the current tenant before opening the data connection and always includetenant_idin the lookup predicate so two tenants can use the same connector/provider identifier without cursor or settings overwrite.Red Hat connector credential custody - the optional
excititor:redhatsubscriptionTokenis persisted as a supported secret reference, never as production plaintext.StellaOps.Excititor.WebServiceseals new control-plane writes and owns the idempotent legacy-row migration plus plaintext sweep; both WebService and Worker resolve references through the unifiedISecretProvideronly into an ephemeral connector-settings copy. A KEK/backend startup probe fails closed before ingest begins. Operator details and the non-revealing SQL sweep are in Provider Credential Entry.
Canonicalisation & hashing
- Parse upstream JSON; sort keys; normalize newlines; encode UTF-8 without BOM. Preserve array order.
- Retain a connector-supplied
sha256:document digest when present because it identifies the fetched source artifact. If the connector supplied no usable digest, computedigest = "sha256:{hex}"over the canonical bytes. - If
size <= inline_threshold_bytes(default 256 KiB) setinline_payload=trueand store the canonical JSON incontent_json; otherwise store the canonical bytes invex_raw_blobsand setinline_payload=false. - Persist
content_size_bytes; for a blob also persistpayload_hash, which validates the stored canonical blob bytes and is not a substitute for the source document digest.
API mapping List/query /vex/raw via SELECT ... FROM vex.vex_raw_documents WHERE tenant=@t ORDER BY retrieved_at DESC, digest LIMIT @n OFFSET @offset; cursor uses (retrieved_at, digest). GET /vex/raw/{digest} loads the row and optional blob; GET /vex/raw/{digest}/provenance projects provenance_json + metadata_json. Filters (providerId, format, since, until, supersedes, hasAttachments) map to indexed predicates; JSON subfields use metadata_json ->> 'field'.
Write semantics
IVexRawStorePostgres implementation enforces append-only inserts; duplicatedigest=> no-op; duplicate (tenant,provider_id,source_uri,etag) with new digest inserts a new row and setssupersedes_digest. The source/ETag tuple is indexed for lookup but is not unique, because Cisco/MSRC-style CSAF sources can republish changed bytes at the same URI without changing ETag.IVexRawWriteGuardruns before insert; tenant is mandatory on every query and write.
Runtime convergence
StellaOps.Excititor.WebService,StellaOps.Excititor.Worker, and CLI hosts that enable VEX evidence linking resolveIVexProviderStore,IVexConnectorStateRepository,IVexClaimStore,IVexAttestationStore,IVexEvidenceLinkStore, and the WebService graph overlay store from persisted services; the live hosts do not register in-memory fallbacks for these paths.StellaOps.Excititor.Persistenceowns startup migrations for the active runtime schema, including providers, observations, observation timeline events, checkpoint state, statements, deltas, claims, attestation storage, graph overlays, tenant-scoped connector state/provider settings, and cleanup of historical demo rows from older local installs.- The Excititor migration assembly embeds only active top-level SQL files. Archived pre-1.0 scripts and demo-seed SQL are excluded so startup/test migration loaders do not replay historical or fake runtime state.
- Worker completion metadata (
LastSuccessAt, heartbeat fields, checkpoint hashes) must not overwrite connector-managed cursor fields. Connectors ownLastUpdated, digests, and resume tokens because those values may reflect upstream timestamps or fallback-path checkpoints rather than the wall-clock job completion time.
Replay completion and mirror bootstrap
Claim persistence is not an atomic completion marker. The claim, product-alias, and VexHub statement writes are separate idempotent operations, so a stop after the claim commit can leave downstream readers incomplete. Normal provider re-ingest therefore re-normalizes a re-seen document and re-drives all three writers; it does not short-circuit merely because vex.claims contains the raw document digest.
The Worker also runs a provider-independent local repair loop. It compares retained claim documents with vexhub.statements, then:
- reuses existing claims, preserving the exact provenance and
vex.application.modemetadata; - never normalizes raw-only documents, because expanding vendor product arrays can recreate the full-corpus product-grain storage explosion;
- writes aliases and VexHub statements through the production idempotent writers;
- excludes advisory-only claims from VexHub consensus/distribution; and
- retries when the VexHub schema is not yet available, without enabling or invoking any upstream provider.
The loop is enabled independently of provider schedules under Excititor:Worker:ProjectionRepair and is bounded by BatchSize and MaxDocumentsPerPass. VexHub migration 003_statement_source_document_lookup.sql owns the composite lookup index used by its anti-join. The admin endpoint POST /excititor/admin/backfill-statements drives the same bounded local runner and returns completed=false when another pass is required. A mirror export that drops non-empty VexHub bytes fails closed unless it retains vex.claims as the rebuild source. Source-only VEX export is not considered safely re-derivable until the normalized membership model ships.
Small inline JSON is canonicalized again after its jsonb round trip before a normalizer reads it, so PostgreSQL key ordering and escaping do not make replay input nondeterministic.
2) Inputs, outputs & canonical domain
1.1 Accepted input formats (ingest)
- OpenVEX JSON documents (attested or raw).
- CSAF VEX 2.x (vendor PSIRTs and distros commonly publish CSAF).
- CycloneDX VEX 1.4+ (standalone VEX or embedded VEX blocks).
- OCI‑attached attestations (VEX statements shipped as OCI referrers) — optional connectors.
All connectors register source metadata: provider identity, trust tier, signature expectations (PGP/cosign/PKI), fetch windows, rate limits, and time anchors.
1.1.1 OpenVEX vulnerability identity precedence
OpenVEX vulnerability objects may mix a canonical name with URI aliases. Normalization is CVE-first:
- inspect
vulnerability.aliases, thenname,@id, andidfor aCVE-YYYY-NNNN+token; - normalize the selected CVE to uppercase, including a CVE embedded in a URI;
- when no CVE exists, preserve the established fallback order: first alias, then
name,@id, andid; - apply the same CVE extraction to scalar
vulnerabilityand legacyvulnvalues.
URI aliases remain evidence aliases and must never become the persisted vulnerability_id when they contain a canonical CVE. This rule is pinned through the production normalizer, PostgreSQL claim writer, and VexHub projection so all downstream keys and digests use the same canonical identity.
1.2 Canonical model (observations & linksets)
VexObservation
observationId // {tenant}:{providerId}:{upstreamId}:{revision}
tenant
providerId // e.g., redhat, suse, ubuntu, osv
streamId // connector stream (csaf, openvex, cyclonedx, attestation)
upstream{
upstreamId,
documentVersion?,
fetchedAt,
receivedAt,
contentHash,
signature{present, format?, keyId?, signature?}
}
statements[
{
vulnerabilityId,
productKey,
status, // affected | not_affected | fixed | under_investigation
justification?,
introducedVersion?,
fixedVersion?,
lastObserved,
locator?, // JSON Pointer/line for provenance
evidence?[]
}
]
content{
format,
specVersion?,
raw
}
linkset{
aliases[], // CVE/GHSA/vendor IDs
purls[],
cpes[],
references[{type,url}],
reconciledFrom[]
}
supersedes?
createdAt
attributes?
VexLinkset
linksetId // sha256 over sorted (tenant, vulnId, productKey, observationIds)
tenant
key{
vulnerabilityId,
productKey,
confidence // low|medium|high
}
observations[] = [
{
observationId,
providerId,
status,
justification?,
introducedVersion?,
fixedVersion?,
evidence?,
collectedAt
}
]
aliases{
primary,
others[]
}
purls[]
cpes[]
conflicts[]? // see VexLinksetConflict
createdAt
updatedAt
VexLinksetConflict
conflictId
type // status-mismatch | justification-divergence | version-range-clash | non-joinable-overlap | metadata-gap
field? // optional pointer for UI rendering
statements[] // per-observation values with providerId + status/justification/version data
confidence
detectedAt
VexConsensus (REMOVED — sprint 20260501-024)
The legacy consensus rollup record (VexConsensus, VexConsensusSource, VexConsensusConflict, VexConsensusStatus) has been deleted from StellaOps.Excititor.Core. Excititor no longer computes or persists consensus rollups; verdict resolution is the responsibility of VexLens, which consumes append-only linkset references. Mirror bundle wire fields that previously carried consensus payloads (consensusDigest, consensusDocument, consensusRevision) are preserved on VexExportManifest for cross-module wire stability and now carry the canonical linkset reference list. New internal metadata uses linksetDigest / linksetEntryCount keys.
1.3 Exports & evidence bundles
- Raw observations — JSON tree per observation for auditing/offline.
- Linksets — grouped evidence for policy/Console/CLI consumption.
- Provider snapshots — last N days of observations per provider to support diagnostics.
- Index —
(productKey, vulnerabilityId) → {status candidates, confidence, observationIds}for high-speed joins.
All exports remain deterministic and, when configured, attested via DSSE + Rekor v2.
3) Identity model — products & joins
2.1 Vuln identity
- Accepts CVE, GHSA, vendor IDs (MSRC, RHSA…), distro IDs (DSA/USN/RHSA…) — normalized to
vulnIdwith alias sets. - Alias graph maintained (from Concelier) to map vendor/distro IDs → CVE (primary) and to GHSA where applicable.
2.2 Product identity (productKey)
- Primary:
purl(Package URL). - Secondary links:
cpe, OS package NVRA/EVR, NuGet/Maven/Golang identity, and OS package name when purl unavailable. - Fallback:
oci:<registry>/<repo>@<digest>for image‑level VEX. - Special cases: kernel modules, firmware, platforms → provider‑specific mapping helpers (connector captures provider’s product taxonomy → canonical
productKey).
Excititor does not invent identities. If a provider cannot be mapped to purl/CPE/NVRA deterministically, we keep the native product string and mark the claim as non‑joinable; the backend will ignore it unless a policy explicitly whitelists that provider mapping.
4) Storage schema (PostgreSQL)
Canonical schema is §1.5, not this section. The shapes below are a legacy MongoDB-style sketch (
_id,{tenant:1, …}index notation, embeddedstatements[]) that predates the relational migration. The live store is PostgreSQL with schemasvexandexcititor(andvex_app), created by the squashed001_v1_excititor_baseline.sqlplus the active forward-only top-level migrations embedded fromStellaOps.Excititor.Persistence/Migrations/; that directory, rather than archived pre-1.0 filenames or a fixed migration count, is the ledger authority. The actual tables are:
- schema
vex:linksets,linkset_observations,linkset_disagreements,linkset_mutations,vex_raw_documents,vex_raw_blobs,vex_raw_attachments,timeline_events(partitioned) +observation_timeline_events,observations,statements,claims,deltas,providers,provider_settings,connector_states,checkpoint_states,checkpoint_mutations,excititor_raw_lineage,product_key_aliases, plus the evidence/attestation/graph-overlay/quarantine tables from §1.5 (evidence_links,attestations,graph_overlays,excititor_quarantine).- schema
excititor:calibration_manifests,calibration_adjustments,source_trust_vectors(Trust-Lattice calibration state).There are no
vex.events,vex.exports,vex.cache, orvex.migrationstables with those literal names — timeline/eventing usesvex.timeline_events+vex.observation_timeline_events, export manifests are carried by theVexExportManifestobject model, and migration tracking is handled by the platform startup-migration runner. Thevex.consensuscollection is removed (sprint 20260501-024). Read the prose below for intent, but trust §1.5 and the migration SQL for column-level truth.
VEX storage-model decision (2026-07-01):
vex.claims(all claims, incl. advisory-only) and the Excititor Worker’s one-directional projection intovexhub.statements(enforced/worker-projected only) are a deliberate double-store, recommended KEEP — two disjoint keys/shapes feeding two independent consumers (matcher/excititor/resolvereadsvex.claimswith an advisory-only apply gate; VexLens consensus reads thevexhub.statementsnormalized/verification shape). See vex-hub/storage-model-decision.md for the blast-radius map, measured footprint, revisit triggers, and the projection-parity guard (PostgresVexHubProjectionSinkTests).
Database: excititor
3.1 Tables (legacy sketch — see banner above)
vex.providers
_id: providerId
name, homepage, contact
trustTier: enum {vendor, distro, platform, hub, attestation}
signaturePolicy: { type: pgp|cosign|x509|none, keys[], certs[], cosignKeylessRoots[] }
fetch: { baseUrl, kind: http|oci|file, rateLimit, etagSupport, windowDays }
enabled: bool
createdAt, modifiedAt
vex.raw(immutable raw documents)
_id: sha256(doc bytes)
providerId
uri
ingestedAt
contentType
sig: { verified: bool, method: pgp|cosign|x509|none, keyId|certSubject, bundle? }
payload: object storage pointer (if large)
disposition: kept|replaced|superseded
correlation: { replaces?: sha256, replacedBy?: sha256 }
vex.observations
{
_id: "tenant:providerId:upstreamId:revision",
tenant,
providerId,
streamId,
upstream: { upstreamId, documentVersion?, fetchedAt, receivedAt, contentHash, signature },
statements: [
{
vulnerabilityId,
productKey,
status,
justification?,
introducedVersion?,
fixedVersion?,
lastObserved,
locator?,
evidence?
}
],
content: { format, specVersion?, raw },
linkset: { aliases[], purls[], cpes[], references[], reconciledFrom[] },
supersedes?,
createdAt,
attributes?
}
- Indexes:
{tenant:1, providerId:1, upstream.upstreamId:1},{tenant:1, statements.vulnerabilityId:1},{tenant:1, linkset.purls:1},{tenant:1, createdAt:-1}.
vex.linksets
{
_id: "sha256:...",
tenant,
key: { vulnerabilityId, productKey, confidence },
observations: [
{ observationId, providerId, status, justification?, introducedVersion?, fixedVersion?, evidence?, collectedAt }
],
aliases: { primary, others: [] },
purls: [],
cpes: [],
conflicts: [],
createdAt,
updatedAt
}
- Indexes:
{tenant:1, key.vulnerabilityId:1, key.productKey:1},{tenant:1, purls:1},{tenant:1, updatedAt:-1}.
vex.events(observation/linkset events, optional long retention)
{
_id: ObjectId,
tenant,
type: "vex.observation.updated" | "vex.linkset.updated",
key,
delta,
hash,
occurredAt
}
- Indexes:
{type:1, occurredAt:-1}, TTL onoccurredAtfor configurable retention.
3.3 VEX Change Events
Sprint: SPRINT_20260112_006_EXCITITOR_vex_change_events
Excititor emits deterministic VEX change events when statements are added, superseded, or conflict. These events drive policy reanalysis in downstream systems.
Event Types
| Event Type | Constant | Description |
|---|---|---|
vex.statement.added | VexTimelineEventTypes.StatementAdded | New VEX statement ingested |
vex.statement.superseded | VexTimelineEventTypes.StatementSuperseded | Statement replaced by newer version |
vex.statement.conflict | VexTimelineEventTypes.StatementConflict | Conflicting statuses detected |
vex.status.changed | VexTimelineEventTypes.StatusChanged | Effective status changed for a product-vulnerability pair |
VexStatementChangeEvent Schema
{
"eventId": "vex-evt-sha256:abc123...", // Deterministic hash-based ID
"eventType": "vex.statement.added",
"tenant": "default",
"vulnerabilityId": "CVE-2026-1234",
"productKey": "pkg:npm/lodash@4.17.21",
"newStatus": "not_affected",
"previousStatus": null, // null for new statements
"providerId": "vendor:redhat",
"observationId": "default:redhat:VEX-2026-0001:v1",
"supersededBy": null,
"supersedes": [],
"provenance": {
"documentHash": "sha256:...",
"documentUri": "https://vendor/vex/...",
"sourceTimestamp": "2026-01-15T10:00:00Z",
"author": "security@vendor.com",
"trustScore": 0.95
},
"conflictDetails": null,
"occurredAtUtc": "2026-01-15T10:30:00Z",
"traceId": "trace-xyz789"
}
VexConflictDetails Schema
When eventType is vex.statement.conflict:
{
"conflictType": "status_mismatch", // status_mismatch | trust_tie | supersession_conflict
"conflictingStatuses": [
{
"providerId": "vendor:redhat",
"status": "not_affected",
"justification": "CODE_NOT_REACHABLE",
"trustScore": 0.95
},
{
"providerId": "vendor:ubuntu",
"status": "affected",
"justification": null,
"trustScore": 0.85
}
],
"resolutionStrategy": "highest_trust", // or null if unresolved
"autoResolved": false
}
Event ID Computation
Event IDs are deterministic SHA-256 hashes computed from:
- Event type
- Tenant
- Vulnerability ID
- Product key
- Observation ID
- Occurred timestamp (truncated to seconds)
This ensures idempotent event emission across retries.
Policy Engine Integration
Policy Engine subscribes to VEX events to trigger reanalysis:
# Policy event subscription
subscriptions:
- event: vex.statement.*
action: reanalyze
filter:
trustScore: { $gte: 0.7 }
- event: vex.statement.conflict
action: queue_for_review
filter:
autoResolved: false
Emission Ordering
Events are emitted with deterministic ordering:
- Statement events ordered by
occurredAtUtcascending - Conflict events emitted after all related statement events
- Events for the same vulnerability sorted by provider ID
vex.consensus— REMOVED (sprint 20260501-024). The collection is no longer written by Excititor. Linksets are append-only; verdict rollups now live in VexLens.
vex.exports(manifest of emitted artifacts)
_id
querySignature
format: raw|index
artifactSha256
rekor { uuid, index, url }?
createdAt
policyRevisionId
cacheable: bool
vex.cache— observation/linkset export cache: {querySignature, exportId, ttl, hits}.
vex.migrations— ordered migrations ensuring new indexes (20251027-linksets-introduced, etc.).
3.2 Indexing strategy
- Hot path queries rely on
{tenant, key.vulnerabilityId, key.productKey}covering linkset lookup. - Observability queries use
{tenant, updatedAt}to monitor staleness. - (Legacy
vex.consensuscollection removed; see sprint 20260501-024.)
5) Ingestion pipeline
4.1 Connector contract
public interface IVexConnector
{
string ProviderId { get; }
Task FetchAsync(VexConnectorContext ctx, CancellationToken ct); // raw docs
Task NormalizeAsync(VexConnectorContext ctx, CancellationToken ct); // raw -> ObservationStatements[]
}
- Fetch must implement: window scheduling, conditional GET (ETag/If‑Modified‑Since), rate limiting, retry/backoff.
- Normalize parses the format, validates schema, maps product identities deterministically, emits observation statements with provenance metadata (locator, justification, version ranges).
4.2 Signature verification (per provider)
Signature verification is a mandatory ingest gate, not an advisory step. As of sprint 20260501-020 (EXCITITOR-VRF-01/02), every connector built on VexConnectorBase MUST produce raw documents through CreateRawDocumentAsync(context, format, sourceUri, content, metadata, verificationOptions, ct). The synchronous overload CreateRawDocument(...) is [Obsolete(DiagnosticId="EXCITITOR-VRF-01")] and will be promoted to error once sprint 20260501-021 wires CSAF connectors through the new contract.
Supported signature formats — all delegated to StellaOps.Cryptography.ICryptoProviderRegistry (no raw System.Security.Cryptography calls in the verification path):
- DSSE + cosign-keyless (Fulcio + Rekor) for OCI referrers and any HTTP-served JSON published with Sigstore bundles.
- Cosign keyful for HTTP-served VEX with detached
.sig+.certsiblings on a ROLIE feed. - DSSE keyful for in-toto attestations.
- PGP (provider keyrings) for distro/vendor feeds (RedHat, Ubuntu) that sign documents with detached OpenPGP
.asc. - X.509 / PKCS#7 for issuers using provider-pinned certs.
Verification flow inside the base class:
- The fetcher hands the byte stream + caller-side metadata to
CreateRawDocumentAsync. - The base class invokes
context.SignatureVerifier.VerifyAsync(...)(theVexSignatureVerifierV1Adapter, which routes to V2 viaProductionVexSignatureVerifier). - Success. The verifier’s metadata is merged into the document’s
Metadatadictionary using theVexSignatureMetadataKeysconstants (vex.signature.status=verified, plusissuer,issuerId,keyId,method,profile,verifiedAt,transparency.entry,transparency.logId). - No signature. If
verificationOptions.AllowUnsigned == true(non-production opt-in), the document is admitted withvex.signature.status=unsigned,vex.signature.policy=allow, and aWarninglog entry. IfAllowUnsigned == false(default),VexSignatureRequiredException(reason=NoSignature)is thrown. - Any other failure. The V1 adapter throws
VexSignatureRequiredExceptioncarrying the V2 failure reason, key id, and crypto profile. WhenverificationOptions.QuarantineOnFailure == true, the base class writes the rejected bytes tovex.excititor_quarantinethroughIVexQuarantineSinkwith reasonsignature-verification:<reason>and then rethrows so the connector run is still observed as failed.
The verifier’s diagnostics are stored on vex_raw_documents.metadata (or its in-memory equivalent) and copied into statements[].signatureState so downstream policy can gate by verification result.
Observation statements from sources failing signature policy are marked
"signatureState.verified=false"and policy can down-weight or ignore them. From sprint 020 onward, such statements are additionally prevented from reaching the raw sink unlessAllowUnsignedwas explicitly enabled at the connector boundary outside production.
Quarantine persistence is provided by sprint 20260502-026:
| Field | Purpose |
|---|---|
tenant | RLS tenant key. |
connector_id, source_uri, captured_at | Where and when the rejected payload was fetched. |
payload, payload_size_bytes, digest | The rejected bytes and their digest. |
reason, diagnostics | Stable failure family plus structured JSON diagnostics. |
expires_at | Retention deadline; default 30 days via VexConnectorVerificationOptions.QuarantineRetention. |
Operational handling is documented in docs/ops/excititor-quarantine.md.
4.2.1 Connector signature sources (sprint 20260501-021)
Sprint 20260501-021 (EXCITITOR-CONN-VRF-CISCO/MSRC/ORACLE/REDHAT/UBUNTU/RANCHER) migrates each CSAF/Rancher connector to CreateRawDocumentAsync and stamps signature artefacts onto VexRawDocument.Metadata so ProductionVexSignatureVerifier can route through the regional crypto provider registry. The per-issuer mapping the connectors implement:
| Connector | Format | Source artefacts | Crypto profile (StellaOps.Cryptography) | Notes |
|---|---|---|---|---|
Cisco (excititor:cisco) | Cosign-signed CSAF (Cisco PSIRT v2 feed) | <docUri>.sig + <docUri>.cert siblings under the ROLIE provider directory | cosign-keyless (Fulcio root cert from offline kit) | Stamped metadata: signature-type=cosign, cosign-signature, cosign-bundle. |
MSRC (excititor:msrc) | Microsoft API; no detached signature today | n/a — quarantine path | x509-chain (mTLS to api.msrc.microsoft.com); revisited when MSRC ships signed feeds | All MSRC documents routed to quarantine sink with excititor.quarantine.reason=issuer-no-signature; not yielded for normalization. |
Oracle (excititor:oracle) | Detached PGP (.asc sibling) | <docUri>.asc next to each CSAF document | pgp-detached | Oracle Linux Security key fingerprint pinned via OracleConnectorOptions.PgpKeyFingerprint; offline kit ships the public key. |
RedHat (excititor:redhat) | Cosign primary, PGP fallback (per-document) | Cosign sig+cert OR PGP .asc, decided per document | cosign-detached (primary), pgp-detached (fallback) | Connector tries cosign first; if <docUri>.sig is absent, falls back to <docUri>.asc. |
Ubuntu (excititor:ubuntu) | Detached PGP (.asc sibling) | <docUri>.asc adjacent to channel-catalog and notice documents | pgp-detached | Canonical security key fingerprint pinned via UbuntuConnectorOptions.PgpKeyFingerprint. |
Rancher SUSE (excititor:suse-rancher) | DSSE bundle in event payload | Bundle is the event itself (no sibling fetch) | dsse | Stamps signature-type=dsse so the verifier reads signatures[] via the DSSE extractor; signature-failure events flow into the existing quarantine sink. |
RedHat CSAF ingestion materializes ROLIE/directory entries before advisory document download, then downloads documents in bounded batches. RedHatConnectorOptions.MaxConcurrentAdvisoryDownloads defaults to 8 and is bindable through Excititor:Connectors:RedHat:MaxConcurrentAdvisoryDownloads; every request still flows through the named RedHat HTTP client and its adaptive per-host throttling handler.
Soft dependency: pgp-detached, cosign-detached, and dsse providers must be registered in StellaOps.Cryptography.ICryptoProviderRegistry. When a provider is unavailable, the connector emits the appropriate metadata stamp but verification fails-closed at runtime — operators must either ship the missing provider via a regional plugin (StellaOps.Cryptography.Plugin.{Pgp,Sigstore,Dsse}) or enable AllowUnsigned=true per-connector with the production startup validator’s permission. The validator refuses to start the host when IHostEnvironment.IsProduction() is true and the global VexSignatureVerification:Connectors:AllowUnsigned knob is set.
Connector-level migration scope (sprint 021):
- All six
csprojfiles have removed the<NoWarn>$(NoWarn);EXCITITOR-VRF-01</NoWarn>suppression introduced as transitional state by sprint 020. - All six connectors call
CreateRawDocumentAsyncexclusively; noCreateRawDocument(...)(sync, obsolete) call sites remain in the connector libraries. - Worker host configuration (
StellaOps.Excititor.Worker/Program.csAddVexSignatureVerification call +etc/excititor.worker.yamlper-connector signature profile bindings) is tracked separately because it lies outside sprint 021’s connector working directory; see EXCITITOR-CONN-VRF-WORKER-07 in the sprint file. - Promotion of
EXCITITOR-VRF-01from warning to compile error is deferred to a follow-up sprint pending availability of thepgp-detached,cosign-detached, anddssecrypto providers.
4.2.1.1 MSRC mirror configuration (sprint 20260501-025)
Sprint 20260501-025 (EXCITITOR-FIX-MSRC-01) made the MSRC base URL and OAuth scope bindable so air-gap deployments can redirect every MSRC request at a local mirror. Sprint 20260507-003 then made MSRC authentication explicit: Public mode is the default and sends no bearer token, ClientCredentials uses Entra or mirror OAuth fields, and OfflineToken uses pre-staged token material. MsrcConnectorOptions.BaseUri and MsrcConnectorOptions.Scope retain their public defaults (https://api.msrc.microsoft.com/sug/v2.0/ and https://api.msrc.microsoft.com/.default) so existing online deployments continue to work without configuration changes; air-gap operators override the endpoint and auth mode when their mirror requires it:
excititor:
connectors:
msrc:
authMode: "ClientCredentials"
baseUri: "https://msrc-mirror.local/sug/v2.0/"
scope: "api://msrc-mirror.local/.default"
MsrcConnectorOptions.Validate and MsrcCsafConnectorOptionsValidator reject empty values and any non-HTTPS scheme on BaseUri. Scope is required only for ClientCredentials; an empty client-credential scope no longer silently falls back to the public default, so mirror misconfiguration fails loud. See etc/excititor.worker.yaml.sample for the full operator template.
4.2.2 CSAF ingest pipeline — schema validation as a hard gate (sprint 20260501-022)
Sprint 20260501-022 (audit pass-2 finding §E2) closes the gap where CSAF connectors (notably MsrcCsafConnector) accepted any well-formed JSON as “CSAF” and deferred structural validation to the normalizer (fail-late, not fail-fast). The StellaOps.Excititor.Formats.CSAF.Validation.CsafSchemaValidator is now a mandatory ingest gate that runs after signature verification (§4.2) and before raw-sink storage. A document that passes the signature gate but fails schema validation is rejected at the connector boundary and routed to the quarantine sink (sprint 20260502-026) with reason code csaf-schema-invalid:<issue-code>.
Validator contract (hand-rolled rather than a generic JSON-Schema runtime — see sprint Decisions & Risks; the OASIS schemas are still embedded as resources at Validation/Schemas/ for future generic-runtime fallback and offline self-attestation):
Issue code (CsafSchemaIssueCodes) | Pointer (RFC 6901) | Failure condition |
|---|---|---|
csaf-schema-version-unsupported | /document/csaf_version | Missing or value not in CsafSchemaOptions.SupportedVersions (default ["2.0", "2.1"]). |
csaf-missing-mandatory-key | /document/title, /document/tracking/id, /document/tracking/status, /document/tracking/current_release_date, /document/publisher/name, /document/publisher/category, /vulnerabilities | Mandatory field missing or wrong JSON value-kind. The /vulnerabilities pointer fires only when both vulnerabilities[] and product_tree are absent — at least one must be present. |
csaf-cve-malformed | /vulnerabilities/{i}/cve | When cve is present, it must match CVE-YYYY-NNNN+. CSAF allows omission (vendor advisory IDs are valid). |
csaf-product-tree-too-deep | /product_tree/branches/.../{n} | product_tree.branches[] recursion exceeds CsafSchemaOptions.MaxProductTreeDepth (default 8). DoS protection during normalization. |
csaf-malformed-json | `` (root) | The byte stream is not well-formed JSON. |
Configuration is bound from the excititor.formats.csaf:* section:
excititor.formats.csaf:
supportedVersions: ["2.0", "2.1"]
maxProductTreeDepth: 8
collectAllIssues: false # default: fail-fast on first issue
Failure semantics:
- The validator returns a
CsafValidationResult(IsValid,Issues[],CsafVersion). The connector base class wraps a non-valid result inCsafSchemaValidationExceptioncarryingDiagnostics(issue code + JSON pointer + message + full ordered issue list ascode@pointer|code@pointer|...) and theQuarantineReasonstring used by the quarantine sink. - When
QuarantineOnFailure=true,VexConnectorBasewrites the rejected bytes tovex.excititor_quarantinewith reasoncsaf-schema-invalid:<issue-code>and rethrows the exception. When the flag is false, it rethrows without persistence. - Rancher SUSE hub events: the validator runs against the inner CSAF decoded from the DSSE envelope, not against the envelope itself. Connector-side responsibility.
- OCI OpenVEX is exempt: its format is
OciAttestation, not CSAF; the base class is no-op for non-CSAF formats. - Schema-load failure is a startup-time crash:
CsafSchemaResources.EnsureLoadableis invoked from the validator’s static initialiser, and any missing embedded resource raisesInvalidOperationExceptionso a packaging regression surfaces loudly rather than as a silent no-op validator.
Embedded schema offline posture: the OASIS Approved Errata 01 schemas (csaf, provider, aggregator + the FIRST CVSS transitive schemas) are vendored at docs/contracts/schemas/eu/csaf/2.0/ and embedded into the StellaOps.Excititor.Formats.CSAF assembly. Air-gap installs and CI environments need no network access; the runtime never fetches schemas (CLAUDE.md §2.8).
4.2.3 AOC enforcement — pre-normalize lineage guard (sprint 20260501-023)
Sprint 20260501-023 (audit pass-2 finding §E3) closes the gap where the existing VexRawWriteGuard.EnsureValid only validated serialized shape at the persistence sink, leaving connector normalizers free to mutate, fabricate, or cross-contaminate VEX claims after the signed bytes had already been content-addressed. The new guard is two-sided and runs at two distinct points in the ingest pipeline:
| Checkpoint | Method | Invariant | Failure exception |
|---|---|---|---|
| Persistence sink (existing) | IVexRawWriteGuard.EnsureValid(RawVexDocument) | Serialized shape of the raw row complies with StellaOps.Aoc.AocViolationCode (forbidden fields, missing provenance, signature payload completeness). | ExcititorAocGuardException (ERR_AOC_*). |
| Pre-consensus, post-normalize (new) | IVexRawWriteGuard.EnsureLineage(VexRawDocument raw, VexClaimBatch normalized) | The normalized batch is byte-traceable, by digest, to the raw blob the connector signed at fetch time. | VexAocLineageBrokenException (aoc.lineage.broken). |
The contract this enforces: the bytes that were signed are the bytes that get content-addressed and the bytes that flow into normalization. Concretely, VexConnectorBase.CreateRawDocumentAsync stamps three lineage keys on VexRawDocument.Metadata after verification succeeds (or after AllowUnsigned=true admits a document):
| Metadata key | Type | Source | Notes |
|---|---|---|---|
aoc.raw.digest | sha256:<lowercase-hex> | The post-verification digest of the raw bytes (always equal to VexRawDocument.Digest). | Stored redundantly so the guard can detect constructor-time tampering. |
aoc.raw.length | base-10 integer string | content.Length at verification time. | Tolerated as absent on legacy rows; mismatch is a hard fail. |
aoc.raw.signedBy | issuer ID, issuer name, or @unsigned | Verifier metadata (Trust.IssuerId → Issuer → connector descriptor); @unsigned sentinel when AllowUnsigned=true. | Always populated; absence indicates CreateRawDocumentAsync was bypassed. |
Constants live in StellaOps.Excititor.Core.Aoc.VexAocLineageMetadataKeys.
Failure modes (VexAocLineageBreakReason):
| Reason | Trigger | Diagnostic field path |
|---|---|---|
MissingLineageStamp | Raw document has no aoc.raw.digest metadata. Indicates a connector path that bypassed CreateRawDocumentAsync. | metadata[aoc.raw.digest] |
RawDigestMismatch | Stamped digest does not match VexRawDocument.Digest. Document was tampered with after verification. | metadata[aoc.raw.digest] |
RawLengthMismatch | Stamped length does not match Content.Length. Truncation or padding. | metadata[aoc.raw.length] |
BatchSourceMismatch | The VexClaimBatch.Source.Digest is not the raw document’s digest. Cross-batch contamination. | claimBatch.source.digest |
ClaimDigestMismatch | A claim’s VexClaimDocument.Digest is not the raw document’s digest. Fabricated or shuffled claim. | claims[i].document.digest |
The guard is fail-closed and fail-fast: it throws on the first violation and returns a structured VexAocLineageBrokenException carrying the connector ID, raw digest, reason, offending field path, and a free-form diagnostics dictionary suitable for routing to a quarantine row. Stable error code: aoc.lineage.broken. Per the established Excititor pattern (parallel to VexSignatureRequiredException from sprint 020 and CsafSchemaValidationException from sprint 022), the exception type is the canonical signal — hosts may catch it to write a quarantine row but MUST re-throw so the connector run is observed as failed.
Coverage: sprint 023 lands the guard contract, the CreateRawDocumentAsync lineage stamps, and the unit-test matrix in StellaOps.Excititor.Core.Tests/Aoc/VexRawWriteGuardLineageTests.cs. Sprint 502_001 (EXCITITOR-WORKER-AOC-04/05/06) lands the worker-side wiring: StellaOps.Excititor.Worker.Aoc.VexWorkerLineageCheckpoint invokes EnsureLineage from the per-document FetchAsync loop in DefaultVexProviderRunner.ExecuteConnectorCoreAsync, routes lineage failures to the existing IVexQuarantineSink with reason aoc-lineage-broken, and emits the excititor.aoc.lineage_violations counter (Meter StellaOps.Excititor.Worker.Aoc) with tags connector / raw_digest / reason. The vex.excititor_raw_lineage table for retention-independent provenance and its compatibility backfill are part of the active squashed baseline 001_v1_excititor_baseline.sql; the historical 011/012 incrementals remain archived pre-1.0 history and are not active startup filenames. In addition to the persisted lineage rows, the contract is still enforced in-flight by the VexRawDocument.Metadata lineage stamps, which the guard validates on every fetch.
4.2.3.1 Lineage pipeline (fetch → verify → sink → normalize → guard)
The diagram below shows the full pipeline including both EnsureValid (sink) and EnsureLineage (pre-consensus) checkpoints. As of sprint 502_001 (EXCITITOR-WORKER-AOC-04/05/06, 2026-05-02) the pre-consensus checkpoint is invoked by the production worker — the previously-shaded “(FETCH-03 wiring deferred)” band is now active code in DefaultVexProviderRunner and the VexWorkerLineageCheckpoint class.
sequenceDiagram
autonumber
participant Source as Issuer (CSAF/OpenVEX)
participant Conn as VexConnectorBase
participant Verify as VexSignatureVerifier
participant Sink as Raw sink (excititor_raw_documents)
participant Worker as StellaOps.Excititor.Worker
participant Norm as IVexNormalizerRouter
participant Guard as IVexRawWriteGuard
participant Link as IAppendOnlyLinksetStore
Source->>Conn: fetch (HTTP GET / OCI pull)
Note over Conn: ReadOnlyMemory body
Conn->>Verify: VerifyAsync(body)
Verify-->>Conn: VexVerifiedDocument { Trust, Digest=SHA-256(body) }
Conn->>Conn: stamp lineage metadata
aoc.raw.digest = sha256:…
aoc.raw.length = body.Length
aoc.raw.signedBy = Trust.IssuerId or @unsigned
Conn->>Sink: persist VexRawDocument
Sink->>Guard: EnsureValid(RawVexDocument)
Guard-->>Sink: pass · or ExcititorAocGuardException (ERR_AOC_*)
Sink-->>Worker: raw document committed (digest is the content address)
rect rgba(180, 220, 255, 0.20)
Note over Worker,Link: --- pre-consensus checkpoint (sprint 502_001 EXCITITOR-WORKER-AOC-04/05) ---
Worker->>Norm: NormalizeAsync(documentRef, retriever)
Note right of Norm: IVexRawBlobRetriever resolves bytes by digest
(VexRawStoreBlobRetriever default)
Norm-->>Worker: VexClaimBatch { Source.Digest, Claims[].Document.Digest }
Worker->>Guard: EnsureLineage(raw, batch)
alt all 5 invariants hold
Guard-->>Worker: pass
Worker->>Link: append observation
else MissingLineageStamp / RawDigestMismatch / RawLengthMismatch / BatchSourceMismatch / ClaimDigestMismatch
Guard-->>Worker: throw VexAocLineageBrokenException
(aoc.lineage.broken, Reason, OffendingField)
Worker->>Sink: write quarantine row
(reason: aoc-lineage-broken)
Note over Worker: emit excititor.aoc.lineage_violations
{connector, raw_digest, reason}
Note over Worker: continue with next document
(per-document fail-fast; batch is not poisoned)
end
end
The blue band is the active pre-consensus checkpoint (sprint 502_001 EXCITITOR-WORKER-AOC-04/05/06, 2026-05-02). The implementation lives in StellaOps.Excititor.Worker.Aoc.VexWorkerLineageCheckpoint; integration coverage on legitimate + tampered fixtures is in StellaOps.Excititor.Worker.Tests/Aoc/AocLineagePipelineIntegrationTests.cs. The metric excititor.aoc.lineage_violations (Meter StellaOps.Excititor.Worker.Aoc, instrument unit {violation}) is incremented exactly once per document that trips the guard, with tags connector (string, e.g. excititor:cisco), raw_digest (string, sha256:<hex>), and reason (one of MissingLineageStamp, RawDigestMismatch, RawLengthMismatch, BatchSourceMismatch, ClaimDigestMismatch). Tag cardinality is bounded by quarantine retention: one row per tampered document, capped by the runbook’s retention policy in docs/ops/excititor-quarantine.md.
4.3 Time discipline
- For each doc, prefer provider’s document timestamp; if absent, use fetch time.
- Statements carry
lastObservedwhich drives tie-breaking within equal weight tiers.
6) Normalization: product & status semantics
5.1 Product mapping
- purl first; cpe second; OS package NVRA/EVR mapping helpers (distro connectors) produce purls via canonical tables (e.g., rpm→purl:rpm, deb→purl:deb).
- Where a provider publishes platform‑level VEX (e.g., “RHEL 9 not affected”), connectors expand to known product inventory rules (e.g., map to sets of packages/components shipped in the platform). Expansion tables are versioned and kept per provider; every expansion emits evidence indicating the rule applied.
- If expansion would be speculative, the statement remains platform-scoped with
productKey="platform:redhat:rhel:9"and is flagged non-joinable; backend can decide to use platform VEX only when Scanner proves the platform runtime.
5.2 Status + justification mapping
Canonical status:
affected | not_affected | fixed | under_investigation.Justifications normalized to a controlled vocabulary (CISA‑aligned), e.g.:
component_not_presentvulnerable_code_not_in_execute_pathvulnerable_configuration_unusedinline_mitigation_appliedfix_available(withfixedVersion)under_investigation
Providers with free‑text justifications are mapped by deterministic tables; raw text preserved as
evidence.
7) Verdict resolution (moved to VexLens)
Sprint 20260501-024 — DELETED. Excititor no longer computes consensus / verdict rollups. The previous
BaselineVexConsensusPolicyandVexConsensusResolvertypes were removed; verdict resolution is the responsibility of VexLens (seedocs/modules/vex-lens/architecture.md).Excititor preserves and transports linksets:
- Connectors emit signed-statement-pointer observations.
- The append-only
IAppendOnlyLinksetStorecorrelates observations per(tenant, vulnerabilityId, productKey)and records disagreements as data, not decisions.- Excititor exposes provider-trust weights and scoring options on the policy snapshot (
VexProviderWeights,VexEvidenceScoringOptions), but does not apply them — those values are passed through to VexLens on/excititor/resolveresponses for downstream resolution.The historical algorithm (filter → score → aggregate → pick → tiebreak → explain) and its
rollupStatus/consensusDigestoutputs have been retired. Existing exports retain aconsensusDigestfield onVexExportManifestfor cross-module wire stability; that field now carries the canonical linkset reference digest.
7.1) Trust Lattice Framework
The Trust Lattice extends the basic consensus algorithm with a sophisticated 3-component trust vector model that enables explainable, deterministically replayable vulnerability decisioning.
7.1.1 Trust Vector Model (P/C/R)
Each VEX source is assigned a TrustVector with three components:
| Component | Symbol | Description | Range |
|---|---|---|---|
| Provenance | P | Cryptographic & process integrity (signatures, key management) | 0.0–1.0 |
| Coverage | C | Scope match precision (how well claims match the target) | 0.0–1.0 |
| Replayability | R | Determinism and input pinning (reproducibility) | 0.0–1.0 |
Base Trust Calculation:
BaseTrust(S) = wP * P + wC * C + wR * R
Default weights:
wP = 0.45 (provenance)
wC = 0.35 (coverage)
wR = 0.20 (replayability)
Default Trust Vectors by Source Class:
| Source Class | P | C | R | Notes |
|---|---|---|---|---|
| Vendor | 0.90 | 0.70 | 0.60 | High provenance, moderate coverage |
| Distro | 0.80 | 0.85 | 0.60 | Strong coverage for package-level claims |
| Internal | 0.85 | 0.95 | 0.90 | Highest coverage and replayability |
| Hub | 0.60 | 0.50 | 0.40 | Aggregated sources, lower baseline |
| Attestation | 0.95 | 0.80 | 0.70 | Cryptographically verified statements |
7.1.2 Claim Scoring
Each VEX claim is scored using the formula:
ClaimScore = BaseTrust(S) * M * F
Where:
S = Source's TrustVector
M = Claim strength multiplier [0.40–1.00]
F = Freshness decay factor [floor–1.00]
Claim Strength Multipliers:
| Evidence Type | Strength (M) |
|---|---|
| Exploitability analysis + reachability proof | 1.00 |
| Config/feature-flag reason with evidence | 0.80 |
| Vendor blanket statement | 0.60 |
| Under investigation | 0.40 |
Freshness Decay:
F = max(exp(-ln(2) * age_days / half_life), floor)
Default:
half_life = 90 days
floor = 0.35 (minimum freshness)
7.1.3 Lattice Merge Algorithm
The ClaimScoreMerger (StellaOps.Excititor.Core/Lattice/ClaimScoreMerger.cs) combines multiple scored claims into a deterministic result:
- Score claims using the ClaimScore formula (
ClaimScoreCalculator.Compute). - Detect conflicts when the scored claims contain more than one distinct
status. - Apply conflict penalty (default δ=0.25, configurable via the
conflictPenaltyconstructor arg) by multiplying every claim’sFinalScoreby(1 − δ)when conflicts exist. - Order candidates by
FinalScoredescending (OrderByDescending). Note: the current code does not apply the secondary tiebreakers (scope specificity → original score → source ID) that earlier drafts described; only the single descending-score sort is implemented. - Select winner as the first (highest-scoring) claim.
Actual MergeResult shape (record fields, verified):
{
"winningClaim": { /* the full VexClaim, or null when no claims */ },
"allClaims": [ /* ScoredClaim[]: { claim, baseTrust, strength, freshness, finalScore, penaltyApplied } */ ],
"hasConflict": true,
"conflictPenaltyApplied": 0.25,
"mergeTimestampUtc": "2026-01-15T10:30:00Z"
}
The
status/confidence/conflicts[]/requiresReplayProoffields shown in earlier drafts are not present onMergeResult. The winning status is read fromWinningClaim.Status; conflict detail beyond theHasConflictflag and per-claimPenaltyAppliedis not surfaced by the merger itself.
7.1.4 Policy Gates (PROPOSED — not implemented)
NOT IMPLEMENTED. No
MinimumConfidenceGate,UnknownsBudgetGate,SourceQuotaGate,ReachabilityRequirementGate, orPolicyGateRegistrytype exists inStellaOps.Excititor.*(the only “gate” in the codebase is the unrelatedAutoVex/DriftGateIntegration). The table below is a forward design fromdocs/modules/excititor/trust-lattice.md(which is marked “Implementation Complete” but whose gate layer has no corresponding code here). Treat these as proposed constraints, not live behaviour.
| Gate (proposed) | Purpose | Default Threshold |
|---|---|---|
MinimumConfidenceGate | Reject verdicts below confidence threshold | 0.75 (prod), 0.60 (staging) |
UnknownsBudgetGate | Fail if unknowns exceed budget | 5 per scan |
SourceQuotaGate | Cap single-source influence | 60% unless corroborated |
ReachabilityRequirementGate | Require reachability proof for criticals | Enabled |
7.1.5 Calibration
Trust vectors are automatically calibrated based on post-mortem truth comparison:
TrustVector' = TrustVector + Δ
Δ = f(accuracy, detected_bias, learning_rate, momentum)
Defaults:
learning_rate = 0.02 per epoch
max_adjustment = 0.05 per epoch
momentum_factor = 0.9
Bias Types:
OptimisticBias→ reduce ProvenancePessimisticBias→ increase ProvenanceScopeBias→ reduce Coverage
Calibration manifests are stored for auditing and rollback.
7.1.6 Configuration
Trust lattice settings in etc/trust-lattice.yaml.sample:
trustLattice:
weights:
provenance: 0.45
coverage: 0.35
replayability: 0.20
freshness:
halfLifeDays: 90
floor: 0.35
defaults:
vendor: { p: 0.90, c: 0.70, r: 0.60 }
distro: { p: 0.80, c: 0.85, r: 0.60 }
internal: { p: 0.85, c: 0.95, r: 0.90 }
calibration:
enabled: true
learningRate: 0.02
maxAdjustmentPerEpoch: 0.05
See docs/modules/excititor/trust-lattice.md for the complete specification.
8) Query & export APIs
Route reality check (verified against
StellaOps.Excititor.WebService/Endpoints/*). Excititor endpoints are not uniformly versioned under/api/v1/vex; the prefixes actually registered are/excititor/*(ingest/admin/resolve/providers/mirror),/vex/*(observations, linksets, raw),/attestations/*,/evidence/*,/risk/v1/*, plus a handful of/api/v1/vex/*candidate-review routes and/v1/vex/*projections. Online queries are exposed asGETwith query-string filters, notPOST .../search. The shapes below have been corrected to match the live route table.Generated OpenAPI contract.
GET /openapi/excititor.jsonis generated from the endpoint metadata registered by the WebService, so provider, trust-policy, resolve, linkset, risk-feed, plugin-diagnostic, and future registered routes do not depend on a second hand-maintained path list. The document is served as OpenAPI 3.1.1, declares the shared error envelope and Bearer scheme, and marks authorization only on endpoints whose metadata requires it.GET /.well-known/openapiremains the anonymous discovery pointer.
7.1 Query (online)
GET /vex/observations (scope: vex.read; RequireAuthorization ExcititorPolicies.VexRead)
query: { vulnerabilityId?, productKey?, providerId?, limit? (1–100, default 50), cursor? }
note: filter is mandatory — a request with no (vulnerabilityId+productKey) or providerId returns 400 ERR_PARAMS.
→ { observations[], ... }
GET /vex/observations/{observationId} (scope: vex.read)
GET /vex/observations/count (scope: vex.read)
GET /vex/linksets (scope: vex.read)
query: { vulnerabilityId?, productKey?, providerId?, hasConflicts?, limit? (1–100, default 50), cursor? }
→ { linksets[], ... }
GET /vex/linksets/{linksetId} (scope: vex.read)
GET /vex/linksets/lookup (scope: vex.read)
GET /vex/linksets/count (scope: vex.read)
GET /vex/linksets/conflicts (scope: vex.read)
POST /consensus/search — REMOVED (sprint 20260501-024). Verdict
rollups live in VexLens. (No replacement search route exists.)
POST /excititor/resolve (scope: vex.read)
body: { productKeys?: string[], purls?: string[], vulnerabilityIds: string[], policyRevisionId?: string }
→ {
resolvedAt,
policy: { activeRevisionId, version, digest, requestedRevisionId },
results: [
{
linksets: [ { digest, connectorId, statementCount, observedAt } ],
claimsByLinksetDigest: { "<digest>": [ /* VexClaim records */ ] },
providerWeights: { vendor, distro, platform, hub, attestation, ceiling },
scoringOptions: { alpha, beta },
policyDigest: { revisionId, version, digest },
vulnerabilityId,
productKey
}
]
}
Sprint 20260501-024 changed
/excititor/resolveto return linkset references plus claims keyed by linkset digest, deferring final verdict resolution to VexLens. The previousstatus/sources/conflicts/decisions/envelopefields are gone.
7.2 Exports (cacheable snapshots)
NOT IMPLEMENTED as
POST /exports. There is no genericPOST /exports/GET /exports/{exportId}endpoint inStellaOps.Excititor.WebService. The export surface that exists is the mirror domain API inMirrorEndpoints.cs(read-only, GET-only), which lists pre-built mirror bundles per domain:GET /excititor/mirror/domains GET /excititor/mirror/domains/{domainId} GET /excititor/mirror/domains/{domainId}/index GET /excititor/mirror/domains/{domainId}/exports/{exportKey} GET /excititor/mirror/domains/{domainId}/exports/{exportKey}/downloadMirror registration bundles are served under
/excititor/mirror/registration(MirrorRegistrationEndpoints.cs: list, get{bundleId},{bundleId}/timeline). Export manifests are persisted (VexExportManifest) but generated by the worker/export library, not via an on-demand HTTPPOST. The block below is a forward design that has not been built.POST /exports (PROPOSED — not present in the route table) body: { signature: { vulnFilter?, productFilter?, providers?, since? }, format: raw|index, policyRevisionId?: string, force?: bool } → { exportId, artifactSha256, rekor? }
7.3 Provider operations
GET /excititor/providers (scope: vex.read) → provider list & signature policy
GET /excititor/providers/{providerId} (scope: vex.read)
PUT /excititor/providers/{providerId} (scope: vex.admin)
POST /excititor/providers/{providerId}/enable (scope: vex.admin)
POST /excititor/providers/{providerId}/disable (scope: vex.admin)
POST /excititor/providers/{providerId}/run (scope: vex.admin) → trigger fetch/normalize window
GET /excititor/providers/{providerId}/configuration (scope: vex.read)
PUT /excititor/providers/{providerId}/configuration (scope: vex.admin)
GET /excititor/providers/{providerId}/artifacts (scope: vex.read)
POST /excititor/providers/{providerId}/artifacts (scope: vex.admin)
GET /excititor/providers/{providerId}/artifacts/{artifactId}/meta (scope: vex.read)
DELETE /excititor/providers/{providerId}/artifacts/{artifactId} (scope: vex.admin)
GET /excititor/oci-trust-policies (scope: vex:read) → tenant trust-rule list
GET /excititor/oci-trust-policies/{id} (scope: vex:read) → one tenant trust rule
POST /excititor/oci-trust-policies (scope: vex.admin)
PUT /excititor/oci-trust-policies/{id} (scope: vex.admin)
DELETE /excititor/oci-trust-policies/{id} (scope: vex.admin)
Note: there is no
GET /providers/{id}/statusroute; per-provider last-fetch / doc-count / signature stats are surfaced through the provider GET + configuration routes and persisted connector state.
Auth: service‑to‑service via Authority tokens; operator operations via UI/CLI with RBAC. Scopes are enforced at the endpoint level via ScopeAuthorization.RequireScope(...) and the named policies in ExcititorPolicies (excititor.vex.admin, excititor.vex.read, excititor.vex.ingest, excititor.vex.attest). Read and ingest policies accept the canonical catalog scopes vex:read and vex:ingest as well as their legacy dot-form aliases; vex.admin and vex.attest remain dot-form catalog scopes. OCI trust-policy GET routes use the read policy, while every mutation remains admin-only.
Runtime defaults (local mirror / compose):
- When
Excititor:Worker:Providersis empty, the worker seeds the built-in providersexcititor:redhat,excititor:ubuntu,excititor:oracle,excititor:cisco,excititor:suse-rancher,excititor:oci-openvex, andexcititor:msrc. - Initial delays are intentionally staggered (
00:05:00,00:07:00,00:09:00,00:11:00,00:13:00,00:15:00,00:17:00) so a fresh mirror does not burst all upstream VEX sources at once. - When Red Hat/Cisco metadata discovery or the Ubuntu root index is unavailable and no offline snapshot exists, the public bootstrap path falls back to deterministic built-in metadata/catalog inference so mirror startup does not fail solely because the discovery document is missing.
excititor:ubuntuandexcititor:oci-openvexsurface as configuration-blocked until the operator supplies the required ETag HMAC secret or image subscriptions.excititor:msrcruns in public mode by default and becomes configuration-blocked only when the operator selectsClientCredentialswithouttenantId/clientId/clientSecretorOfflineTokenwithoutofflineTokenPath/staticAccessToken. UbuntuetagSecretcan be supplied through the persisted provider configuration API or throughExcititor:Connectors:Ubuntu:EtagSecretas a host-config fallback; persisted settings win.excititor:suse-rancheruses the public discovery default unless the operator configures an authenticated hub; validation/discovery failures are persisted into connector state for operator diagnosis.
9) Attestation integration
Exports can be DSSE‑signed via Signer and logged to Rekor v2 via Attestor (optional but recommended for regulated pipelines).
vex.exports.rekorstores{uuid, index, url}when present.Predicate type:
https://stella-ops.org/attestations/vex-export/1with fields:querySignature,policyRevisionId,artifactSha256,createdAt.
9.2 OCI-OpenVEX attestations (estate-driven, fail-closed by identity)
OCI-OpenVEX (excititor:oci-openvex, connector StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest) is not a feed — VEX docs are stored as OCI artifacts attached to specific image digests (cosign/DSSE via the Referrers API). It is ingested pull-on-scan: discovery hooks into the scan/inventory lifecycle so the “image list” is the live estate, registry creds come from the unified registry-access layer (ADR-033), and trust is bound per-publisher by signer identity (fail-closed: unsigned / unlisted-signer ⇒ quarantined, advisory-only until the operator promotes the signer to auto-apply). Because a VEX statement suppresses findings, an unverified attestation must never downgrade a finding. Full design, trust model, UX surfaces, and the phased rollout: operations/oci-openvex-attestations.md.
10) Configuration (YAML)
excititor:
postgres:
connectionString: "Host=postgres;Port=5432;Database=excititor;Username=stellaops;Password=stellaops"
s3:
endpoint: http://rustfs:8080
bucket: stellaops
policy:
weights:
vendor: 1.0
distro: 0.9
platform: 0.7
hub: 0.5
attestation: 0.6
ceiling: 1.25
scoring:
alpha: 0.25
beta: 0.5
providerOverrides:
redhat: 1.0
suse: 0.95
requireJustificationForNotAffected: true
signatureRequiredForFixed: true
minEvidence:
not_affected:
vendorOrTwoDistros: true
connectors:
- providerId: redhat
kind: csaf
baseUrl: https://access.redhat.com/security/data/csaf/v2/
signaturePolicy: { type: pgp, keys: [ "…redhat-pgp-key…" ] }
windowDays: 7
- providerId: suse
kind: csaf
baseUrl: https://ftp.suse.com/pub/projects/security/csaf/
signaturePolicy: { type: pgp, keys: [ "…suse-pgp-key…" ] }
- providerId: ubuntu
kind: openvex
baseUrl: https://…/vex/
signaturePolicy: { type: none }
- providerId: vendorX
kind: cyclonedx-vex
ociRef: ghcr.io/vendorx/vex@sha256:…
signaturePolicy: { type: cosign, cosignKeylessRoots: [ "sigstore-root" ] }
9.1 WebService endpoints
With storage configured, the WebService exposes the following ingress and diagnostic APIs (deterministic ordering, offline-friendly):
GET /excititor/status– returns the active storage configuration and registered artifact stores.GET /excititor/health– simple liveness probe.POST /excititor/statements– accepts normalized VEX statements and persists them viaIVexClaimStore; use this for migrations/backfills.GET /excititor/statements/{vulnId}/{productKey}?since=– returns the immutable statement log for a vulnerability/product pair.POST /vex/evidence/chunks– submits aggregation-only chunks (OpenAPI:schemas/vex-chunk-api.yaml); responds with deterministicchunk_digestand queue id. Telemetry published under meterStellaOps.Excititor.Chunks(see Operations).POST /v1/attestations/verify– verifies Evidence Locker attestations for exports/chunks usingIVexAttestationVerifier; returns{ valid, diagnostics }(deterministic key order). Aligns with Evidence Locker contract v1.POST /excititor/resolve– requiresvex.readscope; accepts up to 256(vulnId, productKey)pairs viaproductKeysorpurlsand returns linkset references plus claims keyed by linkset digest (sprint 20260501-024 D3). Final verdict computation is delegated to VexLens. Returns 409 Conflict when the requestedpolicyRevisionIdmismatches the active snapshot.
For the standard compose/local mirror environment, the WebService may derive Authority:ResourceServer:Authority and the OpenID metadata address from Excititor:Authority:BaseUrls:default when the explicit Authority:ResourceServer:* keys are absent. This keeps /excititor/status and other Authority-backed routes operational in the default stack without duplicating authority configuration in two places.
Run the ingestion endpoint once after applying migration 20251019-consensus-signals-statements to repopulate historical statements with the new severity/KEV/EPSS signal fields.
weights.ceilingraises the deterministic clamp applied to provider tiers/overrides (range 1.0‒5.0). Values outside the range are clamped with warnings so operators can spot typos.scoring.alpha/scoring.betaconfigure KEV/EPSS boosts for the Phase 1 → Phase 2 scoring pipeline. Defaults (0.25, 0.5) preserve prior behaviour; negative or excessively large values fall back with diagnostics.
11) Security model
- Input signature verification enforced per provider policy (PGP, cosign, x509).
- Connector allowlists: outbound fetch constrained to configured domains.
- Tenant isolation: per‑tenant DB prefixes or separate DBs; per‑tenant S3 prefixes; per‑tenant policies.
- AuthN/Z: Authority‑issued OpToks. The scopes Excititor actually enforces are canonical
vex:read(observations, linksets, evidence, attestation reads, resolve, provider reads, and OCI trust-policy reads),vex:ingest(raw ingestion),vex.admin(init/ingest/reconcile, provider and OCI trust-policy mutations), andvex.attest(Rekor attestation writes). The named policies andScopeAuthorizationretain the legacyvex.read/vex.ingestaliases for compatibility. There is novex.exportscope — export reads ridevex:read. - No secrets in logs; deterministic logging contexts include providerId, docDigest, observationId, and linksetId.
12) Performance & scale
Targets:
- Normalize 10k observation statements/minute/core.
- Linkset rebuild ≤ 20 ms P95 for 1k unique
(vuln, product)pairs in hot cache. - Export (observations + linksets) 1M rows in ≤ 60 s on 8 cores with streaming writer.
Scaling:
- WebService handles control APIs; Worker background services (same image) execute fetch/normalize in parallel with rate‑limits; PostgreSQL writes batched; upserts by natural keys.
- Exports stream straight to S3 (RustFS) with rolling buffers.
Caching:
vex.cachemaps query signatures → export; TTL to avoid stampedes; optimistic reuse unlessforce.
11.1 Worker refresh controls (REMOVED — sprint 20260501-024)
The background VexConsensusRefreshService and its Excititor:Worker:Refresh / Excititor:Worker:DisableConsensus configuration keys have been deleted. Linksets are append-only; no periodic recomputation runs in Excititor. Removing these settings is a breaking config change — operators upgrading must scrub their appsettings.json of the obsolete Refresh block.
13) Observability
Metrics (verified against
StellaOps.Excititor.WebService/Telemetry/*and the Worker AOC meter). The instrument names below are the ones actually created; the legacyvex.fetch.*/vex.observations.write_total/vex.linksets.updated_total/vex.exports.bytes_totalnames in earlier drafts are not emitted. The live counters/histograms are prefixedexcititor.vex.*(metersStellaOps.Excititor.WebService.Linksets,...Evidence,...Normalization) plus the Worker AOC counter:- Ingest:
excititor.vex.ingest.latency_seconds,excititor.vex.ingest.operations_total - Observation/linkset stores:
excititor.vex.observation.store_operations_total,excititor.vex.observation.batch_size,excititor.vex.linkset.store_operations_total,excititor.vex.linkset.confidence_score - Conflicts/disagreements:
excititor.vex.linkset.conflicts_total,excititor.vex.linkset.disagreement_count,excititor.vex.linkset.disagreement_by_status - Scope resolution:
excititor.vex.scope.resolution_total,excititor.vex.scope.match_count - Normalization/canonicalization:
excititor.vex.canonicalize.advisory_key_total(+_errors,_scope),excititor.vex.canonicalize.product_key_total(+_errors,_scope,_type),excititor.vex.normalize.errors_total - Suppression/withdrawn:
excititor.vex.suppression.scope_total,excititor.vex.suppression.applied_total,excititor.vex.withdrawn.statements_total,excititor.vex.withdrawn.replacements_total - Evidence/signature/AOC:
excititor.vex.evidence.requests,excititor.vex.evidence.chunk_count,excititor.vex.evidence.retrieval_total,excititor.vex.evidence.retrieval_latency_seconds,excititor.vex.signature.status,excititor.vex.aoc.guard_violations - Worker AOC lineage:
excititor.aoc.lineage_violations{connector,raw_digest,reason}(meterStellaOps.Excititor.Worker.Aoc) /vex/rawparity also exposes a Concelier-styleingestion_write_totalcounter, and the unified signature verifier emitsingestion_signature_verified_total.
- Ingest:
Tracing: spans for fetch, verify, parse, map, observe, linkset, export.
Dashboards: provider staleness, linkset conflict hot spots, signature posture, export cache hit-rate.
Telemetry configuration:
Excititor:Telemetrytoggles OpenTelemetry for the host (Enabled,EnableTracing,EnableMetrics,ServiceName,OtlpEndpoint, optionalOtlpHeadersandResourceAttributes). Point it at the collector profile listed inoperations/observability.mdso Excititor’singestion_*metrics land in the same Grafana dashboards as Concelier.Health endpoint:
/obs/excititor/healthrequires the least-privilegevex:readscope and surfaces ingest/link/signature/conflict SLOs for Console + Grafana. Thresholds are configurable viaExcititor:Observability:*(seeoperations/observability.md).Anti-bloat storage section (VRB-7, SPRINT_20260701_002; OPR-3D): the health document additionally carries a read-only
storagesection.connectionStateisconnectedonly when PostgreSQL statistics were measured and isdegradedwhen the dependency is unavailable; zero-valued placeholders in a degraded response must not be interpreted as a healthy empty database. The independentstatusfield remains the DB-size headroom guardrail (healthy/warning/critical/unknown). The section also carries claim count, distinct product/metadata dedup counts, product/metadata dedup ratios, bytes/claim, per-relationpg_total_relation_size, andpg_database_size. It is computed byVexStorageStatsReader(inStellaOps.Excititor.Persistence) on a tenant-scoped connection (vex.claims is FORCE-RLS, so a system-connection count would raise; thepg_*size functions and the dedup tables are global on that same connection, andvexhub.statementsis probed withto_regclassand skipped when absent). The ceiling comes from the WebService binding ofExcititor:Worker:IngestGuardrail:DbSizeCeilingGb(the same section the Worker guardrail binds), so a deployed override is reflected. Cross-process note: the WebService is a separate process from the Worker and cannot read the Worker’s in-memory guardrail trip counter — the section surfaces the self-measured DB-size-vs-ceiling headroom; the OTLPexcititor.worker.ingest_guardrail_tripscounter remains the Worker-side alert. The Worker’s retention sweep also refreshes three ObservableGauges on theStellaOps.Excititor.Workermeter each run —excititor.worker.vex_metadata_dedup_ratio,excititor.worker.vex_bytes_per_claim,excititor.worker.vex_db_size_bytes(read 0 before the first sweep).Local database: Use Docker Compose or
tools/postgres/local-postgres.sh startto boot a PostgreSQL instance for storage/integration tests.restartrestarts in-place,cleanwipes the managed data/logs for deterministic runs, andstop/status/logscover teardown/inspection.API headers: responses echo
X-StellaOps-TraceIdandX-Stella-CorrelationIdto keep Console/Loki links deterministic; inbound correlation headers are preserved when present.
14) Testing matrix
- Connectors: golden raw docs → deterministic observation statements (fixtures per provider/format).
- Signature policies: valid/invalid PGP/cosign/x509 samples; ensure rejects are recorded but not accepted.
- Normalization edge cases: platform-scoped statements, free-text justifications, non-purl products.
- Linksets: conflict scenarios across tiers; verify confidence scoring + conflict payload stability.
- Batch ingest validation:
dotnet test src/Excititor/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj --filter "Category=BatchIngestValidation"ingests mixed CycloneDX/CSAF/OpenVEX fixtures, asserts/vex/rawparity, confirmsingestion_write_totaltags, and checks/aoc/verifyoutput—run after touching ingest/telemetry code. - Performance: 1M-row observation/linkset export timing; memory ceilings; stream correctness.
- Determinism: same inputs + policy → identical linkset hashes, conflict payloads, and export bytes.
- API contract tests: pagination, filters, RBAC, rate limits.
15) Integration points
- Backend Policy Engine (in Scanner.WebService): calls
POST /excititor/resolve(scopevex.read) with batched(purl, vulnId)pairs to fetch linkset references and the underlying claims; verdict resolution is delegated to VexLens. - Concelier: provides alias graph (CVE↔vendor IDs) and may supply VEX‑adjacent metadata (e.g., KEV flag) for policy escalation.
- UI: VEX explorer screens use
/observations/searchand/linksets/search; show conflicts & provenance. - CLI: the
stella vexcommand group (StellaOps.Cli.Plugins.Vex/VexCliCommandModule.cs) exposesauto-downgrade,check,list,not-reachable,verify,evidence(withevidence exportfor VEX-evidence extraction by digest/component),webhooks, and the Rekorobservationgroup. (There is novex linksets exportsubcommand; the earlier example was inaccurate. Linkset/observation reads are HTTP GETs against/vex/linksetsand/vex/observations.)
16) Failure modes & fallback
- Provider unreachable: stale thresholds trigger warnings; policy can down‑weight stale providers automatically (freshness factor).
- Signature outage: continue to ingest but mark
signatureState.verified=false; downstream verdict resolution (VexLens) may exclude or down‑weight per policy. - Schema drift: unknown fields are preserved as
evidence; normalization rejects only on invalid identity or status.
17) Rollout plan (incremental)
- MVP: OpenVEX + CSAF connectors for 3 major providers (e.g., Red Hat/SUSE/Ubuntu), normalization + linkset assembly +
/excititor/resolve. - Signature policies: PGP for distros; cosign for OCI.
- Exports + optional attestation.
- CycloneDX VEX connectors; platform claim expansion tables; UI explorer.
- Scale hardening: export indexes; conflict analytics.
18) Operational runbooks
- Statement backfill — see
docs/dev/EXCITITOR_STATEMENT_BACKFILL.mdfor the CLI workflow, required permissions, observability guidance, and rollback steps.
19) Appendix — canonical JSON (stable ordering)
All exports and linkset entries are serialized via VexCanonicalJsonSerializer:
- UTF‑8 without BOM;
- keys sorted (ASCII);
- arrays sorted by
(providerId, vulnId, productKey, lastObserved)unless semantic order mandated; - timestamps in
YYYY‑MM‑DDThh:mm:ssZ; - no insignificant whitespace.
