Hub-native advisory identity in Scanner reachability evidence
Status (2026-08-23): SCN-ID-5 DOING; SOURCE PROOF GREEN, LIVE PROOF PENDING. SCN-ID-1 through SCN-ID-4 are implemented. The producer-owned identity codec, product-issues DTOs, embedded JSON schemas, exact vectors, source OpenAPI requiredness, permanent v1 reader, strict v2 reader, ReleaseOrchestrator dual-read selection, typed endpoint mapping, Scanner’s disabled-by-default non-emitting shadow adapter, and the Scanner-owned v2 report model/schema/codec and writer projections now exist. SCN-ID-5 now stages the authoritative hub matcher and v2 writer behind one disabled-by-default emission switch, plus the matching ReleaseOrchestrator availability gate. Live forcing, deployment, and soak remain pending, so the task is not DONE. Old-edge retirement remains unimplemented under
SCN-ID-6. Implementation is tracked asSCN-ID-1throughSCN-ID-6inSPRINT_20260722_017.Decision in one sentence. New evidence identifies a hub issue with the producer-owned, case-sensitive
(vulnerabilityId, productKey)tuple and a typed SHA-256 string derived from its canonical bytes; the hub linkset content hash is a separate observation revision, and no path may manufacture a Guid.
This document resolves the identity question in owner-register item 13. Source is authoritative over this design if it later diverges.
1. Why the Guid cannot cross the boundary
The old CanonicalId is not a portable advisory identifier. It is the primary key of a Concelier advisory_canonical row. The current matcher copies that Guid into SbomAdvisoryMatch, uses it to join advisory display data, and emits it in DependencyReachabilityReport and SARIF. The hub deliberately has no equivalent Guid.
The hub guarantees three different things that must stay different:
- Issue subject identity:
consensus.issue_linksethas primary key(vulnerability_id, product_key). The table is global and has no tenant dimension.product_keyis the DC-01 canonical product key, normally a PURL without version. - Issue observation revision:
content_hashis SHA-256 over the canonical linkset projection, including the sorted live fact revision hashes. It changes when the projection changes. It is a revision value, not the issue’s identity. - Individual fact identity: a fact ID additionally includes issuer, source statement, and applicability. It is too narrow to identify the aggregate issue that Scanner matches, and its revision hash is again a value rather than an identity.
Putting the linkset content hash inside the subject ID would mint a new subject on every advisory update. Truncating either hash into a Guid would preserve the old field’s appearance while changing its meaning. Both are forbidden.
2. Source-traced current surface
The following is the complete relevant path from old producer to stored or consumed evidence. Symbol-normalizer, EvidenceLocker thread, and other fields also named CanonicalId are different identity domains and are not part of this change.
| Surface | Current source truth | Consequence for v2 |
|---|---|---|
| Old match contract | Both StellaOps.Concelier.SbomIntegration.Contracts and the carried StellaOps.Sbom.Contracts copy declare SbomAdvisoryMatch.CanonicalId as required Guid. | The carried copy cannot be reused as the hub authority contract. |
| Old match producers | The live root-namespace SbomAdvisoryMatcher copies CanonicalAdvisory.Id; CheckMatchAsync does the same. It also creates SbomAdvisoryMatch.Id by truncating SHA-256 to 16 bytes and constructing a Guid. The dead .Matching duplicate has the same identity defect. | The hub adapter needs string/digest match and issue identities; neither Guid may survive as new authority. |
| Old persistence/API | vuln.sbom_canonical_match stores canonical_id UUID as a foreign key and Concelier’s SBOM endpoints serialize the match. The old schemas were dropped at the hub cutover and the service is stopped. | There is no surviving authoritative Guid-to-hub crosswalk to migrate. |
| Current hub issue wire | IssueSummary remains implementation-owned in StellaOps.Vulnerabilities.Facts.Serving; SCN-ID-3 now maps the product-issues endpoint through the closed ProductIssuesResponseV1 / VulnerabilityIssueSummaryV1 DTOs added by SCN-ID-1, with typed endpoint metadata and required source OpenAPI fields. | Scanner can consume the zero-ProjectReference producer contract without entering Vulnerabilities implementation source. |
| Scanner hub adapter | HubProductIssuesAdapter derives versionless product keys without case folding, calls the typed product-issues route, rejects missing/duplicate/additive/disagreeing input, and returns typed identities plus the exact linkset hash. SbomReachabilityStageExecutor uses it either for bounded shadow diagnostics or, only when HubAdvisoryV2EmissionEnabled=true, as the authoritative matcher feeding the v2 codec. | Shadow remains observation-only. The authoritative path skips the legacy matcher/v1 writer, treats only a valid available empty response as clean, and marks the whole v2 report unavailable when any requested product is unavailable. |
| Worker boundary | The retained legacy branch still uses ToContractMatches, Guid-keyed summary dictionaries, and the old Guid fallback. The hub-v2 branch bypasses those seams and maps the producer-owned typed issue identity plus exact PURL directly into VulnerabilityReachabilityFilterV2 and ReachabilityReportV2Builder. | The old conversion exists only for rollback until SCN-ID-6. No Guid or legacy fallback can enter the hub-v2 report. |
| Identity-free fallback producer | ReachabilityInputStageExecutor.SynthesizeDependencyReachabilityReport creates a no-vulnerability report for call-graph scans without an SBOM; a later SBOM stage can overwrite it. | Keep the identity-free fallback valid in v2. It must not invent an advisory subject merely to populate the new fields. |
| Filtering | VulnerabilityReachabilityFilter takes IReadOnlyDictionary<Guid, string?> and looks up severity by match.CanonicalId. | Key by the typed issue identity string, while keeping component PURL as the reachability join. |
| Report model | DependencyReachabilityVulnerabilityFinding and DependencyReachabilityAdvisorySummary require Guid CanonicalId. The reporter sorts and joins on it. | v2 replaces it with AdvisoryIdentity plus AdvisoryObservation; there is no v2 canonicalId. |
| SARIF | DependencyReachabilityReporter writes properties.canonicalId as Guid text. The primary SARIF vulnerability ID and evidence URI already use VulnerabilityId. | v2 emits the typed issue ID, product key, and linkset revision under new names; it never reuses canonicalId. |
| Synthetic coverage fixture | SyntheticAnalyzerCoverageReachabilityStageExecutor hashes fixture fields and constructs a StableGuid, then emits it in the same report. | The v2 fixture uses the same identity canonicalizer with an explicitly synthetic scheme, or omits advisory identity when no hub issue exists. It may not fabricate a hub identity. |
| Surface evidence | The legacy branch still writes reachability.report with CanonJson and PascalCase property names. When a v2 artifact exists, SurfaceManifestStageExecutor stores the codec’s exact bytes/hash and uses that digest in composition/Merkle evidence. | Stored v1 bytes and their Merkle roots never change; the v2 surface never reserializes the report. |
| Release evidence | The legacy branch still writes ordinary lower-camel stellaops.reachability-report@v1. A v2 artifact is uploaded as stellaops.reachability-report@v2 using the same canonical bytes already handed to the surface writer. | Surface, release, and replay composition share the exact v2 bytes/hash; no fabricated v1 dual-write occurs. |
| Release consumer | ReachabilityGateEvaluator queries both evidence types, deterministically selects the newest row, and never falls back from a selected malformed/unsupported v2 row. ReachabilityWitnessReader preserves exact v2 source status/reason as well as finding fields. | Only exact available v2 evidence is eligible to pass; partial or wholly unavailable evidence follows the existing blocking/advisory posture. The frozen v1 reader remains. |
| Scanner projection | ScanEvidenceProjectionStageExecutor projects vulnerability ID, PURL, reachability and path into scan_evidence_projections; it drops CanonicalId. | Its stored shape is not an identity migration blocker; add the new identity only if a separately versioned projection contract requires it. |
| Console/CLI/report consumers | No typed Console or CLI reader of DependencyReachabilityReport.CanonicalId exists in src/. Generic evidence download and SARIF consumers can still expose the bytes/properties. | There is no hidden UI Guid contract, but generic evidence and SARIF remain public compatibility surfaces. |
| Schemas/tests | There is no JSON schema or in-payload schema version. Tests pin pieces of the v1 reporter, surface payload, publisher byte/hash equality, and ReleaseOrchestrator’s camel-case fixture. | v2 starts with a producer-owned schema, canonicalizer, golden bytes and red/green version tests before runtime wiring. |
There are currently two byte representations of the same CLR report. Calling both “canonical” does not make them equal. v2 removes that ambiguity rather than blessing one of the two v1 encodings retroactively.
3. Target identity contract (SCN-ID-1 kernel and SCN-ID-3 shadow adoption implemented)
3.1 Structured identity
The producer-owned contract is AdvisoryEvidenceIdentityV1 in the closed StellaOps.Vulnerabilities.Contracts graph:
{
"scheme": "stellaops.vulnerability-issue",
"version": 1,
"id": "stellaops:vulnerability-issue:v1:sha256:7b872e7616829ac55b83854771c5b057f687671a3378275671a25ab87bdff80d",
"vulnerabilityId": "CVE-2026-0001",
"productKey": "pkg:maven/Apache/example"
}
Rules:
vulnerabilityIdandproductKeyare the exact non-empty values returned by the hub. A value with leading/trailing whitespace is rejected, not silently trimmed.- No Unicode normalization is allowed. Composed and decomposed strings are distinct source values, remain distinct on the wire, and hash to distinct IDs. The identity kernel consumes their exact UTF-8 code points.
- Comparison is ordinal and case-sensitive. In particular,
pkg:maven/Apache/exampleandpkg:maven/apache/exampleare different identities. The baseline primary-key DDL does not declare an explicit collation; deterministic corpus/order queries useCOLLATE "C", and the producer contract itself pins ordinal case-sensitive comparison. A consumer must not infer normalization from database ordering or introduce a case-fold. productKeyis versionless. The matched component’s versioned PURL stays a separate finding field and never substitutes for the product key.- No tenant, scan, release, SBOM, environment, status, severity, timestamp, content hash, or corpus generation participates in the issue identity.
- The
idis a typed string, never a UUID. Code must not parse it as a Guid or expose a Guid compatibility alias.
3.2 Canonical identity bytes and ID
The exact identity preimage is the producer-owned canonical UTF-8 JSON profile over these four properties and no others:
{"productKey":"pkg:maven/Apache/example","scheme":"stellaops.vulnerability-issue","version":1,"vulnerabilityId":"CVE-2026-0001"}
The algorithm is:
- Validate the two producer values without normalization.
- In
StellaOps.Vulnerabilities.Contracts, writeproductKey,scheme,version, andvulnerabilityIdin exactly that order with a dependency-freeUtf8JsonWriterusingJavaScriptEncoder.UnsafeRelaxedJsonEscaping, no BOM, and no whitespace. This followsCorpusArtifactBuilder.CanonicalizeManifest; the production contracts SDK keeps zeroProjectReferences. - Compute SHA-256 over those exact bytes.
- Render lower-case hexadecimal and prefix it with
stellaops:vulnerability-issue:v1:sha256:. - On read, recompute and require byte-for-byte equality with
id; a mismatched ID is malformed evidence.
The literal above is 128 UTF-8 bytes. Its SHA-256 is 7b872e7616829ac55b83854771c5b057f687671a3378275671a25ab87bdff80d, so its exact full ID is stellaops:vulnerability-issue:v1:sha256:7b872e7616829ac55b83854771c5b057f687671a3378275671a25ab87bdff80d. The contract test commits all three as independent literals. A test-only reference may characterize byte equality with CanonJson for inputs that CanonJson leaves unchanged; production must not gain a StellaOps.Canonical.Json edge.
A composed escaping/Unicode vector pins the writer profile beyond the all-ASCII case (café contains U+00E9):
{"productKey":"pkg:generic/café/<lib>","scheme":"stellaops.vulnerability-issue","version":1,"vulnerabilityId":"VENDOR-\"quoted\"\\path"}
Those exact UTF-8 bytes have length 137 and SHA-256 08f84e1f929b5cdb6f5787de9c9f974a9a89710c893bef9e869122f8cd01d918. Its full typed ID is stellaops:vulnerability-issue:v1:sha256:08f84e1f929b5cdb6f5787de9c9f974a9a89710c893bef9e869122f8cd01d918.
The decomposed counterpart uses U+0065 followed by U+0301. It is accepted and kept distinct:
{"productKey":"pkg:generic/café/<lib>","scheme":"stellaops.vulnerability-issue","version":1,"vulnerabilityId":"VENDOR-\"quoted\"\\path"}
Those exact bytes have length 138 and SHA-256 11becdebb7a4aefa1f89cea1b251bbca6c4800b6f1d56ddb0fc4683fff9f2327. Its full typed ID is stellaops:vulnerability-issue:v1:sha256:11becdebb7a4aefa1f89cea1b251bbca6c4800b6f1d56ddb0fc4683fff9f2327. Tests assert the two input code-point sequences, bytes, hashes, IDs, and parsed tuples independently. A test-only characterization may show that CanonJson matches the already-composed vector but normalizes the decomposed vector; that intentional divergence is why CanonJson cannot be the production identity writer.
This is grounded in current producer source, not a hypothetical PURL promise: PackageUrl canonicalization percent-escapes strings when a normalizer uses it, but VulnFact, FactWriter, consensus.issue_linkset, HubQueries, and MapIssue enforce no end-to-end Unicode normalization form on the stored or returned tuple. The consumer therefore has no source guarantee that would authorize normalization.
This digest is a compact, collision-resistant reference to the structured tuple. The tuple remains on the wire so an operator never has to reverse or guess what the hash means.
3.3 Observation revision
The matched hub projection is carried separately:
{
"linksetContentHash": "sha256:<64-lower-hex>",
"factCount": 3,
"statusSummary": { "affected": 2, "fixed": 1 },
"severitySummary": { "high": 2 }
}
linksetContentHash is required for a hub-backed finding and must be the exact hash returned with that (vulnerabilityId, productKey) linkset. The summaries are the observation the report used. They do not participate in subject identity. If the source is a generation-stamped corpus artifact, the report also records its corpusManifestDigest; an HTTP linkset lookup must not invent a global generation because the hub has no single global generation number.
Changing only linksetContentHash, fact count, status, severity, or fact revision keeps AdvisoryEvidenceIdentityV1.Id stable and creates a new observation revision.
3.4 Producer-owned issue response
SCN-ID-1 closed the producer-contract side of the HTTP seam. The zero-reference StellaOps.Vulnerabilities.Contracts project owns ProductIssuesResponseV1 and VulnerabilityIssueSummaryV1 with these required fields:
{
"productKey": "pkg:maven/Apache/example",
"issues": [
{
"vulnerabilityId": "CVE-2026-0001",
"productKey": "pkg:maven/Apache/example",
"factCount": 3,
"statusSummary": { "affected": 2, "fixed": 1 },
"severitySummary": { "high": 2 },
"contentHash": "sha256:<64-lower-hex>"
}
]
}
The same slice made all fields required in OpenAPI, pinned the lowercase sha256: hash pattern and non-negative counts, and adds contract/OpenAPI parity tests. SCN-ID-3 binds the hub endpoint to that DTO and advertises the typed 200 response before the Scanner adapter consumes it. Scanner references only this closed contract (or consumes its versioned HTTP JSON); it never references Vulnerabilities.Facts.Serving. The wire version is the /api/vulnerabilities/v1/ route plus the V1 DTO; a future route version requires a new declared DTO/adapter and is never guessed.
The implemented, disabled-by-default shadow adapter validates that the response envelope’s productKey and every issue row’s productKey equal the exact requested/returned producer value ordinally. Missing fields, duplicate identity fields, disagreement, a bad hash, or an undeclared route/contract version are source-unavailable/malformed, never a clean zero. An available response with a valid empty issues array is a genuine clean zero. It preserves the exact returned contentHash and summary maps but does not infer consensus from statusSummary. Its result is diagnostic only and cannot reach a report/evidence writer. This pins both authority and anti-vacuity at the transport boundary without enabling v2 production.
3.5 Match occurrence identity
If a future store needs a unique match occurrence, it uses another typed string:
stellaops:sbom-advisory-match:v1:sha256:<hex> over canonical (sbomDigest, componentPurl, advisoryIdentity.id). This is not the advisory identity and is not emitted as canonicalId. It replaces the old truncated-hash match Guid only where an occurrence key is actually required.
4. DependencyReachabilityReport v2 wire (IMPLEMENTED, SOURCE-ONLY)
4.1 Envelope and finding
The v2 evidence type is stellaops.reachability-report@v2. The payload has an explicit integer version and one source description. The following excerpt focuses on the identity-bearing fields; SCN-ID-4’s schema preserves every other current report field with explicit type/requiredness, and the empty summary and component arrays below are illustrative rather than the golden report vector:
{
"schema": "stellaops.reachability-report",
"schemaVersion": 2,
"advisorySource": {
"kind": "stellaops.vulnerabilities",
"identityContractVersion": 1
},
"summary": {},
"components": [],
"vulnerabilities": [
{
"advisoryIdentity": {
"scheme": "stellaops.vulnerability-issue",
"version": 1,
"id": "stellaops:vulnerability-issue:v1:sha256:7b872e7616829ac55b83854771c5b057f687671a3378275671a25ab87bdff80d",
"vulnerabilityId": "CVE-2026-0001",
"productKey": "pkg:maven/Apache/example"
},
"advisoryObservation": {
"linksetContentHash": "sha256:<64-lower-hex>",
"factCount": 3,
"statusSummary": { "affected": 2, "fixed": 1 },
"severitySummary": { "high": 2 }
},
"vulnerabilityId": "CVE-2026-0001",
"purl": "pkg:maven/Apache/example@1.2.3",
"isReachable": true,
"confidence": 1.0,
"reachabilityPath": []
}
],
"filteredVulnerabilities": [],
"analysisMode": "combined"
}
4.1.1 Call-graph PURL attribution (producer note, 2026-09-02)
ReachGraphReachabilityCombiner relates a call-graph node to an SBOM component only through the node’s PURL. The Worker’s input stage attributes PURLs solely from build-stage SBOM components, which exist for scan-rootfs inputs only; for an ordinary image scan that lookup is empty, no node carries a PURL, and the combiner returns the SBOM-only report, so every dependency reads unreachable/no-path even when the extractor proved an entry-to-sink path. SbomReachabilityStageExecutor therefore applies RichGraphPurlAttribution to the loaded graph before combining: a Go node id go:{package}.{Symbol} / go:external/{package}.{Symbol} is matched against every pkg:golang component’s module path (version stripped, longest /- or .-bounded prefix wins); nodes of other languages and nodes that already carry a PURL are unchanged. The combiner also attaches the entry-to-symbol witness the traversal actually walked (CallGraphReachabilityResult.PurlPaths, deterministic: ordinal node order, first reachable node per PURL) to every call-graph-reachable finding that has no SBOM path, because the ReleaseOrchestrator reachability gate fails closed on a reachable finding with an empty reachabilityPath (measured 2026-09-02: reachable witness incomplete). The committed reachability lane policy (devops/etc/plugins/scanner/reachability-policy.yaml, selected by Scanner__Worker__Reachability__PolicyPath in docker-compose.reachability.yml) uses analysisMode: callGraph; without a policy file the loader defaults to sbomOnly and the extracted graph is ignored entirely. Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/StellaOps.Scanner.Reachability.Tests.csproj -Class "*RichGraphPurlAttributionTests*" (pins the trap and the repair; verified against the commit that introduced RichGraphPurlAttribution, git log -S RichGraphPurlAttribution --oneline).
The existing ergonomic vulnerabilityId and versioned purl fields remain so the release gate and SARIF pipeline do not need to infer them from identity. For a hub finding, the reader requires finding.vulnerabilityId == finding.advisoryIdentity.vulnerabilityId using ordinal comparison. canonicalId is absent and forbidden in a v2 write.
Nullable legacy display fields such as title, free-text summary, or affected versions may remain only when a real producer supplied them. The current hub IssueSummary does not supply those values; the adapter must omit them rather than fabricating them.
4.2 One canonical codec
ReachabilityReportV2Codec owns v2 serialization and parsing. Generic JsonSerializer.Serialize(report) is not a permitted evidence writer.
The Scanner-owned codec:
- emits lower-camel property names through one explicit
Utf8JsonWriterpath as UTF-8 with no BOM or insignificant whitespace. This deliberate report-local writer preserves exact Unicode code-point sequences; it does not call the repositoryCanonJsonhelper that SCN-ID-1 proved normalizes decomposed text; - sorts components by
(componentRef, purl)ordinal; - sorts both finding arrays by
(advisoryIdentity.id, purl, componentRef)ordinal; - sorts summary-map keys ordinal;
- preserves semantic sequence for call paths and conditions rather than sorting them;
- rejects duplicate component or finding sort tuples, so stable-sort ties can never smuggle caller order into the byte stream;
- omits optional null properties consistently;
- rejects duplicate identity properties, a mismatched identity digest, a bad
sha256:revision, a missing required field, and a non-integral or unsupported schema version.
The exact output bytes are reused, without reserialization, for:
- the
reachability.reportsurface payload and its CAS/composition digest; stellaops.reachability-report@v2release evidence andRawContentBase64hash verification;- any replay bundle that embeds the report.
ReachabilityReportV2Artifact couples a deeply immutable report reconstructed from the validated wire to a defensive copy of those exact bytes and their sha256: digest. Surface and release writer projections accept that artifact rather than a raw report, and replay’s composition recipe hashes the surface payload bytes. Thus one report has one SHA-256 no matter which transport carries it. SARIF is a separate representation and has its own digest, but it carries the same subject ID, product key, and linkset content hash as advisoryIssueId, advisoryProductKey, and advisoryLinksetContentHash. It does not write properties.canonicalId for new findings.
Draft 2020-12 compares JSON numbers mathematically, so its integer/const rules cannot distinguish the lexical tokens 2, 2.0, and 2e0. The embedded schema records that limitation in $comment; the codec additionally requires the exact tokens schemaVersion: 2, identityContractVersion: 1, and nested advisoryIdentity.version: 1, matching the already-deployed ReleaseOrchestrator and owning advisory-identity codecs. Ordinary integer-valued counts accept schema-equivalent spellings such as 2.0 and 2e0, then the sole writer returns them to canonical integer tokens. Executable schema tests pin the golden/negative documents, exact raw-number confidence/percentage bounds, count bounds, ordinary integer parity, and this deliberate three-field lexical codec layer.
5. Consensus and tenant semantics
The hub issue identity is global. Tenant and environment remain on the scan, release-evidence envelope, and policy evaluation context; they never enter the identity preimage.
The current product-issues endpoint returns a statusSummary count map. It does not return EffectiveConsensus or a decisionHash, and counts are not enough to reconstruct issuer trust precedence or a tenant overlay. Therefore this change does not make Scanner a second consensus engine:
- a live hub issue linkset is a match observation and is emitted;
- Scanner does not suppress a linkset merely because its count map contains
fixedornot_affected; - Scanner records the exact global observation and reachability;
- shared consensus/Policy remains responsible for global trust evaluation, tenant VEX overlays, and the effective gate decision.
If a future contract wants Scanner to emit an effective consensus result, the hub or the shared consensus contract must supply that result and its decision hash in a separately versioned change. The matcher must not derive one from counts.
6. Backwards read and upcast policy
Evidence compatibility has two different windows:
- Operational rolling window: active consumers understand v2 and v1 before the v2 producer is enabled. This is the normal n/n-1 deployment window.
- Archival evidence window: the frozen v1 decoder is permanent. Historical evidence remains readable after v1 is no longer writable, because an evidence format cannot expire merely because the service rollout moved beyond n-1.
The version dispatcher uses the evidence type plus the in-payload version:
| Input | Reader behavior |
|---|---|
stellaops.reachability-report@v1, no schemaVersion | Parse with the frozen v1 shape. Preserve its original bytes and hash. |
stellaops.reachability-report@v2, schemaVersion: 2 | Parse and validate the v2 schema, identity digest, revisions, and deterministic contract. |
| Type/version disagreement | Reject as malformed; do not guess. |
| Unknown version | Fail closed with an operator-visible unsupported-version result. Do not reinterpret as v1 or ignore the version. |
SCN-ID-2 implements this dispatcher in ReleaseOrchestrator. The gate queries @v2 and @v1 independently, orders the combined rows by createdAt, prefers v2 only on an exact timestamp tie, and parses only that selected row. A selected row that is malformed, uses an unsupported version/type, or disagrees with its type is reported with a stable reachability_witness.* failure code and is not replaced by an older clean row. The v1 decoder and its literal byte/hash fixture remain frozen. This is a source change only; it does not deploy or enable a v2 producer.
A v1 read can be projected into an in-memory compatibility view only:
authority = legacy
scheme = concelier-canonical-guid
legacyCanonicalId = <lower-case D-format Guid>
That view is not AdvisoryEvidenceIdentityV1, is never serialized as v2, and is not accepted by a hub-authority API. The v1 vulnerabilityId and versioned PURL remain observations. They are insufficient to prove the historical hub product key or linkset revision, so the reader must not derive a hub ID from them.
No old blob, release-evidence row, surface-cache object, composition recipe, Merkle root, SARIF file, or replay bundle is rewritten. A fresh scan against the hub is the only promotion route from legacy evidence to hub-native v2 evidence. It creates a new evidence object with a new hash; it is not an upcast or backfill.
7. Migration and backfill
A lossless relational backfill is impossible:
- the old Guid names a Concelier row, not a hub tuple;
- the old
vuln,vex,vexhub, andconcelierschemas were dropped at G1; - a v1 report does not carry the exact hub product key and historical linkset content hash needed by v2;
- deriving a current product key/hash from a historical PURL would silently replace historical authority with today’s corpus;
- rewriting stored evidence would invalidate its content hash and provenance.
The implementation therefore has no migration SQL. Readers preserve v1; new scans write v2 after the rollout gate. An operator may intentionally rescan an old subject, but the new report must cite the new scan and current hub revision and must not claim to be the old observation.
8. Rollout order
SCN-ID-1 is implemented in the closed producer contract graph, SCN-ID-2 in the ReleaseOrchestrator consumer, SCN-ID-3 as the typed endpoint plus Scanner shadow adapter, and SCN-ID-4 as the source-only v2 report/writer slice. SCN-ID-5 is DOING: its source switch, hub-backed match path, and consumer availability gate have green source forcing/rollback coverage, but live forcing and soak are still required. SCN-ID-6 is BLOCKED on that acceptance and otherwise unimplemented.
- SCN-ID-1 — contract kernel (DONE 2026-08-22). Added
AdvisoryEvidenceIdentityV1, its dependency-free exact writer/parser, JSON schemas,ProductIssuesResponseV1/VulnerabilityIssueSummaryV1, literal golden vectors, and red/green conformance tests in the closedStellaOps.Vulnerabilities.Contractsgraph. The matching OpenAPI fields are required and parity-tested, and the SDK retains its zero-reference production graph. Tests characterizeCanonJsononly to prove why it matches unchanged inputs but is forbidden for decomposed exact strings. No matcher, report model, writer, host binding, or runtime registration changes. This is the exact first implementation slice. - SCN-ID-2 — consumers first (DONE 2026-08-22). Added the permanent frozen v1 reader and strict v2 reader, including ordinal tuple-identity validation, observation hash/count validation, rejection of
canonicalId, and stable failure codes. ReleaseOrchestrator now queries both evidence types and evaluates the newest combined row; an equally-timed v2 row wins, while a malformed or unsupported newest row never falls back to an older clean row. Existing v1 fixtures remain, with their literal compact-byte hash pinned. - SCN-ID-3 — hub adapter behind a non-authoritative switch (DONE 2026-08-22). The hub product-issues endpoint now maps and advertises the closed response DTO. Scanner’s adapter uses only that zero-reference producer SDK, preserves exact case and linkset hash, rejects missing/duplicate/additive/disagreeing input as unavailable, and treats only a valid empty array as clean. The Worker derives exact versionless PURL product keys and logs bounded shadow counts only when explicitly enabled; it does not change the v1 match, report, SARIF, evidence, or replay result. Real non-empty and genuinely-clean fixtures plus an always-empty red mutation pin the transport anti-vacuity boundary. No Concelier source was removed and no v2 evidence was emitted.
- SCN-ID-4 — v2 model and one-codec writer (DONE 2026-08-22). Filtering and summaries are keyed by exact typed issue ID, and the shadow result has an explicit mapping seam into the v2 match/observation shape. The strict embedded schema and sole codec pin ordering, optional-property omission, exact paths, identity/revision validation, literal golden bytes, and one report hash. Surface, release evidence, and replay consume that artifact’s exact bytes; SARIF uses the three new advisory property names; the synthetic coverage fixture is identity-free and no longer creates
StableGuid. Independent newline mutations in each writer made the cross-wire gate red before restore. This slice does not connect shadow results to normal scan production. - SCN-ID-5 — producer flip and soak (DOING 2026-08-23). One
HubAdvisoryV2EmissionEnabledswitch selects the hub matcher and v2 writer together. The enabled path never calls the legacy matcher or writes an invented v1 row; disabling it returns future scans to the unchanged legacy path without deleting or rewriting stored v1/v2 evidence. Product keys and PURLs match ordinally, tenant context never enters issue identity, and any unavailable product lookup makes the whole report explicitly unavailable, including partial responses and a zero-product scan with no adapter. ReleaseOrchestrator preserves the exact source status/reason and allows only exactavailablev2 evidence to pass. Source forcing and rollback validation are part of this slice. The durablescanner-hub-advisory-v2-cutoverprocedure and its offline fail-closed record validator now define the exact source/CI/image preflight, non-empty/clean-zero/unavailable trio, blocking ReleaseOrchestrator comparison, reversible rollback, evidence-hash preservation, and soak/abort thresholds. Those live actions and their receipts remain pending before DONE; procedure source is not live proof. - SCN-ID-6 — retirement (2026-09-03). The Scanner-owned advisory matcher and every fresh v1 producer are gone: the legacy branch of
SbomReachabilityStageExecutor, the non-emitting shadow observation, the no-SBOMDependencyReachabilityReportsynthesis inReachabilityInputStageExecutor, the v1 fallback inReachabilityPublishStageExecutor, and the v1 branches of the surface and scan-evidence projections.ISbomAdvisoryMatcher,NullSbomAdvisoryMatcher,ConcelierHttpAdvisoryService, the v1VulnerabilityReachabilityFilterand the reporter’s v1BuildReport/ExportSarifAsyncwere deleted with them, as were theHubAdvisoryShadowEnabledandHubAdvisoryV2EmissionEnabledswitches. The hub product-issues adapter is now the lane’s only advisory source, required wheneverReachability:Enabledis true. What survives on purpose: the frozen v1 evidence decoder and golden fixtures in ReleaseOrchestrator, theDependencyReachabilityReportshape, the dependency-graph exporter used by the v2 path, and the affected-symbol bridge (nowHubHttpAffectedSymbolProvider, VULN-B1 source cutover 2026-09-11), which maps advisory CVEs to sink symbols through the Vulnerabilities owner API. It uses the sameHubAdvisoryBaseUrlas product issues; the old symbol URL setting is removed. TheConcelier.SbomIntegrationimplementation edge was already absent from the Worker before this slice. Verified-by:StellaOps.Scanner.Worker.Tests542/542,StellaOps.Scanner.Reachability.Tests970 (2 perf skips),BuildBoundaryConformanceTests40/40,ReachabilityWitnessReaderTests15/15 unchanged, and an isolateddotnet publishwhosedeps.jsoncompiles 95 project assemblies with no Concelier or Excititor library.
9. Rollback
Rollback never means v2-to-v1 conversion.
- SCN-ID-6 (2026-09-03) retired the emission switch together with the legacy producer. The lane lever is now
Scanner:Worker:Reachability:Enabled: with it off, a scan publishes no reachability evidence at all, which is exactly the pre-cutover posture. There is no configuration that resurrects a v1 write, and no Guid is ever synthesized to keep one alive. - The proven rollback is the compose chain, not a flag: recreate the Worker on the baseline list without
docker-compose.reachability.yml. That is what the SCN-ID-5 rollback drill exercised and what its receipt records. - Dual readers and the v2 decoder remain deployed after any rollback so already stored v2 evidence stays readable.
- A rollback does not delete or rewrite v2 evidence. A later scan is a new observation.
Because the reachability advisory overlay is not active in the current live estate and its old Concelier target is stopped, the honest emergency posture is unavailable, not a fabricated clean v1 report.
The exact rollback drill is in scanner-hub-advisory-v2-cutover.md: restore the captured image ID and ordered compose/configuration fingerprint, leave reachability and Hub v2 emission disabled, re-read every stored v2 row and prove the byte hashes unchanged, then reapply the same candidate digest only when the drill is green. No rollback scan is allowed against the stopped legacy source.
10. Required red/green and anti-vacuity gates
Each implementation task must record the pre-declared red mutation and the restored green result. A test that only checks that “a hash exists” is vacuous.
| Gate | Required green proof | Required red mutation/proof |
|---|---|---|
| Literal identity vectors | The ASCII vector is exactly 128 bytes with SHA-256 7b872e7616829ac55b83854771c5b057f687671a3378275671a25ab87bdff80d; the U+00E9 quote/backslash vector is 137 bytes with 08f84e1f929b5cdb6f5787de9c9f974a9a89710c893bef9e869122f8cd01d918; its U+0065/U+0301 counterpart is 138 bytes with 11becdebb7a4aefa1f89cea1b251bbca6c4800b6f1d56ddb0fc4683fff9f2327. Full typed IDs and parsed tuples equal independent literals. | Case-fold Apache, rename version to v, change escaping/encoder, reorder a field, or add revision hash; a literal test turns red. |
| Unicode exactness | U+00E9 and U+0065/U+0301 remain exact and produce distinct bytes/IDs. Test-only characterization records CanonJson divergence for the decomposed input without a production edge. | Add .Normalize(FormC) anywhere in validation/writing; the distinct-ID and exact-round-trip tests turn red. |
| Case significance | Two values differing only in product-key case produce distinct IDs and retain exact case on round trip. | Add ToLowerInvariant; the test turns red. |
| Revision separation | Changing only linkset content hash/status/severity keeps ID stable while report bytes/hash change. | Put content hash in identity preimage; stable-ID assertion turns red. |
| Tenant exclusion | The same hub tuple in two tenant envelopes has the same issue ID; envelope/report hashes may differ. | Add tenant to the preimage; equality turns red. |
| No Guid authority | No v2 DTO/property/API accepts Guid; architecture scan rejects canonicalId, Guid.Parse, new Guid, UUIDv5, and truncated-hash Guid construction in new-authority code. | Introduce a compatibility Guid property or StableGuid helper; conformance turns red. |
| Golden report bytes | Two logically identical reports with permuted input enumeration produce one exact committed v2 byte vector and SHA-256. Surface and release publishers receive those same bytes. | Bypass the codec in either writer or remove an ordinal sort; byte/hash equality turns red. |
| Semantic path order | Call-path order survives canonicalization and round trip. | Sort path nodes; expected path turns red. |
| Strict versioning | v1 and v2 fixtures parse through their declared reader; unknown v3, fractional versions, and type/version disagreement fail closed. | Treat unknown as v1 or ignore schemaVersion; negative fixture turns red. |
| Legacy non-promotion | v1 upcast is labelled legacy and is rejected wherever hub authority is required; original bytes/hash remain identical. | Convert the Guid/PURL into AdvisoryEvidenceIdentityV1; authority-boundary test turns red. |
| Rescan-only promotion | A v2 record requires a new scan ID and real hub identity/revision. | Attempt to serialize an in-memory v1 compatibility view as v2; writer rejects it. |
| Consumer ordering | Release gate reads v2 and v1 and preserves v1 decisions. Newest malformed/unsupported evidence produces the configured fail-closed result, not fallback. | Skip the newest bad row and select an older clean one; selection test turns red. |
| Hub contract integrity | The endpoint deserializes into the closed response DTO; OpenAPI required fields match it; identity tuple, exact case, and content hash round-trip from producer fixtures. Missing/malformed fields fail as unavailable, while a valid empty array is clean. | Return an anonymous shape missing productKey, loosen an OpenAPI required field, disagree with the envelope case, or accept a non-sha256: hash; parity/contract tests turn red. |
| Producer forcing and availability | With v2 emission enabled, a real exact-case issue becomes a typed v2 finding, a genuinely available empty response is clean zero, and any missing/failed product response makes the whole report unavailable even when another product returned findings or no products were derived. ReleaseOrchestrator preserves the exact status/reason and never reports a non-available v2 witness as passed. | Invert the actual emission switch or bypass the ReleaseOrchestrator source-status gate; the forcing/release tests turn red. |
Golden vectors must be computed once from reviewed source values and committed as literals. Generating “expected” bytes or hashes by calling the system under test inside the assertion is not evidence.
11. Completion boundary
The item is implemented only when:
- the producer-owned identity contract and v2 report schema are source-controlled;
- v1 archival reads and v2 strict reads pass their literal fixtures;
- Scanner emits no new advisory Guid on any hub path;
- surface, release, and replay carry identical v2 report bytes;
- ReleaseOrchestrator consumes both versions without unsafe fallback;
- a real non-empty hub match and a clean-zero match pass the forcing pair;
- the final Concelier implementation edge is gone from Scanner’s isolated build;
- old v1 evidence remains byte/hash-identical and readable.
SCN-ID-1 through SCN-ID-4 and the staged source portion of SCN-ID-5 do not satisfy this overall boundary. SCN-ID-5 remains DOING until its live forcing, release comparison, and soak are recorded; SCN-ID-6 is BLOCKED on that live acceptance before the old edge can be retired. The source and consumer changes do not by themselves authorize a live flip, schema mutation, evidence rewrite, or Concelier data reconstruction.
12. Source anchors
- Hub identity/revision:
Vulnerabilitiesarchitecture - Hub issue API:
vulnerabilities/openapi.yaml - Implemented identity codec:
AdvisoryEvidenceIdentityV1.cs - Implemented product-issues DTOs:
ProductIssuesContractsV1.cs - Embedded producer schemas:
Schemas/ - Exact vectors and parity gates:
StellaOps.Vulnerabilities.Contracts.Tests - Current implementation-owned issue query:
HubQueries.cs - Implemented typed endpoint mapping:
Vulnerabilities WebService Program.cs - Implemented non-emitting adapter:
HubProductIssuesAdapter.cs - Implemented v2 model and sole codec:
ReachabilityReportV2Models.csandReachabilityReportV2Codec.cs - Embedded v2 schema:
reachability-report-v2.schema.json - Exact golden/cross-wire gates:
ReachabilityReportV2CodecTests.cs - Hub wire/version window:
CorpusWire.cs - Zero-reference canonical-writer precedent:
CorpusArtifact.cs - Test-only normalizing comparator (forbidden as the identity writer):
CanonJson.Writer.cs - Current PURL canonicalizer (not an end-to-end Unicode-form guarantee):
PackageUrl.cs - Current fact identity/storage tuple:
VulnFact.csandFactWriter.cs - Current report model:
DependencyReachabilityReport.cs - Current report writer/SARIF:
DependencyReachabilityReporter.cs - Current Worker seam:
SbomReachabilityStageExecutor.cs - Implemented release readers:
ReachabilityWitnessReader.cs - Implemented dual-read selector:
ReachabilityGateEvaluator.cs - Reader/selection contract tests:
ReleaseOrchestrator integration tests
