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.


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:

  1. Immutable vex_raw rows. Upstream OpenVEX/CSAF/CycloneDX files are stored verbatim (content.raw) with provenance (issuer, statement_id, timestamps, signatures). Revisions append new versions linked by supersedes.
  2. 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.
  3. 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)

  1. Deterministic canonicalisation. Writers sort JSON keys/arrays, normalize timestamps (UTC ISO‑8601), and hash content for reproducible exports.
  2. AOC verifier. StellaOps.AOC.Verifier runs 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:

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:

Sprint 20260507-004 (MIRROR-005 closeout) unified the WebService and Worker signature verification surfaces:

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/raw and ingestion.

Schema: vex

Canonicalisation & hashing

  1. Parse upstream JSON; sort keys; normalize newlines; encode UTF-8 without BOM. Preserve array order.
  2. Retain a connector-supplied sha256: document digest when present because it identifies the fetched source artifact. If the connector supplied no usable digest, compute digest = "sha256:{hex}" over the canonical bytes.
  3. If size <= inline_threshold_bytes (default 256 KiB) set inline_payload=true and store the canonical JSON in content_json; otherwise store the canonical bytes in vex_raw_blobs and set inline_payload=false.
  4. Persist content_size_bytes; for a blob also persist payload_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

Runtime convergence

  1. StellaOps.Excititor.WebService, StellaOps.Excititor.Worker, and CLI hosts that enable VEX evidence linking resolve IVexProviderStore, 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.
  2. StellaOps.Excititor.Persistence owns 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.
  3. 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.
  4. Worker completion metadata (LastSuccessAt, heartbeat fields, checkpoint hashes) must not overwrite connector-managed cursor fields. Connectors own LastUpdated, 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:

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)

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:

  1. inspect vulnerability.aliases, then name, @id, and id for a CVE-YYYY-NNNN+ token;
  2. normalize the selected CVE to uppercase, including a CVE embedded in a URI;
  3. when no CVE exists, preserve the established fallback order: first alias, then name, @id, and id;
  4. apply the same CVE extraction to scalar vulnerability and legacy vuln values.

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

All exports remain deterministic and, when configured, attested via DSSE + Rekor v2.


3) Identity model — products & joins

2.1 Vuln identity

2.2 Product identity (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, embedded statements[]) that predates the relational migration. The live store is PostgreSQL with schemas vex and excititor (and vex_app), created by the squashed 001_v1_excititor_baseline.sql plus the active forward-only top-level migrations embedded from StellaOps.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, or vex.migrations tables with those literal names — timeline/eventing uses vex.timeline_events + vex.observation_timeline_events, export manifests are carried by the VexExportManifest object model, and migration tracking is handled by the platform startup-migration runner. The vex.consensus collection 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 into vexhub.statements (enforced/worker-projected only) are a deliberate double-store, recommended KEEP — two disjoint keys/shapes feeding two independent consumers (matcher /excititor/resolve reads vex.claims with an advisory-only apply gate; VexLens consensus reads the vexhub.statements normalized/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?
}

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
}

vex.events(observation/linkset events, optional long retention)

{
  _id: ObjectId,
  tenant,
  type: "vex.observation.updated" | "vex.linkset.updated",
  key,
  delta,
  hash,
  occurredAt
}

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 TypeConstantDescription
vex.statement.addedVexTimelineEventTypes.StatementAddedNew VEX statement ingested
vex.statement.supersededVexTimelineEventTypes.StatementSupersededStatement replaced by newer version
vex.statement.conflictVexTimelineEventTypes.StatementConflictConflicting statuses detected
vex.status.changedVexTimelineEventTypes.StatusChangedEffective 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:

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:

  1. Statement events ordered by occurredAtUtc ascending
  2. Conflict events emitted after all related statement events
  3. 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


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[]
}

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):

Verification flow inside the base class:

  1. The fetcher hands the byte stream + caller-side metadata to CreateRawDocumentAsync.
  2. The base class invokes context.SignatureVerifier.VerifyAsync(...) (the VexSignatureVerifierV1Adapter, which routes to V2 via ProductionVexSignatureVerifier).
  3. Success. The verifier’s metadata is merged into the document’s Metadata dictionary using the VexSignatureMetadataKeys constants (vex.signature.status=verified, plus issuer, issuerId, keyId, method, profile, verifiedAt, transparency.entry, transparency.logId).
  4. No signature. If verificationOptions.AllowUnsigned == true (non-production opt-in), the document is admitted with vex.signature.status=unsigned, vex.signature.policy=allow, and a Warning log entry. If AllowUnsigned == false (default), VexSignatureRequiredException(reason=NoSignature) is thrown.
  5. Any other failure. The V1 adapter throws VexSignatureRequiredException carrying the V2 failure reason, key id, and crypto profile. When verificationOptions.QuarantineOnFailure == true, the base class writes the rejected bytes to vex.excititor_quarantine through IVexQuarantineSink with reason signature-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 unless AllowUnsigned was explicitly enabled at the connector boundary outside production.

Quarantine persistence is provided by sprint 20260502-026:

FieldPurpose
tenantRLS tenant key.
connector_id, source_uri, captured_atWhere and when the rejected payload was fetched.
payload, payload_size_bytes, digestThe rejected bytes and their digest.
reason, diagnosticsStable failure family plus structured JSON diagnostics.
expires_atRetention 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:

ConnectorFormatSource artefactsCrypto profile (StellaOps.Cryptography)Notes
Cisco (excititor:cisco)Cosign-signed CSAF (Cisco PSIRT v2 feed)<docUri>.sig + <docUri>.cert siblings under the ROLIE provider directorycosign-keyless (Fulcio root cert from offline kit)Stamped metadata: signature-type=cosign, cosign-signature, cosign-bundle.
MSRC (excititor:msrc)Microsoft API; no detached signature todayn/a — quarantine pathx509-chain (mTLS to api.msrc.microsoft.com); revisited when MSRC ships signed feedsAll 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 documentpgp-detachedOracle 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 documentcosign-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 documentspgp-detachedCanonical security key fingerprint pinned via UbuntuConnectorOptions.PgpKeyFingerprint.
Rancher SUSE (excititor:suse-rancher)DSSE bundle in event payloadBundle is the event itself (no sibling fetch)dsseStamps 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):

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_versionMissing 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, /vulnerabilitiesMandatory 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}/cveWhen 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:

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:

CheckpointMethodInvariantFailure 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 keyTypeSourceNotes
aoc.raw.digestsha256:<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.lengthbase-10 integer stringcontent.Length at verification time.Tolerated as absent on legacy rows; mismatch is a hard fail.
aoc.raw.signedByissuer ID, issuer name, or @unsignedVerifier metadata (Trust.IssuerIdIssuer → 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):

ReasonTriggerDiagnostic field path
MissingLineageStampRaw document has no aoc.raw.digest metadata. Indicates a connector path that bypassed CreateRawDocumentAsync.metadata[aoc.raw.digest]
RawDigestMismatchStamped digest does not match VexRawDocument.Digest. Document was tampered with after verification.metadata[aoc.raw.digest]
RawLengthMismatchStamped length does not match Content.Length. Truncation or padding.metadata[aoc.raw.length]
BatchSourceMismatchThe VexClaimBatch.Source.Digest is not the raw document’s digest. Cross-batch contamination.claimBatch.source.digest
ClaimDigestMismatchA 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


6) Normalization: product & status semantics

5.1 Product mapping

5.2 Status + justification mapping


7) Verdict resolution (moved to VexLens)

Sprint 20260501-024 — DELETED. Excititor no longer computes consensus / verdict rollups. The previous BaselineVexConsensusPolicy and VexConsensusResolver types were removed; verdict resolution is the responsibility of VexLens (see docs/modules/vex-lens/architecture.md).

Excititor preserves and transports linksets:

  • Connectors emit signed-statement-pointer observations.
  • The append-only IAppendOnlyLinksetStore correlates 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/resolve responses for downstream resolution.

The historical algorithm (filter → score → aggregate → pick → tiebreak → explain) and its rollupStatus/consensusDigest outputs have been retired. Existing exports retain a consensusDigest field on VexExportManifest for 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:

ComponentSymbolDescriptionRange
ProvenancePCryptographic & process integrity (signatures, key management)0.0–1.0
CoverageCScope match precision (how well claims match the target)0.0–1.0
ReplayabilityRDeterminism 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 ClassPCRNotes
Vendor0.900.700.60High provenance, moderate coverage
Distro0.800.850.60Strong coverage for package-level claims
Internal0.850.950.90Highest coverage and replayability
Hub0.600.500.40Aggregated sources, lower baseline
Attestation0.950.800.70Cryptographically 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 TypeStrength (M)
Exploitability analysis + reachability proof1.00
Config/feature-flag reason with evidence0.80
Vendor blanket statement0.60
Under investigation0.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:

  1. Score claims using the ClaimScore formula (ClaimScoreCalculator.Compute).
  2. Detect conflicts when the scored claims contain more than one distinct status.
  3. Apply conflict penalty (default δ=0.25, configurable via the conflictPenalty constructor arg) by multiplying every claim’s FinalScore by (1 − δ) when conflicts exist.
  4. Order candidates by FinalScore descending (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.
  5. 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[] / requiresReplayProof fields shown in earlier drafts are not present on MergeResult. The winning status is read from WinningClaim.Status; conflict detail beyond the HasConflict flag and per-claim PenaltyApplied is not surfaced by the merger itself.

7.1.4 Policy Gates (PROPOSED — not implemented)

NOT IMPLEMENTED. No MinimumConfidenceGate, UnknownsBudgetGate, SourceQuotaGate, ReachabilityRequirementGate, or PolicyGateRegistry type exists in StellaOps.Excititor.* (the only “gate” in the codebase is the unrelated AutoVex/DriftGateIntegration). The table below is a forward design from docs/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)PurposeDefault Threshold
MinimumConfidenceGateReject verdicts below confidence threshold0.75 (prod), 0.60 (staging)
UnknownsBudgetGateFail if unknowns exceed budget5 per scan
SourceQuotaGateCap single-source influence60% unless corroborated
ReachabilityRequirementGateRequire reachability proof for criticalsEnabled

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:

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 as GET with query-string filters, not POST .../search. The shapes below have been corrected to match the live route table.

Generated OpenAPI contract. GET /openapi/excititor.json is 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/openapi remains 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/resolve to return linkset references plus claims keyed by linkset digest, deferring final verdict resolution to VexLens. The previous status / sources / conflicts / decisions / envelope fields are gone.

7.2 Exports (cacheable snapshots)

NOT IMPLEMENTED as POST /exports. There is no generic POST /exports / GET /exports/{exportId} endpoint in StellaOps.Excititor.WebService. The export surface that exists is the mirror domain API in MirrorEndpoints.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}/download

Mirror 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 HTTP POST. 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}/status route; 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):

9) Attestation integration

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):

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.


11) Security model


12) Performance & scale

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


14) Testing matrix


15) Integration points


16) Failure modes & fallback


17) Rollout plan (incremental)

  1. MVP: OpenVEX + CSAF connectors for 3 major providers (e.g., Red Hat/SUSE/Ubuntu), normalization + linkset assembly + /excititor/resolve.
  2. Signature policies: PGP for distros; cosign for OCI.
  3. Exports + optional attestation.
  4. CycloneDX VEX connectors; platform claim expansion tables; UI explorer.
  5. Scale hardening: export indexes; conflict analytics.

18) Operational runbooks


19) Appendix — canonical JSON (stable ordering)

All exports and linkset entries are serialized via VexCanonicalJsonSerializer: