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 as SCN-ID-1 through SCN-ID-6 in SPRINT_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:

  1. Issue subject identity: consensus.issue_linkset has primary key (vulnerability_id, product_key). The table is global and has no tenant dimension. product_key is the DC-01 canonical product key, normally a PURL without version.
  2. Issue observation revision: content_hash is 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.
  3. 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.

SurfaceCurrent source truthConsequence for v2
Old match contractBoth 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 producersThe 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/APIvuln.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 wireIssueSummary 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 adapterHubProductIssuesAdapter 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 boundaryThe 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 producerReachabilityInputStageExecutor.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.
FilteringVulnerabilityReachabilityFilter 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 modelDependencyReachabilityVulnerabilityFinding and DependencyReachabilityAdvisorySummary require Guid CanonicalId. The reporter sorts and joins on it.v2 replaces it with AdvisoryIdentity plus AdvisoryObservation; there is no v2 canonicalId.
SARIFDependencyReachabilityReporter 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 fixtureSyntheticAnalyzerCoverageReachabilityStageExecutor 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 evidenceThe 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 evidenceThe 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 consumerReachabilityGateEvaluator 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 projectionScanEvidenceProjectionStageExecutor 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 consumersNo 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/testsThere 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:

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:

  1. Validate the two producer values without normalization.
  2. In StellaOps.Vulnerabilities.Contracts, write productKey, scheme, version, and vulnerabilityId in exactly that order with a dependency-free Utf8JsonWriter using JavaScriptEncoder.UnsafeRelaxedJsonEscaping, no BOM, and no whitespace. This follows CorpusArtifactBuilder.CanonicalizeManifest; the production contracts SDK keeps zero ProjectReferences.
  3. Compute SHA-256 over those exact bytes.
  4. Render lower-case hexadecimal and prefix it with stellaops:vulnerability-issue:v1:sha256:.
  5. 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:

The exact output bytes are reused, without reserialization, for:

  1. the reachability.report surface payload and its CAS/composition digest;
  2. stellaops.reachability-report@v2 release evidence and RawContentBase64 hash verification;
  3. 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:

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:

The version dispatcher uses the evidence type plus the in-payload version:

InputReader behavior
stellaops.reachability-report@v1, no schemaVersionParse with the frozen v1 shape. Preserve its original bytes and hash.
stellaops.reachability-report@v2, schemaVersion: 2Parse and validate the v2 schema, identity digest, revisions, and deterministic contract.
Type/version disagreementReject as malformed; do not guess.
Unknown versionFail 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 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.

  1. 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 closed StellaOps.Vulnerabilities.Contracts graph. The matching OpenAPI fields are required and parity-tested, and the SDK retains its zero-reference production graph. Tests characterize CanonJson only 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.
  2. 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.
  3. 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.
  4. 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.
  5. SCN-ID-5 — producer flip and soak (DOING 2026-08-23). One HubAdvisoryV2EmissionEnabled switch 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 exact available v2 evidence to pass. Source forcing and rollback validation are part of this slice. The durable scanner-hub-advisory-v2-cutover procedure 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.
  6. 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-SBOM DependencyReachabilityReport synthesis in ReachabilityInputStageExecutor, the v1 fallback in ReachabilityPublishStageExecutor, and the v1 branches of the surface and scan-evidence projections. ISbomAdvisoryMatcher, NullSbomAdvisoryMatcher, ConcelierHttpAdvisoryService, the v1 VulnerabilityReachabilityFilter and the reporter’s v1 BuildReport/ExportSarifAsync were deleted with them, as were the HubAdvisoryShadowEnabled and HubAdvisoryV2EmissionEnabled switches. The hub product-issues adapter is now the lane’s only advisory source, required whenever Reachability:Enabled is true. What survives on purpose: the frozen v1 evidence decoder and golden fixtures in ReleaseOrchestrator, the DependencyReachabilityReport shape, the dependency-graph exporter used by the v2 path, and the affected-symbol bridge (now HubHttpAffectedSymbolProvider, VULN-B1 source cutover 2026-09-11), which maps advisory CVEs to sink symbols through the Vulnerabilities owner API. It uses the same HubAdvisoryBaseUrl as product issues; the old symbol URL setting is removed. The Concelier.SbomIntegration implementation edge was already absent from the Worker before this slice. Verified-by: StellaOps.Scanner.Worker.Tests 542/542, StellaOps.Scanner.Reachability.Tests 970 (2 perf skips), BuildBoundaryConformanceTests 40/40, ReachabilityWitnessReaderTests 15/15 unchanged, and an isolated dotnet publish whose deps.json compiles 95 project assemblies with no Concelier or Excititor library.

9. Rollback

Rollback never means v2-to-v1 conversion.

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.

GateRequired green proofRequired red mutation/proof
Literal identity vectorsThe 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 exactnessU+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 significanceTwo values differing only in product-key case produce distinct IDs and retain exact case on round trip.Add ToLowerInvariant; the test turns red.
Revision separationChanging 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 exclusionThe 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 authorityNo 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 bytesTwo 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 orderCall-path order survives canonicalization and round trip.Sort path nodes; expected path turns red.
Strict versioningv1 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-promotionv1 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 promotionA 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 orderingRelease 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 integrityThe 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 availabilityWith 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:

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