Reachability & Sink Detection — Product Strategy (Decision Doc)

Status: RATIFIED 2026-06-10 — D1–D10 ratified by the operator, with D3 modified (all-language plan, see §10 ratification note and §11). Date: 2026-06-10. Author: Product/security-architecture strategy pass (codebase-verified). Audience: Operator (decision-maker), Scanner/Concelier/Signals/Policy guilds. Scope: What “reachability” should mean for Stella Ops, how sinks should be detected and proven, what to build in which order, and the explicit decisions required. No code in this pass.

Every load-bearing claim about current behavior below was verified against src/ on 2026-06-10; file paths are cited inline. Competitive-landscape and ecosystem-data-rate statements come from training knowledge (offline pass — no web fetches) and are marked [knowledge].


1. Executive summary — the call

Stella Ops should not position reachability as “we have a sink rule list” (every SAST tool has one) and should not chase whole-program taint analysis (CodeQL/Semgrep own it; it is non-deterministic in practice across 8+ languages and unaffordable to maintain). The defensible, on-strategy position is:

“This CVE is reachable in your image, and here is the cryptographically signed, offline-replayable call path that proves it — or the signed coverage statement that explains why we say it is not.”

Concretely:

  1. Make the vulnerable symbol the primary sink. Reachability questions are keyed by (CVE, purl, image digest), and the sink set is the CVE’s affected functions — not a generic “dangerous API” list. The data model for this already exists end-to-end (Concelier AffectedSymbol ingest projection → Scanner ICveSymbolMappingServiceReachabilityAnalyzer explicit-sinks BFS → PathWitness DSSE signing); it is plumbing-complete and population-starved (§3.3, §6.1).
  2. Demote the generic sink catalogs to what they really are: capability annotations. CmdExec/UnsafeDeser/Ssrf marks on a path tell the operator what an attacker gains at the end of a reachable path (“this reachable CVE path terminates in process execution”). They grade severity and feed Policy’s evidence-weighted scoring; they must never be the headline “reachable” claim. They also become data packs, not C# code (§7.4).
  3. Sell the honesty lattice, not a boolean. Four public verdicts — reachable (proven), likely reachable, not observed, unknown — each with a mandatory machine-checkable evidence object, and a hard product rule: no verdict ever claims more than its evidence layer can prove, and “unreachable” always carries a graph-coverage qualifier. This is the only stance that survives an auditor and the only one consistent with Stella’s attestation posture.
  4. The OCI referrer carries the pruned, vuln-keyed subgraph + witnesses, not the whole graph. Full graphs stay in CAS; the attached evidence is the minimal entry→sink slice (bounded by maxPaths × maxDepth, kilobytes not megabytes). This simultaneously solves auditability and the referrer size problem (§8).
  5. Sequence: fix the four structural defects first (cheap, verified, §3.5), prove one language end-to-end (Go — best symbol data + only SSA-grade extractor), then scale by data fan-out, then build the patch-diff surface factory as the long-term moat.

What makes this defensible: SaaS reachability vendors (Endor, Snyk) cannot follow Stella into air-gap, cannot make their analysis deterministically replayable by the customer, and do not emit signed per-path evidence bound to an image digest. Rule-list scanners (Trivy-class) have no path proofs at all. Stella’s wedge is reachability as attested evidence — a property of the evidence pipeline, which is already Stella’s core competence, not of the analyzer, where Stella will never out-analyze CodeQL.


2. What reachability must mean for Stella users (jobs-to-be-done, ranked)

Stella is a release control plane: its user is deciding “may this image go to prod?” under audit obligations (NIS2/CRA/DORA), often air-gapped. Rank of jobs:

RankJobWhy this rankProduct implication
1Auditable evidence for a release decision — “we shipped despite CVE-X because it is not reachable; prove it to the auditor in 2 years”This is the job nobody else can do offline + signed; it monetizes Stella’s existing attestation spine (DSSE, referrers, EvidenceLocker, deterministic replay). A reachability verdict that gates a release is itself a release-decision artifact and must meet the same evidentiary bar as the rest of the platform.Every verdict ships a signed witness or a signed coverage statement. Replayable: same inputs → same verdict hash. VEX statements are drafted from verdicts, with operator countersign.
2Prioritization — “of 400 CVE findings, which 12 are actually exploitable in my deployment?”This is the daily-pain job and the demo moment. But ranking can tolerate likely; it does not need proof for every line of the list.The lattice verdict + capability-sink annotation + gates feed Policy scoring/EWS; UI sorts by evidence tier first.
3Noise reduction — auto-downgrade/suppress not-reachable findingsHighest economic value but highest risk: a wrong “not reachable” is a shipped vulnerability with Stella’s signature on it. Must be earned by #1’s evidence machinery, never by rule-list heuristics.Suppression requires not observed + coverage above policy threshold + signed suppression witness (SuppressionWitness already exists in src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/); policy-gated, operator-visible.

All three, in that order. The order matters because #1 is the moat that makes #2 and #3 trustworthy; competitors do #2 first and then struggle to be believed on #3. (Decision D1.)


3. Ground truth: what exists today (verified)

This section is the honest inventory the strategy stands on. The architecture is unusually far along — the gap is not missing design, it is four structural defects + unpopulated data paths + dormant default wiring.

3.1 Call-graph extractors (src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Extraction/)

Two tiers exist, and the distinction drives the whole strategy:

TierLanguagesMethodPrecision (verified)
ToolchainGo (GoCallGraphExtractor → external SSA tool), Java (JavaCallGraphExtractor → bytecode/ASM), Node (NodeCallGraphExtractor → Babel tool), .NET tier-A (DotNetCallGraphExtractor → Roslyn semantic, needs restore), Binary (BinaryCallGraphExtractor → x86/arm64 disassembly, PLT/IAT, DWARF)Real resolutionGo SSA resolves calls properly; Java resolves bytecode call targets and marks sink call-targets as synthetic nodes (verified lines 103–147 — graph-construction logic only: the underlying class-file parser had a position-loss bug that made every REAL .class parse fail until LANG-JAVA fixed it 2026-06-11, see §11 Java row). Binary extracts direct calls from disassembly.
Standalone source-AST (the build-time set wired in ReachabilityCallGraphGenerator: dotnet, javascript, python, rust)DotNetLightweightCallGraphExtractor (Roslyn parse only, no semantic model), JavaScriptCallGraphExtractor, PythonCallGraphExtractor, RustCallGraphExtractor (regex/AST sweeps)Lexical name resolutionNo type resolution, no virtual/interface dispatch, no overload resolution, no cross-assembly knowledge. The .NET lightweight resolver matches invocation text against same-project method names; unresolved calls become synthetic “external” nodes whose symbol is the literal receiver text (e.g. _logger.LogDebug); ambiguity is handled by deterministic lowest-ordinal pick at confidence 0.5, externals at 0.4 (verified DotNetLightweightCallGraphExtractor.cs lines 853–977). Header honestly documents the gaps (generics, source-gen, cross-assembly virtual dispatch, fluent chains).

Strategic read: the standalone extractors are adequate for first-party application code (the app’s own entry→call structure, where most code is non-virtual local calls) and inadequate for deep dependency-graph traversal (which is exactly where vulnerable symbols live). Entrypoint detection, by contrast, is genuinely strong and broad (Flask/FastAPI/Django/Celery/Click, Spring/JAX-RS/Micronaut/Servlet/gRPC, minimal APIs/hosted services, Module Federation — see src/Scanner/AGENTS.md Sprint 018/020 coverage tables with negative-test discipline). Conclusion: the entry side of the graph is trustworthy; the sink side must be anchored by symbol matching (purl-scoped), not by chasing call chains through dependency internals with a lexical resolver. Where dependency-internal traversal is needed (trigger→sink inside a package), the per-package internal graphs from VulnSurfaces (CecilInternalGraphBuilder, JavaInternalGraphBuilder, …) are the right tool — computed once per package version offline, not re-derived per scan.

3.2 Sink machinery — wired, but structurally broken in the two languages that matter most

Per-language sink matchers exist for ALL languages (Python/JS/Java/Go/Rust/Ruby/PHP/Bun/Deno) with substantial pattern tables, and they ARE invoked by extractors. The “sinks=0” symptom has precise, fixable causes:

  1. .NET — qualified-name mismatch. SinkRegistry patterns are fully qualified (System.Diagnostics.Process.Start), but the lightweight extractor’s external symbols are lexical receiver text (Process.Start). SinkRegistry.MatchSink does symbol.Contains(pattern) — a shorter symbol can never contain a longer pattern, so .NET sinks ≈ 0 unless code calls fully-qualified. Verified: SinkRegistry.cs line 142 vs DotNetLightweightCallGraphExtractor.cs lines 889–894 (BuildExternalNodeMatchSink("dotnet", resolved.TargetSymbol)).
  2. Python — no call extraction at all. PythonCallGraphExtractor.ExtractCallsFromFunction returns [] (“Simplified: would need proper AST parsing”, line 477–481), so the Python graph has zero edges; and the sink matcher is applied to function definitions in first-party code (line 94) rather than call targets — first-party apps do not define os.system, so sinks are structurally zero. A zero-edge graph also always fails the build-time stub-guard (nodes > 1 && edges > 0), so the Python build-time reachability referrer can never attach.
  3. Triplicate enums. SinkCategory is defined in StellaOps.Scanner.Contracts/SinkCategory.cs (17 values, the canonical one) AND StellaOps.Scanner.Reachability/Explanation/PathExplanationModels.cs line 212 (14 different values); EntrypointType exists three times (Contracts/CallGraphEnums.cs, Reachability/Stack/ReachabilityStack.cs line 162, Reachability/Explanation/PathExplanationModels.cs line 175) with different member sets. Witness/explanation payloads therefore cannot round-trip categories losslessly across layers.
  4. JS/Rust/Java/Go work where imports resolve. JS synthesizes sink nodes from import-alias matches (verified JavaScriptCallGraphExtractor.cs lines 440–535 — require/ESM import aliases → js:{pkg}/sink.{module.method} nodes); Rust matches use-decl module paths; Java marks bytecode call targets; Go matches SSA node packages. These are real but only as good as the import-alias table (JS member-calls on non-imported receivers are skipped — honest under-approximation). — EXTENDED 2026-06-11 (LANG-JS): JS now also synthesizes js:{pkg}/external.{module}.{method} targets for import-verified calls that are NOT catalog sinks (vulnerability-sink matching needs the external call surface in the graph, §7.2 note); non-imported receivers remain honest skips. — EXTENDED 2026-06-11 (LANG-RUST): Rust call chains are use-decl-expanded before sink matching and target synthesis (the matcher sees full module paths instead of receiver-text suffix luck), first-party-rooted paths resolve to declared nodes, and extern targets carry the module path as their Package anchor (§11 Rust row).

Strategic read: the sink taxonomy and plumbing are sound; the defects are local and cheap. But even fixed, a capability-sink catalog is a commodity. It earns its keep as path annotation and scoring input, not as the product claim. (Decision D2.)

3.3 The CVE→symbol feasibility gate — VERIFIED OPEN, with a caveat

The question “does Stella’s advisory model carry affected symbols, or only version ranges?” gates the whole vulnerability-driven strategy. Verified answer:

Verdict on the gate: vulnerability-driven sinks are feasible and mostly built — blocked by (a) one nested-shape extractor fix, (b) one Concelier→Scanner bridge, © ecosystem data coverage, which is structural and must be answered by the three-source ladder (§6.1), not denied.

3.4 The layered reachability stack, witnesses, and pruning — strong bones

3.5 The four structural defects (Phase 0 targets — all verified, all cheap)

#DefectWhereFix shape
1.NET sink patterns can never match lexical symbolsContracts/SinkRegistry.cs line 142 + lightweight extractor external symbolsSuffix-aware/alias-aware matching keyed on Type.Method tail; or resolve via using-directives at parse time — FIXED 2026-06-10: SinkRegistry.MatchSinkLexical adds a conservative segment-suffix rule (lexical symbol must be the exact dot-segment tail of the pattern, ≥2 segments = Type.Method minimum; equal/longer symbols still require the full pattern, so Acme.Workflow.Process.Start does NOT match); the lightweight extractor uses it for external nodes only (first-party declarations keep strict MatchSink, so a first-party Process.Start look-alike is never flagged), and using-alias receivers (SD.Process.Start) are expanded to their qualified form at parse time (pure syntax; also improves in-project resolution). using static deliberately not expanded (would be a guess). Pinned by SinkRegistryLexicalMatchTests (qualified + lexical + alias + 10 negatives) and lexical/qualified/alias/negative extractor fixtures in DotNetLightweightCallGraphExtractorTests
2Python extractor emits zero edges; sink match targets definitionsExtraction/Python/PythonCallGraphExtractor.cs lines 363/406/448, 477–481, 94Implement call extraction against the import-alias table (the JS extractor is the working in-repo template) — FIXED 2026-06-10: two-pass lexical call extraction mirroring the JS template — per-file import-alias table (import X [as Y], from M import a [as b], parenthesized and relative froms) + project-wide first-party function index; in-module and cross-module direct/member calls become real edges (graph now clears the build-time stub-guard), and dangerous imported call TARGETS (e.g. os.system via import os/import os as o/from subprocess import run as launch) synthesize py:{pkg}/sink.{module}.{func} nodes — sink matching now applies to call targets (the definition-side match at l.94 is retained for the library-self-scan case, same shape as JS). Non-imported receivers, getattr reflection, and import * remain honest skips (no fabricated reachability). Pinned by PythonCallGraphExtractorTests (in/cross-module + member-call edges incl. stub-guard clearance, plain + aliased import sink nodes with CmdExec category and caller→sink edges, negative no-edge/no-sink fixture)
3Triplicate SinkCategory/EntrypointType enums with divergent membersContracts vs Explanation/PathExplanationModels.cs vs Stack/ReachabilityStack.csConverge on StellaOps.Scanner.Contracts; type-alias or map at the witness boundary — FIXED 2026-06-10: duplicates removed, Reachability copies are now using-aliases of the Contracts enums; member sets reconciled append-only (FileAccess/NetworkClient/Other added to SinkCategory; GraphQlResolver/PublicApi/Main/Timer/Constructor/StaticInitializer/TestMethod added to EntrypointType; compact-v2 integer wire values pinned by test); canonical wire-name helper SinkCategoryWire added and the lossy PostgresReachabilityDriftResultRepository fallback (_ => CmdExec) replaced; lossless Scanner→witness→back round-trip proven by SinkCategoryWitnessRoundTripTests
4Symbol extractor misses Go vulndb imports[].symbols and RustSec affects.functionsConcelier.Core/Signals/IAffectedSymbolProvider.cs lines 589–649Descend the two known nested shapes (provenance-tagged per shape); add fixture tests from real OSV entries; backfill via re-upsert — FIXED 2026-06-10: OsvAffectedSymbolExtractor now descends Go vulndb imports[].{path, symbols[]} (import path → Module; the documented Type.Method form becomes a Method symbol with receiver ClassName, bare names stay FunctionCanonicalId round-trips losslessly either way) and RustSec affects.functions (fully-qualified Rust path split at the last :: into Module/Symbol, also lossless under CanonicalId); AffectedSymbolProvenance gained an optional ExtractionShape field and every extracted symbol — nested AND flat — is now tagged with its JSON shape path (ecosystem_specific.imports[].symbols, ecosystem_specific.affects.functions, {container}.{field} for flat arrays). Non-object ecosystem_specific/database_specific values are now guarded instead of throwing. Pinned by OsvAffectedSymbolExtractorTests (fixtures from the real GO-2021-0113, GO-2022-0969, RUSTSEC-2020-0071 entry shapes + empty/malformed negatives + same-input determinism). Backfill DONE 2026-06-11 (sprint 20260610_002 BACKFILL): PostgresAffectedSymbolBackfillService (src/Concelier/__Libraries/StellaOps.Concelier.Persistence/Postgres/Repositories/) is the idempotent, offline re-upsert sweep — per tenant it keyset-pages vuln.advisory_observations (ordinal observation_id order, deterministic), rehydrates each observation from the stored observation_json via the SAME deserializer live lookups use, re-runs IAffectedSymbolExtractor.ExtractAsync with exactly the UpsertAsync inputs/provenance (no upstream fetch), and calls ReplaceObservationSymbolsAsync ONLY when the re-extraction differs from the stored projection (multiset compare over every persisted field except ExtractedAt), so a repeated sweep leaves existing rows byte-identical (ids and timestamps included); per-observation fault isolation (a malformed stored payload is counted+skipped, never aborts the sweep; infra failures abort). Operator surface: the signals:backfill-affected-symbols job (AffectedSymbolBackfillJob, registered cron-less in JobRegistrationExtensions — trigger via the jobs API with optional tenant/pageSize parameters). Pinned by PostgresAffectedSymbolBackfillServiceTests (Testcontainers Postgres: pre-fix-shaped observation with EMPTY projection → nested-shape symbols populated; second sweep idempotent with raw rows byte-identical; tenant filter + page-size-1 keyset paging + already-correct projection untouched; malformed-payload skip)

4. The two sink semantics — name them and assign roles

The codebase currently conflates two meanings of “sink.” The strategy splits them explicitly:

Capability sinksVulnerability sinks
DefinitionA call into an API with dangerous capability (CmdExec, UnsafeDeser, Ssrf, …)The specific functions an advisory says are vulnerable (JndiLookup.lookup, lodash.template)
SourceCurated catalogs (today: SinkRegistry + per-language matchers)Advisory symbol data, patch diffs, curated bundles
Question answered“What could an attacker do if they control this path?”“Is CVE-X exploitable in this image?”
False-positive costLow (annotation, scoring weight)Catastrophic if wrong in either direction (gates releases, signs VEX)
Stella role (this strategy)Path annotation + blast-radius grading + EWS input + drift signal. Never a “vulnerable” claim by itself.The primary reachability product. Keys every verdict, witness, VEX draft, and policy gate.

This split resolves the strategic confusion cleanly: the existing matchers/catalogs are kept (fixed per §3.5) but repositioned; a reachable CVE path whose terminal frames also carry CmdExec capability is presented as “reachable AND ends in process execution” — a severity multiplier no competitor expresses as signed evidence. Capability-sink reachability alone (no CVE) surfaces as an attack-surface inventory view (and powers ReachabilityDrift’s “newly reachable dangerous capability” alerts), clearly labeled as informational, never as findings.


5. Approaches considered (trade-offs under Stella’s constraints)

Constraints recap: offline/air-gap (no SaaS calls, no model APIs at runtime), determinism (same inputs → same verdict bytes; TimeProvider injection, ordinal ordering everywhere), attestability (DSSE/referrer-grade evidence), multi-language, image-AND-source inputs, on-prem compute budgets.

ApproachPrecision / RecallFP costOfflineDeterministicVerdict
A. Rule-based capability sink catalogs (exists)Low precision as a vuln signal (flags Path.Combine, logging.info, JSON.parse…); fine as annotationHigh if presented as findings; near-zero as annotationYesYesKeep, demote to annotation + scoring; externalize as signed data packs (§7.4)
B. Vulnerability-driven sinks (CVE→symbol; plumbing exists)High precision where symbol data exists; recall bounded by data coverageLow — claims are scoped to a real CVE and a real pathYes (symbols ride Concelier feeds / offline bundles)YesAdopt as primary. Close the data gap with the three-source ladder (§6.1)
C. Static taint/dataflow (source→sanitizer→sink)Highest theoretical precision for injection classesVery high engineering + per-language maintenance; solver-style analyses are hard to keep bit-deterministic and bounded; duplicates CodeQL/Semgrep [knowledge]YesHardReject as core. The existing lightweight ersatz — GuardDetector + Gates/ multipliers + CallEdgeExplanationType.TaintGate — already captures the cheap 80% (path conditionality) without a solver. Operators who want taint depth run CodeQL and can feed results in as an external evidence source (future, D8)
D. Entrypoint→sink graph reachability (exists: BFS, explicit sinks, deterministic path selection)Precision = extractor precision; honest over/under-approximation controls via edge confidenceManaged by confidence on edgesYesYes (verified design)Keep as the L1 engine. Invest in extractor precision only where it changes verdicts (§7.2)
E. Binary/loader layer (L2; model exists, engine partial)Decisive negative evidence (symbol not linked/not present ⇒ unreachable with high confidence); decisive presence evidence for nativeLow — it’s a linker fact, not a heuristicYesYesBuild as the native-stack lane and as the highest-quality “absence” prover (§7, Phase 4). Unify the two L2 semantics (loader-resolution vs Ghidra patch-presence) under one layer with two evidence kinds
F. Runtime confirmation (eBPF collectors exist, default-off)Converts likelyproven (observed) and strengthens not observed over time; never proves absence aloneLow (only upgrades/never downgrades static claims)Yes (on-cluster)Recording is inherently non-deterministic; witnesses of recordings are deterministic artifactsOpt-in lane, after the static spine. Primitive generation is modeled (ObservationType, claim IDs, RuntimeWitnessGenerator); production static/runtime matching and the StaticConfirmed transition are not composed yet.
G. Hybrid layered confidence (stack + lattice exist)n/a — the integration layern/aYesYesAdopt: one verdict vocabulary across Scanner stack verdicts, Signals buckets, Policy gates (§7.3)
H. LLM-assisted sink/path triagen/aViolates determinism + offline at runtimeNo (runtime)NoReject at verdict time. Permissible only as offline catalog authoring aid whose output is reviewed, signed data (never in the scan path)

6. Competitive frame and differentiation [knowledge section]

How the field frames reachability, from training knowledge (no fetches; treat names/feature details as directional):

Stella’s differentiation — lean into all four, they compound:

  1. Offline-first: the entire loop (advisory symbols → graph → verdict → witness → policy gate) runs inside the customer boundary; symbol data ships in Concelier feeds and offline kits. No competitor’s reachability works disconnected. [knowledge]
  2. Deterministic + replayable: verdicts are pure functions of CAS-addressed inputs (graph_hash, symbol-set hash, policy digest); POST /api/slices/replay already exists as a contract. “Re-run it yourself and get the same bytes” is an auditor-grade claim no probabilistic SaaS makes.
  3. Signed, image-bound evidence: DSSE witnesses attached as OCI referrers to the image digest, flowing into EvidenceLocker and release decisions. “Reachable — here is the cryptographically-attested call path” / “suppressed — here is the signed coverage statement.”
  4. Release-gate integration: reachability is not a report; it is an input to the policy-gated promotion Stella already owns end-to-end (the 409-blocking deploy gate is live). Reachability changes deploy decisions, with evidence persisted per decision.

The honest weakness to own: Stella will not match Endor/CodeQL analyzer depth per-language for years. The strategy therefore anchors precision in data (symbols, patch diffs, loader facts) rather than analysis cleverness, and makes honesty itself (lattice + coverage statements) the differentiator a regulated buyer values more than recall.


7.0 North star

For each (image digest, CVE, purl): a deterministic verdict from the lattice below, with a mandatory evidence object, computed offline, signed, attached, and consumed by Policy.

Public verdictMinimum evidence required (machine-checked before signing)Maps from
reachable:proven≥1 PathWitness whose path exists in the referenced graph_hash, entry node is a classified entrypoint, terminal node symbol-matches the CVE symbol set (match rule + symbol provenance recorded); runtime-confirmed witnesses upgrade strengthStack Exploitable/LikelyExploitable with L1 path
reachable:likelyPackage-API-level path (entry → vulnerable package public API) when function-level symbols are unavailable; witness carries `surface=package-symbolsheuristic` source + confidence
not-observedSigned coverage statement: graph hash, entrypoint count, % nodes resolved, edge-confidence distribution, symbol-match attempt log, analysis limits (depth/paths), known blind spots (reflection/dynamic edges encountered)Stack Unreachable / tier Unreachablerenamed publicly: the analyzer observed no path, which is not a proof of absence unless L2 adds linker facts
unknownReason code (no symbols for CVE; no graph for language; graph below stub-guard; …)Stack Unknown / tier Present

Hard rules: (1) never emit reachable:proven without a verifiable witness; (2) never emit plain “unreachable” — not-observed + coverage, upgradeable to not-present only by L2 linker/symbol-table facts (the one true absence proof); (3) every verdict object embeds the input digests so replay is closed under content addressing.

IMPLEMENTED 2026-06-10 (D4 canonical contract — sprint 20260610_002 LATTICE, partial): PublicReachabilityVerdict (src/Scanner/__Libraries/StellaOps.Scanner.Contracts/PublicReachabilityVerdict.cs) carries exactly the wire names reachable:proven / reachable:likely / not-observed / unknown + reserved not-present. The hard rules are machine-checked structurally, not test-only: the wire helper’s static initializer refuses any member whose wire name contains unreachable/safe (every use of the type would throw), TryParse rejects the forbidden vocabulary and member-name spellings, and not-present is non-emittable (IsEmittable=false; EnsureEmittable and verdict-object construction throw) until an L2 engine exists. The per-(image digest, CVE, purl) verdict object (PublicReachabilityVerdictObject.Create) enforces the minimum-evidence column before the object can exist (hence before signing): proven ⇒ ≥1 PathWitnessReference bound to the input graph_hash; likely ⇒ witness + symbol source tier (surface|package-symbols|heuristic|curated) + confidence ∈ [0,1]; not-observedReachabilityCoverageStatement (graph hash, entrypoint count, % nodes resolved, edge-confidence distribution, symbol-match attempt log, depth/path limits, known blind spots) over the same graph hash, and no contradictory path witnesses; unknown ⇒ reason code (PublicReachabilityVerdictReasons). Rule 3 is enforced via ReachabilityInputDigests; canonical bytes are deterministic (collections ordinal-normalized at construction; ToCanonicalJsonBytes). Mapping judgment call (the “Maps from” column made total): stack PossiblyExploitable also maps to reachable:proven because the evaluator truth table (ReachabilityStackEvaluator.DeriveVerdict) only yields any reachable verdict when Layer1.IsReachable — an L1 path exists for all three; the rule-1 witness gate still applies, and PublicReachabilityVerdictMapper.FromStackVerdict(verdict, hasVerifiableWitness:false) downgrades a pathless would-be proven to unknown (witness_unavailable), never to a weaker/stronger reachability claim. Pinned by PublicReachabilityVerdictLatticeTests / PublicReachabilityVerdictObjectTests / ReachabilityCoverageStatementTests (Contracts.Tests, 46) and PublicReachabilityVerdictMapperTests (Reachability.Tests, 18, exhaustive over both internal vocabularies). Signals rebase landed 2026-06-11 (LATTICE 3a), the CLI rebase landed 2026-06-11 (LATTICE 3b — ReachabilityVerdictDisplay fold), and the Console FE rebase landed 2026-06-11 (LATTICE 3c — normalizeFindingReachabilityState boundary fold, §7.3): all three D9 surfaces now speak the lattice.

7.1 Phasing (each phase ships value alone)

Phase 0 — Fix the floor (small, surgical; mostly src/Scanner, one Concelier file). The four defects of §3.5 + wire the dormant defaults: a real ICallGraphSnapshotProvider backed by the Worker’s existing snapshot output, and the Concelier→Scanner symbol bridge (ICveSymbolMappingService implementation that queries/syncs the AffectedSymbol projection — or replaces the unpopulated Postgres mapping table as the read path). Exit criterion: a Go or .NET fixture scan produces a non-zero sink count and one end-to-end ReachabilityStack with a real L1 verdict from CVE-derived sinks. — WIRING LANDED 2026-06-10: WorkerCallGraphSnapshotProvider (registered by AddReachabilityEvidence, fed by ReachabilityInputStageExecutor per image digest) replaces the null snapshot provider, and AffectedSymbolCveSymbolMappingService bridges the AffectedSymbol projection into the mapping-service read path (merge semantics: curated store rows > projection facts; deterministic confidence-DESC/name-ASC ordering; tenant via ReachabilitySymbolBridgeOptions, default default). Worker hosts get the projection over HTTP (ConcelierHttpAffectedSymbolProvider, gated on Reachability.AdvisoryBaseUrl; degrades to empty-set → honest Unknown on outage). Pinned by AffectedSymbolCveSymbolMappingServiceTests (populated projection → CVE sinks, purl-version-insensitive match, module-granularity exclusion, curated-wins merge, outage fallback, determinism) and WorkerCallGraphSnapshotProviderTests (digest keying, persisted-snapshot replay on forceRecompute, corrupt/missing-file fallback). EXIT CRITERION MET (in-process) 2026-06-10: Phase0CveSinkFloorEndToEndTests (Scanner.Reachability.Tests/Evidence) is the network-free deterministic integration proof of the floor — raw Go-vulndb-shaped OSV JSON → real OsvAffectedSymbolExtractor (defect-#4 nested imports[].symbols shape) → AffectedSymbol projection → the bridge → real ReachabilityEvidenceJobExecutor (real BFS ReachabilityAnalyzer with CVE-derived explicit sinks over a 5-node Go fixture graph with SinkIds EMPTY, real WorkerCallGraphSnapshotProvider, real ReachabilityStackEvaluator) → non-zero sink count (2 canonical ids) + end-to-end ReachabilityStack verdict PossiblyExploitable(L1 yes / L2 unknown / L3 unknown truth-table row) with the exact entry→mid→sink path; a second CVE symbol present in the graph but called only from a non-entrypoint stays pathless (BFS is entrypoint-rooted, selective); plus a real PathWitnessBuilder PathWitness whose path_hash is independently recomputed in the test from the witness path + purl and whose path is edge-closure-verified against the snapshot; plus byte-identical ResultDigest/stack determinism across two fresh runs and the honest-Unknown inverse for a symbol-less CVE. What remains owed for Phase 1 is the live variant only: the same loop fired by a real image scan on a running stack.

Phase 1 — One language, the whole truth (Go). Go is the only lane where every layer is simultaneously strongest: SSA-grade extractor (toolchain tier), the industry’s best symbol corpus (Go vulndb imports[].symbols [knowledge]), static binaries for future L2. Ship: symbol intake (fixed shape) → mapping bridge → explicit-sink BFS → PathWitness DSSE → pruned-subgraph referrer (§8) → Signals fact → Policy gate consumes verdict tier → Console “why is this reachable” path rendering (PathRenderer/Explanation exist). This is the demo and the template. Exit criterion: a known-reachable fixture (e.g., vulnerable handler calling an affected vulndb symbol) gates a release with a verifiable witness, and the inverse fixture produces not-observed + coverage statement that replays byte-identically.

Phase 2 — Symbol-intake hardening + offline kit (Concelier-led). RustSec nested shape; GHSA/NVD remain range-only (accept — ladder rung 3 covers them); backfill job re-extracting symbols from stored observations (raw JSON is already persisted, so this is a re-upsert sweep) — LANDED 2026-06-11 (sprint 20260610_002 BACKFILL: PostgresAffectedSymbolBackfillService + the operator-triggered signals:backfill-affected-symbols job, see §3.5 #4 for the full contract); export AffectedSymbolSets into the offline kit bundle format (cve-symbol-mapping.md §5) — LANDED 2026-06-12 (sprint SPRINT_20260612_012 AG1: AffectedSymbolOfflineBundleExporter exports the per-tenant projection into bundle schema v2 — deterministic CanonJson canonical bytes, no timestamps, pinned sha256 digest, optional DSSE signing on the catalog-pack/FileKeyProvider pattern, per-source license-allowlist gate defaulting to osv/ghsa/nvd; the symmetric import is BundleAffectedSymbolProvider, an IAffectedSymbolProvider over the bundle file, so a disconnected host activates the SAME AffectedSymbolCveSymbolMappingService read path a connected host uses. In-process proof at the provider seam: AffectedSymbolOfflineBundleTests (Concelier.Core.Tests/Signals, 7 tests — export/round-trip/byte-faithful re-import/allowlist/DSSE-tamper). UPSTREAM advisory symbols only — derived patch-diff surfaces stay counsel-gated, §7.5 row 4. The disconnected-verdict forcing-function proof LANDED 2026-06-12 (AGV, in-process): AirgapOfflineBundleReachabilityEndToEndTests (Scanner.Reachability.Tests/Evidence, built-exe run Total: 6, Failed: 0; Evidence namespace Total: 90, Failed: 0) proves an air-gapped host with ONLY the kit bundle closes the whole loop — frozen OSV fixtures → AG1 export → DSSE-signed bundle file verified offline fail-closed against a local trusted-keys dir → BundleAffectedSymbolProvider as the sole symbol source (NO Concelier projection, NO HTTP) → the REAL AffectedSymbolCveSymbolMappingService bridge + REAL ReachabilityEvidenceJobExecutor over a fixture Go graph → reachable:proven with a PW-SCN-003 witness whose path hash is independently recomputed in the test; the disconnected configuration’s persisted verdict objects are BYTE-IDENTICAL to the connected (live projection) configuration’s — bundles fully stand in for online feeds; honest inverses pinned (CVE absent from the bundle ⇒ unknown/no_symbols_for_cve, zero witnesses, nothing fabricated; bundled-but-unreachable symbol ⇒ not-observed with the mandatory coverage statement); two fully fresh runs replay byte-identically (pinned bundle digest sha256:7e088a20…054d0f + verdict bytes). Still owed: the LIVE kit-import variant (a real disconnected stack importing a kit and gating a release) rides the standing §11 live full-loop remainders, and the curated-precedence case rides the AG2 loader (sprint SPRINT_20260612_012); derived patch-diff surfaces remain EXCLUDED from kits — counsel-gated per §7.5 row 4 / AG3 BLOCKED); a curated Top-N bundle (the ~200–500 CVEs that dominate container scan results [knowledge] — KEV-weighted, hand-verified symbols) as the deterministic backstop for ecosystems with no upstream symbols. Curation is feed-time work, shippable + signable — exactly Concelier’s existing operating model. FORMAT + LOADER + curated-wins precedence LANDED 2026-06-12/13 (sprint SPRINT_20260612_012 AG2): schema stellaops.curated-affected-symbols/v1 (cve-symbol-mapping.md §5.4) with mandatory per-item curator provenance (curator/method/ISO curatedAt) and mandatory per-entry confidence; CuratedAffectedSymbolBundleValidator fail-closes the whole bundle on wrong schema / blank cveId / missing provenance / non-ISO date / unknown symbolType / out-of-range confidence / duplicate canonical id; CuratedAffectedSymbolProvider loads + materializes curated facts (provenance source curated); LadderAffectedSymbolProvider overlays curated over an upstream provider with curated-wins on canonical-id collision (closing the §6.1 ladder); optional offline DSSE (distinct payload type, fail-closed). In-process proof: CuratedAffectedSymbolBundleTests (Concelier.Core.Tests/Signals, built-exe run Total: 12, Failed: 0 — example-load, all validation rejections, malformed-file fail-closed, curated-beats-upstream-on-collision, deterministic ordinal merge, signed round-trip + tamper/missing-envelope fail-closed, pinned canonical digest sha256:270d3f71…58d4aa5). The actual curation of real KEV-weighted CVEs is deliberately NOT done here — it stays OPEN feed-time human work; the shipped example uses clearly-synthetic CVE-2099-* ids. The deliverable is that a curator file ships/validates/loads/wins precedence, not that any real CVE is covered.

Phase 3 — Scale by data fan-out (per-language agents). With the Go template proven, per-language work becomes data + one normalizer (§7.2): .NET (dogfood — Stella scans itself; lightweight extractor + suffix matching + package-symbols fallback), Java (bytecode extractor already marks sink targets; Maven surfaces next), JS/TS (import-alias machinery exists), Python (after defect #2). Symbol-format normalizers reconcile advisory symbol naming with extractor node symbols per ecosystem (the CanonicalId ↔ node-symbol match rule — each language’s rule is a documented, testable contract recorded in the witness).

Phase 4 — The moat: patch-diff surface factory + binary/loader L2. (a) Productionize VulnSurfaces as a feed-time factory: a Concelier-adjacent job (network allowed where feeds are allowed; air-gapped sites consume the exported surfaces in offline kits) that, on advisory ingest with a fix version, downloads vuln+fixed, diffs methods, extracts triggers, stores + signs the surface. This converts Stella from a consumer of scarce symbol data into a producer — the data moat Snyk built, but self-hostable and exportable [knowledge]. (b) Implement the loader-resolution L2 over the existing ELF/PE models + BinaryCallGraphExtractor capability for native images, unifying with the Ghidra patch-presence verifier as two evidence kinds within one layer; L2 facts upgrade not-observednot-present. (a) and (b) are independent; (b) may be pulled earlier for native-heavy customers — see D6.

Phase 4(a) MAPPING-BRIDGE LANDED 2026-06-12 (sprint SPRINT_20260612_002): computed surfaces are now a live read source for ICveSymbolMappingService via VulnSurfaceCveSymbolMappingService (Scanner.Reachability/Services) — a read-side decorator OUTERMOST in the mapping chain (store → Concelier projection bridge → surface bridge; inner wins on canonical-id collision, so the ladder is curated > upstream symbols > patch-diff, and a surface always beats a range-only advisory). Surface-tier sinks project as MappingSource.PatchAnalysis (verdict-composer label surface, function-level ⇒ proven-capable) with confidence capped at 0.8; the package-symbols tier carries the established stella://vuln-surfaces/package-symbols/ evidence-URI convention (⇒ reachable:likely downgrade); the empty heuristic tier projects nothing. MethodKeys fold to the {module}::{Type}.{method} CanonicalId convention via MethodKeyCanonicalIdFolder (VulnSurfaces/MethodKeys; nuget fold contract-tested through the real DotNetSymbolNormalizer; maven/npm/pypi folds remain best-effort from emitted MethodKeys, with LANE-REG-TESTS pinning the registered lanes’ deterministic sink keys). IVulnSurfaceService.HasSurfaceAsync (CVE-only) keeps the Worker’s HasMappingAsync evidence-job gate open for surface-only CVEs. Namespace-twin resolved by routing: the placeholder StellaOps.Scanner.Reachability.IVulnSurfaceService (SubgraphExtractor PoE seam, Worker-nulled) is annotated as NOT the surface source; surfaces reach scans exclusively through the mapping read path. Chosen over the write-through shape so derived rows never enter the cve_symbol_mappings store the offline-bundle flows read (legal boundary: internal use only, kit export counsel-gated). Still owed: the feed-time factory trigger (FEED-TIME-TRIGGER) and the host registration of the VulnSurfaces read path (the decorator joins the DI chain only when a host registers StellaOps.Scanner.VulnSurfaces.Services.IVulnSurfaceService).

Phase 4(a) FEED-TIME-TRIGGER LANDED 2026-06-12 (sprint SPRINT_20260612_002): the factory is wired end-to-end as ingest-hook → queue table → Scanner-side executor. Seam: PostgresAdvisoryObservationStore.UpsertAsync (the same hook that populates the affected-symbol projection) extracts (cve, ecosystem, package, vulnVersion, fixedVersion) tuples from OSV affected[] entries carrying a fixed range event (VulnSurfaceRequestExtractor, Concelier.Persistence) and enqueues them into vuln.vuln_surface_requests(Concelier migration 007; unique-keyed, so re-ingest dedupes at the queue). The contract seam is the TABLE — no Concelier↔Scanner csproj coupling. Scanner side (StellaOps.Scanner.VulnSurfaces/Feed/): VulnSurfaceFeedWorker claims rows (FOR UPDATE SKIP LOCKED), VulnSurfaceFeedProcessor runs IVulnSurfaceBuilder (the patch-diff engine), persists the surface tenant-global (Guid.Empty, matching the read path), records a deterministic canonical digest (VulnSurfaceDsse.CanonicalPayload — ordinal sink ordering, no timestamps; recompute ⇒ same digest) in vuln_surfaces.attestation_digest, and DSSE-signs the canonical payload with a local ECDSA P-256 PEM key (the catalog-pack FileKeyProvider pattern, §7.4 — fully offline, envelope rides side-by-side in the configured attestation directory). Opt-in, default OFF — flag VulnSurfaces:FeedTrigger (Scanner Worker config): with the flag off AddVulnSurfaceFeedTrigger registers nothing (pinned by test). Fail-open: download failures persist failed/package_not_found rows (new status/error columns, Scanner.Storage migration 008) — never a fabricated surface, never a blocked ingest; failed rows do not block a later retry. Every computation logs the registry endpoint used (per-ecosystem ToS audit trail, sprint Decisions & Risks). Enabling the trigger also registers the real IVulnSurfaceService, which activates the MAPPING-BRIDGE decorator — closing the host-registration gap noted above. Test posture (web policy §2.8): the network download stays a production-only path; the suite runs the REAL Cecil fingerprinter + diff engine over frozen Mono.Cecil-synthesized vuln/fixed pairs with a faked downloader. Still owed (recorded, not ticked): the LIVE feed-time forcing-function proof on a running stack (real Concelier ingest → queue → worker → surface → verdict), alongside the §11 live full-loop rows; kit export of computed surfaces stays counsel-gated (KIT-EXPORT BLOCKED).

Feed-trigger opt-in reconciliation 2026-06-12: the same VulnSurfaces:FeedTrigger:Enabled flag now gates Concelier request emission (VulnSurfaceRequestOptions) as well as Scanner consumption (VulnSurfaceFeedOptions). With the flag off, advisory ingest does not mutate vuln.vuln_surface_requests; with it on, Concelier writes the dedupe-keyed queue rows and Scanner consumes them only when its feed worker is enabled.

Phase 4(a) LANE-REG-TESTS LANDED 2026-06-12 (sprint SPRINT_20260612_002): AddVulnSurfaces now registers the built Maven/npm/PyPI downloaders, fingerprinters, and internal graph builders alongside NuGet/Cecil. Frozen fixtures pin the previously dark lanes without network access: Maven parses a synthesized real .class file through JavaBytecodeFingerprinter; npm and PyPI pin positive extraction plus honest-miss negatives for regex-only syntax gaps; each lane has a vuln-vs-fixed fixture pair proving deterministic expected sinks across two fresh builds. Confidence is now fidelity-aware: bytecode/Cecil lanes retain multiplier 1.0; regex lanes (npm/PyPI) carry multiplier 0.65, so lexical surfaces cannot outrank bytecode/Cecil surfaces before the downstream curated-row cap is applied. LANE-GAPS landed 2026-06-12: gem/rubygems and composer/packagist now have downloader and fingerprinter aliases, Ruby/PHP MethodKey folding through the real RubySymbolNormalizer/PhpSymbolNormalizer, and frozen registry extraction plus vuln/fixed diff fixtures. Full-chain VERIFY fan-out for these newly registered lanes remains owed. LANE-AST-UPGRADE landed 2026-06-12: the npm fingerprinter is no longer regex — JavaScriptMethodFingerprinter now parses a real Esprima AST (Esprima 3.0.5, BSD-3-Clause, already a Scanner production dependency via Analyzers.Lang.Node; offline cache, no new download) covering ESM/CJS exports, single-param arrows, class-field arrows, and nested-paren defaults the regexes missed; its fidelity multiplier rises to 0.85 (AST tier — above 0.65 lexical, below 1.0 bytecode; unparseable files are skipped, never guessed at). The Java class-file parser is additionally de-risked against a REAL javac -g --release 21 frozen fixture (JavaBytecodeRealClassFileTests: Long/Double double-slot constants, MethodHandle/MethodType/InvokeDynamic tags, exception tables, Code sub-attributes, synthetic-lambda exclusion — the ByteReader-precedent class-of-bug is now pinned; no desync found). PyPI stays at the 0.65 regex tier: a real Python AST parse is BLOCKED offline — no license-compatible Python parser exists in the local NuGet cache and downloading is forbidden (web policy 2.8 / air-gap posture); see the sprint’s Decisions log.

Phase 4(a) LANE-PARSER-HANDROLL landed 2026-06-13 (sprint SPRINT_20260612_002, operator-directed): the 0.65 regex tier is retired from production lanes. PythonAstFingerprinter, RubyMethodFingerprinter, and PhpMethodFingerprinter are now hand-rolled tokenizer-parsers (the air-gap-correct path when no license-compatible parser exists offline — boundaries parsed by hand, zero new dependencies): Python masks all string/comment forms and parses def/class scopes by indentation with logical-line joining; Ruby masks heredocs/%-literals/regex-literals and balances def/end blocks with modifier-keyword discipline (class << self, endless defs included); PHP gains heredoc/nowdoc masking, balanced-paren parameters, and trait/interface/enum members. Fidelity ladder now: regex 0.65 (retired floor) < tokenizer-parser 0.8 (pypi/gem/composer) < AST 0.85 (npm/Esprima) < bytecode 1.0 (nuget/maven). Parser-tier hardening tests pin the previously-desyncing constructs (defs inside literals, heredoc keywords, nested-paren defaults) as positives while every honest-miss negative (lambda assignment, define_method, variable-bound closures) survives. Proof via built exes: full VulnSurfaces suite 121/121, Phase-4a chain fan-out re-proven over the new parsers 10/10.

Phase 4(a) VERIFY — starvation closure proven in-process 2026-06-12 (sprint SPRINT_20260612_002): Phase4aSurfaceFactoryEndToEndTests (Scanner.Reachability.Tests/Normalization) pins the full chain on frozen fixtures with every component REAL: a range-only nuget CVE with NO surface yields an honest unknown (no_symbols_for_cve, zero witnesses); the SAME app + SAME CVE after the feed-time factory computes a surface (REAL VulnSurfaceFeedProcessorCecilMethodFingerprinterMethodDiffEngine over Mono.Cecil-synthesized vuln/fixed pairs; the downloader is the only fake — web policy §2.8) resolves function-level surface-tier mappings through the MAPPING-BRIDGE, binds via the REAL DotNetSymbolNormalizer (receiver-text tail rule), and the REAL evidence-job executor emits reachable:provenwith a verifiable PW-SCN-003 witness — the computed surface is the single flipped input. Honest inverses pinned: a FAILED computation fabricates nothing (still unknown); a computed surface whose sink is unreachable yields not-observed with coverage, never proven. Two fully fresh runs are byte-identical (surface digest + persisted verdict bytes). Phase-4a lane coverage today: nuget = full chain proven; maven/npm/pypi = registered and frozen-fixture hardened, with per-lane full-chain proof still left to the active VERIFY task; gem/rubygems and composer/packagist = downloader/fingerprinter/fold fixtures landed but still owe full-chain VERIFY fan-out; Go/Rust deliberately none (symbols ride upstream). Standing remainders: the LIVE feed-time proof (real Concelier ingest on a running stack, flag ON) is recorded alongside the §11 live full-loop rows, not ticked; KIT-EXPORT of derived surfaces into offline kits stays counsel-gated (§7.5 row 4; same derived-data-redistribution class as docs/legal/decisions/trivy-db-builder-counsel-thread-airgap-export.md) — the “air-gapped sites consume the exported surfaces in offline kits” sentence in Phase 4(a) above is therefore NOT yet operative.

Approved-subset KIT-EXPORT loader/proof landed 2026-06-13 (sprint SPRINT_20260612_013 SURF-EXPORT-POLICY/SURF-EXPORT-FORMAT/SURF-EXPORT-LOADER/SURF-EXPORT-VERIFY): derived patch-diff surface export now has an explicit legal gate and separate stellaops.vuln-surface-export/v1 format under StellaOps.Scanner.VulnSurfaces.Export. Only PyPI, RubyGems, NuGet, and Packagist rows can pass, and only when no package content/literals are present, package-license classification is OSS/free/permissive, registry endpoint + ToS snapshot + User-Agent/contact + rate-limit/Retry-After + audit-log controls are present, and the row carries the deterministic computation digest. npm and maven rows are excluded by policy/loader with auditable reason codes until written registry permission or a commercial arrangement is recorded. Canonical bytes are timestamp-free and byte-stable; signed rows use DSSE over the canonical row payload and tamper verification fails closed. The loader verifies trusted DSSE signatures, canonical bundle bytes, row payload digests, and policy/provenance before exposing rows as a read-only IVulnSurfaceService; it does not write derived rows into the curated/upstream symbol store, and the read path still preserves curated > upstream advisory symbols > derived surface precedence. In-process disconnected proof: VulnSurfaceExportLoaderTests (10/10) plus VulnSurfaceCveSymbolMappingServiceTests.ApprovedExportBundleLoader_FeedsMappingReadPathAndVerdictInputDeterministically prove approved signed bundle -> loaded surface facts -> surface mapping attribution -> deterministic reachable:proven verdict bytes, with npm/Maven/generic non-approved/tampered/unsigned/no-audit/no-provenance negatives fail-closed. The all-six KIT-EXPORT row in sprint SPRINT_20260612_002 remains blocked because npm and Maven Central are still not approved.

npm + Maven Central kit export ABANDONED 2026-06-14 (operator decision) — supported set is the counsel-approved 4. The original KIT-EXPORT framing (“all six ecosystems”) is closed by declaring npm and Maven Central explicitly out of scope for offline-kit surface export. Counsel left only one path for these two — written npm/GitHub confirmation that Stella-computed surfaces fall outside npm’s security-data redistribution clause, and a Sonatype written permission / commercial arrangement for Maven Central’s commercial-scanner-use restriction — and the operator has decided not to pursue that registry permission. The shipped, supported export scope is therefore exactly the four counsel-approved ecosystems PyPI, RubyGems, NuGet, Packagist (done in SPRINT_20260612_013); the exporter fail-closes npm/Maven (and every other unapproved ecosystem and every unknown/proprietary/custom/restricted package license) by construction — producer gate VulnSurfaceExportPolicy.Evaluate (Export/VulnSurfaceExportPolicy.cs:95-127) and loader gate (VulnSurfaceExportLoaderTests.cs:139-142, rejects signed-but-blocked rows). This is a permanent product decision, not a pending block; the per-ecosystem WHY and the single re-enable path (record written permission under docs/legal/decisions/, then add to ApprovedEcosystems) are documented in docs/modules/reach-graph/guides/cve-symbol-mapping.md §5.3 “Unsupported ecosystems: npm + Maven Central”. Internal-only use (mapping bridge / scan-time resolution) of npm/Maven surfaces is unaffected.

Phase 5 — Runtime lane + drift (opt-in). eBPF observation upgrading static claims via existing claim-id linking; ReachabilityDrift re-keyed on vulnerability sinks (“CVE-X became reachable between scan A and B because guard G was removed” — DriftCause machinery exists).

7.2 Generic vs per-language (so agents fan out on data, not logic)

Generic core (one implementation, language-blind): graph schema (CallGraphSnapshot/richgraph-v1), BFS + path selection + determinism, stack evaluator + lattice mapping, witness building/signing/verification, subgraph extraction + referrer serialization, coverage-statement computation, symbol-store bridge + match framework, gate multiplier model, Signals scoring, Policy/VEX emission.

Per-language (the fan-out surface):

  1. Extractor (exists ×8 — improve only when a fixture shows verdict-changing imprecision);
  2. Entrypoint catalog → data (patterns like @app.route, MapGet largely declarative);
  3. Capability-sink catalog → data packs (§7.4);
  4. Symbol normalizer (advisory symbol ↔ graph symbol match rule) — the only genuinely per-language logic, small and contract-tested;
  5. Golden fixtures: one known-reachable CVE, one known-not-observed, one negative (dynamic dispatch must NOT produce a witness) — the existing Sprint-018/020 negative-test discipline generalized.

IMPLEMENTED 2026-06-11 (items 4+5, Go reference lane — sprint 20260610_002 LANG-GO): the normalizer contract is now code: ISymbolNormalizer/SymbolNormalizerRegistry (src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/Analysis/ISymbolNormalizer.cs — language-blind interface; rule id + version are part of the contract and ride witnesses) with GoSymbolNormalizer (Extraction/Go/GoSymbolNormalizer.cs, rule stellaops.go.symbol-normalizer@1.0.0) as the reference implementation the other lanes mirror. ReachabilityEvidenceJobExecutor applies the registry before BFS so ExplicitSinks receives real node ids; an all-unresolved symbol set short-circuits to honest unknown/symbol_unresolved (never a guessed match). WitnessSink.match_rule (append-only wire field: rule id/version + canonical id + symbol source) records the binding per §7.0; ReachabilityCoverageStatementFactory composes the §7.0 coverage statement from the real snapshot + normalization attempt log. See §11 Go row for the match rule and golden-trio evidence. .NET joined 2026-06-11 (LANG-DOTNET): DotNetSymbolNormalizer (Extraction/DotNet/DotNetSymbolNormalizer.cs, rule stellaops.dotnet.symbol-normalizer@1.0.0) is the second registered rule — the hardest of the eight because .NET node ids are content hashes and lightweight external symbols are lexical receiver text; the rule therefore binds on node Symbol with a strict first-party/external split (source-anchored nodes never tail-match). See §11 .NET row for the rule items and golden-trio evidence. Java joined 2026-06-11 (LANG-JAVA): JavaSymbolNormalizer (Extraction/Java/JavaSymbolNormalizer.cs, rule stellaops.java.symbol-normalizer@1.0.0) is the third registered rule — the most precise lexical lane of the eight because bytecode call targets are always fully qualified (constant-pool class + method + descriptor), so the rule needs no receiver-text tail: fully-qualified ordinal equality on node symbols (descriptor-insensitive, binds all overloads) plus a descriptor-precise node-id form pinning one overload; its golden trio was also the live forcing-function that exposed and fixed a parser-killing bug (JavaBytecodeAnalyzer.ByteReader passed by value — real class files never parsed; see §11 Java row). JavaScript/TypeScript joined 2026-06-11 (LANG-JS, Bun/Deno ride the lane): JsSymbolNormalizer (Extraction/JavaScript/JsSymbolNormalizer.cs, rule stellaops.js.symbol-normalizer@1.0.0) is the fourth registered rule — package-anchored: it never inspects node-id text (a first-party file named lodash.js or sink.ts is not confusable with a synthesized target); a node binds only when its Symbol == {Package}.{name} for a bare npm specifier Package — true exactly for import-verified synthesized call targets. The lane also gained the verdict-changing extractor piece (the Go-precedent): the JS extractor now synthesizes js:{app}/external.{module}.{method} targets for ALL import-alias-verified calls, not only capability-catalog sinks — without it no advisory symbol outside the catalog could ever appear in the graph. See §11 JS row for the rule items and golden-trio evidence. Python joined 2026-06-11 (LANG-PYTHON): PythonSymbolNormalizer (Extraction/Python/PythonSymbolNormalizer.cs, rule stellaops.python.symbol-normalizer@1.0.0) is the fifth registered rule — package-anchored like JS but with NO class erasure: Python member calls preserve the dotted chain in the resolved import path, so Method canonicals refold the class INTO the module ({module}::{Class}.{method}{module}.{Class}::{method}), and hyphenated PyPI distribution-name modules fold -_ (PEP 503; names differing beyond that, e.g. Pillow→PIL, honestly do not bind). The lane also gained the same verdict-changing extractor piece: the Python extractor now synthesizes py:{app}/external.{module}.{func} targets for import-verified non-catalog calls; getattr/__import__ reflection, import *, non-imported receivers, and first-party modules remain honest skips. See §11 Python row for the rule items and golden-trio evidence. Rust joined 2026-06-11 (LANG-RUST): RustSymbolNormalizer (Extraction/Rust/RustSymbolNormalizer.cs, rule stellaops.rust.symbol-normalizer@1.0.0) is the sixth registered rule — package-anchored like JS/Python, binding only nodes whose Symbol == {Package}::{name} for a valid Rust module-path Package (true exactly for the extractor’s extern-verified call targets and crate-root self-scan free functions); RustSec CanonicalIds reconstruct the published crate::path::func losslessly, so the rule needs no receiver-text guessing — Method dots refold to ::, generics strip, and Cargo hyphen names fold to the extern-crate spelling on the node side. The lane’s verdict-changing extractor piece is the use-decl reconciliation the language note names: per-file use-alias tables expand call-chain roots before target synthesis (plus first-party declared resolution, bare-call edges, and sanitized sweeps), so a crate-local mod time shadowing the advisory crate is resolved first-party exactly as rustc would — structurally unbindable. See §11 Rust row for the rule items and golden-trio evidence. Ruby joined 2026-06-11 (LANG-RUBY): RubySymbolNormalizer (Extraction/Ruby/RubySymbolNormalizer.cs, rule stellaops.ruby.symbol-normalizer@1.0.0) is the seventh registered rule — package-anchored like JS/Python/Rust, binding only nodes whose Symbol == {Package}.{ConstChain}.{method} for a valid require-feature-path Package (true exactly for the extractor’s require-verified synthesized targets); the canonical module is the gem’s REQUIRE FEATURE PATH (erb::ERB.new, net/http::Net::HTTP.get), the rubysec # instance-method spelling and dot-spelled constant chains fold deterministically, and the flat global:: form is matched whole. This lane was extractor-FIRST (§11: Ruby could not execute any part of the loop): the extractor gained the full two-pass sweep (per-file require tables, first-party class/top-level resolution where a shadowing class ERB WINS over the require — the Rust precedent — catalog-sink + external target synthesis on call TARGETS, Sinatra route-block bodies, offset-preserving comment/string sanitization, reflection deny-list for send/const_get/method_missing shapes), plus DI registration and Worker scan-side routing (Gemfile/Gemfile.lock/config.ru). See §11 Ruby row for the rule items and golden-trio evidence. PHP joined 2026-06-11 (LANG-PHP — the eighth and final lane): PhpSymbolNormalizer (Extraction/Php/PhpSymbolNormalizer.cs, rule stellaops.php.symbol-normalizer@1.0.0) — package-anchored like JS/Python/Rust/Ruby, binding only nodes whose Symbol == {Package}.{Class}.{method} / {Package}.{function} for a valid PSR-4 StudlyCaps namespace-path Package (true exactly for the extractor’s use-verified synthesized targets; first-party symbols keep PHP’s {Ns}\{Class}::{method} spelling and never contain a dot, so the parse is structurally exclusive); the canonical module is the class’s PSR-4 NAMESPACE, never the Packagist package name, the natural advisory FQN spelling (GuzzleHttp\Psr7\Message::parseMessage) folds deterministically into the Method form, and the flat global:: form is matched whole. This lane was extractor-FIRST like Ruby (§11: PHP could not execute any part of the loop): the extractor gained the full two-pass sweep (namespace + use/use function tables, first-party-wins resolution where a shadowing namespace GuzzleHttp\Psr7; class Message beats the use import, catalog-sink + external target synthesis on call TARGETS, core-class/core-function sinks kept structurally non-bindable, comment/string/heredoc sanitization with PHP 8 #[ attributes preserved, and a definition-side sink match narrowed to class-scoped rules so a first-party function exec() is never flagged as PHP’s core exec), plus DI registration and Worker scan-side routing (composer.json/composer.lock). See §11 PHP row for the rule items and golden-trio evidence.

7.3 One verdict vocabulary (convergence requirement)

Today three vocabularies coexist: stack verdicts (Exploitable/…), ReachabilityConfidenceTier (Confirmed/Likely/Present/Unreachable/Unknown), Signals buckets (entrypoint/direct/runtime/unknown/unreachable + weights). Adopt the §7.0 lattice as the public contract; keep stack verdicts as the internal evaluator output mapped 1:1; rebase Signals buckets to consume the verdict + evidence tier rather than recomputing semantics (lattice v1 design in docs/modules/reach-graph/guides/lattice.md §9 is the convergence vehicle). One vocabulary in Console, CLI, VEX, policy expressions.

PARTIALLY IMPLEMENTED 2026-06-10 (D9 — sprint 20260610_002 LATTICE): the single public contract now exists (PublicReachabilityVerdict + verdict object + coverage statement in StellaOps.Scanner.Contracts, see §7.0 note) and both internal vocabularies map onto it totally and deterministically via PublicReachabilityVerdictMapper (src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/PublicReachabilityVerdictMapper.cs): stack Exploitable/LikelyExploitable/PossiblyExploitablereachable:proven (witness-gated), Unreachablenot-observed, Unknownunknown; tier Confirmedproven, Likelylikely, Presentunknown, Unreachablenot-observed, Unknownunknown — exhaustively truth-table-pinned, and no internal verdict can surface as not-present or as a forbidden unreachable/safe string (guard test over every projection). Stack verdicts and tiers remain internal-only per this section.

CONVERGENCE AUDIT 2026-06-11 (D9 — sprint 20260610_002 CONVERGE-VERIFY): repo-wide verification of who speaks what. Lattice-only (mapping layers only): Scanner stack/tier (the mapper above); the policy gate on BOTH layers — ReachabilityVerdictGate consumes only the verdict object (its decision DTO exposes only VerdictWireName), and Policy.Engine’s promote-time gate normalizes pack/finding tokens through one function (FindingReachabilityStates.Normalize, src/Policy/StellaOps.Policy.Engine/Services/IFindingsLookup.cs l.85–115 — lattice wire names accepted, legacy not_reachable/unreachable fold to not_observed) with IsReachableProof proven-only (l.72); VEX (IVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict)); and the OCI referrer (ReachabilityEvidenceSetPayload embeds canonical verdict objects; the verifier re-runs the §7.0 gates on parse). The full loop is pinned per language by the GoldenReachable_FullLoop_ReferrerVerifiesAndPolicyGateBlocks_ByteIdentically facts (proven verdict → pruned referrer verified from bytes alone → gate BLOCK under the real shipped pack, byte-identical on replay — see §11). Found at audit time still recomputing their own semantics — all three since REBASED the same day (see the re-audit note below): (a) Signals bucket scoring — ReachabilityScoringService.ComputeScores (src/Signals/StellaOps.Signals/Services/ReachabilityScoringService.cs l.656–699) derives v0 buckets from its own BFS and ReachabilityLattice.FromV0Bucket (src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs l.145–163) derives the internal v1 state; ReachabilityStateDocument.Bucket/LatticeState ride the Signals API. The true rebase (Signals consuming Scanner verdict objects instead of recomputing) was blocked on the scan-side verdict-object feed — FEED LANDED 2026-06-11 (sprint 20260610_002 VERDICT-FEED): ReachabilityEvidenceJobExecutor now composes a PublicReachabilityVerdictObject per (image digest, CVE, purl) for EVERY completed evidence job (proven via analyzer-built PathWitness references carrying PW-SCN-003 path hashes; reachable:likely when every binding mapping is package-API tier; not-observed with the real coverage statement over ISymbolNormalizer.KnownBlindSpots; honest unknown with reason codes on every early-out) and persists it through IPublicReachabilityVerdictStore (src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Verdicts/ — Postgres reachability_public_verdicts auto-migrated by Scanner.Storage migration 005, canonical bytes stored verbatim as TEXT so byte-determinism/signature replay survive storage, reads re-validated through ReachabilityEvidenceJson.ParseVerdictObject; in-memory twin for non-storage hosts), queryable over HTTP at GET /reachability/verdicts/{imageDigest}[/{vulnId}?purl=…] (Scanner.WebService, canonical bytes verbatim). SIGNALS REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3a): Signals scoring now CONSUMES the feed — ReachabilityRecomputeRequest.PublicVerdicts carries the canonical verdict-object JSON per target (exactly the bytes the feed serves); parsing goes through the re-validating ReachabilityEvidenceJson.ParseVerdictObject, so a tampered/evidence-less verdict fails its own §7.0 gate before scoring sees it. For verdict-fed targets, NO Signals-side BFS runs: ReachabilityScoringService.BuildVerdictDrivenState maps the verdict 1:1 (state Bucket = the lattice wire name; ReachabilityLattice.FromPublicVerdict folds proven/likely⇒statically-reachable, not-observed⇒statically-unreachable, unknown⇒bottom, then joins Signals’ runtime-evidence lane — not-observed + runtime hit ⇒ Contested, never silently safe; reachable:likely scores with the verdict’s own gated confidence), and the consumed verdict’s content address + witness ids ride ReachabilityEvidenceDocument.PublicVerdict into the fact digest. The legacy v0-bucket recompute (ComputeScores/FromV0Bucket, both annotated fallback-only in code) runs ONLY for targets with no verdict object. Pinned by ReachabilityScoringVerdictConsumptionTests (Signals.Tests, 12 — incl. consumption-beats-BFS both directions and byte-determinism); lattice.md declares the buckets internal-only. (b) CLI — REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3b): the CLI witness/verdict surface now renders ONLY the public wire names through one display fold (src/Cli/StellaOps.Cli/Commands/ReachabilityVerdictDisplay.cs: canonical names pass through; legacy tier/stack tokens fold per the ratified mapper truth table — unreachable/safe/not_reachable/not_observednot-observed, presentunknown; missing/unrecognized tokens are honest unknown; every output routes through ToWireName(), so the §7.0 naming guard structurally bans a public “unreachable”/“safe”). reachability witness-ops list/show (ReachabilityCommandGroup.cs) speak lattice (VERDICT column, --verdict filter restricted to the four emittable wire names with --tier as legacy alias, the “Reachable: Yes/No” absence-claim column dropped); the live stella witness list/show handlers (CommandHandlers.Witness.cs) no longer fabricate a tier (previously: hardcoded confirmed on show, the --tier filter echoed into every list row) — the witness APIs carry no per-witness verdict yet, so the CLI renders honest unknown, exposes JSON key verdict (lattice) instead of confidenceTier, and warns that a --verdict filter cannot be applied. Pinned by ReachabilityVerdictDisplayTests (Cli.Tests, 38: fold truth table, forbidden-term guard, both surfaces, honest-unknown). © Console FE — REBASED 2026-06-11 (sprint 20260610_002 LATTICE criterion 3c): the Console-boundary fold is normalizeFindingReachabilityState (src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts, the Policy-Normalize/CLI-Fold precedent applied at the FE seam): NormalizedFindingReachabilityState IS the lattice display vocabulary (reachable:proven / reachable:likely / not-observed / unknown, + reserved not-present pass-through for claim-faithful display, + transient computing which is a job-progress state, not a verdict) — the forbidden public terms are structurally unrepresentable in the normalized union; canonical wire names pass through, legacy tokens fold per the ratified truth table (legacy reachable/boolean truereachable:proven mirroring Policy l.112, potentially_reachablereachable:likely, not_reachable/unreachable/safe/boolean falsenot-observed, unrecognized ⇒ honest unknown). reachability-chip.component.ts renders the six display states (not-observed tooltip carries the §7.0 coverage qualifier, never “safe”; hop count renders on both witness-bearing tiers), security-findings.client.ts exposes lattice-only FindingDto.reachabilityState (legacy nullable boolean kept derived: proven/likely ⇒ true per the CLI --reachable-only fold, not-observed/not-present ⇒ false, unknown/computing ⇒ null), and the findings page facets/saved-views/labels/reasons + finding-row/list/detail/drawer + vulnerability-detail-page all consume the fold (legacy URL facet params fold inbound). Pinned by reachability-chip.component.spec.ts (34: lattice rendering, legacy-fold truth table, forbidden-term guard) + the client/page/row/detail/drawer/list specs (113 across 6 files, vitest). Out-of-scope adjacent FE surfaces recorded in the sprint Decisions & Risks (CLI (b)(v) precedent).

RE-AUDIT 2026-06-11 (D9 closure verification — sprint 20260610_002 CONVERGE-VERIFY re-run, post-rebase): with VERDICT-FEED + LATTICE 3a/3b/3c landed, all six surfaces were re-verified against src/. File:line evidence: Scanner stack/tierPublicReachabilityVerdictMapper.FromStackVerdict/FromConfidenceTier (src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Stack/PublicReachabilityVerdictMapper.cs l.40/55/69, total + witness-gated). Scan-side feedReachabilityEvidenceJobExecutor persists a verdict object per completed job through IPublicReachabilityVerdictStore (…/Jobs/ReachabilityEvidenceJobExecutor.cs l.260, 595–606, 624; null store ⇒ verdict still rides the job result, l.77–79), served as canonical bytes verbatim at GET /reachability/verdicts/{imageDigest}[/{vulnId}] (src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityEvidenceEndpoints.cs l.71/77). Signals CONSUMESReachabilityRecomputeRequest.PublicVerdicts (src/Signals/StellaOps.Signals/Models/ReachabilityRecomputeRequest.cs l.30) → ParsePublicVerdicts/BuildVerdictDrivenState (…/Services/ReachabilityScoringService.cs l.106/116/699/730; l.845 documents that verdict-fed targets never reach the legacy path), ReachabilityLattice.FromPublicVerdict (…/Lattice/ReachabilityLattice.cs l.154) with FromV0Bucket explicitly “LEGACY FALLBACK ONLY” (l.178–183). PolicyFindingReachabilityStates.Normalize single fold accepts lattice wire names, legacy tokens collapse to not_observed (src/Policy/StellaOps.Policy.Engine/Services/IFindingsLookup.cs l.101–118), IsReachableProof proven-only (l.72–76), ReachabilityVerdictGate switches on the lattice enum only (…/Reachability/Vex/ReachabilityVerdictGate.cs l.202–235). VEXIVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict) (…/Vex/IVexStatusDeterminer.cs l.34/45; VexStatusDeterminer.cs l.40; VexStatementDraft gates on NotObserved, l.180). CLIReachabilityVerdictDisplay routes every output through ToWireName() so the §7.0 naming guard is structural (src/Cli/StellaOps.Cli/Commands/ReachabilityVerdictDisplay.cs l.36–59). Console FENormalizedFindingReachabilityState/normalizeFindingReachabilityState lattice union + fold (src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts l.55/79–117/184–200), consumed by the chip (…/shared/components/reachability-chip.component.ts l.30/36/195) and the lattice-typed FindingDto.reachabilityState (…/core/api/security-findings.client.ts l.47/353–363). Re-run counts (fresh builds, built exes + vitest, 2026-06-11): full-loop *GoldenReachable_FullLoop_* Total 8, Failed 0; the 8 Lang*GoldenTrioEndToEndTests classes Total 49, Failed 0; PublicReachabilityVerdictFeedTests+PostgresPublicReachabilityVerdictStoreContractTests Total 11, Failed 0; PublicReachabilityVerdictMapperTests Total 18, Failed 0; Signals ReachabilityScoringVerdictConsumptionTests Total 12, Failed 0; CLI ReachabilityVerdictDisplayTests Total 38, Failed 0; FE reachability-chip.component.spec.ts 34/34 + 6 adjacent findings-chain specs 113/113. D9 verdict: one public vocabulary remains — every audited surface speaks PublicReachabilityVerdict through mapping/fold layers only. The single remaining reachability deliverable is NOT a vocabulary gap: the LIVE-stack forcing-function proof (a real image scan on a running Scanner+Concelier producing a CVE-derived reachable:proven that blocks a release) is still owed and tracked separately (§7.1 Phase 0 closing note, §11 per-lane “live full-loop” remainders).

7.4 Sink catalogs become signed data packs

Move capability-sink (and entrypoint) catalogs out of C# into versioned, DSSE-signed JSON packs loaded at startup (same trust-root pattern as scanner plugin manifests / policy packs; default packs in-repo, operator packs via devops/plugins-style drop folder). Effects: operators extend catalogs in air-gap without rebuilds (their internal frameworks’ sinks/entrypoints); catalog version + digest lands in every witness (today a registry edit silently changes verdicts with no provenance — an attestation hole); agents/curators fan out on data; SinkRegistry/matchers become the loader + match engine. CWE mapping and severity weights ride the pack.

IMPLEMENTED 2026-06-11 (D7 — sprint 20260610_002 CATALOG-PACKS): the sink catalogs are data packs; the engines are code. Format stellaops.reachability-catalog-pack.v1 (src/Scanner/__Libraries/StellaOps.Scanner.Contracts/CatalogPacks/): registrySinks (fully-qualified patterns for SinkRegistry), matcherSinks ((module, symbol, category) tuples for the per-language matchers — each matcher keeps its language-specific match semantics in code and reads its table from the pack), entrypoints (declarative rules, e.g. Python decorator-prefix); CWE + severity weights ride the entries; array order is first-match-wins and preserved. Default packs are embedded resources extracted content-equal from the prior C# tables (SinkRegistry.BuildRegistry + the 7 live matcher tables + the Python decorator table) and pinned by byte-parity + behavior-parity tests against a frozen verbatim copy (DefaultCatalogPackParityTests/LegacyCatalogTables; the full pre-existing extractor suites re-ran green over pack data — same fixtures, same sinks). Loader (ReachabilityCatalogPackLoader → process-wide ReachabilityCatalogRuntime, installed at Worker startup via Reachability:CatalogPackDirectory/CatalogTrustedKeysDirectory): defaults + operator packs from the drop folder (devops/plugins/scanner/catalog-packs/), DSSE-verified offline against local PEM key files (ECDSA P-256 over the DSSE v1 PAE; payload must byte-equal the pack file; no cloud KMS), fail-closed on unsigned/tampered/untrusted packs; deterministic occurrence-indexed override-in-place merge (operator overrides keep first-match positions; PHP’s legitimate duplicate identities survive). Witness pinning: every PathWitness now carries evidence.catalog_packs (name/version/sha256 digest/source per pack, from the SAME snapshot the engines matched against) — PathWitnessCatalogPackStampTests proves presence and that a pack change changes the witness id. The attestation hole is closed for everything the packs cover. Remainder CLOSED 2026-06-11 (D7 completion pass, packs v1.1.0): every remaining DECLARATIVE table is now pack data, generated byte-parity from frozen verbatim copies of the C# tables (LegacyCatalogTables, zero hand transcription — the parity test’s bootstrap emission regenerates the committed JSON): Go entrypoints (kinds framework-method exact / package-prefix StartsWith), Java (method-annotation / controller-class-annotation), Rust (attribute exact-then-::-suffix, Ordinal / free-function-name), Ruby (controller-action-name / controller-parent-class), PHP (controller-action-name / annotation-prefix / controller-parent-class), plus the Bun/Deno sink tables (language keys bun/deno; Module empty, Symbol = substring pattern; engines keep exact-then-Contains semantics in pack order — the extractor classes themselves remain unregistered/unrouted per §11, so a future match-semantics rework is a pack version bump, not a frozen shape). The declarative-vs-heuristic split, per language (heuristics stay in the classifier engines): Python — decorators in pack; Django view-class suffixes (View/ViewSet/APIView), Servicer suffix, lambda/main/CLI name conventions in code. Go — framework-method + package-prefix tables in pack; main/init handling and receiver-suffix conventions (Server/Handler/Controller) in code. Java — annotation tables in pack; main signature, servlet inheritance (doGet…), special-method names in code. Rust — attribute + free-function-name tables in pack; AST-derived launch/Axum-router/Rocket-fairing detection, FFI exports, clap-command detection in code. Ruby — action-name + parent-class tables in pack; Controller/Job/Worker/CLI/Command class-suffix and rake-module conventions in code (the dead RailsCallbacks table was removed, not migrated). PHP — action-name + annotation-prefix + parent-class tables in pack; Controller/Job/Listener/Command/Schedule suffix-and-substring conventions in code. JavaScript has NO live declarative entrypoint table — its in-code ModulePatterns/NestJsControllerDecorators tables were dead (never consumed) and were deleted rather than migrated: pack data must reflect live behavior (pinned by JavaScriptEntrypointRules_AreDeliberatelyAbsent). Parity: DefaultCatalogPackParityTests (36 incl. loader) byte-pins both packs against the frozen tables and sequence-pins all 9 matcher languages + 6 entrypoint languages; the full pre-existing extractor/entrypoint suites re-ran green over pack data (CallGraph.Tests Total 466, Failed 0 pre-existing; Total 483, Failed 0 with the new BunDenoSinkMatcherPackEngineTests of the completion pass — Deno’s first behavior pin, since no Deno suite existed).

7.5 Top risks

RiskSeverityMitigation
Symbol coverage disappoints outside Go/Rust → most verdicts reachable:likely/unknown → product feels hollowHighThree-source ladder (§6.1/Phase 2/4); UI is explicit about evidence tier (a likely with package-API path still beats every rule-list competitor); patch-diff factory is the structural answer
Lexical extractor under/over-approximation produces a wrong gating verdictHighVerdict-tier policy: only proven (verifiable witness) may block; not-observed may downgrade only with coverage ≥ policy threshold — IMPLEMENTED 2026-06-11 (ReachabilityVerdictGate + live PolicyRuntimeEvaluator proof gate, see §10 D5 note); negative-fixture discipline; edge-confidence already biases under-approximation (right direction)
A signed “not-observed” precedes an exploit (the existential reputational risk)HighNaming (not-observed, never “safe”); mandatory coverage statement; L2 facts required for not-present; operator countersign on VEX not_affected (D5) — IMPLEMENTED 2026-06-11 (VexStatementDraft drafted-pending-countersign state machine, see §10 D5 note); suppression witnesses revocable + re-evaluated on every graph/symbol refresh (drift machinery)
Patch-diff factory legal/licensing (redistributing derived data from downloaded packages in offline kits)MediumDerived facts (method names/hashes) not package content; counsel review before kit export (pattern: existing trivy-db counsel thread); per-ecosystem ToS check at feed time
Graph scale on monorepos/large images (189k-edge incident already observed)MediumPruned-subgraph referrer (§8); compact-v2+gzip exists; per-language caps + SUBGRAPH_EXTRACTION.md §8 size/depth tables; Valkey cache (ValkeyCallGraphCacheService exists)
Scope creep toward taint analysisMediumD8 records the explicit rejection; gates/guards remain the bounded substitute
Convergence debt (3 verdict vocabularies, 2 L2 semantics, 3 enums) hardens furtherMediumPhase 0/§7.3 normalize before new surface area is added

8. Evidence sizing: what gets attached (the referrer answer)

Layered storage, all content-addressed and cross-linked by digest:

  1. Full call graph → CAS only (richgraph-v1 / snapshot; cached, queryable, replay input). Never the referrer payload.
  2. OCI referrer per image → the vuln-keyed evidence set: for each (CVE, purl) evaluated — verdict object + pruned entry→sink subgraph (bounded: maxPaths ≤ 5, maxDepth ≤ 10–15 per SUBGRAPH_EXTRACTION.md; nodes ≈ paths × depth ⇒ kilobytes, gzip-compact-v2 encoded) + witness DSSE envelopes + coverage statement + input digests (graph_hash, symbol-set hash, catalog-pack digests, policy digest). Bounded by findings count, not graph size — the scaling answer.
  3. Today’s whole-graph compact referrer remains a transitional/debug artifact; deprecate as default once (2) lands (its own comment notes no consumer reads it back). — DONE 2026-06-11: no longer the default; opt-in via BuildTimeOciAttachRequest.AttachWholeGraphReferrer only (see the implementation note below).

This makes the referrer simultaneously small, sufficient for offline third-party verification (subgraph + witness + digests verify without the full graph), and meaningful (every byte attached is evidence for a decision).

IMPLEMENTED 2026-06-11 (D10 — sprint 20260610_002 REFERRER): the vuln-keyed evidence set is now the wire contract AND the attach default. (2) ReachabilityEvidenceSetPayload + ReachabilityEvidenceReferrerBuilder + ReachabilityEvidenceReferrerSerializer (src/Scanner/__Libraries/StellaOps.Scanner.Contracts/ReachabilityEvidenceReferrer*.cs): per (CVE, purl) — the canonical PublicReachabilityVerdictObject (the coverage statement rides inside it for not-observed, per §7.0) + the pruned entry→sink subgraph in compact-v2 conventions with node ids retained (the slice must verify standalone; bounds clamped to maxPaths ≤ 5 / maxDepth ≤ 15 per SUBGRAPH_EXTRACTION.md) + the PathWitness DSSE envelopes + input digests (set-level ReachabilityInputDigests and per-finding inside each verdict), gzip canonical JSON (artifactType application/vnd.stellaops.reachability.evidence-set.v1+json, payload format reachability-evidence-set v1). The builder is fail-closed: fabricated paths (non-edges), witnesses referencing pruned-away paths, path-hash mismatches (PW-SCN-003 recipe recomputed), graph/image binding mismatches, and reachable claims whose evidence cannot fit the bounds all THROW before anything can attach. Third-party verification is real: ReachabilityEvidenceReferrerVerifier.Verify(bytes) works from the payload alone — parsing re-runs the §7.0 evidence gates (verdict parse goes through Create), witness paths are edge-checked against the included subgraph, and path hashes are recomputed. Size is pinned by test as bounded by findings, not graph: a 189k-edge fixture and its 378k-edge double produce byte-identical decompressed payloads modulo the graph hash, kilobytes total (ReachabilityEvidenceReferrerTests, Contracts.Tests). (1)/(3) BuildTimeOciAttachService now attaches the evidence set BY DEFAULT for generated graphs (zero findings at build time — the set still binds graph_hash, the CAS content address of the full graph, plus bounds to the image digest; the scan-side evidence pipeline populates findings); the whole-graph compact-v2 referrer is DEPRECATED/TRANSITIONAL behind BuildTimeOciAttachRequest.AttachWholeGraphReferrer (deliberately not exposed as a CLI flag) and remains the canonical wire form for CAS storage of full graphs. Annotations: org.stellaops.reachability.graph_hash + org.stellaops.reachability.findings ride the manifest. Pinned by PrunedEvidenceReferrerDefaultTests (Sbom.BuildTime.Tests): default ⇒ evidence set with no graph node/edge tables; whole graph only behind the flag; byte-determinism.


9. What we explicitly do NOT build


10. Decisions required from the operator

#DecisionRecommendation
D1Ratify job ranking: auditable evidence > prioritization > noise reduction (drives every later trade-off)Yes — evidence-first
D2Ratify the two-sink split: vulnerability sinks = product; capability sinks = annotations/scoring/drift only (no standalone findings)Yes
D3First-language orderGo first (end-to-end truth fastest: best data + SSA extractor), .NET second (dogfood: Stella attests its own images)
D4Public verdict vocabulary + the naming rule (not-observed w/ mandatory coverage statement; not-present reserved for L2 facts; no public “unreachable/safe”)Adopt §7.0 lattice
D5Policy/VEX defaults: only reachable:proven blocks; not-observed + coverage ≥ threshold may downgrade; VEX not_affected auto-drafted but requires operator countersign (cf. ADR-025 operator-signed decisions)Adopt; thresholds in policy packs
D6Patch-diff surface factory (Phase 4a): commit now as the data moat, run feed-side, export to offline kits? Pull binary/loader L2 (4b) earlier for native-heavy estates?Commit 4a after Phase 1 proves the loop; 4b on customer signal. Open counsel thread on derived-data redistribution before kit export
D7Sink/entrypoint catalogs as DSSE-signed data packs (operator-extensible, digest-pinned in witnesses)Yes — closes an attestation hole and enables agent fan-out
D8Confirm rejections (§9): no taint engine, no LLM at verdict time, no SaaS laneConfirm
D9Vocabulary convergence mandate: Scanner stack / Signals buckets / Policy gates / Console adopt the single lattice (lattice-v1 design as vehicle); fold the duplicate-enum cleanup into Phase 0Yes
D10Referrer policy: pruned vuln-keyed evidence set as the attached artifact; full graph CAS-only; deprecate whole-graph referrer defaultYes

RATIFIED 2026-06-10 by the operator: D1–D10, as recommended — with one modification to D3. D3 (modified): NOT “Go first, .NET second”. The operator directs a plan for every supported language (Go, .NET, Java, JavaScript/TypeScript, Python, Rust, Ruby, PHP — Bun and Deno ride the JS lane). Rationale: §7.2 already establishes that the per-language surface is data + one small normalizer, so the data-fan-out applies to all lanes, not a two-language pilot. Go remains the template lane (best symbol corpus + SSA extractor) and lands first within the all-language sprint, but every language gets its own normalizer + golden-fixture task in the same plan. See §11 for the verified per-language coverage matrix and docs/implplan/SPRINT_20260610_002_Scanner_reachability_lattice_referrer_per_language.md for the implementation tracker (LATTICE D4/D9, REFERRER D10, POLICY-VEX D5, CATALOG-PACKS D7, LANG-× 8, CONVERGE-VERIFY).

IMPLEMENTED 2026-06-11 (D5 — sprint 20260610_002 POLICY-VEX): the policy/VEX defaults are code, on both layers. (a) Verdict-tier gateReachabilityVerdictGate (src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Vex/ReachabilityVerdictGate.cs): pure, deterministic function of (PublicReachabilityVerdictObject, pack thresholds). reachable:provenBlock (the witness is machine-checked before the verdict object can exist, §7.0 rule 1); reachable:likely / unknown → never block, FeedsScoring=true; not-observedDowngradeDrafted iff its mandatory coverage statement clears the pack threshold (percent-nodes-resolved + entrypoint floor), else the downgrade is refused (not_observed_coverage_below_threshold). The never-block semantics are hard rules of the evaluator, not configuration. Thresholds have NO code defaults: ReachabilityGateThresholds requires pack provenance (name/version/sha256) and is produced by the fail-closed ReachabilityGatePolicyPackLoader from the default pack devops/policies/reachability-verdict-gate.policy.yaml(shipped: ≥ 80% nodes resolved, ≥ 1 entrypoint); a missing pack/settings node throws — no constant fallback. The same defaults are live on the promote-time gate: PolicyRuntimeEvaluator (src/Policy/StellaOps.Policy.Engine/Services/) now treats ONLY proven reachability as blocking proof (FindingReachability.IsReachableProof; potentially_reachable/reachable:likely no longer block), FindingReachabilityStates.Normalize accepts the lattice wire names (legacy not_reachable/unreachable tokens fold into not_observed per D4), and gate expressions can constrain reachability.evidence_tier alongside reachability.state. (b) Drafted-pending-countersign VEXVexStatementDraft (…/Vex/VexStatementDraft.cs): a VEX not_affected statement is auto-DRAFTED from a not-observed verdict (only above threshold — a below-threshold draft is unrepresentable) and follows the ADR-025 state machine PendingCountersign → Countersigned → Published (+ terminal Rejected); PublishToAsync refuses BEFORE touching the channel unless an OperatorCountersign (operator sub, enrolled keyId, operator-decision@v1 envelope digest) is attached, so an uncountersigned draft can never reach a publication channel. Draft ids are deterministic (content hash of verdict digest + product + pack digest, no clock). © Verdict→VEX mappingIVexStatusDeterminer.DetermineStatus(PublicReachabilityVerdict) + GetJustificationCategory: provenaffected; likelyunder_investigation (judgment call: a data-starvation tier must not claim affected); not-observednot_affected + vulnerable_code_not_reachable (draft-only per (b)); unknownunder_investigation; reserved not-present throws (will map to vulnerable_code_not_present when L2 exists). Pinned by ReachabilityVerdictGateTests/ReachabilityGatePolicyPackLoaderTests/VexStatementDraftTests/VexStatusDeterminerLatticeTests (Scanner.Reachability.Tests, 42) and the lattice/evidence-tier additions to PolicyRuntimeEvaluatorTests/PostgresFindingsLookupReachabilityTests/CveDenylistExtractorTests (Policy.Engine.Tests). Still owed (tracked in the sprint): durable draft persistence + countersign endpoints (rides the ADR-025 WS5 surfaces). The other half of this remainder — the scan-side wiring that feeds verdict objects per language — LANDED 2026-06-11 (VERDICT-FEED): the evidence job executor composes + persists the per-(image, CVE, purl) verdict object on every completed job (language-blind composer over the per-language normalizer outputs; see the §7.3 feed note), so gate consumers now have one durable, re-validating source of verdict objects instead of constructing their own.


11. Per-language coverage plan (D3 as ratified — all lanes)

Verified against src/ on 2026-06-10. Two cross-cutting facts frame every row:

  1. The symbol normalizer (§7.2 item 4) exists for NO language. ReachabilityAnalyzer matches ExplicitSinks by exact node-id equality (CallGraph/Analysis/ReachabilityAnalyzer.cs lines 111–119: targetSinks.Where(origins.ContainsKey)), while advisory symbols arrive as CanonicalId ({module}::{Class}.{symbol} / {module}::{symbol}, Concelier.Core/Signals/AffectedSymbol.cs lines 128–140) and graph node ids are per-language shapes (go:{package}.{func} per GoSymbolIdBuilder.cs; rust:{package}/{symbol}; .NET lightweight node ids are hashes of dotnet:lightweight:{symbol}(arity)DotNetLightweightCallGraphExtractor.cs lines 376–377, so .NET must match on node Symbol, not NodeId). The Phase 0 floor proof passes only because the fixture hand-crafts node ids equal to the CanonicalIds (Phase0CveSinkFloorEndToEndTests.cs lines 75–77, 516–517). The normalizer — a documented, contract-tested CanonicalId→node-id resolution step applied before BFS, with the match rule recorded in the witness — is the per-language deliverable owed everywhere. — GO LANDED 2026-06-11 (sprint 20260610_002 LANG-GO): the contract (ISymbolNormalizer/SymbolNormalizerRegistry) + the Go reference rule (GoSymbolNormalizer, stellaops.go.symbol-normalizer@1.0.0) are wired into the evidence executor pre-BFS, recorded in WitnessSink.match_rule and coverage-statement attempt logs. .NET LANDED 2026-06-11 (LANG-DOTNET): DotNetSymbolNormalizer (stellaops.dotnet.symbol-normalizer@1.0.0) joins the default registry — it binds on node Symbol (node ids are hashes in both .NET tiers), with the tail rule restricted to unresolved-surface nodes so first-party look-alikes never bind (see the .NET row). Java LANDED 2026-06-11 (LANG-JAVA): JavaSymbolNormalizer (stellaops.java.symbol-normalizer@1.0.0) — fully-qualified ordinal equality on {class}.{method} node symbols + descriptor-precise node-id pinning; simple-name/global-module canonicals refuse to bind (see the Java row). JS/TS LANDED 2026-06-11 (LANG-JS, Bun/Deno ride the lane): JsSymbolNormalizer (stellaops.js.symbol-normalizer@1.0.0) — package-anchored binding to import-verified synthesized targets only; first-party look-alike files structurally cannot bind (see the JS row). Python LANDED 2026-06-11 (LANG-PYTHON): PythonSymbolNormalizer (stellaops.python.symbol-normalizer@1.0.0) — package-anchored binding to import-verified synthesized targets (sink + external forms); dotted-symbol fold instead of class erasure; PyPI -_ fold (see the Python row). Rust LANDED 2026-06-11 (LANG-RUST): RustSymbolNormalizer (stellaops.rust.symbol-normalizer@1.0.0) — package-anchored binding to extern-verified, use-decl-expanded call targets; Method-dot refold, generics strip, Cargo hyphen fold (see the Rust row). Ruby LANDED 2026-06-11 (LANG-RUBY): RubySymbolNormalizer (stellaops.ruby.symbol-normalizer@1.0.0) — package-anchored binding to require-verified synthesized targets (canonical module = the gem’s require feature path); #-spelling and dot-chain folds; flat global:: matched whole (see the Ruby row). PHP LANDED 2026-06-11 (LANG-PHP): PhpSymbolNormalizer (stellaops.php.symbol-normalizer@1.0.0) — package-anchored binding to use-verified synthesized targets (canonical module = the class’s PSR-4 namespace, never the Packagist package name); natural-FQN/()/->/all-:: folds; flat global:: matched whole (see the PHP row). All 8 lanes now carry a registered rule (see §7.2 implementation note and the rows below).
  2. Routing is narrower than the extractor inventory. Worker scan-side language detection routes only dotnet, javascript, python, go, java (ReachabilityInputStageExecutor.DetectLanguageCandidates, lines 637–712) — rust joined 2026-06-11 (LANG-RUST: Cargo.toml/Cargo.lock candidate, routing-tested), ruby joined 2026-06-11 (LANG-RUBY: Gemfile/Gemfile.lock/config.ru candidate, routing-tested; the extractor is also now DI-registered), and Bun/Deno manifests ride the javascript lane since LANG-JS, php joined 2026-06-11 (LANG-PHP: composer.json/composer.lock candidate, routing-tested; the extractor is also now DI-registered); the build-time referrer generator routes only dotnet, javascript, python, rust (ReachabilityCallGraphGenerator.CreateExtractor, lines 195–208); DI registers DotNet/Java/Node/JavaScript/Php/Python/Ruby/Rust/Go/Binary (CallGraphServiceCollectionExtensions.cs). The Bun and Deno extractor classes remain unregistered/unrouted — by decision their manifests ride the JS lane (§9, no bespoke pipelines).
LanguageExtractor tier (§3.1)Entrypoint catalogCapability-sink catalogSymbol normalizer (§7.2 #4)Advisory symbol data: source + coverageGolden fixturesConcrete remaining gaps
GoToolchainGoCallGraphExtractor → external SSA tool, with static source fallback (Sprint 020 shapes). Fallback now extracts real call edges (LANG-GO 2026-06-11): two-pass lexical design (Python/JS template) — in-package direct calls, project-local selector calls via go.mod module path, dependency/stdlib selector calls synthesized as go:external/{path}.{func} target nodes (sink-matched on the call TARGET); honest skips: non-imported receivers, dotted chains, dot-imports, method values, reflection, go member/closure spawns (pinned by GoCallGraphExtractorCallEdgeTests incl. no-edge negative + string/comment sanitization). Worker-routed (go.mod/go.sum); NOT in build-time setStrongGoEntrypointClassifier + Sprint 020 table w/ negative discipline (HandleFunc/router verbs, init(), main, goroutine pseudo-nodes)GoSinkMatcher (SSA node packages) + SinkRegistry go entries — works where imports resolve (§3.2 #4); post-LANG-GO also applied to fallback call targetsDONE 2026-06-11GoSymbolNormalizer (stellaops.go.symbol-normalizer@1.0.0): canonical {module}::{symbol} / {module}::{Receiver}.{method} resolved against go:{module}.{sym} / go:external/{module}.{sym} node ids + identity form, terminal-/vN version normalization on BOTH sides, pointer-receiver folding; ordinal-exact only — first-party look-alike packages (go:ssh.NewServerConn) and short-name self-scan nodes deliberately do NOT match (unknown, never guessed). Contract-tested (GoSymbolNormalizerTests: function/method/version-suffix/external/identity + 5 negatives + determinism); recorded in WitnessSink.match_ruleBest in industry — Go vulndb imports[].symbols via OSV; nested-shape extractor FIXED 2026-06-10 (§3.5 #4). No VulnSurfaces lane (not needed)Toy svc-04-text-template-go (tier-labeled); Phase 0 floor proof (synthetic graph, identity-keyed); golden trio via the REAL extractor DONE 2026-06-11LangGoGoldenTrioEndToEndTests: reachable ⇒ reachable:proven + independently-recomputed path_hash witness; not-observed ⇒ not-observed + coverage statement replaying byte-identically across two fresh runs; negative (interface dispatch + reflection + method value + look-alike pkg) ⇒ no witness, unknown/symbol_unresolvedSSA tool presence in worker images; add go to build-time set or document why not; live full-loop proof (CONVERGE-VERIFY)
.NETBoth — toolchain DotNetCallGraphExtractor (Roslyn semantic, opt-in DotNetExtractor=msbuild + restore); standalone DotNetLightweightCallGraphExtractor is the scan-path default (S075-002) and the build-time extractorStrong — Sprint 018 table w/ negatives (minimal API Map*, [Http*], hosted services, gRPC, static Main)SinkRegistry dotnet entries; lexical-match defect FIXED 2026-06-10 (§3.5 #1, MatchSinkLexical + using-alias expansion)DONE 2026-06-11DotNetSymbolNormalizer (stellaops.dotnet.symbol-normalizer@1.0.0, Extraction/DotNet/): node ids are HASHES in both tiers, so the rule binds on node Symbol — (1) identity for canonical-keyed curated graphs; (2) fully-qualified {ns}.{Type}.{method} ordinal equality on ANY node (library self-scan, tier-A invoked externals, alias-expanded lightweight externals); (3) Type.Method dot-boundary tails (MatchSinkLexical floor) ONLY on unresolved-surface nodes (File empty) — first-party look-alikes (Acme.Process.Start, namespace-less Process.Start) and variable receivers (serializer.Deserialize) NEVER bind; Function-shaped canonicals ({ns}::{symbol}) never tail-match (no type anchor — bareword ban). Contract-tested (DotNetSymbolNormalizerTests, 20: qualified/lexical-receiver/alias-expanded-via-REAL-extractor/partial-tail/overloads/global-module + 8 negatives + determinism); recorded in WitnessSink.match_ruleThin — GHSA/NVD range-only; needs the patch-diff factory (VulnSurfaces NuGet downloader + Cecil fingerprinter EXIST, ~70%, §3.3); golden trio honestly rides the curated + package-symbols tiers meanwhileToy svc-05-xmlserializer-dotnet; Sprint 018 + Phase 0 lexical/alias extractor fixtures; golden trio via the REAL lightweight extractor DONE 2026-06-11LangDotNetGoldenTrioEndToEndTests (6): reachable ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + curated tier recorded); package-symbols tier ⇒ reachable:likely verdict object (tier + calibrated 0.4 confidence, never a fabricated proven); not-observed ⇒ coverage statement declaring the cross-assembly/virtual-dispatch blind spots, replaying byte-identically across two fresh runs; negative (reflection Type.GetType/Activator.CreateInstance + variable receiver + a REACHABLE first-party look-alike XmlSerializer class) ⇒ no witness, unknown/symbol_unresolvedSymbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles; live full-loop proof (CONVERGE-VERIFY)
JavaToolchainJavaCallGraphExtractor (bytecode, sink call-targets as synthetic nodes, §3.1). Parser fixed 2026-06-11 (LANG-JAVA): JavaBytecodeAnalyzer.ByteReader (mutable ref struct) was passed BY VALUE to every parsing helper, losing all position advances — real .class files ALWAYS desynced and parsed to null (the call-target logic was dead code on real bytecode; the pre-existing suite is JavaClassInfo-built and never forced a real parse). All helpers now take ref ByteReader; the golden trio parses real class files end-to-end. Worker-routed (pom.xml/gradle); NOT in build-time setStrong — Sprint 020 table w/ negatives (Spring/JAX-RS/Micronaut/Servlet/gRPC/EJB/JSR-250)JavaSinkMatcher on bytecode call targets + registry entries (§3.2 #4) — now actually reachable on real bytecode post-parser-fixDONE 2026-06-11JavaSymbolNormalizer (stellaops.java.symbol-normalizer@1.0.0): canonical {package}::{Class}.{method} / {package.Class}::{method} resolved against java:{class}.{method}{descriptor} node ids by fully-qualified ordinal equality on node SYMBOLS (descriptor-insensitive — binds all overloads, documented) + descriptor-precise node-id equality (a JVM descriptor pins ONE overload; source-form parameter lists are stripped) + inner-class source→binary folding (Outer.Inner.mOuter$Inner.m) + javadoc # spellings + <init>/<clinit>; simple-name/global-module canonicals NEVER bind (the look-alike trap — com.demo.JndiLookup.lookup stays unmatched) and class-granularity facts do not fan out to methods. Contract-tested (JavaSymbolNormalizerTests, 20: package/FQ-class modules, synthetic sink targets, overloads, descriptors, inner classes, constructors + 7 negatives + determinism); recorded in WitnessSink.match_ruleThin — GHSA Maven range-only; needs patch-diff factory (Maven downloader + JavaBytecodeFingerprinter EXIST); golden trio honestly rides the curated tier meanwhileToy svc-01-log4shell-java; golden trio via the REAL bytecode extractor DONE 2026-06-11LangJavaGoldenTrioEndToEndTests (5): real synthesized class files (deterministic in-test class-file writer: constant pool + Code attributes with invoke instructions) → log4shell-shaped reachable (mainLogService.processJndiLookup.lookup) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + curated tier recorded); not-observed (dormant importer calls the symbol, no entrypoint reaches it) ⇒ coverage statement replaying byte-identically across two fresh runs; negative (Class.forName/Method.invoke reflection + absent dependency definition + REACHABLE first-party look-alike com.demo.JndiLookup) ⇒ no witness, unknown/symbol_unresolvedSymbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles; live full-loop proof (CONVERGE-VERIFY)
JS/TS (Bun + Deno ride this lane)Both — toolchain NodeCallGraphExtractor (Babel tool); standalone JavaScriptCallGraphExtractor (import-alias sink synthesis, lines 440–535). Externals synthesized for ALL import-verified calls (LANG-JS 2026-06-11): member calls on imported aliases and direct calls of named/default imports that are NOT capability-catalog sinks now synthesize js:{app}/external.{module}.{method} targets (Go-lane precedent — the external call surface must exist for CanonicalId matching); honest skips unchanged: non-imported receivers, computed keys, dynamic require(expr), relative-path and protocol-prefixed (node:, https://) specifiers (pinned by JavaScriptCallGraphExtractorExternalCallTests incl. negatives + determinism). Worker- and build-time-routed; Bun/Deno manifests (bun.lockb/bun.lock/deno.json/deno.jsonc) now route into this lane when no package.json exists (routing test), and the extractor reads Deno project metadata from deno.json/deno.jsonc— the unregistered Bun/Deno extractor classes stay unrouted (no bespoke pipelines, §9)Strong — Sprint 018 table w/ negatives (Express/Fastify, Module Federation); Bun/Deno classifiers exist outside the 018/020 disciplineJsSinkMatcher import-alias table (catalog sinks keep the sink.{…} node shape, IsSink=true; externals are NOT capability claims)DONE 2026-06-11JsSymbolNormalizer (stellaops.js.symbol-normalizer@1.0.0, Extraction/JavaScript/): package-anchored — a node binds only when Symbol == {Package}.{name} for a bare npm specifier Package (true exactly for import-verified synthesized targets; node-id text is never inspected, so file-name look-alikes like lodash.js/sink.ts are structurally unmatchable). Rule items: (1) identity for canonical-keyed curated graphs; (2) {package}::{export} incl. scoped packages + npm package-root variant for deep imports (lodash/template answers for lodash); (3) Method form {module}::{Class}.{method} class-erased to module+member (JS receivers carry no type — documented over-approximation, module verified by import); (4) flat GHSA form global::{module}.{export} matched whole (never class-erased — the global placeholder names no module). Contract-tested (JsSymbolNormalizerTests, 18: ESM/CJS alias forms through the REAL extractor, scoped/deep/flat/self-scan + 7 negatives + determinism); recorded in WitnessSink.match_ruleThin — GHSA npm range-only (rare flat database_specific fields — the golden trio exercises exactly that shape through the real OSV extractor); needs patch-diff factory (npm downloader + JavaScriptMethodFingerprinter EXIST; fingerprinter upgraded to a real Esprima AST parse at fidelity 0.85, LANE-AST-UPGRADE 2026-06-12)Toy svc-02-prototype-pollution-node; golden trio via the REAL extractor DONE 2026-06-11LangJsGoldenTrioEndToEndTests (5): GHSA-shaped flat OSV JSON (CVE-2021-23337 lodash.template) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real npm project in temp dirs → executor → mapper; reachable (GET /renderrenderTemplateexternal.lodash.template) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (symbol present via dormant helper, unreachable) ⇒ coverage statement (JS blind-spot catalog) byte-identical across two fresh runs; negative (non-imported receiver + computed key + dynamic require + REACHABLE first-party look-alike file lodash.js) ⇒ no witness, unknown/symbol_unresolved, nothing storedSymbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles or the rare flat-GHSA entries; Bun/Deno matcher TABLES moved to the packs 2026-06-11 (D7 completion; languages bun/deno, engines keep exact-then-Contains semantics — the extractor classes stay unrouted, and any future match-semantics rework is now a pack version bump); live full-loop proof (CONVERGE-VERIFY)
PythonStandalonePythonCallGraphExtractor; defect #2 FIXED 2026-06-10 (two-pass lexical call extraction + imported-call sink synthesis, §3.5 #2). Externals synthesized for import-verified non-catalog calls (LANG-PYTHON 2026-06-11): member calls on imported aliases and direct calls of from-imported names that are NOT capability-catalog sinks synthesize py:{app}/external.{module}.{func} targets (Go/JS-lane precedent — the external call surface must exist for CanonicalId matching); honest skips unchanged: non-imported receivers, getattr/__import__ reflection, import *, and first-party modules (pinned by PythonCallGraphExtractorExternalCallTests incl. negatives + determinism). Worker- and build-time-routedStrong — Sprint 020 table w/ negatives (Flask/FastAPI/Django/Celery/APScheduler/Click/Typer/Lambda/gRPC/asyncio.run)PythonSinkMatcher applied to call targets post-fix + registry entries (catalog sinks keep the sink.{…} node shape, IsSink=true; externals are NOT capability claims)DONE 2026-06-11PythonSymbolNormalizer (stellaops.python.symbol-normalizer@1.0.0, Extraction/Python/): package-anchored — a node binds only when Symbol == {Package}.{name} for a valid dotted Python module path Package (true exactly for import-verified synthesized targets; first-party look-alikes — even a vendored PIL/ImageColor.py shim REACHABLE from an entrypoint — are structurally unmatchable because their Package is the app, and PyPI project names with hyphens fail the module-path check by construction). Rule items: (1) identity for canonical-keyed curated graphs; (2) {module-path}::{func}; (3) dotted-symbol fold {module}::{Class}.{method}{module}.{Class}::{method} (the class joins the import path — Python member calls preserve dotted chains, so NO class erasure / no JS-style over-approximation is needed); (4) PyPI -_ fold (PEP 503) for hyphenated distribution-name modules, case never folds; (5) flat global::{dotted.path} matched whole. Distribution names differing beyond the hyphen fold (Pillow→PIL, PyYAML→yaml) deliberately do NOT bind — curated rows must carry the import module path. Contract-tested (PythonSymbolNormalizerTests, 18: module.func + aliased + relative-from THROUGH THE REAL extractor, sink/external/self-scan/hyphen-fold/flat/identity/Method-fold positives + 7 negatives + shape/determinism); recorded in WitnessSink.match_ruleThin — PYSEC/GHSA mostly range-only (the golden trio exercises the rare STRUCTURED ecosystem_specific.symbols object shape — name+module — through the real OSV extractor); needs patch-diff factory (PyPI downloader + PythonAstFingerprinter EXIST)Toy svc-03-pickle-deserialization-python; post-fix extractor fixtures w/ negatives; golden trio via the REAL extractor DONE 2026-06-11LangPythonGoldenTrioEndToEndTests (5): CVE-2021-23437-shaped OSV JSON (Pillow, PIL.ImageColor::getrgb) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real Python project in temp dirs → executor → mapper; reachable (GET /colorresolve_colorexternal.PIL.ImageColor.getrgb; build-time stub-guard predicate asserted) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (dormant helper calls the symbol, no entrypoint reaches it) ⇒ coverage statement (Python blind-spot catalog) byte-identical across two fresh full runs; negative (getattr/__import__ reflection + import * + REACHABLE vendored first-party look-alike PIL/ImageColor.py) ⇒ no witness, unknown/symbol_unresolved, nothing storedSymbol data via VulnSurfaces (D6 factory) — until then verdicts above likely need curated bundles or the rare structured-OSV entries; live full-loop proof (CONVERGE-VERIFY)
RustStandaloneRustCallGraphExtractor (cargo-metadata + regex/AST sweep, Tier-B). Two-pass lexical call extraction (LANG-RUST 2026-06-11, Go/Python/JS template): per-file use-decl alias tables expand call-chain roots (use std::process::Command; Command::newstd::process::Command::new), first-party-rooted path calls (crate::/self::/super::, package name, file-derived or mod-declared module roots) resolve to DECLARED nodes, in-module bare calls and use-imported bare calls (use time::at_utc; at_utc()) become real edges, and extern-rooted call targets carry the verified module path in Package (the normalizer anchor); call sweeps run over a sanitized view (strings/comments/raw strings blanked, offsets preserved). Honest skips: non-imported method receivers, glob imports, nested use groups, macro invocations, fn-pointer/function-value dispatch, turbofish-in-chain (pinned by RustCallGraphExtractorCallEdgeTests incl. shadowed-mod time + sanitization negatives). DI-registered, in the build-time set, AND Worker scan-side now routes rust (Cargo.toml/Cargo.lock candidate in DetectLanguageCandidates, LANG-RUST; ReachabilityInputStageLanguageRoutingTests)RustEntrypointClassifier (main/tokio/actix/rocket/axum/warp) + RustAstEntrypointEnrichmentTests; not under the 018/020 negative-discipline tablesRustSinkMatcher on use-decl module paths + registry entries (§3.2 #4) — post-LANG-RUST the matcher sees use-decl-EXPANDED module paths (exact catalog matches instead of receiver-text suffix luck)DONE 2026-06-11RustSymbolNormalizer (stellaops.rust.symbol-normalizer@1.0.0, Extraction/Rust/): package-anchored (JS/Python precedent) — a node binds only when Symbol == {Package}::{name} for a valid Rust module-path Package and single-identifier name (true exactly for extern-verified synthesized targets and, deliberately, crate-root free functions in a library self-scan). Rule items: (1) identity for canonical-keyed curated graphs; (2) full path {module}::{symbol} (RustSec CanonicalIds reconstruct the published path losslessly); (3) Method-dot refold {module}::{Class}.{method}{module}::{Class}::{method} (no class erasure — the type is part of the Rust path); (4) generics-stripped (::<…>/<…> removed); (5) Cargo hyphen fold on the NODE side (tracing-core manifest → tracing_core code spelling); (6) flat global::{path} matched whole. First-party shadowing mod time (rustc-consistent resolution), method receivers, scopeless barewords, and deep-module/impl self-scan symbols deliberately do NOT bind. Contract-tested (RustSymbolNormalizerTests, 16: nested modules, refold, generics, hyphen fold, flat, identity, self-scan, use-decl form THROUGH THE REAL extractor + 7 negatives + shape/determinism); recorded in WitnessSink.match_ruleGood — RustSec affects.functions (subset of advisories); nested-shape extractor FIXED 2026-06-10. No crates.io VulnSurfaces laneToy svc-07-time-segfault-rust (labels.yaml tier R4, corpus-validated); golden trio via the REAL extractor DONE 2026-06-11LangRustGoldenTrioEndToEndTests (5): RUSTSEC-2020-0071-shaped OSV JSON (CVE-2020-26235, affects.functions) → real OsvAffectedSymbolExtractor → bridge → real extractor over a real crate in temp dirs → executor → mapper; reachable (mainhandlers::render_clocktime::at_utc) ⇒ reachable:proven + independently-recomputed path_hash witness (match rule + osv source recorded); not-observed (dormant unused_helper calls the symbol, unreachable) ⇒ coverage statement (Rust blind-spot catalog) byte-identical across two fresh full runs; negative (fn-pointer dispatch + function value + REACHABLE first-party shadowing mod time) ⇒ no witness, unknown/symbol_unresolved, nothing storedTier-A bitcode pass still a hook; entrypoint classifier not under 018/020 negative tables (its declarative attribute/function-name tables moved to the packs 2026-06-11, D7 completion); live full-loop proof (CONVERGE-VERIFY)
RubyStandaloneRubyCallGraphExtractor; zero-edge defect FIXED 2026-06-11 (LANG-RUBY, the Python/JS two-pass template): per-file require tables, in-class/sibling and project-global top-level bare-call edges, constant member-call resolution where first-party classes WIN over a same-named required gem (a shadowing class ERB resolves first-party, the Rust shadowed-mod time precedent), require-verified catalog-sink (rb:{app}/sink.{feature}.{Const}.{method}, sink matched on the call TARGET) and external (rb:{app}/external.…, IsSink=false — the surface advisory CanonicalIds bind to) target synthesis, core-constant catalog sinks without require (Marshal.load, bare system(), Sinatra route-block bodies, and offset-preserving comment/string sanitization. Honest skips pinned by negatives: send/__send__/const_get/instance_eval/define_method reflection (deny-list), method_missing, non-constant receivers (locals/ivars/chained ERB.new(x).result), paren-less invocations, ::-form method calls, unverified constants (Rails autoloads), literals/comments (RubyCallGraphExtractorCallEdgeTests, 11). DI-registered + Worker scan-side routed (Gemfile/Gemfile.lock/config.ru); NOT in the build-time set (like Go/Java)RubyEntrypointClassifier (Rails controllers/actions, Sinatra routes, Sidekiq/ActiveJob, rake, CLI) — drives the golden trio’s controller entrypoints; still not under the 018/020 negative-discipline tables (declarative action-name/parent-class tables moved to the packs 2026-06-11, D7 completion)RubySinkMatcher over pack data (Kernel.system, Marshal.load, ERB.new, …) — now actually invoked on call TARGETS (definition-side match retained for the library-self-scan case, same shape as JS/Python)DONE 2026-06-11RubySymbolNormalizer (stellaops.ruby.symbol-normalizer@1.0.0, Extraction/Ruby/): package-anchored — a node binds only when Symbol == {Package}.{ConstChain}.{method} for a valid require-feature-path Package (true exactly for require-verified synthesized targets; core-constant sinks carry an uppercase Package and are deliberately NOT advisory-bindable). Rule items: (1) identity for canonical-keyed curated graphs; (2) {feature}::{Const::Chain}.{method} (the canonical module is the gem’s REQUIRE FEATURE PATH, not the gem name when they differ); (3) spelling folds — rubysec #. and dot-spelled constant chains → :: (class-method vs instance-method distinction not modeled at the lexical tier, documented); (4) narrowed self-scan positive — class methods of feature-path-named projects (scanning the erb gem, ERB#result IS the vulnerable definition); top-level defs do not bind; (5) flat global::{published-string} matched whole, never class-erased. First-party look-alikes (class ERB in svc-app), scopeless barewords, class-less {feature}::{method} canonicals, and case/near-misses deliberately do NOT bind. Contract-tested (RubySymbolNormalizerTests, 19: sink/external/nested/slash-feature/#-fold/refold/flat/identity/self-scan + THROUGH THE REAL extractor + 7 negatives + shape/determinism); recorded in WitnessSink.match_ruleThin — GHSA/rubysec range-only (structured symbols absent); Gem D6 lane now has the RubyGems downloader, Ruby lexical fingerprinter, alias registration, require-feature MethodKey folding through the real RubySymbolNormalizer, and frozen diff fixtures; golden trio still rides the CURATED tier until full-chain VERIFY fan-out proves computed surfaces through the Ruby evidence-job path; curated rows must carry the require feature path as ModuleToy svc-06-erb-injection-ruby (tier corpus); golden trio via the REAL extractor DONE 2026-06-11LangRubyGoldenTrioEndToEndTests (5): curated rows → REAL bridge (EMPTY Concelier projection = the honest range-only posture) → REAL RubyCallGraphExtractor over a real Gemfile project in temp dirs → executor → mapper → verdict objects; reachable (svc-06 shape: GreetingsController#createrender_greetingsink.erb.ERB.new; stub-guard predicate clears) ⇒ reachable:proven with independently-recomputed path_hash + edge-closure check (match rule + curated tier recorded); not-observed (dormant renderer calls the symbol, no entrypoint reaches it) ⇒ coverage statement (Ruby blind-spot catalog) byte-identical across two fresh full runs; negative (const_get+send dispatch + method_missing + REACHABLE first-party shadowing class ERB whose symbol folds to the EXACT advisory spelling) ⇒ no witness, unknown/symbol_unresolved, nothing storedSymbol data: Gem D6 patch-diff lane exists in frozen fixtures; full-chain VERIFY fan-out and live full-loop remain before replacing curated-bundle dependency broadly; entrypoint classifier negative tables (pack migration DONE 2026-06-11, D7 completion); build-time-set decision
PHPStandalonePhpCallGraphExtractor; zero-edge defect FIXED 2026-06-11 (LANG-PHP, the Ruby/Python/JS two-pass template): per-file namespace + use/use function tables, project-global bare-call edges, $this->/self::/parent:: sibling resolution, static-member-call resolution where first-party classes WIN over a same-named use import (a shadowing namespace GuzzleHttp\Psr7; class Message resolves first-party, the Ruby/Rust precedent), use/rooted-verified catalog-sink (php:{app}/sink.{Ns/…}.{Class}.{method}, sink matched on the call TARGET) and external (php:{app}/external.…, IsSink=false — the surface advisory CanonicalIds bind to) target synthesis with Package = {Namespace}, core-class sinks without use (new SimpleXMLElement(, \PDO::query:: symbol spelling, structurally non-bindable), core global-function sinks (exec(, unserialize(Package = php), new-expression __construct targets, use-function externals, and offset-preserving comment/string/backtick/heredoc sanitization (PHP 8 #[ attributes survive). Honest skips pinned by negatives: variable functions/methods ($fn(, $obj->$m(, new $cls(), unrooted multi-segment refs in namespaced files, paren-less constructs, literals/comments/heredocs, first-party exec function/method look-alikes (definition-side sink match narrowed to class-scoped catalog rules — userland cannot define PHP’s core exec) (PhpCallGraphExtractorCallEdgeTests, 14). DI-registered + Worker scan-side routed (composer.json/composer.lock); NOT in the build-time set (like Go/Java/Ruby)PhpEntrypointClassifier (Laravel/Symfony/Slim) — drives the golden trio’s controller entrypoints; still not under the 018/020 negative-discipline tables (declarative action-name/annotation-prefix/parent-class tables moved to the packs 2026-06-11, D7 completion)PhpSinkMatcher over pack data (exec/unserialize/eval/…) — now actually invoked on call TARGETS; definition-side match narrowed to class-scoped rules (MatchDefinition, library-self-scan case)DONE 2026-06-11PhpSymbolNormalizer (stellaops.php.symbol-normalizer@1.0.0, Extraction/Php/): package-anchored — a node binds only when Symbol == {Package}.{Class}.{method} (class form) or {Package}.{function} (function form) for a valid PSR-4 StudlyCaps namespace-path Package (true exactly for use-verified synthesized targets; first-party symbols are {Ns}\{Class}::{method} — never dotted — so the parse is structurally exclusive). Rule items: (1) identity for canonical-keyed curated graphs; (2) {Namespace}::{Class}.{method} + the natural FQN spelling {Namespace}\{Class}::{method} (curators can paste advisory text verbatim); (3) spelling folds — trailing () strips, -> and all-:: symbol parts fold to the Method form (static vs instance not modeled, documented); (4) function form {Namespace}::{function} for use-function targets; (5) flat global::{FQN-spelling} matched whole, never class-erased; (6) narrowed self-scan positive — first-party namespaced class methods bind iff the namespace’s top segment lowercases to the composer VENDOR segment (scanning guzzlehttp/psr7, the GuzzleHttp\Psr7\Message::parseMessage definition IS the vulnerable code). Composer package names as modules, core sinks, lowercase namespaces, scopeless barewords, and case/near-misses deliberately do NOT bind. Contract-tested (PhpSymbolNormalizerTests, 19: Method/natural/folds/flat/function/identity/self-scan + THROUGH THE REAL extractor + 8 negatives + shape/determinism); recorded in WitnessSink.match_ruleThin — GHSA Packagist / FriendsOfPHP range-only (structured symbols absent); Composer D6 lane now has the Packagist downloader, PHP lexical fingerprinter, alias registration, PSR-4 MethodKey folding through the real PhpSymbolNormalizer, and frozen diff fixtures; golden trio still rides the CURATED tier until full-chain VERIFY fan-out proves computed surfaces through the PHP evidence-job path; curated rows must carry the PSR-4 namespace as ModuleToy svc-08-psr7-header-parse-php (labels.yaml tier R4, corpus-validated); golden trio via the REAL extractor DONE 2026-06-11LangPhpGoldenTrioEndToEndTests (5): curated rows → REAL bridge (EMPTY Concelier projection = the honest range-only posture) → REAL PhpCallGraphExtractor over a real composer project in temp dirs → executor → mapper → verdict objects; reachable (svc-08 shape: GatewayController::storeparse_raw_messageexternal.GuzzleHttp/Psr7.Message.parseMessage; stub-guard predicate clears) ⇒ reachable:proven with independently-recomputed path_hash + edge-closure check (match rule + curated tier recorded); not-observed (dormant parser calls the symbol, no entrypoint reaches it) ⇒ coverage statement (PHP blind-spot catalog) byte-identical across two fresh full runs; negative (variable-function + call_user_func dispatch + REACHABLE first-party shadowing class Message in the exact advisory namespace) ⇒ no witness, unknown/symbol_unresolved, nothing storedSymbol data: Composer D6 patch-diff lane exists in frozen fixtures; full-chain VERIFY fan-out and live full-loop remain before replacing curated-bundle dependency broadly; entrypoint classifier negative tables (pack migration DONE 2026-06-11, D7 completion); build-time-set decision

Honest summary. Go now demonstrates the full in-process loop natively (LANG-GO, 2026-06-11): real vulndb-shaped CanonicalIds resolve against a REAL GoCallGraphExtractor graph through the documented stellaops.go.symbol-normalizer@1.0.0 rule, with the golden trio (proven / not-observed+coverage / dynamic-dispatch negative) green and byte-identical on replay — what remains for Go is the live stack proof (CONVERGE-VERIFY) plus SSA-tool packaging and the build-time-set decision. .NET now demonstrates the same loop through the lightweight extractor (LANG-DOTNET, 2026-06-11) — the dogfood lane: curated/package-symbols-tier CanonicalIds resolve against a REAL DotNetLightweightCallGraphExtractor graph through stellaops.dotnet.symbol-normalizer@1.0.0, golden trio green and byte-identical on replay; its honest limit is DATA, not plumbing — upstream advisories are range-only, so anything above reachable:likely needs curated bundles until the D6 patch-diff factory runs for NuGet. Java now demonstrates the same loop through the real bytecode extractor (LANG-JAVA, 2026-06-11) — and its golden trio was a live forcing-function: it exposed that JavaBytecodeAnalyzer could never parse a real class file (ByteReader passed by value, now fixed — §11 Java row); curated-tier CanonicalIds resolve against a REAL JavaCallGraphExtractor graph through stellaops.java.symbol-normalizer@1.0.0, golden trio green and byte-identical on replay; like .NET its honest limit is DATA (GHSA Maven is range-only), so anything above reachable:likely needs curated bundles until the D6 patch-diff factory runs for Maven. JavaScript/TypeScript now demonstrates the same loop through the real extractor (LANG-JS, 2026-06-11) — flat-GHSA-shaped CanonicalIds resolve against a REAL JavaScriptCallGraphExtractor graph through the package-anchored stellaops.js.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gap (only catalog sinks were synthesized) is closed by import-verified external-target synthesis, and Bun/Deno manifests route into the lane. Its honest limit remains DATA: GHSA npm symbols are rare flat fields, so most npm verdicts above reachable:likely need curated bundles until the D6 patch-diff factory runs for npm. Python now demonstrates the same loop through the real extractor (LANG-PYTHON, 2026-06-11) — structured-OSV-shaped CanonicalIds resolve against a REAL PythonCallGraphExtractor graph through the package-anchored stellaops.python.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gap (only catalog sinks were synthesized) is closed by import-verified external-target synthesis. Its honest limit remains DATA: PYSEC/GHSA symbol fields are rare, so most PyPI verdicts above reachable:likely need curated bundles until the D6 patch-diff factory runs for PyPI. Rust now demonstrates the same loop through the real extractor (LANG-RUST, 2026-06-11) — RustSec-shaped CanonicalIds (the §3.5 #4 affects.functions ingestion, end to end) resolve against a REAL RustCallGraphExtractor graph through the package-anchored stellaops.rust.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay; the lane’s verdict-changing extractor gaps (no use-decl expansion, no first-party resolution, no bare-call edges, literal/comment noise) are closed by the two-pass sweep, and Worker scan-side now routes Cargo manifests. Unlike the thin-data lanes its honest limit is COVERAGE, not plumbing: RustSec function lists exist on a subset of advisories, and the Tier-A bitcode pass remains a hook. Ruby now demonstrates the same loop through the real extractor (LANG-RUBY, 2026-06-11) — the lane was extractor-FIRST (it could not execute any part of the loop): the extractor gained the full two-pass sweep (require tables, first-party-wins constant resolution, sink/external target synthesis, route-block bodies, sanitization), DI registration, and Worker routing; curated-tier CanonicalIds (require-feature-path modules) resolve against a REAL RubyCallGraphExtractor graph through the package-anchored stellaops.ruby.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay. Like .NET/Java its honest limit is DATA: GHSA/rubysec are range-only, so anything above reachable:likely needs curated bundles until the gem patch-diff lane gets full-chain VERIFY fan-out (D6 lane fixtures now exist). PHP now demonstrates the same loop through the real extractor (LANG-PHP, 2026-06-11) — the eighth and final lane: it was extractor-FIRST like Ruby (zero-edge extractor, no registration, no routing): the extractor gained the full two-pass sweep (namespace + use/use function tables, first-party-wins resolution, sink/external target synthesis on call TARGETS, heredoc-aware sanitization, class-scoped definition-side matching), DI registration, and Worker routing; curated-tier CanonicalIds (PSR-4 namespace modules) resolve against a REAL PhpCallGraphExtractor graph through the package-anchored stellaops.php.symbol-normalizer@1.0.0 rule, golden trio green and byte-identical on replay. Like .NET/Java/Ruby its honest limit is DATA: GHSA/FriendsOfPHP are range-only, so anything above reachable:likely needs curated bundles until the composer patch-diff lane gets full-chain VERIFY fan-out (D6 lane fixtures now exist). Bun/Deno are folded into the JS lane by decision (this section): same advisory ecosystem (npm), same normalizer — and since LANG-JS their manifests (bun.lockb/bun.lock/deno.json/deno.jsonc) actually route there when no package.json exists.

Final per-language coverage (CONVERGE-VERIFY, 2026-06-11). Every lane now proves the FULL in-process loop in one integration class — advisory symbols → bridge → normalizer → REAL-extractor graph → explicit-sink BFS → lattice verdict → witness → pruned evidence-set referrer (D10, third-party-verified from bytes alone) → policy gate BLOCK under the real shipped pack (D5) — with byte-identical verdict AND referrer payload bytes across two fresh runs (GoldenReachable_FullLoop_ReferrerVerifiesAndPolicyGateBlocks_ByteIdentically per Lang*GoldenTrioEndToEndTests class, all green via built exe: Go 6 / .NET 7 / Java 6 / JS 6 / Python 6 / Rust 6 / Ruby 6 / PHP 6, Failed 0; full Reachability suite Total 863, Failed 0, Skipped 2 pre-existing). Status per lane, with the honest reason for anything short of done:

LaneIn-process loop (incl. referrer + gate)StatusRemaining reason
Goprovendone (in-process)live full-loop stack proof; SSA-tool packaging (tracked BLOCKED infra: SPRINT_20260612_002 FRONT1-GO-SSA-PACKAGING — GoCallGraphExtractor.cs:67-70 falls back to the genuine lexical tier when the binary is absent); build-time-set decision
.NETprovendone (in-process)DATA gap CLOSED in-process 2026-06-12: the D6 NuGet patch-diff lane (Phase 4(a) VERIFY note) now carries a range-only CVE to reachable:proven from a computed surface, no curated bundle (Phase4aSurfaceFactoryEndToEndTests); remaining: factory enablement (flag default OFF) + live full-loop
Javaprovendone (in-process)DATA gap CLOSED in-process 2026-06-12 by the D6 Maven patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); live full-loop
JS/TS (+Bun/Deno)provendone (in-process)DATA gap CLOSED in-process 2026-06-12 by the D6 npm patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); flat-GHSA symbol fields remain rare; live full-loop (Bun/Deno matcher tables in packs since 2026-06-11)
Pythonprovendone (in-process)DATA gap CLOSED in-process 2026-06-12 by the D6 PyPI patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); fingerprinter upgraded 2026-06-13 to a hand-rolled tokenizer-parser (0.8 tier, sprint 20260612_002 LANE-PARSER-HANDROLL — the former BLOCKED item, resolved); live full-loop
Rustprovendone (in-process)COVERAGE: RustSec affects.functions on a subset of advisories; Tier-A bitcode hook; entrypoint negative tables (pack migration done); live full-loop
Rubyprovendone (in-process)DATA gap CLOSED in-process 2026-06-12 by the D6 Gem patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); entrypoint negative tables (pack migration done); build-time-set decision; live full-loop
PHPprovendone (in-process)DATA gap CLOSED in-process 2026-06-12 by the D6 Composer patch-diff lane (Phase4aSurfaceFactoryEndToEndTests fan-out); entrypoint negative tables (pack migration done); build-time-set decision; live full-loop

Cross-cutting remainder (not per-language): the D9 vocabulary remainder is CLOSED — the Signals bucket rebase (3a), the CLI lattice DTOs (3b, ReachabilityVerdictDisplay), and the Console FE rebase (3c, normalizeFindingReachabilityState — see the §7.3 audit note) all landed 2026-06-11; the “live full-loop” rows all name the same single gap (the loop fired by a real image scan on a running stack, the §7.1 Phase-0 note’s owed live variant).

Front-1 two-layer coverage matrix (2026-06-12, Phase 4(a) checkpoint — sprint 20260612_002 L4 verification; refreshed by VERIFY fan-out). The CONVERGE-VERIFY table above tracks the in-process reachability loop; this matrix splits each lane into its TWO independent layers so symbol-data starvation is never hidden behind a green extractor: layer 1 = reachability plumbing (call-graph extractor + entrypoint catalog + symbol normalizer, proven by the golden trio), layer 2 = advisory symbol data (native corpus | patch-diff surface | none) and whether the full CVE→symbol→reachable:proven chain has been demonstrated in-process FROM that data source. Fresh layer-1 evidence this date: all 8 Lang*GoldenTrioEndToEndTests classes re-run via the built exe — Total 49, Failed 0 (Go 6 / .NET 7 / Java 6 / JS 6 / Python 6 / Rust 6 / Ruby 6 / PHP 6). Layer-1 ENTRYPOINT-CATALOG status (Front-1 L3 re-verification, 2026-06-12): the declarative entrypoint tables for ALL 6 declarative-table languages (Python + Go/Java/Rust/Ruby/PHP) are signed-pack-backed in stellaops.entrypoints.default.catalog.json (D7 completion commit 610974471d); the classifiers consume the pack via ReachabilityCatalogRuntime.Current.GetEntrypoints(...) and keep genuine structural naming heuristics in-engine; JavaScript deliberately has no declarative table. Byte-parity vs the frozen C# tables (DefaultCatalogPackParityTests, Total 28, Failed 0 via the built StellaOps.Scanner.Contracts.Tests.exe) — the migration the Front-1 brief asked for is already complete and pinned by regression, not owed. Layer-2 fan-out evidence: Phase4aSurfaceFactoryEndToEndTests Total 10, Failed 0 proves NuGet plus Maven/npm/PyPI/RubyGems/Composer through the real VulnSurfaceFeedProcessor path; RubyGems/Packagist advisory spellings canonicalize to gem/composer before persistence and purl readback.

All-8 final re-verification (2026-06-13, full-matrix sign-off — sprint 20260612_002 FULL-MATRIX row). Both layers re-run via freshly built exes and confirmed honest against src/: all 8 Lang*GoldenTrioEndToEndTests + Phase4aSurfaceFactoryEndToEndTests Total 59, Failed 0 (49 trio + 10 fan-out); upgraded-fingerprinter set (LaneRegistrationAndFingerprinterTests + JavaBytecodeRealClassFileTests + EcosystemAliasFoldingTests) Total 35, Failed 0; FULL StellaOps.Scanner.VulnSurfaces.Tests.exe Total 122, Failed 0. Fidelity ladder is source-pinned (ConfidenceMultiplier): bytecode 1.0 (NuGet Cecil / Java) > AST 0.85 (npm Esprima) > hand-rolled tokenizer-parser 0.8 (PyPI/gem/composer) — no lane left at bare regex. The ONLY honest non-proven residuals remain Go-SSA worker-image packaging (docker/infra, BLOCKED GoCallGraphExtractor.cs:67-70) and the per-lane LIVE forcing-function (the explicit owed item below).

LanguageLayer 1 — extractor + entrypoints + normalizerLayer 2 — symbol-data sourceLayer 2 — full in-process chain from that sourceHonest status (both layers)
Goproven (trio 6/6 re-run 2026-06-12; SSA-tool worker-image packaging tracked BLOCKED at SPRINT_20260612_002 FRONT1-GO-SSA-PACKAGING — lexical fallback is genuine, GoCallGraphExtractor.cs:67-70)native corpus — Go vulndb imports[].symbols, ingested by the real OsvAffectedSymbolExtractor (the trio fixture is the real OSV shape, not hand-fed canonicals)proven — trio carries the vulndb shape end-to-end to reachable:proven; no patch-diff lane neededproven (in-process); live full-loop owed
Rustproven (trio 6/6; entrypoint classifier outside the 018/020 negative-discipline tables — declarative tables in packs)native corpus — RustSec affects.functions (present on a SUBSET of advisories)proven — trio carries RUSTSEC-shaped OSV end-to-end to reachable:provenproven (in-process); data coverage limited to advisories that publish function lists; live full-loop owed
.NETproven (trio 7/7)patch-diff surface — nuget D6 lane (Cecil IL fingerprinter, fidelity 1.0); upstream GHSA/NVD are range-onlyprovenPhase4aSurfaceFactoryEndToEndTests (5/5, 2026-06-12): computed surface ⇒ function-level surface-tier mappings ⇒ reachable:proven; the contrast is pinned — WITHOUT the surface the same CVE stays unknown/no_symbols_for_cveproven (in-process); factory flag VulnSurfaces:FeedTrigger default OFF (enablement is a per-deployment decision); live feed-time loop owed
Javaproven (trio 6/6)patch-diff surface — maven D6 lane registered (binary class-file fingerprinter, fidelity 1.0, de-risked against a real frozen javac class — no desync found)provenPhase4aSurfaceFactoryEndToEndTests fan-out carries the computed Maven surface through JavaSymbolNormalizer to a proven-capable verdict pathproven (in-process); live full-loop owed
JS/TS (+Bun/Deno)proven (trio 6/6)patch-diff surface — npm D6 lane registered (real Esprima AST parse, fidelity 0.85); flat-GHSA symbol fields rareprovenPhase4aSurfaceFactoryEndToEndTests fan-out carries the computed npm surface through JavaScriptSymbolNormalizer to a proven-capable verdict pathproven (in-process); live full-loop owed
Pythonproven (trio 6/6)patch-diff surface — pypi D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: string/comment-masking tokenizer + indentation-scope def/class parser, multi-line signatures, nested classes; the former regex-only/BLOCKED state is resolved — sprint 20260612_002 LANE-PARSER-HANDROLL)provenPhase4aSurfaceFactoryEndToEndTests fan-out carries the computed PyPI surface through PythonSymbolNormalizer to a proven-capable verdict path (re-proven over the new parser, 10/10)proven (in-process); live full-loop owed
Rubyproven (trio 6/6; entrypoint classifier outside the 018/020 tables)patch-diff surface — gem D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: heredoc/%-literal/regex-literal masking + keyword-balanced def/end blocks, class << self, endless defs — sprint 20260612_002 LANE-PARSER-HANDROLL); GHSA/rubysec range-onlyprovenPhase4aSurfaceFactoryEndToEndTests fan-out proves the canonical gem chain through the real RubySymbolNormalizer (re-proven over the new parser, 10/10); EcosystemAliasFoldingTests proves RubyGems advisory spelling persists/readbacks as gemproven (in-process); live full-loop owed
PHPproven (trio 6/6; entrypoint classifier outside the 018/020 tables)patch-diff surface — composer D6 lane (hand-rolled tokenizer-parser since 2026-06-13, fidelity 0.8: heredoc/nowdoc masking + balanced-paren params + trait/interface/enum members — sprint 20260612_002 LANE-PARSER-HANDROLL); GHSA/FriendsOfPHP range-onlyprovenPhase4aSurfaceFactoryEndToEndTests fan-out proves the canonical composer chain through the real PhpSymbolNormalizer (re-proven over the new parser, 10/10); EcosystemAliasFoldingTests proves Packagist advisory spelling persists/readbacks as composerproven (in-process); live full-loop owed

Explicit owed item (out of Front-1 scope): per-lane LIVE forcing-function. Every row above is an in-process capability proof. The live variant — a real advisory ingest with the feed-time factory enabled (VulnSurfaces:FeedTrigger), a real image scan on a running stack, and the policy gate flipping on the computed/native symbol — is owed PER LANE and is recorded here so it cannot be ticked silently (capability-proof ≠ live forcing-function; same class as the §7.1 Phase-0 note’s owed live variant and the CONVERGE-VERIFY “live full-loop” rows).


Appendix A — Verified code findings index

FindingEvidence
Sink matchers exist for all languages and are invokedExtraction/{Python,JavaScript,Rust,Java,Go,Bun,Deno,Php,Ruby}/...SinkMatcher.cs; invocation verified in Python (l.94), JS (l.102/121/460/512), Rust (l.282/366), Java (l.103/129), Go (l.183/368), DotNet lightweight (l.483/500)
.NET sink match structurally fails (qualified pattern vs lexical symbol)Contracts/SinkRegistry.cs l.142 (Contains); DotNetLightweightCallGraphExtractor.cs l.889–894 (external symbol = receiver text)
Python extractor: zero edges, sink match on definitionsPythonCallGraphExtractor.cs l.477–481 (return []), l.94, Calls = [] at l.406/448
Triplicate enumsContracts/SinkCategory.cs l.14, Contracts/CallGraphEnums.cs l.41; Reachability/Explanation/PathExplanationModels.cs l.175/212; Reachability/Stack/ReachabilityStack.cs l.162
3-layer stack model + conservative evaluator real; ILayer1/2/3Analyzer have no implementationsStack/ReachabilityStack.cs, Stack/ReachabilityStackEvaluator.cs (truth table l.40–51, low-confidence-gate rule l.132–139); repo-wide search: interfaces only
Evidence job: L1 real (BFS, explicit sinks), L2 = optional Ghidra patch-verify, L3 = optional eBPF; honest Unknown fallbacksJobs/ReachabilityEvidenceJobExecutor.cs l.79–157, 268–406
Default DI dormant (Null providers) — PARTIALLY WIRED 2026-06-10 (snapshot provider + symbol bridge real; evidence storage/Ghidra/eBPF still null defaults)Was: Reachability/ServiceCollectionExtensions.cs l.52–76. Now: WorkerCallGraphSnapshotProvider + ICallGraphSnapshotRegistry (Jobs/WorkerCallGraphSnapshotProvider.cs), AffectedSymbolCveSymbolMappingService (Services/), Worker HTTP adapter ConcelierHttpAffectedSymbolProvider; tests WorkerCallGraphSnapshotProviderTests, AffectedSymbolCveSymbolMappingServiceTests; end-to-end floor proof Phase0CveSinkFloorEndToEndTests (§7.1 Phase 0 exit criterion, in-process)
Deterministic BFS with explicit sink setsCallGraph/Analysis/ReachabilityAnalyzer.cs l.50–119 (WIT-007B)
Concelier AffectedSymbol projection wired at observation upsert; Postgres store; fail-closed defaultConcelier.Core/Signals/AffectedSymbol.cs, IAffectedSymbolProvider.cs; PostgresAdvisoryObservationStore.cs l.64–72; UnsupportedAffectedSymbolStore.cs
Symbol extractor missed nested Go vulndb / RustSec shapes (FIXED 2026-06-10, §3.5 #4)Was: IAffectedSymbolProvider.cs l.589–649 (flat field probe, no descent); OSV DTO preserves ecosystem_specific (OsvVulnerabilityDto.cs l.88). Now: ExtractGoImportSymbols/ExtractRustAffectsFunctions in the same file + ExtractionShape provenance tag; tests OsvAffectedSymbolExtractorTests
Canonical advisory model = ranges only (correct)Concelier.Models/Advisory.cs l.157–230 (AffectedPackage, no symbol field)
Patch-diff pipeline built out for D6 managed lanesScanner.VulnSurfaces/ (downloaders ×6, fingerprinters ×6, MethodDiffEngine, TriggerMethodExtractor, internal graph builders ×4, Postgres repo, VulnSurfaceService confidence ladder l.59–93; Ruby/PHP lexical lanes intentionally do not add internal graph builders yet)
Witness/PoE/slices/signing primitives complete; claim confirmation composition partialWitnesses/PathWitness.cs (path_hash, node_hashes, claim_id, observation_type), WitnessMatcher.cs (not consumed by production code), SuppressionWitness*, Slices/ (DSSE signer, verdict computer, replay), SUBGRAPH_EXTRACTION.md; FunctionMap/Verification/ClaimVerifier.cs verifies node-hash coverage and has no claim-ID field
Build-time referrer: whole compact graph, stub-guard, no readerSbom.BuildTime/ReachabilityCallGraphGenerator.cs (compact-v2+gzip l.307–394, stub-guard l.480–486); invoked from Cli/Commands/Sbom/SbomAttachCommand.cs via BuildTimeOciAttachService
Scoring lattice live in Signals (v0 buckets, v1 design)docs/modules/reach-graph/guides/lattice.md
CVE→symbol source ladder already documenteddocs/modules/reach-graph/guides/cve-symbol-mapping.md (surfaces > linksets > offline bundles)