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.

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:

  1. 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, in src/Aoc/__Analyzers/) scan ingestion writers and fail builds if forbidden properties appear.
  2. Immutable raw rows. Every upstream advisory is persisted in vuln.advisory_raw with append-only semantics. A changed content_hash produces a new row (a fresh deterministic id), linking back through the optional supersedes/supersedes_id pointer.
  3. Mandatory provenance. Collectors record source (vendor, connector, version, optional stream), upstream metadata (upstream_id, optional document_version, retrieved_at, content_hash, a string→string provenance map), and signature presence (upstream.signature) before writing.
  4. Linkset only. Derived joins (aliases, scopes, relationships, PURLs, CPEs, references) are stored inside linkset and never mutate content.raw.
  5. Deterministic canonicalisation. Writers use canonical JSON (sorted object keys, lexicographic arrays) ensuring identical inputs yield the same hashes/diff-friendly outputs.
  6. 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.
  7. Verifier & CI. The same AocWriteGuard runs in CI and at runtime, raising AocGuardException with typed AocViolationCode values that map (via AocViolationCodeExtensions, in src/Aoc/__Libraries/StellaOps.Aoc/) to ERR_AOC_0xx error codes: ForbiddenFieldERR_AOC_001, MergeAttemptERR_AOC_002, IdempotencyViolationERR_AOC_003, MissingProvenanceERR_AOC_004, SignatureInvalidERR_AOC_005, DerivedFindingDetectedERR_AOC_006, UnknownField (unknown top-level fields) → ERR_AOC_007, MissingRequiredFieldERR_AOC_008, InvalidTenantERR_AOC_009, InvalidSignatureMetadataERR_AOC_010 (the None sentinel maps to ERR_AOC_000). Operator-facing verification is exposed via the POST /aoc/verify endpoint (scopes advisory:read + aoc:verify) and the stella aoc verify CLI command.

Feature toggle: set concelier:features:noMergeEnabled=true to disable the legacy Merge module and its merge:reconcile job once Link-Not-Merge adoption is complete (MERGE-LNM-21-002). Analyzer CONCELIER0002 prevents 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; source uses connector/version (not api/collector_version); upstream carries retrieved_at + a provenance map (not fetched_at/received_at); linkset additionally carries scopes, relationships, and notes.

1.2 Connector lifecycle

  1. Snapshot stage — connectors fetch signed feeds or use offline mirrors keyed by {vendor, stream, snapshot_date}.
  2. Parse stage — upstream payloads are normalised into strongly-typed DTOs with UTC timestamps.
  3. Guard stage — connector fetches run through IAdvisoryRawWriteGuard in SourceFetchService before raw payload/document persistence; direct HTTP/import ingestion repeats the same guard in AdvisoryRawService. The underlying AocWriteGuard performs schema validation, forbidden-field checks, provenance validation, and deterministic normalization. A rejected connector envelope raises ERR_AOC_00x and writes neither the storage document nor raw payload.
  4. Write stageAdvisoryRawService.IngestAsync persists in a fixed order (per the pipeline contract documented in the vuln.advisory_raw DDL block of 001_v1_concelier_baseline.sql): (a) _writeGuard.EnsureValid(...); (b) IAdvisoryRawRepository.UpsertAsync writes the canonical immutable row into vuln.advisory_raw(baseline line 2574, dedupe key (tenant_id, source_vendor, upstream_id, content_hash) — duplicate hash is a no-op via ON CONFLICT DO NOTHING, changed hash creates a new row with a fresh deterministic ID and an optional supersedes_id pointer); © IAdvisoryObservationSink.UpsertAsync projects the normalized vuln.advisory_observationsrow used by Link-Not-Merge consumers; (d) IAdvisoryLinksetSink.UpsertAsync refreshes the vuln.lnm_linkset_cachelinkset cache. The raw store is the parent record — observations and linksets are downstream projections of the same ingest call.
  5. Event stageadvisory.observation.updated and advisory.linkset.updated events (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:

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:

Scale: HA by running N replicas; locks prevent overlapping jobs per source/exporter.


3) Canonical domain model

Current-state warning (2026-07-22): the vuln and concelier schemas below are still stored in the shared physical database stellaops_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 the vuln and concelier schemas — 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"]
  }

7.3 Hand‑off to Signer/Attestor (optional)


9) REST APIs

Route truthfulness note. Concelier’s HTTP surface is not uniformly prefixed with /api/v1/concelier. Routes are registered across several roots in StellaOps.Concelier.WebService.Program.cs and the Extensions/*EndpointExtensions.cs files: 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 /jobs and /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:

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} and GET /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.Readadvisory:readraw/linkset reads, evidence, chunks, summary, feed-mirror reads, mirror reads
Concelier.Advisories.Ingestadvisory:ingestPOST /ingest/advisory, statement provenance, air-gap/bundle/version-lock writes
Concelier.Observations.Readvuln:viewobservation projections, interest-score reads
Concelier.Aoc.Verifyadvisory:read + aoc:verifyPOST /aoc/verify
Concelier.Jobs.Readconcelier.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.Triggerconcelier.jobs.trigger (configurable via Authority.RequiredScopes)POST /jobs/{*jobKind}, /internal/orch/*, feed-mirror sync (write-class job triggering)
Concelier.Sources.Manageintegration:write or integration:operatemutative advisory-source + mirror-domain management
Concelier.Interest.Readvuln:viewinterest-score reads
Concelier.Interest.Adminadvisory:ingestinterest-score compute
Concelier.Canonical.Readadvisory:read/api/v1/canonical/* reads
Concelier.Canonical.Ingestadvisory:ingest/api/v1/canonical/* writes
(credential dry-run)concelier:credentials:dry-runPOST /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:, and severity: YAML blocks are illustrative of the conceptual model only; they do not correspond to bindable ConcelierOptions sections. Gate decision precedence is configured only through the bindable concelier:gateDecisionProjection:sourcePrecedence map above; ingestion and linkset correlation remain precedence-free.


11) Security & compliance


12) Performance targets & scale

Scale pattern: add Concelier replicas; PostgreSQL scaling via indices and read/write connection pooling; object storage for oversized docs.


13) Observability


14) Testing matrix


15) Failure modes & recovery


16) Operator runbook (quick)


17) Rollout plan

  1. MVP: Red Hat (CSAF), SUSE (CSAF), Ubuntu (USN JSON), OSV; JSON export.
  2. Add: GHSA REST, Debian (DSA HTML/JSON), Alpine secdb; Trivy DB export.
  3. Attestation hand‑off: integrate with Signer/Attestor (optional).
    • Advisory evidence attestation parameters and path rules are documented in docs/modules/concelier/attestation.md.
  4. Scale & diagnostics: provider dashboards, staleness alerts, export cache reuse.
  5. 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