component_architecture_concelier.md — Stella Ops Concelier (Sprint 22)
Derived from Epic 1 – AOC enforcement and aligned with the Export Center evidence interfaces first scoped in Epic 10.
Scope. Implementation-ready architecture for Concelier: the advisory ingestion and Link-Not-Merge (LNM) observation pipeline that produces deterministic raw observations, correlation linksets, and evidence events consumed by Policy Engine, Console, CLI, and Export centers. Covers domain models, connectors, observation/linkset builders, storage schema, events, APIs, performance, security, and test matrices.
0) Mission & boundaries
Mission. Acquire authoritative vulnerability advisories (vendor PSIRTs, distros, OSS ecosystems, CERTs), persist them as immutable observations under the Aggregation-Only Contract (AOC), construct linksets that correlate observations without merging or precedence, and export deterministic evidence bundles (JSON, Trivy DB, Offline Kit) for downstream policy evaluation and operator tooling.
Boundaries.
- Concelier does not sign with private keys. When attestation is required, the export artifact is handed to the Signer/Attestor pipeline (out‑of‑process).
- Concelier does not decide PASS/FAIL; it provides data to the Policy engine.
- Online operation is allowlist‑only; air‑gapped deployments use the Offline Kit.
Mounted plugin runtime (updated 2026-06-04). Concelier executable advisory source/exporter bundles are mounted read-only from devops/plugins/concelier/<profile>/<plugin-id>/ to /app/plugins/concelier/<profile>/<plugin-id>/. The base compose points CONCELIER_PLUGINS__BASEDIRECTORY at /app/plugins/concelier/base; the recommended overlay points it at the profile parent /app/plugins/concelier and mounts both base and recommended read-only so required base validation and optional discovery can run together. CONCELIER_PLUGINS__DIRECTORY remains . so the selected read-only root itself is scanned. Config is mounted at /app/etc/plugins/concelier, trust roots at /app/trust-roots/plugins/concelier, and scratch/probe output belongs under /var/lib/stellaops/plugin-scratch/concelier. Static source catalogs and rate-limit config remain data, not executable plugins.
The current Concelier producer emits signed base advisory-source/exporter bundles and signed recommended optional advisory-source bundles. The recommended profile covers vendor PSIRT, national/regional CERT, optional distro, package ecosystem, cloud, ICS, exploit-intelligence, hardware-vendor, and StellaOps mirror-only connectors under devops/plugins/concelier/recommended. The shared recommended compose overlay points Concelier at /app/plugins/concelier and mounts both base and recommended read-only; required base validation still resolves the base profile while recursive plugin discovery can execute optional recommended jobs. The air-gap E2E compose overlay follows the same parent-root convention for the publisher and replica so stellaops-mirror is delivered as a signed mounted recommended bundle instead of an image-resident connector. For sealed/mounted proof lanes that only validate startup, plugin status/probe, and job catalog surfaces, set Concelier:Jobs:Scheduler:Enabled=false (or CONCELIER_JOBS__SCHEDULER__ENABLED=false) on every Concelier replica. This keeps cron-triggered source fetches from falling back to live upstream defaults while preserving registered job definitions for explicit manual/API triggers. The harness profile now has signed fixture-only Concelier bundles concelier-harness-source and concelier-harness-exporter, both backed by StellaOps.Concelier.Connector.Harness. They register explicit jobs source:harness:fetch and export:harness and write deterministic local JSON artifacts under Concelier:Harness:OutputRoot without live upstream access. docker-compose.plugins.harness.yml mounts the normal Concelier profile parent with base, recommended, and harness read-only roots so the fixture bundles can be validated beside the mounted runtime inventory. Runtime evidence on 2026-06-13 proves the signed harness bundles admit/probe and the two harness jobs succeed in an isolated harness-only diagnostic stack; full mounted base-plus-recommended-plus-harness status still needs a startup unblock before the fixture compose path is final acceptance.
Concelier and Excititor probe reports treat a bare DLL as incomplete. A bundle is eligible for admitted=true, loaded=true, and probed=true only when the bundle root contains the executable payload plus manifest evidence (plugin.json, manifest.json, or equivalent), checksums.sha256, and signature evidence (adjacent .sig, signed manifest, or signature document). Incomplete required base bundles fail the required-bundle startup/readiness gate; optional incomplete bundles are visible as rejected/not admitted rather than reported as successfully probed.
Required mounted bundles now distinguish signature evidence from signature verification. When Concelier:Plugins:EnforceSignatureVerification=true or Excititor:Worker:Plugins:EnforceSignatureVerification=true, the host requires a real verifier such as Signature:Provider=cosign plus the mounted trust root. Signature:Provider=none, NullPluginVerifier, bad signatures, or AllowUnsigned=true with enforcement enabled fail closed before required source/exporter/VEX bundles are loaded.
Direct-reference migration guard (updated 2026-06-06): Concelier WebService no longer compiles source/exporter implementation assemblies. Source readiness and mirror runtime contracts that the host owns now live in the allowed StellaOps.Concelier.Connector.Common library, while Cisco, MSRC, Oracle, StellaOpsMirror, regional, distro, vendor PSIRT, ICS, Astra, Russian-source, cloud, exploit-intelligence, hardware-vendor, CERT, JSON, and TrivyDB executables stay outside the runtime project graph. Their job/source catalog entries remain string metadata until signed mounted bundles provide the executable payloads. The frozen guard is enforced by src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/RuntimePluginProjectReferenceGuardTests.cs so the debt cannot grow unnoticed. The scoped audit now reports zero Concelier WebService connector/exporter violations, with shared Connector.Common, the legacy plugin host library, and the build-only merge analyzer allowed separately. Job registration receives the same PluginHostOptions as the runtime plugin host and resolves both scheduler job types and IDependencyInjectionRoutine registrations from mounted plugin assemblies before falling back to compile-time deps.json resolution. Runtime acceptance still requires signed bundle material, trust-root verification, and compose probe evidence for the replacement payloads.
Required-bundle job resolution is fail-closed. When RequireBaseBundleSet=true or RequiredBundles names a Concelier source/exporter bundle, scheduler registration treats the corresponding job assembly as mandatory: if the mounted plugin catalog and deps.json fallback cannot resolve the expected job type, startup/options validation raises an actionable error naming the missing job kind, assembly, payload DLL, and required-bundle setting. Optional unmounted bundles remain non-fatal and continue to appear as not-mounted/disabled in the plugin probe surface.
1) Aggregation-Only Contract guardrails
Epic 1 distilled — the service itself is the enforcement point for AOC. The guardrail checklist is embedded in code (AocWriteGuard, namespace StellaOps.Aoc, in src/Aoc/__Libraries/StellaOps.Aoc/) and must be satisfied before any advisory hits PostgreSQL:
- No derived semantics in ingestion. Connector envelopes cannot add top-level derived severity, consensus, reachability, merged status, or fix hints; source-observed fields remain preserved inside
content.raw. Roslyn analyzers (StellaOps.Aoc.Analyzers, insrc/Aoc/__Analyzers/) scan ingestion writers and fail builds if forbidden properties appear. - Immutable raw rows. Every upstream advisory is persisted in
vuln.advisory_rawwith append-only semantics. A changedcontent_hashproduces a new row (a fresh deterministicid), linking back through the optionalsupersedes/supersedes_idpointer. - Mandatory provenance. Collectors record
source(vendor,connector,version, optionalstream),upstreammetadata (upstream_id, optionaldocument_version,retrieved_at,content_hash, a string→stringprovenancemap), and signature presence (upstream.signature) before writing. - Linkset only. Derived joins (aliases, scopes, relationships, PURLs, CPEs, references) are stored inside
linksetand never mutatecontent.raw. - Deterministic canonicalisation. Writers use canonical JSON (sorted object keys, lexicographic arrays) ensuring identical inputs yield the same hashes/diff-friendly outputs.
- Idempotent upserts. The DB dedupe key
(tenant_id, source_vendor, upstream_id, content_hash)uniquely identifies a document. Duplicate hashes short-circuit (ON CONFLICT DO NOTHING); new hashes create a new row. - Verifier & CI. The same
AocWriteGuardruns in CI and at runtime, raisingAocGuardExceptionwith typedAocViolationCodevalues that map (viaAocViolationCodeExtensions, insrc/Aoc/__Libraries/StellaOps.Aoc/) toERR_AOC_0xxerror codes:ForbiddenField→ERR_AOC_001,MergeAttempt→ERR_AOC_002,IdempotencyViolation→ERR_AOC_003,MissingProvenance→ERR_AOC_004,SignatureInvalid→ERR_AOC_005,DerivedFindingDetected→ERR_AOC_006,UnknownField(unknown top-level fields) →ERR_AOC_007,MissingRequiredField→ERR_AOC_008,InvalidTenant→ERR_AOC_009,InvalidSignatureMetadata→ERR_AOC_010(theNonesentinel maps toERR_AOC_000). Operator-facing verification is exposed via thePOST /aoc/verifyendpoint (scopesadvisory:read+aoc:verify) and thestella aoc verifyCLI command.
Feature toggle: set
concelier:features:noMergeEnabled=trueto disable the legacy Merge module and itsmerge:reconcilejob once Link-Not-Merge adoption is complete (MERGE-LNM-21-002). AnalyzerCONCELIER0002prevents new references to Merge DI helpers when this flag is enabled.
1.1 Advisory raw document shape
The serialized document_json payload is the AdvisoryRawDocument record (src/Concelier/__Libraries/StellaOps.Concelier.RawModels/AdvisoryRawDocument.cs). Its real JSON shape (the row id / dedupe key / column projections in vuln.advisory_raw are computed by the repository, not carried inside this document):
{
"tenant": "default",
"source": {
"vendor": "OSV",
"connector": "osv",
"version": "concelier/1.7.3",
"stream": "github"
},
"upstream": {
"upstream_id": "GHSA-xxxx-....",
"document_version": "2025-09-01T12:13:14Z",
"retrieved_at": "2025-09-01T13:04:05Z",
"content_hash": "sha256:...",
"signature": {
"present": true,
"format": "dsse",
"key_id": "rekor:.../key/abc",
"sig": "base64...",
"certificate": null,
"digest": null
},
"provenance": { "...": "string→string provenance map" }
},
"content": {
"format": "OSV",
"spec_version": "1.6",
"raw": { "...": "unmodified upstream document" },
"encoding": null
},
"identifiers": {
"aliases": ["CVE-2025-12345", "GHSA-xxxx-...."],
"primary": "GHSA-xxxx-...."
},
"linkset": {
"aliases": ["CVE-2025-12345"],
"scopes": [],
"relationships": [ {"type": "...", "source": "...", "target": "...", "provenance": null} ],
"purls": ["pkg:npm/lodash@4.17.21"],
"cpes": ["cpe:2.3:a:lodash:lodash:4.17.21:*:*:*:*:*:*:*"],
"references": [
{"type": "advisory", "url": "https://...", "source": null},
{"type": "fix", "url": "https://...", "source": null}
],
"reconciled_from": ["content.raw.affected.ranges", "content.raw.pkg"],
"notes": {}
},
"advisory_key": "CVE-2025-12345",
"links": [
{"scheme": "CVE", "value": "CVE-2025-12345"},
{"scheme": "GHSA", "value": "GHSA-XXXX-...."}
],
"supersedes": null
}
Differences from earlier drafts: there is no document-level
_id;sourceusesconnector/version(notapi/collector_version);upstreamcarriesretrieved_at+ aprovenancemap (notfetched_at/received_at);linksetadditionally carriesscopes,relationships, andnotes.
1.2 Connector lifecycle
- Snapshot stage — connectors fetch signed feeds or use offline mirrors keyed by
{vendor, stream, snapshot_date}. - Parse stage — upstream payloads are normalised into strongly-typed DTOs with UTC timestamps.
- Guard stage — connector fetches run through
IAdvisoryRawWriteGuardinSourceFetchServicebefore raw payload/document persistence; direct HTTP/import ingestion repeats the same guard inAdvisoryRawService. The underlyingAocWriteGuardperforms schema validation, forbidden-field checks, provenance validation, and deterministic normalization. A rejected connector envelope raisesERR_AOC_00xand writes neither the storage document nor raw payload. - Write stage —
AdvisoryRawService.IngestAsyncpersists in a fixed order (per the pipeline contract documented in thevuln.advisory_rawDDL block of001_v1_concelier_baseline.sql): (a)_writeGuard.EnsureValid(...); (b)IAdvisoryRawRepository.UpsertAsyncwrites the canonical immutable row intovuln.advisory_raw(baseline line 2574, dedupe key(tenant_id, source_vendor, upstream_id, content_hash)— duplicate hash is a no-op viaON CONFLICT DO NOTHING, changed hash creates a new row with a fresh deterministic ID and an optionalsupersedes_idpointer); ©IAdvisoryObservationSink.UpsertAsyncprojects the normalizedvuln.advisory_observationsrow used by Link-Not-Merge consumers; (d)IAdvisoryLinksetSink.UpsertAsyncrefreshes thevuln.lnm_linkset_cachelinkset cache. The raw store is the parent record — observations and linksets are downstream projections of the same ingest call. - Event stage —
advisory.observation.updatedandadvisory.linkset.updatedevents (carrying canonical hashes + provenance references) are published on the messaging transport to notify downstream services (Policy, Export Center, CLI).
1.3 Export readiness
Concelier feeds Export Center profiles (Epic 10) by:
- Maintaining canonical JSON exports with deterministic manifests (
export.json) listing content hashes, counts, andsupersedeschains. - Producing Trivy DB-compatible artifacts (SQLite + metadata) packaged under
db/with hash manifests. - Surfacing mirror manifests that reference PostgreSQL snapshot digests, enabling Offline Kit bundle verification.
Running the same export job twice against the same snapshot must yield byte-identical archives and manifest hashes.
2) Topology & processes
Process shape: single ASP.NET Core service StellaOps.Concelier.WebService hosting:
- Scheduler with distributed PostgreSQL leases backed by
vuln.job_leases, durable job-run history invuln.job_runs, and durable internal orchestrator runtime state invuln.orchestrator_registry,vuln.orchestrator_heartbeats,vuln.orchestrator_commands, andvuln.orchestrator_manifests. - Connectors (fetch/parse/map) that emit immutable observation candidates.
- Observation writer enforcing AOC invariants via
AocWriteGuard. - Linkset builder that correlates observations into the
vuln.lnm_linkset_cachelinkset cache and annotates conflicts. - Event publisher emitting
advisory.observation.updatedandadvisory.linkset.updatedmessages. - Exporters (JSON, Trivy DB, Offline Kit slices) fed from observation/linkset stores.
- Minimal REST for health/status/trigger/export, raw observation reads, and evidence retrieval (
GET /vuln/evidence/advisories/{advisory_key}).
Scale: HA by running N replicas; locks prevent overlapping jobs per source/exporter.
3) Canonical domain model
Current-state warning (2026-07-22): the
vulnandconcelierschemas below are still stored in the shared physical databasestellaops_platform, but that placement is not the target architecture. Live evidence shows vulnerability source/serving data dominates the physical Platform database and crosses lifecycle/retention ownership. The separation proposal and migration gates are in Vulnerability data-plane separation investigation. Schema ownership must not be read as physical capacity or failure isolation.
Stored in PostgreSQL (shared database
stellaops_platform; Concelier owns thevulnandconcelierschemas — see the consolidation ADR at the end of this document), serialized with a canonical JSON writer (stable order, camelCase, normalized timestamps).
2.1 Core entities
AdvisoryObservation
observationId // deterministic id: {tenant}:{source.vendor}:{upstreamId}:{revision}
tenant // issuing tenant (lower-case)
source{
vendor, stream, api, collectorVersion
}
upstream{
upstreamId, documentVersion, fetchedAt, receivedAt,
contentHash, signature{present, format?, keyId?, signature?}
}
content{
format, specVersion, raw, metadata?
}
identifiers{
cve?, ghsa?, vendorIds[], aliases[]
}
linkset{
purls[], cpes[], aliases[], references[{type,url}],
reconciledFrom[]
}
createdAt // when Concelier recorded the observation
attributes // optional provenance metadata (batch ids, ingest cursor)
```jsonc
#### AdvisoryLinkset
```jsonc
linksetId // sha256 over sorted (tenant, product/vuln tuple, observation ids)
tenant
key{
vulnerabilityId,
productKey,
confidence // low|medium|high
}
observations[] = [
{
observationId,
sourceVendor,
statement{
status?, severity?, references?, notes?
},
collectedAt
}
]
aliases{
primary,
others[]
}
purls[]
cpes[]
conflicts[]? // see AdvisoryLinksetConflict
createdAt
updatedAt
```jsonc
#### AdvisoryLinksetConflict
```jsonc
conflictId // deterministic hash
type // severity-mismatch | affected-range-divergence | reference-clash | alias-inconsistency | metadata-gap
field? // optional JSON pointer (e.g., /statement/severity/vector)
observations[] // per-source values contributing to the conflict
confidence // low|medium|high (heuristic weight)
detectedAt
```jsonc
#### ObservationEvent / LinksetEvent
```jsonc
eventId // ULID
tenant
type // advisory.observation.updated | advisory.linkset.updated
key{
observationId? // on observation event
linksetId? // on linkset event
vulnerabilityId?,
productKey?
}
delta{
added[], removed[], changed[] // normalized summary for consumers
}
hash // canonical hash of serialized delta payload
occurredAt
```jsonc
#### ExportState
```jsonc
exportKind // json | trivydb
baseExportId? // last full baseline
baseDigest? // digest of last full baseline
lastFullDigest? // digest of last full export
lastDeltaDigest? // digest of last delta export
cursor // per-kind incremental cursor
files[] // last manifest snapshot (path → sha256)
```jsonc
Legacy `Advisory`, `Affected`, and merge-centric entities remain in the repository for historical exports and replay but are being phased out as Link-Not-Merge takes over. New code paths must interact with `AdvisoryObservation` / `AdvisoryLinkset` exclusively and emit conflicts through the structured payloads described above.
### 2.2 Product identity (`productKey`)
* **Primary:** `purl` (Package URL).
* **OS packages:** RPM (NEVRA→purl:rpm), DEB (dpkg→purl:deb), APK (apk→purl:alpine), with **EVR/NVRA** preserved.
* **Secondary:** `cpe` retained for compatibility; advisory records may carry both.
* **Image/platform:** `oci:<registry>/<repo>@<digest>` for image‑level advisories (rare).
* **Unmappable:** if a source is non‑deterministic, keep native string under `productKey="native:<provider>:<id>"` and mark **non‑joinable**.
---
## 4) Source families & precedence
The source catalog **is** the `SourceDefinitions.All` array in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs` — read the array for the authoritative membership and count (a hardcoded number here rots; this doc previously claimed 71 while the array held 67). For the full connector index see `docs/modules/concelier/connectors.md`. The catalog reflects removal of legacy ARM, `poc-github`, Docker Official Images metadata, MITRE ATT&CK/D3FEND, and the deprecated CSAF/VEX stub rows.
> **The family prose in §3.1 is a reading aid, not the catalog.** It names a few vendors/CERTs that have **no** `SourceDefinition` entry and therefore no runnable source: **Juniper**, **NCSC-CH**, and **CentOS** (CentOS advisories arrive via the `rhel` source, not a CentOS one). Trust `SourceDefinitions.All` over the bullet lists below.
Runtime surfaces intentionally distinguish between catalog breadth and runnable support. `/api/v1/advisory-sources/catalog`, `/api/v1/advisory-sources/status`, `/api/v1/advisory-sources/{sourceId}/sync-history`, and the source configuration response expose `installed`, `installationState` (`installed` or `missing`), and the backward-compatible `syncSupported` flag plus the resolved `fetchJobKind`. Configuration responses also expose persisted operator state as `enabled` and `operatorState` (`enabled` or `disabled`). Only sources with a registered fetch pipeline can be enabled or synced through the live host. Operators should use the canonical runtime IDs from the catalog, including `jpcert`, `auscert`, `krcert`, `cert-de`, `adobe`, and `chromium`; legacy aliases such as `jvn`, `acsc`, `kisa`, `cert-bund`, `vndr-adobe`, and `vndr-chromium` remain compatibility fallbacks for configuration binding and source normalization only.
**Federated catalog (Sprint 20260503-012).** The catalog endpoint composes contributors implementing `IAdvisorySourceCatalogContributor` (`StellaOps.Concelier.Core.Sources.Configuration`). Concelier registers `SourceRegistryCatalogContributor` (`Backend = "concelier"`); Excititor registers `ExcititorAdvisorySourceCatalogContributor` (`Backend = "excititor"`) so the 7 canonical VEX providers project into the same surface under the `excititor:{provider}` source-id namespace and `Category = "Vex"`. Mutations against `excititor:`-prefixed ids route to Excititor's `VexProviderConfigurationService` — Excititor remains authoritative for trust weight, PGP fingerprints, cosign issuer/identity, crypto profile, and offline-snapshot toggles. The 3 stub `SourceDefinitions` entries (`csaf`, `csaf-tc`, `vex`) are deprecated; tooling should target the canonical `excititor:{provider}` ids. See `docs/modules/vex-hub/architecture.md` §11 for the operator migration note.
Per-source field coverage validates source ids against this same federated catalog. Local Concelier ids resolve through `ISourceRegistry`; foreign ids resolve through their `IAdvisorySourceCatalogContributor`. A cataloged provider with no persisted `vuln.sources` row returns the documented zero scorecard, while an id absent from both catalogs returns `404`. Coverage values continue to come from the shared `vuln` read model; this does not duplicate Excititor-owned trust or configuration state.
### 3.1 Families
* **Primary databases**: NVD, OSV, GHSA, CVE.org (MITRE).
* **Vendor PSIRTs**: Red Hat, Microsoft, Oracle, Adobe, Apple, Chromium, Cisco, VMware, Fortinet, Juniper, Palo Alto, plus cloud providers (AWS, Azure, GCP).
* **Linux distros**: Debian, Ubuntu, Alpine, SUSE, RHEL, CentOS, Fedora, Arch, Gentoo, Astra Linux.
* **OSS ecosystems**: npm, PyPI, Go, RubyGems, NuGet, Maven, Crates.io, Packagist, Hex.pm.
* **Package manager native**: RustSec (cargo-audit), PyPA (pip-audit), Go Vuln DB (govulncheck), Ruby Advisory DB (bundler-audit).
* **CSAF/VEX**: surfaced via the Excititor sibling contributor under the `excititor:{provider}` source-id namespace (see §3, "Federated catalog"); the legacy stub `SourceDefinitions` entries (`csaf`, `csaf-tc`, `vex`) have been removed from `SourceDefinitions.All`.
* **Exploit metadata**: Exploit-DB and Metasploit Modules. The speculative PoC-in-GitHub source was removed because repository search is not an approved vulnerability/VEX feed.
* **Container**: Chainguard Advisories through OSV. The speculative Docker Official CVEs source was removed because no official Docker Official Images CVE/VEX feed was identified.
* **OSV-backed distro/container aliases**: Wolfi Security and Chainguard image advisories are ingested through the OSV aggregator and attributed to dedicated source buckets.
* **Hardware/firmware**: Intel PSIRT, AMD Security, Siemens ProductCERT. The legacy ARM source was removed because the previous backend failed closed and no approved machine-readable Arm feed is available.
* **ICS/SCADA**: Siemens ProductCERT, Kaspersky ICS-CERT.
* **CERTs / national CSIRTs**: CERT-FR, CERT-Bund (`cert-de`), CERT.at, CERT.be, NCSC-CH, CERT-EU, CCCS (Canadian Centre for Cyber Security), JPCERT/CC, CERT/CC, CISA (US-CERT), CERT-UA, CERT.PL, AusCERT, KrCERT/CC, CERT-In.
* **Russian/CIS**: FSTEC BDU, NKCKI (both promoted to stable).
* **Threat intelligence**: EPSS (FIRST), CISA KEV. (MITRE ATT&CK and MITRE D3FEND were removed from the catalog under PM Path 2, `SPRINT_20260513_008` / `CON-MITRE-004`.)
* **StellaOps Mirror**: Pre-aggregated advisory mirror for offline/air-gap deployments.
### Source category enum
Primary, Vendor, Distribution, Ecosystem, Cert, Csaf, Threat, Exploit, Container, Hardware, Ics, PackageManager, Mirror, Other
### 3.2 Gate-only precedence (when claims conflict)
Link-Not-Merge ingestion and correlation apply **no precedence**: every source observation and every
conflict remains intact. A release gate still needs one stable basis, so
`IssueGateDecisionResolver` resolves status, severity, and affected range independently at the gate
boundary. Lower configured ranks win; `AdvisorySourcePrecedenceDefaults` is only the fallback table.
The resolver records the winning observation and every lower-ranked alternative for each field. Its
output is materialized in `vuln.issue_gate_decisions`, keyed by `(tenant_id, issue_key)`, with input,
settings, and decision hashes. It is a rebuildable decision artifact, never a replacement source
record. Changing `concelier:gateDecisionProjection:sourcePrecedence` changes the settings hash and
automatically makes the old projection rows eligible for bounded re-derivation.
Steady-state invalidation is queue-driven. Migration `024_issue_gate_decision_dirty_queue.sql` installs
an `issue_linksets` trigger that upserts changed issue keys into
`vuln.issue_gate_decision_rebuild_queue`; a successful decision upsert acknowledges the key. The
projector records the reconciled settings hash in `vuln.issue_gate_decision_projection_state` only
after a zero-candidate proof. When that state matches the current settings, candidate reads are bounded
to the dirty queue. A settings change deliberately falls back to the full ordered reconciliation and
does not advance the state until the corpus is current. This keeps normal no-op ticks independent of
the multi-million-row corpus size without weakening settings-hash or linkset-content-hash invalidation.
For severity, literal `unknown` is absence rather than a winning value: the resolver first selects a
known source severity by configured precedence, then derives from the highest valid source-aligned
CVSS base score, then uses linked-CVE severity only when that evidence belongs to the same product
linkset. If no product-aligned severity or CVSS evidence exists, the result remains unknown; severity
is never copied from another product merely because it shares a CVE. The advisory issue backfill
preserves each source's severity, title, summary, and CVSS metrics in its observation facts instead of
copying the same canonical severity onto every source edge. Title and summary choose the richest
available text within the linkset, with the other source values retained as alternatives.
---
## 5) Connectors & normalization
### 4.1 Connector contract
```csharp
public interface IFeedConnector {
string SourceName { get; }
Task FetchAsync(IServiceProvider sp, CancellationToken ct); // -> document collection
Task ParseAsync(IServiceProvider sp, CancellationToken ct); // -> dto collection (validated)
Task MapAsync(IServiceProvider sp, CancellationToken ct); // -> advisory/alias/affected/reference
}
```jsonc
* **Fetch**: windowed (cursor), conditional GET (ETag/Last‑Modified), retry/backoff, rate limiting.
* **Parse**: schema validation (JSON Schema, XSD/CSAF), content type checks; write **DTO** with normalized casing.
* **Map**: build canonical records; all outputs carry **provenance** (doc digest, URI, anchors). KEV references use `reference` provenance anchored to the catalog search URL.
### 4.2 Version range normalization
* **SemVer** ecosystems (npm, pypi, maven, nuget, golang): normalize to `introduced`/`fixed` semver ranges (use `~`, `^`, `<`, `>=` canonicalized to intervals).
* **RPM EVR**: `epoch:version-release` with `rpmvercmp` semantics; store raw EVR strings and also **computed order keys** for query.
* **DEB**: dpkg version comparison semantics mirrored; store computed keys.
* **APK**: Alpine version semantics; compute order keys.
* **Generic**: if provider uses text, retain raw; do **not** invent ranges.
### 4.3 Severity & CVSS
* Normalize **CVSS v2/v3/v4** where available (vector, baseScore, severity).
* Track every CVSS source. Gate effective severity follows the configured source precedence within
the product linkset, then the best valid source-aligned CVSS score; it does not take a corpus-wide
maximum or copy another product's score.
* **ExploitKnown** toggled by KEV and equivalent sources; store **evidence** (source, date).
---
## 6) Observation & linkset pipeline
> **Goal:** deterministically ingest raw documents into immutable observations, correlate them into evidence-rich linksets, and broadcast changes without precedence or mutation.
### 5.1 Observation flow
1. **Connector fetch/parse/map** — connectors download upstream payloads, validate signatures, and map to DTOs (identifiers, references, raw payload, provenance). All outbound HTTP traffic is paced by the [adaptive host throttler](#511-adaptive-host-throttling) before reaching the upstream.
2. **AOC guard** — `AocWriteGuard` verifies forbidden keys, provenance completeness, tenant claims, timestamp normalization, and content hash idempotency. Violations raise `ERR_AOC_00x` mapped to structured logs and metrics.
3. **Append-only write** — observations insert into `advisory_observations`; duplicates by `(tenant, source.vendor, upstream.upstreamId, upstream.contentHash)` become no-ops; new content for same upstream id creates a supersedes chain.
4. **Event publish** — the ingest path publishes `advisory.observation.updated@1` events via `IAdvisoryObservationEventPublisher` (router messaging transport, default stream `CONCELIER_OBS`) with deterministic payloads (IDs, hash, supersedes pointer, linkset summary). Policy Engine, Offline Kit builder, and guard dashboards subscribe; durable replay is served from the shared `StellaOps.Eventing` PostgreSQL timeline store.
#### 5.1.1 Adaptive host throttling
Sprint 20260504_003 introduced an in-memory **AdaptiveHostRateLimiter** under `StellaOps.Concelier.Connector.Common.Fetch`. It is a singleton keyed by lowercased `Uri.Authority` (host + port). All Concelier connectors flow through it via `SourceFetchService.SendAsync`; the seven Excititor CSAF connectors flow through it via `AdaptiveThrottlingHandler` (a `DelegatingHandler` chained on each connector's `IHttpClient` builder).
**Algorithm (AIMD).** First request to any host is permitted immediately (no warm-up). Subsequent requests sleep until `lastRequestUtc + 1/currentRpsCap`, perturbed by ±15 % jitter. After `K=10` consecutive 200/304 responses the cap is additively raised by `+0.1 RPS` (capped at `maxRpsCap`). Any 429/503 response halves the cap (floored at `minRpsCap`) and clears the success counter. A `Retry-After` (or `X-RateLimit-Reset`) header sets a hard `quietUntilUtc` floor that overrides everything else.
**Default per-host caps** (from `Concelier:Throttling`; see `connector-configuration.md` for the full schema):
| Vendor class | Initial RPS | Min RPS | Max RPS | Example hosts |
|---|---|---|---|---|
| Big-iron APIs | 1.0 | 0.1 | 10.0 | `services.nvd.nist.gov`, `api.github.com`, `api.msrc.microsoft.com`, `cveawg.mitre.org` |
| Vendor portals | 0.5 | 0.05 | 5.0 | `api.cisco.com`, `www.oracle.com`, `helpx.adobe.com`, `support.apple.com` |
| Distro trackers | 0.25 | 0.05 | 2.0 | `security-tracker.debian.org`, `ubuntu.com`, `secdb.alpinelinux.org`, `ftp.suse.com`, `access.redhat.com` |
| CERTs | 0.5 | 0.05 | 3.0 | `www.kb.cert.org`, `www.cert-bund.de`, `cyber.gc.ca`, `bdu.fstec.ru`, `jvndb.jvn.jp`, `www.kisa.or.kr` |
| OSV/EPSS | 2.5 | 0.5 | 20.0 | `api.osv.dev`, `api.first.org` |
| Mirror/internal | 25.0 | 5.0 | 100.0 | `*.stella-ops.local` |
Hosts with no explicit mapping inherit `defaultClass`. Operators override per-host via `Concelier:Throttling:Hosts:{authority}:Class` or by overriding `InitialRps`/`MinRps`/`MaxRps` directly.
**Failure model.** Any internal exception inside the limiter is logged at Warning and treated as "permit immediately" — ingestion never blocks on limiter bugs.
**Observability.** Meter `StellaOps.Concelier.Connector.Common`:
| Instrument | Kind | Tags | Notes |
|---|---|---|---|
| `concelier.throttle.wait_ms` | Histogram | `host`, `class` | Time spent sleeping in `WaitForSlotAsync`. |
| `concelier.throttle.rps_cap` | Gauge (observable) | `host`, `class` | Current `currentRpsCap` per host. |
| `concelier.throttle.aimd_decrease_total` | Counter | `host`, `class`, `reason` ∈ {`429`, `503`, `retry_after`} | Cap halvings. |
Operators read live state at `GET /api/v1/diagnostics/throttle` (Concelier WebService) or the Excititor equivalent. On Concelier this read endpoint is gated by the `Concelier.Jobs.Read` policy (scope `concelier.jobs.read` **or** `concelier.jobs.trigger`; the trigger scope stays configurable via `Authority.RequiredScopes`) — there is no `concelier.admin` scope.
### 5.2 Linkset correlation
1. **Queue** — observation deltas enqueue correlation jobs keyed by `(tenant, vulnerabilityId, productKey)` candidates derived from identifiers + alias graph.
2. **Canonical grouping** — `StellaOps.Concelier.Models.AliasSchemeRegistry` is the single alias classification and identity-priority authority used by model validation, persistence conversion, CVE normalization, and identity resolution. Identity is CVE-first, then follows the registry's deterministic fallback order. A GHSA/CVE twin therefore has one CVE identity while retaining both source aliases. Product keys remain normalized independently (purl preferred), and LNM never discards the source observations.
3. **Linkset materialization** — `vuln.lnm_linkset_cache` rows store sorted observation references, normalized linkset payload, product keys, range metadata, and conflict payloads. Writes are idempotent (unique `(tenant_id, advisory_id, source)`); unchanged hashes skip updates.
4. **Conflict detection** — builder emits structured conflicts with typed severities (Hard/Soft/Info). Conflicts carry per-observation values for explainability.
5. **Event emission** — `advisory.linkset.updated@1` summarizes deltas (`added`, `removed`, `changed` observation IDs, conflict updates, confidence changes) and includes a canonical hash for replay validation.
#### Correlation Algorithm (v2)
The v2 correlation algorithm (see `linkset-correlation-v2.md`) replaces intersection-based scoring with graph-based connectivity and adds new signals:
| Signal | Weight | Description |
|--------|--------|-------------|
| Alias connectivity | 0.30 | LCC ratio from bipartite graph (transitive bridging) |
| Alias authority | 0.10 | Scope hierarchy (CVE > GHSA > VND > DST) |
| Package coverage | 0.20 | Pairwise + IDF-weighted overlap |
| Version compatibility | 0.10 | Equivalent/Overlapping/Disjoint classification |
| CPE match | 0.10 | Exact or vendor/product overlap |
| Patch lineage | 0.10 | Shared commit SHA from fix references |
| Reference overlap | 0.05 | Positive-only URL matching |
| Freshness | 0.05 | Fetch timestamp spread |
Conflict penalties are typed:
- **Hard** (`distinct-cves`, `disjoint-version-ranges`): -0.30 to -0.40
- **Soft** (`affected-range-divergence`, `severity-mismatch`): -0.05 to -0.10
- **Info** (`reference-clash` on simple disjoint sets): no penalty
Selected via `LinksetCorrelationOptions.Version` (`"v1"` | `"v2"`; `LinksetCorrelationService` switches between `LinksetCorrelation.Compute` and `LinksetCorrelationV2.Compute`). **The code default is `v1`**, with `v2` opt-in; v2 also exposes `EnableIdfWeighting` (default true) and `EnableTextSimilarity` (default false, `Concelier:Correlation:TextSimilarity` section) plus an optional per-signal `Weights` override dictionary. The signal weights above are the `LinksetCorrelationV2.Weights` constants.
### 5.3 Event contract
| Event | Schema | Notes |
|-------|--------|-------|
| `advisory.observation.updated@1` | `events/advisory.observation.updated@1.json` | Fired on new or superseded observations. Includes `observationId`, source metadata, `linksetSummary` (aliases/purls), supersedes pointer (if any), SHA-256 hash, and `traceId`. |
| `advisory.linkset.updated@1` | `events/advisory.linkset.updated@1.json` | Fired when correlation changes. Includes `linksetId`, `key{vulnerabilityId, productKey, confidence}`, observation deltas, conflicts, `updatedAt`, and canonical hash. |
Events are emitted on the router messaging transport via `IAdvisoryObservationEventPublisher` / `IAdvisoryLinksetEventPublisher` (default stream `CONCELIER_OBS`, subject `concelier.advisory.observation.updated.v1`, dead-letter subject `concelier.advisory.observation.updated.dead.v1`; the publisher options expose `NatsUrl`/`Stream`/`Subject`). Consumers acknowledge idempotently using the hash; duplicates are safe. Durable replay for the timeline/Offline Kit is served from the shared `StellaOps.Eventing` PostgreSQL timeline store (see §7), not a Concelier-owned event table.
---
## 7) Storage schema (PostgreSQL)
Fresh blank databases must enable PostgreSQL extension prerequisites before Concelier's initial schema applies. In practice, Concelier now carries a dedicated pre-schema startup migration for `pg_trgm`, so trigram-backed GIN indexes converge on first boot without relying on external manual database prep.
### Tables & indexes (LNM path)
> Concelier owns two PostgreSQL schemas in the shared `stellaops_platform` database (`PostgresStorageOptions.SchemaName` defaults to `vuln`): the `vuln` schema holds canonical advisory data, observations, linksets, and scheduler/orchestrator runtime state; the `concelier` schema holds connector-facing raw payloads, DTOs, export state, and vendor flag projections. Schemas and tables are created via embedded SQL migrations (`src/Concelier/__Libraries/StellaOps.Concelier.Persistence/Migrations/`) applied on startup; all `CREATE` statements are idempotent (`IF NOT EXISTS`).
> **Migration-filename note (2026-07-12).** The pre-1.0 chain was collapsed into
> `001_v1_concelier_baseline.sql` (+ `S001_v1_concelier_seed_baseline.sql`); live forward
> migrations continue at `002`–`017`. The old per-feature files (`008_*`, `014_*`, `026_*`,
> `028_*`–`031_*`, …) exist only under `Migrations/_archived/pre_1.0/mig061/` and are **not**
> embedded (the `.csproj` embeds the top-level `Migrations\*.sql` only). Table citations below
> point at the baseline; do not cite the archived numbers as live migrations.
> **Ownership boundary (corrected 2026-07-11):** Concelier owns only its
> advisory-domain schemas. It no longer references the ReleaseOrchestrator
> environment library, applies `ReleaseOrchestrator.Environment` migrations,
> registers topology stores/services, or exposes the `/api/v1/{regions,
> environments,targets,agents,infrastructure-bindings,pending-deletions}`
> compatibility families. Those aliases now route to the owning
> ReleaseOrchestrator WebApi. Do not add Concelier-owned topology tables or
> direct `release.*` reads/writes.
* `vuln.sources` `{id, key(unique), name, source_type, url, priority, enabled, config(jsonb), metadata(jsonb), created_at, updated_at}` — connector catalog. Index `idx_sources_enabled(enabled, priority DESC)`.
* `vuln.source_states` `{id, source_id(unique FK→vuln.sources), cursor, last_sync_at, last_success_at, last_error, sync_count, error_count, metadata(jsonb), updated_at}` — per-source run-state.
* `concelier.source_documents` `{id, source_id, source_name, uri, sha256, status, content_type, headers_json, metadata_json, etag, last_modified, payload(bytea), created_at, updated_at, expires_at}` — raw upstream payload registry. PK `(source_name, uri)`; indexes on `source_id` and `status`.
* `concelier.dtos` `{id, document_id(PK), source_name, format, payload_json(jsonb), schema_version, created_at, validated_at}` — normalized connector DTOs used for replay. Index `(source_name, created_at DESC)`.
* `vuln.advisory_raw` — canonical immutable upstream-document store (consolidated baseline `001_v1_concelier_baseline.sql:2574`; folded in from the pre-1.0 `030_add_advisory_raw_store.sql`, now archived and not embedded). Backs `IAdvisoryRawRepository` and is written FIRST in the ingest pipeline (before observation/linkset projections). Schema:
{ id : TEXT (SHA-256 over tenant|vendor|upstream_id|content_hash|ingest UTC ticks), tenant_id : TEXT, source_vendor : TEXT, source_stream : TEXT?, upstream_id : TEXT, content_hash : TEXT, document_version : TEXT?, advisory_key : TEXT, advisory_key_lower : TEXT (indexed when non-empty), aliases : TEXT[], alias_keys : TEXT[] (lower-case, GIN-indexed), package_urls : TEXT[] (GIN-indexed), cpes : TEXT[], supersedes_id : TEXT?, retrieved_at : TIMESTAMPTZ, ingested_at : TIMESTAMPTZ, created_at : TIMESTAMPTZ, document_json : JSONB (full normalized AdvisoryRawDocument) }
* Primary key: `(tenant_id, id)`.
* Dedupe key: `UNIQUE (tenant_id, source_vendor, upstream_id, content_hash)` — identical re-ingests return the existing row via `ON CONFLICT DO NOTHING`.
* Other indexes: `(tenant_id, source_vendor, upstream_id)`, `(tenant_id, ingested_at, source_vendor)`, `(tenant_id, ingested_at DESC, id ASC)`, `(tenant_id, advisory_key_lower) WHERE non-empty`, GIN on `alias_keys` and `package_urls`, `(tenant_id, content_hash)`.
* AOC append-only: revisions never UPDATE in place; a changed `content_hash` always produces a new row.
* `vuln.advisory_observations` (baseline `001_v1_concelier_baseline.sql:1175`) — the LNM observation projection. The full canonical observation is stored as JSONB; promoted columns back the query indexes:
{ tenant_id : TEXT, observation_id : TEXT, – “tenant:vendor:upstreamId:revision” source_vendor : TEXT, upstream_id : TEXT, linkset_aliases : TEXT[], linkset_alias_keys : TEXT[] (GIN-indexed, lower-cased), linkset_purls : TEXT[] (GIN-indexed), linkset_cpes : TEXT[] (GIN-indexed), created_at : TIMESTAMPTZ, observation_json : JSONB – full AdvisoryObservation (source, upstream, content, identifiers, linkset, …) }
* PK `(tenant_id, observation_id)`; indexes: `(tenant_id, created_at DESC, observation_id ASC)`, `(tenant_id, upstream_id)`, GIN on `linkset_alias_keys`, `linkset_purls`, `linkset_cpes`.
* `vuln.lnm_linkset_cache` (baseline `001_v1_concelier_baseline.sql:427`) — the linkset cache written by `IAdvisoryLinksetSink` (there is **no** `advisory_linksets` table):
{ id : UUID (PK), tenant_id : TEXT, source : TEXT, advisory_id : TEXT, observations: TEXT[], normalized : JSONB, conflicts : JSONB, provenance : JSONB, confidence : DOUBLE PRECISION, built_by_job_id : TEXT, created_at : TIMESTAMPTZ }
* Unique `(tenant_id, advisory_id, source)`; index `(tenant_id, created_at DESC, advisory_id, source)`.
* `vuln.issue_observations` + `vuln.issue_linksets` — the unified issue projection used for
cross-source Link-Not-Merge decisions. Observations remain source-specific; linksets group them by
deterministic vulnerability/product `issue_key` and retain status/severity conflicts.
* `vuln.issue_gate_decisions` (migration `017_issue_gate_decision_projection.sql`) — the gate-only,
rebuildable decision store. Effective status, severity, affected range, title, and summary are accompanied by
per-field winner/alternative JSON and `linkset_content_hash`, `input_hash`, `settings_hash`, and
`decision_hash`. An idempotent upsert does not touch `derived_at` when the decision hash is
unchanged. `vuln.issue_gate_decision_dead_letters` durably records per-issue derivation failures;
a later successful derivation clears that issue's dead letter. The background projector emits
`concelier.gate_decision.changed` and `concelier.gate_decision.failures` counters.
A first-install or settings-change backlog is drained continuously in bounded batches (default 10,000
candidates with at most 16 concurrent PostgreSQL issue reads/writes) until the current candidate set is
empty. Candidate pages use the last ordered `issue_key` as a keyset cursor, so later pages seek past the
processed prefix instead of rescanning it. A partial batch ends the drain; a failed batch stops until the
next interval so durable dead letters cannot hot-loop. Migration
`019_issue_gate_decision_projection_scan_indexes.sql` adds covering indexes for the ordered linkset/decision
comparison, keeping the fully converged no-op scan below the reader statement timeout without weakening
settings-hash or linkset-content-hash invalidation. Migration `018_rederive_issue_projection_source_facts.sql` resets only the rebuildable
advisory projection cursor once, forcing existing advisory edges to pick up source-aligned severity,
title, summary, CVSS, and linked-CVE facts without touching raw/source records. Migration
`020_rederive_issue_projection_decision_ranges.sql` performs the same cursor-only reset after the
range parser learned the persisted object/array shapes. Migration
`021_issue_gate_decision_package_match.sql` adds normalized `package_name` and `ecosystem` match keys
plus their partial index for Policy and compact export. Existing PURL decisions with null match keys
remain rebuild candidates, and the idempotent upsert repairs those keys even when the semantic
`decision_hash` itself did not change; non-PURL products remain explicit parity gaps rather than
guessed package names. Migration `024_issue_gate_decision_dirty_queue.sql` adds the steady-state
rebuild queue, linkset-change trigger, and projection-state row described in section 3.2. It seeds
existing drift and records an already-converged state only when all rows share one settings hash and
no missing or stale decision exists.
* **Events are not persisted in a Concelier table.** `advisory.observation.updated@1` / `advisory.linkset.updated@1` are published to the router messaging transport (default stream `CONCELIER_OBS`, subject `concelier.advisory.observation.updated.v1`) via `IAdvisoryObservationEventPublisher` / `IAdvisoryLinksetEventPublisher`. Durable replay for the timeline/Offline Kit comes from the shared `StellaOps.Eventing` PostgreSQL timeline store, not a Concelier-owned event table.
* `concelier.export_states` `{id(PK), export_cursor, last_full_digest, last_delta_digest, base_export_id, base_digest, target_repository, files(jsonb), exporter_version, updated_at}`.
* `concelier.psirt_flags`, `concelier.jp_flags`, `concelier.change_history` — vendor flag and change-history projections used by specific connectors.
* `vuln.advisory_raw` (above) is the canonical immutable upstream-document store; `vuln.advisory_source_payload` stores content-addressed raw source payloads keyed by `source_doc_hash` for canonical source edges; `vuln.advisory_canonical` + `vuln.advisory_source_edge` back the canonical advisory read model and the operator-facing source freshness/signature/content-count projections surfaced by `/api/v1/advisory-sources`. Canonical source edges no longer store per-row raw payload JSON; reads resolve raw payloads through `vuln.advisory_source_payload` by hash.
* `vuln.job_leases` `{lease_key, holder, acquired_at, heartbeat_at, lease_ms, ttl_at}` (baseline `001_v1_concelier_baseline.sql:1158`) used by live scheduler coordination; expired leases can be stolen safely by another runner.
* `vuln.job_runs` (baseline `001_v1_concelier_baseline.sql:1233`) `{run_id, kind, status, created_at, started_at, completed_at, trigger, parameters_hash, error, timeout_ms, lease_duration_ms, parameters_json}` stores durable scheduler history surfaced by `/jobs`.
* `vuln.orchestrator_registry` `{tenant_id, connector_id, source, capabilities[], auth_ref, schedule_json, rate_policy_json, artifact_kinds[], lock_key, egress_guard_json, created_at, updated_at}` stores durable connector runtime registrations.
* `vuln.orchestrator_heartbeats` `{tenant_id, connector_id, run_id, sequence, status, progress, queue_depth, last_artifact_hash, last_artifact_kind, error_code, retry_after_seconds, timestamp_utc}` stores append-only heartbeat history for `/internal/orch/heartbeat`.
* `vuln.orchestrator_commands` `{tenant_id, connector_id, run_id, sequence, command, throttle_json, backfill_json, created_at, expires_at}` stores pending internal orchestrator commands with expiry-aware reads.
* `vuln.orchestrator_manifests` `{tenant_id, connector_id, run_id, cursor_range_json, artifact_hashes[], dsse_envelope_hash, completed_at}` stores durable completed-run manifests for internal orchestrator consumers.
**Legacy tables** (`vuln.advisories`, `vuln.advisory_aliases`, `vuln.advisory_affected`, `vuln.advisory_references`, `vuln.merge_events`) created by the baseline (`001_v1_concelier_baseline.sql:184/234/268/299/380`) remain available during the Link-Not-Merge migration window to support back-compat exports and compatibility projections. New code paths should write through the raw/observation/linkset path rather than these normalized tables.
**Object storage**: `concelier.source_documents` (payload bytea, immutable) for raw payloads; the export tree on the configured object store (`Mirror`/`s3`) for historical JSON/Trivy archives.
---
## 8) Exporters
### 7.1 Deterministic JSON (vuln‑list style)
* Folder structure mirroring `/<scheme>/<first-two>/<rest>/…` with one JSON per advisory; deterministic ordering, stable timestamps, normalized whitespace.
* `manifest.json` lists all files with SHA‑256 and a top‑level **export digest**.
### 7.2 Trivy DB exporter
* Builds Bolt DB archives compatible with Trivy; supports **full** and **delta** modes.
* In delta, unchanged blobs are reused from the base; metadata captures:
```json
{
"mode": "delta|full",
"baseExportId": "...",
"baseManifestDigest": "sha256:...",
"changed": ["path1", "path2"],
"removed": ["path3"]
}
- Optional ORAS push (OCI layout) for registries.
- Offline kit bundles include Trivy DB + JSON tree + export manifest.
- Mirror-ready bundles: when
concelier.trivy.mirrordefines domains, the exporter emitsmirror/index.jsonplus per-domainmanifest.json,metadata.json, anddb.tar.gzfiles with SHA-256 digests so Concelier mirrors can expose domain-scoped download endpoints. - Concelier.WebService serves
/concelier/exports/index.jsonand/concelier/exports/mirror/{domain}/…directly from the export tree with hour-long budgets (index: 60 s, bundles: 300 s, immutable) and per-domain rate limiting. Live downloads now require persisted mirror-domain state from the mirror read model rather than env-seeded domain lists;Testingcan still seed that state through the management API. The legacysources:mirrorServer:rateLimits:forEnvironmentruntime path fails closed because Concelier does not bind a durable fail-closed distributed limiter for that config. Operators may still configure explicit instance-only mirror limits for local/test/single-instance throttling, but environment-wide budgets must be enforced by the persisted mirror-domain quotas, the gateway/CDN layer, or a future Concelier-owned durable limiter.
7.3 Hand‑off to Signer/Attestor (optional)
- On export completion, if
attest: trueis set in job args, Concelier posts the artifact metadata to Signer/Attestor; Concelier itself does not hold signing keys. - Export record stores returned
{ uuid, index, url }from Rekor v2.
9) REST APIs
Route truthfulness note. Concelier’s HTTP surface is not uniformly prefixed with
/api/v1/concelier. Routes are registered across several roots inStellaOps.Concelier.WebService.Program.csand theExtensions/*EndpointExtensions.csfiles: health/diagnostics at the root (/health,/ready,/api/v1/diagnostics/throttle), advisory raw/observation/linkset surfaces under/advisories/*,/concelier/observations,/v1/lnm/linksets/*,/linksets, ingest at/ingest/advisory, AOC verification at/aoc/verify, evidence at/vuln/evidence/advisories/{advisoryKey}and/obs/*, jobs at/jobsand/jobs/{*jobKind}, internal orchestrator at/internal/orch/*, ingested advisory catalog browsing under/api/v1/advisories/*, advisory-source management under/api/v1/advisory-sources/*, mirror management under/api/v1/advisory-sources/mirror/*, federation under/api/v1/federation/*, air-gap under/api/v1/concelier/{airgap,mirrors,snapshots,bundles,imports,version-locks}, canonical advisories under/api/v1/canonical/*, signals under/v1/signals/symbols/*, and the mirror export tree under/concelier/exports/*.
Operator-authored VEX (Excititor WebService)
POST /excititor/statements/simple
The gateway exposes this route as POST /api/v1/vex/statements/simple and places that exact route before the generic VexHub /api/v1/vex(.*) distribution route. Excititor owns authoring because it already owns the tenant-scoped raw-document, normalization, and claim pipeline; VexHub remains the global distribution store. The endpoint requires an authenticated deployment tenant header and the vex.admin scope.
The request creates exactly one OpenVEX statement. It validates a CVE id, product reference, supported status, optional component/justification/action, and at most 20 typed evidence links. not_affected requires a supported OpenVEX justificationType. Excititor persists the canonical raw document, normalizes it, appends an enforced operator-authored claim, and projects that claim through the existing VexHub sink under source excititor:operator. The response and projection share VexHubStatementIdentityDeriver, so the returned statement id is the exact VexHub detail id. A 201 is returned only after exactly one VexHub row is projected; if the global distribution schema is unavailable, the retained tenant claim is reported as 503 and is not presented as queryable.
Health & diagnostics
GET /health → service status snapshot
GET /ready → readiness (DB/migrations)
GET /api/v1/diagnostics/throttle → adaptive host-throttle live state (scope: concelier.jobs.read OR concelier.jobs.trigger)
GET /diagnostics/aliases/{seed} → alias-graph resolution debug
Advisory ingest & reads (scopes shown per route)
POST /ingest/advisory → ingest a raw advisory (scope: advisory:ingest)
GET /advisories/raw → list raw advisory rows (scope: advisory:read)
GET /advisories/raw/{id} → single raw row (scope: advisory:read)
GET /advisories/raw/{id}/provenance → provenance for a raw row (scope: advisory:read)
GET /advisories/observations → observation projection (scope: vuln:view)
GET /concelier/observations → observation projection (scope: vuln:view)
GET /advisories/linksets → linkset query (scope: advisory:read)
GET /advisories/linksets/export → linkset export (scope: advisory:read)
GET /v1/lnm/linksets | /v1/lnm/linksets/{advisoryId} | POST /v1/lnm/linksets/search
GET /linksets → linkset list
GET /advisories/{advisoryKey}/chunks → paragraph-anchored chunks for Advisory AI (scope: advisory:read)
GET /advisories/summary → advisory summary (scope: advisory:read)
POST /aoc/verify → AOC verification (scopes: advisory:read + aoc:verify)
GET /vuln/evidence/advisories/{advisoryKey} → evidence bundle (scope: advisory:read)
Ingested advisory catalog browse (/api/v1/advisories)
GET /api/v1/advisories -> page `vuln.advisories` with total count (scope: advisory:read)
GET /api/v1/advisories/stats -> severity and source facets (scope: advisory:read)
GET /api/v1/advisories/packages -> affected package aggregates (scope: advisory:read)
GET /api/v1/advisories/packages/vulnerabilities -> package drill-down (scope: advisory:read)
GET /api/v1/advisories/{id} -> advisory detail + aliases/affected/CVSS/KEV
Unified issues and gate decisions (/api/v1/issues, scope advisory:read)
GET /api/v1/issues -> product-grain LNM linksets
GET /api/v1/issues/{issueKey} -> linkset observations and evidence
GET /api/v1/issues/{issueKey}/gate-decision -> effective fields + winner/loser source audit
GET /api/v1/issues/cve/{id}/consensus -> CVE-grain cross-source consensus
GET /api/v1/issues/_consensus/metrics -> corpus consensus counts
The gate-decision read is tenant-scoped and returns only the rebuildable migration-017 projection. Each effective field includes the winning observation/source/rank plus lower-precedence alternatives and the input/settings/decision hashes. It never rewrites or hides the underlying LNM observations.
The console “Vulnerability Database” page uses the catalog API for browsing because it must show the full ingested source catalog, then uses unified-issue reads for the Sources & consensus panel. Operators can load the gate decision for a particular product on demand to see the effective status/severity/range/title/summary, the configured-precedence winner, the losing observations, and the decision hash. The list endpoint supports server-side free-text search (q, backed by search_vector), severity, sourceId, since/modified-since, page, and pageSize; CountCatalogAsync applies the same filter so paging totals remain stable. CWE is intentionally not a v1 filter on this endpoint: CWE/weakness values live in child tables and are not a first-class column on vuln.advisories.
The package lens is also catalog-only. GET /api/v1/advisories/packages aggregates current vuln.advisories joined to vuln.advisory_affected and returns deterministic package rows ordered by descending vulnerability count, then package key. The package key is the affected row PURL when present; otherwise it is lower(ecosystem) + "/" + lower(packageName). Supported filters are q, severity, sourceId, and since; unsupported FE concepts are explicit in the response metadata as unsupportedFilters (kev, epss, reachability, fixability, image, release) because those signals live in other schemas or release/runtime projections.
Example aggregate request:
GET /api/v1/advisories/packages?q=lodash&severity=high&page=1&pageSize=25
Example aggregate response:
{
"items": [
{
"packageKey": "pkg:npm/lodash@4.17.20",
"ecosystem": "npm",
"packageName": "lodash",
"purl": "pkg:npm/lodash@4.17.20",
"vulnerabilityCount": 2,
"affectedRowCount": 2,
"sourceCount": 2,
"severity": {
"critical": 1,
"high": 1,
"medium": 0,
"low": 0,
"informational": 0,
"none": 0,
"unknown": 0
},
"latestModifiedAt": "2026-06-02T12:00:00+00:00",
"representativeVulnerabilityIds": ["CVE-2026-10001", "CVE-2026-10002"],
"representativeAdvisoryKeys": ["nvd:CVE-2026-10001", "ghsa:GHSA-aaaa-bbbb-cccc"]
}
],
"total": 1,
"page": 1,
"pageSize": 25,
"supportedFilters": ["q", "severity", "sourceId", "since"],
"unsupportedFilters": ["kev", "epss", "reachability", "fixability", "image", "release"]
}
GET /api/v1/advisories/packages/vulnerabilities drills into one aggregate row. Clients should prefer packageKey copied from the aggregate row. If no PURL exists, pass both ecosystem and packageName; the API returns 400 package_selector_required when neither selector is complete. Rows are ordered by most recent advisory change, then vulnerability id/advisory id.
GET /api/v1/advisories/packages/vulnerabilities?packageKey=pkg%3Anpm%2Flodash%404.17.20
GET /api/v1/advisories/packages/vulnerabilities?ecosystem=pypi&packageName=flask
Jobs
GET /jobs | /jobs/active | /jobs/{runId} | /jobs/definitions[/{kind}[/runs]] (scope: concelier.jobs.read OR concelier.jobs.trigger)
POST /jobs/{*jobKind} → trigger a job (scope: concelier.jobs.trigger)
Advisory source management (/api/v1/advisory-sources)
GET / | /summary | /{id}/freshness (scope: advisory:read)
GET /catalog | /status | /{sourceId}/configuration (scope: advisory:read)
GET /{sourceId}/custodian (scope: advisory:read)
PUT /{sourceId}/configuration (scope: integration:write | integration:operate)
PUT /{sourceId}/custodian (scope: integration:write | integration:operate)
POST /{sourceId}/enable | /{sourceId}/disable | /batch-enable | /batch-disable
POST /check | /{sourceId}/check | /{sourceId}/sync | /sync (scope: integration:write | integration:operate)
Current runtime note: /jobs, /internal/orch/*, and the coordinator-backed manual sync compatibility routes (/api/v1/advisory-sources/{sourceId}/sync, /api/v1/advisory-sources/sync, /api/v1/concelier/mirrors/{mirrorId}/sync) now run against durable PostgreSQL-backed runtime services (PostgresJobStore, JobCoordinator, and PostgresOrchestratorRegistryStore) in the live host. Process-local placeholder implementations remain limited to Testing harnesses. Restart-safe endpoint verification now covers persisted job runs, orchestrator registry records, heartbeats, and queued commands through the live HTTP surface, and the obsolete UnsupportedJobCoordinator / UnsupportedOrchestratorRegistryStore live-host guards have been removed. See SPRINT_20260419_029_Concelier_durable_jobs_orchestrator_runtime for the cutover record and SPRINT_20260419_030_Concelier_durable_runtime_endpoint_verification for the endpoint proof. Current signals note: /v1/signals/symbols/* is backed by a durable PostgreSQL affected-symbol store (PostgresAffectedSymbolStore). Advisory observations are persisted via PostgresAdvisoryObservationStore, which invokes the registered IAffectedSymbolExtractor chain during UpsertAsync to project ingest-time symbols into the affected-symbol store. The legacy non-testing 501 fallback has been removed; in-memory implementations remain only for Testing environments. See SPRINT_20260419_027_Concelier_durable_affected_symbol_runtime for the cutover record. Current reusable DI note: bare Concelier service collection extensions fail closed when a host has not registered durable runtime services first. AddJobScheduler, AddConcelierOrchestrationServices, AddConcelierSignalsServices, AddConcelierObservationPipeline, AddConcelierRiskServices, and AddSourceCommon no longer supply silent null, no-op, or in-memory persistence defaults. Local/test harnesses that need process-local state must opt in through the explicit ForTesting helpers, including AddSourceCommonForTesting for connector document storage, or the existing web-service test factory. Current Feedser signal event note: AddSignalAttachers no longer registers a process-local InMemorySignalEventEmitter for runtime use. Production hosts must register a durable/replayable emitter, currently AddTimelineSignalEventEmitter backed by StellaOps.Eventing and its PostgreSQL timeline store, or signal emission fails closed with a configuration error. Local/test harnesses must use AddSignalAttachersForTesting to opt into process-local delivery. Offline Kit replay must read these signal updates from the durable event/timeline store rather than from process memory. Current advisory source note: the live host also exposes /api/v1/advisory-sources/* for operator/UI source status, enablement, health checks, sync triggers, and visible review counts. Enabled state is now persisted in the Concelier source store so restarts, setup skip/apply flows, and later integrations-page toggles all observe the same source truth. Advisory totals and signature rollups are projected from distinct source_advisory_id values in vuln.advisory_source_edge, so the freshness surface reflects the canonical ingest path rather than the legacy vuln.advisories.source_id compatibility rows. The signature/count projection is the materialized view vuln.advisory_source_signature_projection with a unique source_id index, refreshed by AdvisorySourceSignatureRefreshService so cold source-list reads do not repeatedly scan the edge table. Platform setup uses bootstrap-key-protected /internal/setup/advisory-sources/{probe,apply} endpoints to seed initial source configuration without requiring a tenant session.
The list, summary, and single-source freshness reads now expose one composed freshness projection instead of requiring clients to merge freshnessStatus with the source-management readiness endpoint. Its mutually exclusive states are disabled, unsupported, blocked, error, unavailable, stale, warning, fresh, and unknown, in that precedence order. Age and the warning boundary come from the persisted source freshness SLA; breach is strict (age > SLA), and slaBreached is true only when the canonical state is stale so a higher-precedence disabled/readiness/error state cannot advertise a contradictory SLA alarm. The old top-level freshness fields and summary counters remain compatibility fields, while freshness and summary stateCounts are authoritative for new consumers. All three 30-second caches are keyed by the authenticated IStellaOpsTenantAccessor tenant and the responses echo that tenant id; the raw request header is never a cache authority. Source definitions and source execution state remain operator-global by schema, so tenant scoping here means authenticated response/cache isolation rather than inventing tenant-specific source rows.
Each list item and single-source freshness response also exposes a nested custodian projection with state (assigned or unassigned), nullable name, and source: stored-source-metadata. The authoritative value is an installation-wide operator label persisted as vuln.sources.metadata.custodian; it is not inferred from a source display name, tenant, user, or team. GET /{sourceId}/custodian reads it and audited PUT /{sourceId}/custodian assigns or clears it (null/blank clears). Writes trim labels, reject values longer than 128 characters or containing control characters, and preserve connector configuration and enablement. Assigning a custodian to a registered source that has no persisted row creates a disabled row with empty configuration; ownership assignment never enables a feed. Security & Risk renders the stored label or the actionable absence no owner assigned — assign custodian, while the Connections catalog owns the edit flow. The existing 30-second freshness caches mean a list read can lag a just-completed write until its bounded cache expires; the direct custodian read is immediate.
The operator-facing review counters exposed by /api/v1/advisory-sources now have fixed semantics:
sourceDocumentCountand the backward-compatibletotalAdvisoriesalias are both the count of distinct upstreamsource_advisory_idvalues linked to the source.canonicalAdvisoryCountis the count of distinct canonical advisory ids reached throughvuln.advisory_source_edge.cveCountis the count of distinct non-empty canonical CVE ids associated with those linked advisories.vexDocumentCountis the count of distinct linked source advisory documents carrying non-null vendor-status/VEX statements.
Web operators should derive visible summary cards and per-source detail panels from this same advisory-source contract so Integrations, Security, and setup review screens do not drift in headline counts or terminology. Mirror bootstrap truthfulness note: /internal/setup/advisory-sources/{probe,apply} now validates the selected mirror/source configuration before apply succeeds. Mirror mode probes /concelier/exports/index.json with normal runtime TLS rules, so certificate/hostname mismatches are returned as actionable setup failures instead of “background sync” warnings.
Exports
Exports are run as scheduled jobs, not bespoke POST /exports/* REST endpoints. Trigger them via the jobs surface:
POST /jobs/export:json → run the JSON exporter (scope: concelier.jobs.trigger)
POST /jobs/export:trivy-db → run the Trivy DB exporter (scope: concelier.jobs.trigger)
POST /jobs/federation:bundle:export → run a federation bundle export
The generated export tree is served read-only at:
GET /concelier/exports/index.json → mirror index describing available domains/bundles
GET /concelier/exports/{**relativePath} → per-domain manifest.json / bundle.json / bundle.json.jws, db.tar.gz, etc.
Current mirror runtime note: the public mirror download surface no longer trusts ConcelierOptions.Mirror.Domains in live runtime. Outside Testing, index/download requests succeed only when the requested domain exists in the persisted mirror-domain store; config-only seeded domains are ignored. Current connector truthfulness note: Astra Linux OVAL ingestion is production-wired against the current 1.7 and 1.8 feeds under https://download.astralinux.ru/astra/oval/, while the source catalog remains disabled by default for export-control/operator approval. Astra can fall back to an explicitly enabled operator-managed local XML/ZIP seed path; seed payloads carry operator_checked_mirror or mirror_unverified provenance and are not vendored into the repo. Intel PSIRT uses the official public Intel Security Center CSAF JSON files from the intel/security-center repository and persists canonical advisories during fetch; AMD and Siemens use their production vendor fetchers through the same fetch-phase persistence pattern. ARM catalog/project rows were removed in Sprint 20260601_001 because the previous production backend failed closed and no approved no-credential machine-readable Arm feed has been identified. Air-gap bundle source validation only reports healthy for inspected local directories; archive and remote sources fail validation until real manifest/checksum or remote verification is wired.
Search / replay (operator debugging)
GET /concelier/advisories/{vulnerabilityKey}/replay → deterministic replay of an advisory
GET /advisories/raw?... → filtered raw rows (alias/purl/vendor/window)
GET /advisories/linksets?... → linkset query by vulnerability/product
GET /diagnostics/aliases/{seed} → alias-graph expansion
The
GET /advisories/{key}andGET /affected?productKey=...routes shown in earlier drafts are not registered; use the raw/linkset/replay routes above.
Mirror domain management (under /api/v1/advisory-sources/mirror)
GET /config → current mirror config (mode, signing, refresh interval)
PUT /config → update mirror mode/signing/refresh settings
GET /domains → list all mirror domains with export counts
POST /domains → create a new mirror domain with exports/filters
GET /domains/{domainId} → domain detail (exports, status)
PUT /domains/{domainId} → update domain (name, auth, rate limits, exports)
DELETE /domains/{domainId} → remove a mirror domain
POST /domains/{domainId}/exports → add an export to a domain
DELETE /domains/{domainId}/exports/{exportKey} → remove an export from a domain
POST /domains/{domainId}/generate → trigger on-demand bundle generation
GET /domains/{domainId}/status → domain sync status (last generate, staleness)
POST /test → test mirror endpoint connectivity
Mirror domain sourceIds are validated against the registered advisory source catalog exposed by /api/v1/advisory-sources/catalog. Operators can create domains from catalog keys such as nvd and osv even on a fresh database before any source-status workflow has persisted rows into vuln.sources; unknown keys are still rejected.
Mirror consumer configuration (under /api/v1/advisory-sources/mirror)
GET /consumer → current consumer connector configuration (base address, domain, signature, timeout, connection status, last sync)
PUT /consumer → update consumer connector config (base address, domain ID, index path, HTTP timeout, signature settings)
POST /consumer/discover → fetch mirror index from base address, return available domains with metadata (domain ID, display name, advisory count, bundle size, export formats, signed status, last generated)
POST /consumer/verify-signature → fetch JWS header from selected domain's bundle, return detected algorithm, key ID, and provider
The consumer endpoints configure the StellaOpsMirrorConnector at runtime without requiring service restarts. Configuration is persisted via IMirrorConsumerConfigStore in PostgreSQL. The /consumer/discover endpoint enables the UI setup wizard to present operators with a list of available domains before committing to a configuration.
Air-gap bundle import (under /api/v1/advisory-sources/mirror)
POST /import → import a mirror bundle from a local filesystem path { bundlePath, verifyChecksums, verifyDsse, trustRootsPath? }
GET /import/status → import progress and result (exports imported, total size, errors, warnings)
The current HTTP mirror import path is durably implemented in the live host. /api/v1/advisory-sources/mirror/import validates a local mirror bundle, persists import status, projects manifest.json and bundle.json into the live /concelier/exports/mirror/<domain> surface, and refreshes the public index.json. /api/v1/advisory-sources/mirror/import/status reads the latest persisted import status instead of fabricating progress from a filesystem inspection pass. Both bundlePath and trustRootsPath must resolve under the configured Mirror.ImportRoot; relative paths are resolved against that allowlisted root, and paths outside it are rejected. Detached JWS verification is supported when callers provide a trustRootsPath; unsigned bundles still import truthfully with a warning rather than a synthetic success banner.
Mirror domains group export plans with shared rate limits and authentication rules. Exports support multi-value filter shorthands: sourceCategory (e.g., "Distribution" resolves to all distro sources), sourceTag (e.g., "linux"), and comma-separated sourceVendor values. Domain configuration is persisted in excititor.mirror_domains / excititor.mirror_exports tables. The MirrorExportScheduler background service periodically regenerates stale bundles (configurable via RefreshIntervalMinutes, default 60 minutes).
AuthN/Z: Authority tokens (OpTok). The scopes Concelier actually enforces (StellaOps.Auth.Abstractions/StellaOpsScopes.cs, mapped to ASP.NET policies in Program.cs) are:
| Policy (code) | Required scope(s) | Used by |
|---|---|---|
Concelier.Advisories.Read | advisory:read | raw/linkset reads, evidence, chunks, summary, feed-mirror reads, mirror reads |
Concelier.Advisories.Ingest | advisory:ingest | POST /ingest/advisory, statement provenance, air-gap/bundle/version-lock writes |
Concelier.Observations.Read | vuln:view | observation projections, interest-score reads |
Concelier.Aoc.Verify | advisory:read + aoc:verify | POST /aoc/verify |
Concelier.Jobs.Read | concelier.jobs.read or concelier.jobs.trigger (any-of; trigger implies read) | GET /jobs, /jobs/active, /jobs/{runId}, /jobs/definitions[/{kind}[/runs]], throttle diagnostics — the read surface the Console renders (c17 R2). |
Concelier.Jobs.Trigger | concelier.jobs.trigger (configurable via Authority.RequiredScopes) | POST /jobs/{*jobKind}, /internal/orch/*, feed-mirror sync (write-class job triggering) |
Concelier.Sources.Manage | integration:write or integration:operate | mutative advisory-source + mirror-domain management |
Concelier.Interest.Read | vuln:view | interest-score reads |
Concelier.Interest.Admin | advisory:ingest | interest-score compute |
Concelier.Canonical.Read | advisory:read | /api/v1/canonical/* reads |
Concelier.Canonical.Ingest | advisory:ingest | /api/v1/canonical/* writes |
| (credential dry-run) | concelier:credentials:dry-run | POST /api/v1/connectors/{connectorId}/dry-run-credential |
There is no concelier.read / concelier.admin / concelier.export scope; concelier.merge exists in the scope catalog but is legacy (Merge module). Internal setup endpoints (/internal/setup/advisory-sources/{probe,apply}) are bootstrap-key-protected rather than scope-gated. Deployment-topology compatibility routes are not part of Concelier’s API; ReleaseOrchestrator enforces orch:read or orch:operate plus tenant context on them.
10) Configuration
The host binds the ConcelierOptionstree (StellaOps.Concelier.WebService/Options/ConcelierOptions.cs). Per-source endpoints, credentials, and feed kinds are not declared inline in this options tree — they live in the SourceDefinitions catalog (code), in the persisted vuln.sources rows, and in per-connector option sections; runtime overrides flow through the advisory-source management API. The adaptive throttling caps bind from a separate Concelier:Throttlingsection (AdaptiveThrottlingOptions). The real top-level shape is:
concelier:
postgresStorage: # ConcelierOptions.PostgresStorage (preferred storage path)
enabled: true
connectionString: "Host=postgres;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops"
schemaName: vuln # default; the `concelier` schema is also owned by this service
autoMigrate: true # embedded SQL migrations applied on startup
maxPoolSize: 100
commandTimeoutSeconds: 30
authority: # AuthorityOptions
enabled: true
allowAnonymousFallback: false
issuer: "https://authority.stella-ops.local"
audiences: [ "concelier" ]
requiredScopes: [ "concelier.jobs.trigger" ] # gates the Jobs.Trigger policy
bypassNetworks: [ ]
mirror: # MirrorOptions (export/import roots, domain budgets)
enabled: false
exportRoot: exports/json
importRoot: imports/mirror
requireAuthentication: false
maxIndexRequestsPerHour: 600
domains: [ ] # NB: live download surface trusts the persisted mirror-domain store, not this list
features: # FeaturesOptions
noMergeEnabled: true # disables the legacy Merge module + merge:reconcile job (analyzer CONCELIER0002)
lnmShadowWrites: true
advisoryChunks: # AdvisoryChunkOptions (Advisory AI chunk endpoint)
defaultChunkLimit: 200
maxChunkLimit: 400
cacheDurationSeconds: 30
evidence: { enabled: true, root: out/evidence/bundles } # EvidenceBundleOptions
federation: { enabled: false, siteId: default, requireSignature: true }
telemetry: { enabled: true, enableTracing: true, enableMetrics: true }
# plugins, crypto, airGap, feedSnapshot, router sections also exist.
Concelier:Throttling: # AdaptiveThrottlingOptions (note the PascalCase section name)
enabled: true
defaultClass: …
hosts: { } # per-authority Class / InitialRps / MinRps / MaxRps overrides
concelier:gateDecisionProjection:
enabled: true
intervalSeconds: 120
batchSize: 10000
maxDegreeOfParallelism: 16
tenantId: default
sourcePrecedence: { } # optional source-id -> integer rank overrides; lower wins
The earlier inline
sources:,exporters:,precedence:, andseverity:YAML blocks are illustrative of the conceptual model only; they do not correspond to bindableConcelierOptionssections. Gate decision precedence is configured only through the bindableconcelier:gateDecisionProjection:sourcePrecedencemap above; ingestion and linkset correlation remain precedence-free.
11) Security & compliance
- Outbound allowlist per connector (domains, protocols); proxy support; TLS pinning where possible.
- Signature verification for raw docs (PGP/cosign/x509) with results stored in
document.metadata.sig. Docs failing verification may still be ingested but flagged; Policy Engine or downstream policy can down-weight them. Explicit verification-disabled ingest modes are allowed only when outputs remain visibly unverified and cannot satisfy strict release-gating trust requirements. - No secrets in logs; auth material via
env:or mounted files; HTTP redaction ofAuthorizationheaders. - Multi‑tenant: per‑tenant DBs or prefixes; per‑tenant S3 prefixes; tenant‑scoped API tokens.
- Determinism: canonical JSON writer; export digests stable across runs given same inputs.
12) Performance targets & scale
- Ingest: ≥ 5k documents/min on 4 cores (CSAF/OpenVEX/JSON).
- Normalize/map: ≥ 50k observation statements/min on 4 cores.
- Observation write: ≤ 5 ms P95 per row (including guard + PostgreSQL write).
- Linkset build: ≤ 15 ms P95 per
(vulnerabilityId, productKey)update, even with 20+ contributing observations. - Export: 1M advisories JSON in ≤ 90 s (streamed, zstd), Trivy DB in ≤ 60 s on 8 cores.
- Memory: hard cap per job; chunked streaming writers; backpressure to avoid GC spikes.
Scale pattern: add Concelier replicas; PostgreSQL scaling via indices and read/write connection pooling; object storage for oversized docs.
13) Observability
Metrics
Instruments actually emitted in code (with their owning
Meter):- Meter
StellaOps.Concelier.Connector(SourceDiagnostics, per-connector HTTP fetch telemetry):concelier.source.http.requests(Counter)concelier.source.http.retries(Counter)concelier.source.http.failures(Counter)concelier.source.http.not_modified(Counter)concelier.source.http.duration(Histogram, ms)concelier.source.http.payload_bytes(Histogram, byte)
- Meter
StellaOps.Concelier.Connector.Common(AdaptiveHostRateLimiter, adaptive host throttling — see §5.1.1):concelier.throttle.wait_ms(Histogram)concelier.throttle.rps_cap(observable Gauge)concelier.throttle.aimd_decrease_total(Counter)
- Meter
StellaOps.Concelier.WebService.AdvisoryAi(AdvisoryAiMetrics):advisory_ai_chunk_requests_total{tenant,result,cache}andadvisory_ai_guardrail_blocks_total{tenant,reason,cache}instrument the/advisories/{advisoryKey}/chunkssurface that Advisory AI consumes. Cache hits emit the same guardrail counters so operators can see blocked segments even when responses are served from cache.
Not implemented (design-intent only). Earlier drafts listed pipeline counters such as
concelier.fetch.docs_total,concelier.fetch.bytes_total,concelier.parse.failures_total,concelier.map.statements_total,concelier.observations.write_total,concelier.linksets.updated_total,concelier.linksets.conflicts_total,concelier.export.bytes, andconcelier.export.duration_seconds. No instruments by these names exist insrc/Concelier/. Treat them as a planned ingest/linkset/export metric catalog, not a runtime contract; the only emitted advisory-pipeline telemetry today is the source-HTTP and throttle instruments above plus structured logs.- Meter
Tracing around fetch/parse/map/observe/linkset/export.
Logs: structured with
source,uri,docDigest,advisoryKey,exportId.
14) Testing matrix
- Connectors: fixture suites for each provider/format (happy path; malformed; signature fail).
- Version semantics: EVR vs dpkg vs semver edge cases (epoch bumps, tilde versions, pre‑releases).
- Linkset correlation: multi-source conflicts (severity, range, alias) produce deterministic conflict payloads; ensure confidence scoring stable.
- Export determinism: byte‑for‑byte stable outputs across runs; digest equality.
- Performance: soak tests with 1M advisories; cap memory; verify backpressure.
- API: pagination, filters, RBAC, error envelopes (RFC 7807).
- Offline kit: bundle build & import correctness.
15) Failure modes & recovery
- Source outages: scheduler backs off with exponential delay; run-state tracked in
vuln.source_states(last_error,error_count,last_success_at); alerts on staleness. - Schema drifts: parse stage marks DTO invalid; job fails with clear diagnostics; connector version flags track supported schema ranges.
- Partial exports: exporters write to temp prefix; manifest commit is atomic; only then move to final prefix and update
concelier.export_states. - Resume: all stages idempotent;
vuln.source_states.cursorsupports window resume.
16) Operator runbook (quick)
- Trigger a source fetch:
POST /api/v1/advisory-sources/{sourceId}/sync(orPOST /api/v1/advisory-sources/syncfor all enabled). - Run the JSON exporter:
POST /jobs/export:json - Run the Trivy DB exporter:
POST /jobs/export:trivy-db - Inspect observations:
GET /concelier/observationsorGET /advisories/observations - Query linksets:
GET /advisories/linksets?.../GET /v1/lnm/linksets/{advisoryId} - Replay an advisory:
GET /concelier/advisories/{vulnerabilityKey}/replay - Disable a noisy source:
POST /api/v1/advisory-sources/{sourceId}/disable - Throttle state:
GET /api/v1/diagnostics/throttle
17) Rollout plan
- MVP: Red Hat (CSAF), SUSE (CSAF), Ubuntu (USN JSON), OSV; JSON export.
- Add: GHSA REST, Debian (DSA HTML/JSON), Alpine secdb; Trivy DB export.
- Attestation hand‑off: integrate with Signer/Attestor (optional).
- Advisory evidence attestation parameters and path rules are documented in
docs/modules/concelier/attestation.md.
- Advisory evidence attestation parameters and path rules are documented in
- Scale & diagnostics: provider dashboards, staleness alerts, export cache reuse.
- Offline kit: end‑to‑end verified bundles for air‑gap.
ADR: Advisory Domain Source Consolidation (Sprint 203, 2026-03-04)
2026-07-22 supersession note: the source-tree consolidation and independent service identities below remain valid. The shared physical-database rationale does not: schema isolation did not provide capacity, write-lifecycle, least-privilege, or retention isolation. The target must move advisory/VEX source data and the vulnerability serving projection out of
stellaops_platform; see Vulnerability data-plane separation investigation. This preserves the historical decision without treating it as current database guidance.
Decision
Absorb src/Feedser/ (4 projects) and src/Excititor/ (38+ projects) into src/Concelier/ as a source-only consolidation. No namespace renames. No database schema merge. No service identity changes.
Context
The advisory domain spans three service-level source directories (Concelier, Feedser, Excititor) that all contribute to the same logical pipeline: raw advisory ingestion, proof evidence generation, and VEX observation correlation. Keeping them as separate top-level directories created confusion about domain ownership and complicated cross-module reference tracking for 17+ dependent projects.
Rationale for no DB merge
All three DbContexts (ConcelierDbContext, ExcititorDbContext, ProofServiceDbContext) connect to the same PostgreSQL database (stellaops_platform) but own distinct schemas (vuln/concelier, vex/excititor, vuln/feedser). The 49 entities across 5 schemas have distinct write lifecycles (raw ingestion vs. proof generation vs. VEX processing). Merging DbContexts would couple unrelated write patterns for zero operational benefit. Schema isolation is a feature.
Consequences
src/Concelier/is now the single domain root for all advisory-related source code.- Feedser projects live at
src/Concelier/StellaOps.Feedser.*andsrc/Concelier/__Tests/StellaOps.Feedser.*. - Excititor projects live at
src/Concelier/StellaOps.Excititor.*,src/Concelier/__Libraries/StellaOps.Excititor.*, andsrc/Concelier/__Tests/StellaOps.Excititor.*. - Runtime service identities are unchanged: Excititor WebService and Worker deploy as separate containers with the same Docker image names and HTTP paths.
- Deployment boundary is frozen: Concelier and Excititor remain independently deployable services.
- CI path-filters updated:
excititorsection replaced with comment pointing toconcelierpaths. src/Feedser/andsrc/Excititor/top-level directories have been deleted.
