Concelier Unified Issue Projection
The unified issue projection is the Stella Ops Mirror skeleton used by Scanner, Findings, Policy, VEX, and advisory views to discuss the same vulnerability without collapsing the underlying source evidence.
Contract
- Identity is
issue:sha256:<digest>over normalizedvulnerability_idandproduct_key. - Missing product context is explicit as
product:unknown; it is not merged with a package-specific issue later. - Source rows stay as observations. The projection groups them as linksets, but original advisory, distro, VEX, and future finding evidence remains reachable through evidence references.
- Disagreements are preserved in JSON conflict/material fields rather than resolved into a single truth value at ingestion time.
Tables
vuln.issue_observations: source-specific observations keyed by source kind, source id, and source observation id.vuln.issue_linksets: materialized issue skeletons grouped by vulnerability and product, with source/status/severity summaries.vuln.issue_evidence_refs: pointers from observations back to raw source rows such asvuln.advisory_source_edge,vex.claims, andvexhub.statements.
Migration 026_add_unified_issue_projection.sql creates these tables and is embedded in StellaOps.Concelier.Persistence, so fresh databases converge on service startup.
API
GET /api/v1/issues: list materialized issue linksets. Filters:issueKey,vulnerabilityId,productKey,sourceKind,offset,limit.GET /api/v1/issues/{issueKey}: return one linkset with observations and evidence references.POST /api/v1/issues/resolve: map vulnerability/product tuples to stable issue keys and return any current linkset.GET /api/v1/issues/_consensus/metrics: return the authenticated tenant’s corpus-level CVE totals fromvuln.issue_cve_consensus. A successful response is typed withstate: "ready"when rows exist orstate: "empty"with zero counts when no rows exist. The response preservestenantId,totalCveCount,multiSourceCveCount, andrebuiltAt.POST /api/v1/issues/_projection/backfill: bounded operational backfill from existing stores. Supportedsourcevalues areadvisory,vex-claims, andvexhub-statements. Use the returnedlastCursoras the nextaftervalue.
Read endpoints require Concelier.Advisories.Read. Backfill requires Concelier.Advisories.Ingest and must be run in chunks so mirror aggregation continues to make progress.
The consensus-metrics owner validates that the store result belongs to the authenticated tenant and that 0 <= multiSourceCveCount <= totalCveCount. Projection timeouts/database failures return a sanitized 503 error envelope with error.code: "ISSUE_CONSENSUS_METRICS_UNAVAILABLE" and metadata state: "degraded", retryable: true. An impossible or cross-tenant store result returns ISSUE_CONSENSUS_METRICS_INVALID with state: "invalid", retryable: false; the foreign tenant or underlying exception detail is never returned.
For local/recovery runs where the automation client cannot mint the ingest scope, the repository tool exposes the same production store backfill path:
dotnet run --project src\Concelier\StellaOps.Concelier.MirrorSeedTool\StellaOps.Concelier.MirrorSeedTool.csproj -- `
backfill-issues `
--connection-string "<postgres dsn>" `
--tenant-id default `
--source advisory,vex-claims `
--limit 50000 `
--max-iterations 5
Use --after <lastCursor> when continuing one source across batches. Large vex.claims stores are disk-sensitive because every claim can materialize an observation/evidence row. On the 2026-05-11 workstation, roughly 0.9M issue observations were enough to add several GB to PostgreSQL, so the live backfill was stopped before host disk exhaustion and the clean replay proof was moved to a bounded E2E fixture.
Operational replay contract
Each bounded source batch and its affected-linkset refresh commit in one PostgreSQL transaction. The store applies a transaction-local 300-second server statement_timeout and the same Npgsql client timeout; the longer maintenance budget therefore cannot leak back into the connection pool. Any cancellation or refresh failure rolls back the batch, and the background service persists a new last_cursor only after the complete operation succeeds. A repeated 57014 is a retryable failed batch, not evidence that replay has completed.
The source schema owner must also provide an index matching each keyset cursor. For vex.claims, Excititor startup migration 009_issue_projection_claims_cursor_index.sql owns idx_claims_issue_projection_cursor (tenant, created_at, claim_hash). On a large existing mirror, operators can create that exact index CONCURRENTLY before the Excititor restart; the idempotent startup migration then records convergence without rebuilding it. Do not reset projection cursors to work around a missing index or timeout failure.
Stella Ops Mirror Seed
stellaops-mirror-seed-v1 exports the projection tables together with the public/no-credential connector stores. Import should run before Concelier and Excititor schedulers start, then schedulers resume from imported cursors instead of refetching the whole bootstrap dataset.
The WebService seed endpoints are:
POST /api/v1/advisory-sources/mirror/seeds/exportPOST /api/v1/advisory-sources/mirror/seeds/validatePOST /api/v1/advisory-sources/mirror/seeds/import
The archive stores manifest.json plus Zstandard-compressed PostgreSQL COPY chunks under data/*.copy.zst. The manifest records format version, tenant, checkpoint mode, build version/source revision where available, active work for checkpointed exports, source/provider summaries, per-table columns, row counts, byte counts, and SHA-256 hashes. Import validation verifies the manifest and target schema before any row copy, and normal import rejects non-empty target tables unless the caller explicitly requests replacement.
Fresh setup uses the Mirror:SeedImport startup gate. When enabled, Concelier imports or validates the seed before mirror export and source scheduler hosted services start. The default restart behavior skips the import when target tables already contain mirror rows, while real validation/import failures stop startup before schedulers can redownload from upstream.
The seed remains secret-free:
- include public source rows, raw public documents, DTOs, VEX raw rows, VexHub statements, and unified issue projection rows;
- include per-source counts, status, trust classification, migration watermark, build revision, and content hashes in the manifest;
- exclude credentials, private tokens, tenant secrets, and unapproved redistribution sources.
If a live database has not yet applied the issue-projection migration, the seed manifest records the projection tables as skipped. That archive can still seed source stores and VEX claims, but it is not the final issue-skeleton seed until the migration and projection backfill have run.
The repeatable setup proof is devops/mirror/test-concelier-seed-import-e2e.ps1. It applies real Concelier and Excititor startup migrations to isolated source and target PostgreSQL containers, exports a bounded seed containing advisory and VEX issue evidence, imports it through Mirror:SeedImport before Concelier schedulers start, and verifies the imported target counts plus credential stripping.
