Graph + ReachGraph consolidation — S0 target design (SPRINT_20260722_023 GRA-1)
Status: APPROVED (owner, 2026-08-03, ledger round 88 ruling C). Verification base: the sprint’s pinned consumer digs (2026-07-23 + round 19), the live migration homes (re-enumerated that pass), and the 2026-08-03 build-boundary register.
CORRECTIONS APPLIED 2026-08-04 (GRA-3 implementation). Building the baseline found two factual errors and one missing discovery path in the approved text. They are corrected inline below and marked [GRA-3 correction]; the design’s decisions are unchanged, only facts the decisions rested on. Summary:
ID Was Is D-GRA3-1 “ReachGraph zero-Postgres posture CONFIRMED … vestigial Npgsql PackageReference with zero source usage … no persistence project exists” src/__Libraries/StellaOps.ReachGraph.Persistenceis a full EF Core + Npgsql persistence library withMigrations/001_reachgraph_store.sql(3 tables + RLS), referenced by the WebService, migrated on its startup and registered in the central migrator. The S0 dig searchedsrc/ReachGraph/; the library lives undersrc/__Libraries/.D-GRA3-2 two duplicate pairs enumerated ( graph_idempotency/idempotency_tokens,graph_snapshots/pending_snapshots)three — graph_analyticsis the unread twin of the typedcentrality_scores+cluster_assignmentspair. Plusgraph_nodes/graph_edges, which are not a pair but a column union.D-GRA3-3 migration homes described as three, with graph-api merely calling the Indexer migrator graph-api is itself a migration home: GraphSavedViewsMigrationHostedServicestands up a startup-migration host overtypeof(Program).Assembly, which is the only reasonGraph.Api/Migrations/003_saved_views.sqlapplies. Five deployed discovery paths, not three.
1. Current → target shape
| Current | Target | |
|---|---|---|
| Deployables | graph-api (+ indexer role), reachgraph-web | one Graph family service (S1 already approved the merge, 2026-07-22; Signals stays SEPARATE — restated) |
| Persistence | TWO live DDL homes (§2 hazard): Graph.Api/Migrations (graph.* schema) and Graph.Indexer.Persistence/Migrations; the never-wired Graph.Core/migrations home was deleted at GRA-3 and its remaining source frozen under src/__Obsoleted/Graph/ at GRA-10 | one fresh baseline plus forward RLS hardening, own database + fail-closed env per the recipe |
| ReachGraph storage | reachability CAS (digest-addressed) + Valkey cache, backed by PostgreSQL — [GRA-3 correction D-GRA3-1] src/__Libraries/StellaOps.ReachGraph.Persistence holds an EF Core context, compiled models, PostgresReachGraphRepository and Migrations/001_reachgraph_store.sql (subgraphs, slice_cache, replay_log + RLS). reachgraph-web references it and calls AddStartupMigrations on it prefix-free; platform-web registers ReachGraphMigrationModulePlugin for it too (plugin removed at source 2026-08-24, 133c6a51f8; the plugin mechanism itself deleted 2026-09-14, SPRINT_20260722_021 PLT-4). The WebService’s direct Npgsql PackageReference is redundant (zero direct usage in its own .cs), but Npgsql arrives transitively via the persistence project — so it is not “vestigial with no persistence behind it”, and it dies by deletion at GRA-10 with the host, not by an S3 csproj edit to a live host | CAS semantics preserved verbatim, DDL and RLS carried into the consolidated baseline |
2. Table inventory + the multi-home hazard
Re-enumerated this pass — the migration homes overlap. [GRA-3 correction] the two bullets below describe DDL lineages, not the homes that ship them; the actual file layout on disk is: the Graph.Api home holds only 003_saved_views.sql, the Graph.Core home held only 003_cve_observation_nodes.sql (dead, deleted at GRA-3), and everything else in both lineages lives in the single collapsed Graph.Indexer.Persistence baseline (third bullet). A fourth home, ReachGraph.Persistence/Migrations/001_reachgraph_store.sql, was missed entirely — see D-GRA3-1.
graph.*lineage:graph_nodes,graph_edges,centrality_scores,cluster_assignments,idempotency_tokens,pending_snapshots.- Schema-less lineage:
graph_nodes,graph_edges,graph_snapshots,graph_analytics,graph_idempotency,saved_views,cve_observation_nodes,asset_registry_events. reachgraph(ReachGraph.Persistence home, [GRA-3 correction D-GRA3-1] — absent from the approved inventory):subgraphs,slice_cache,replay_log, all three under row-level security keyed onapp.tenant_id.- Graph.Indexer.Persistence: ENUMERATED (2026-08-03) — its
001_v1_graph_indexer_baseline.sqlcarries BOTH lineages: thegraph.*set (graph_nodes,graph_edges,centrality_scores,cluster_assignments,idempotency_tokens,pending_snapshots) AND the schema-less twins (graph_nodes,graph_edges,graph_analytics,graph_idempotency,graph_snapshots,asset_registry_events) — the Indexer baseline absorbed Graph.Core’s DDL, which is HOW the live estate ended with one physical lineage plus duplicate pairs.
The S0 headline — RESOLVED BY LIVE EVIDENCE (read-only catalog query, 2026-08-03): the live estate runs ONE physical lineage — both DDL homes converged into the graph schema (the schema-less Core migrations applied with search_path=graph), leaving DUPLICATE PAIRS as separate live tables (graph_idempotency vs idempotency_tokens; graph_snapshots vs pending_snapshots) — and every one of the ten live graph tables holds ZERO rows; cve_observation_nodes never applied at all — unwired home IDENTIFIED (2026-08-03): Graph.Core/migrations is entirely DEAD — no EmbeddedResource wiring, no AddStartupMigrations registration anywhere (the only wired migrator is Graph.Indexer.Persistence, whose baseline absorbed Core’s earlier DDL); 003_cve_observation_nodes.sql, added to Core after the absorption, was never picked up by anything. The fresh baseline deletes the dead home outright. Consequence: the divergent-data fear is VOID — the fresh baseline collapses the lineages freely, picks ONE idempotency and ONE snapshot store, and the graph side of S9 has NO data to move (the ReachGraph CAS is the family’s only data). The asset_registry_events question is ANSWERED (010’s round-75 sweep): NOT twins — graph.asset_registry_events is THIS family’s raw upstream event log (written by AssetIndexingPipeline); findings.asset_registry_events is the 010 family’s projection SINK with different columns, whose source reads our table CROSS-SCHEMA (the findings ledger role holds SELECT on graph). At the database split that read becomes an event/API seam — coordinate 023×010; this family remains the producer/owner. Coordinate RESOLVED in source (2026-08-26): graph-api now serves the family’s asset-registry catch-up feed (GET /graph/asset-registry/events — global (recorded_at, event_id) order, exclusive cursor, origin-derived stream epoch, byte-bounded pages; wire shapes in the closed StellaOps.Graph.Contracts leaf), admitted ONLY by the dedicated machine scope graph:asset-registry:read-all (Q-23 shape; ordinary graph:read is refused). The consolidated findings-web consumes it behind a default-off gate; the predecessor keeps the same-database read until FND-10. Contract doc: asset-registry-feed-contract. The live NO-GO in GRA-9 resolves its SOURCE half here; deployment + live proof remain window work.
[GRA-3 correction D-GRA3-2] — the pairs are THREE, plus a column union. Implementing the collapse re-read every reader in src/ and found the design under-counted:
| Pre-merge relation | Fate | Why |
|---|---|---|
graph_idempotency | DROP | idempotency_tokens is what PostgresIdempotencyStore and PostgresGraphDocumentWriter write (INSERT INTO graph.idempotency_tokens). |
graph_snapshots | DROP | pending_snapshots is what PostgresGraphSnapshotProvider upserts, drains and deletes from, and is the canonical change feed PostgresGraphRuntimeRepository reads. |
graph_analytics | DROP — not enumerated by the approved design | the live PostgresGraphAnalyticsWriter writes the TYPED pair centrality_scores + cluster_assignments. The generic metric-row table has no reader at all (only a legacy migration test naming it). |
graph_nodes, graph_edges | column union, resolved to the EF-repository shape | not duplicated as separate relations: both lineages named the same relation, so the second CREATE was a no-op under IF NOT EXISTS and the follow-up ALTER bolted the other shape’s columns on. Only id/batch_id/document_json/written_at have readers (GraphIndexerDbContext maps exactly those; both Postgres graph repositories read tenancy from document_json->>'tenant'), so tenant_id/node_type/data/created_at do not carry. |
Dispositions: nodes/edges = operational graph store (carry, ONE lineage); centrality/cluster = derived-rebuildable (carry with rebuild paths — re-queue the snapshot and re-run analytics — index budget per P13); idempotency/pending = ephemeral, classed cache-bounded; asset_registry_events = append-audit (this family PRODUCES it); saved_views = user data (carry, source-of-truth) — note its DDL lived in the graph-api host assembly pre-merge and moves into the family DAL. Reachability CAS: subgraphs = source-of-truth (a dropped subgraph cannot be recomputed without the original scan inputs), slice_cache = cache-bounded, replay_log = append-audit.
GRA-9 owner-RLS correction: migration 001_graph_consolidated_baseline.sql enables RLS and creates the three tenant policies, but PostgreSQL table owners bypass ordinary RLS even when the role is NOSUPERUSER NOBYPASSRLS. The same service role may converge and serve the database, so 002_force_reachgraph_tenant_rls.sql is an appended forward migration that FORCEs RLS on all three CAS tables. The Doctor requires both ledger rows and reports any unforced table Critical; a real PostgreSQL proof migrates and serves with the same table-owning role and verifies a tenant-A session cannot read tenant-B’s subgraph. Migration 001 remains unchanged. cve_observation_nodes — DROP COMPLETE (dead substrate, dig-verified 2026-08-03; frozen 2026-08-24): its migration home was embedded by NOTHING, so the table never applied anywhere. GRA-3 deleted the dead migration, GRA-4 deleted the zero-consumer repository pair, and GRA-10 moved the remaining model/self-test source to src/__Obsoleted/Graph/ while removing all live build and test routing. The consolidated baseline does not carry the table.
3. Pinned contracts (break = silent data loss)
- Cartographer compat —
CartographerWebhookClient.cs(+GraphJobService) drive/api/graphs/builds+/overlays; the 012 merge carries this client, so route stability spans BOTH programs. [GRA-4 correction 2026-08-05] the client is no longer atScheduler.WebService/GraphJobs/— 012’s own S4 moved it tosrc/JobEngine/StellaOps.JobEngine.__Libraries/StellaOps.JobEngine.Scheduling/GraphJobs/. The wire contract is unchanged; only the consumer’s source home moved, and GRA-4 verified the graph-api endpoints it calls are untouched. - ReachGraph readers —
AdvisoryAI/Chat/ReachabilityDataProvider,BinaryIndex.Analysis/ReachGraphBinaryReachabilityService(the latter retires with the 014 fold — sequencing note). [GRA-5 correction 2026-08-05 — where these contracts actually live] The approved text implies all three are protected by holding routes stable. Two of them are not gateway-routed at all:CartographerWebhookClientcalls graph-api directly atScheduler__Worker__Graph__Cartographer__BaseAddress: "http://graph.stella-ops.local"(docker-compose.stella-services.yml:2298), andHttpReachGraphPublishercalls reachgraph-web directly atSTELLAOPS_REACHGRAPH_URL/http://reachgraph:8080. The load-bearing dependency is the compose network ALIAS, not a route — GRA-8 owns it. Sharper:^/api/graph(.*)maps/api/graphs/buildsonto/graphs/builds, which the real host resolves toMapGet("/graphs/{graphId}")withgraphId="builds"(400 GET / 405 POST), so the Cartographer paths are gateway-mis-routed today, not merely unrouted. Consequently the GRA-5 route swap cannot break either contract, and no route test can prove either one; both facts are pinned instead byGraphConsolidatedRouteConfigTests.CartographerCompat_IsNotGatewayServedToday_SoTheSwapCannotBreakItand by a live probe of the real host (POST /api/graphs/builds→ 200).
GRA-9 source prerequisite (2026-08-23): graph-api now maps the carried Graph and ReachGraph controllers first, then publishes their canonical /api/graph/v1/* forms as real ASP.NET RouteEndpoint aliases before Router refreshes its HELLO registration. The aliases preserve the owning handler and metadata; a middleware rewrite is intentionally not used because Router dispatch bypasses middleware. This discharges the host-side remapping obligation below without changing live routes: the gateway swap, Console graph prefix flip and direct-writer alias move remain window work.
- ReachGraph WRITER (X20) —
Scanner.Worker/HttpReachGraphPublisherpostsReachGraphMinimaltoPOST /v1/reachgraphs(gated byPublishToReachGraph+ base URL). The merged service preserves the POST contract VERBATIM and keeps thereachgraphcompose network alias through the transition; Scanner’s base-URL repoint rides its next deploy (with 017). A broken publisher is SILENT loss (scans succeed, graphs never arrive) — the S8 forcing function is a live scan whose subgraph is then queryable via the merged API. Contract tests pin all three surfaces BEFORE any route change. MBI-5 now carries that wire shape in the producer-owned BCL-onlysrc/__Libraries/StellaOps.ReachGraph.Contractsproject: Scanner compiles the contract, while ReachGraph hashing, canonical serialization, deduplication and signing stay in the implementation project. The originalStellaOps.ReachGraph.Schemanamespace is unchanged. Retired 2026-09-14 (SPRINT_20260914_001 VRP-5): the writer was measured unarmed in every estate (PublishToReachGraphunset, default false; noreachgraphcontainer; host deleted at GRA-10) and deleted at source together with its options, its analysis key and the Worker’sReachGraph.Contractsreference. Thereachgraphalias now serves only theSTELLAOPS_REACHGRAPH_URLpointer; X20 in the ownership matrix is CLOSED.
4. Build-boundary + platform hygiene
graph-apiandreachgraph-webcarry zero foreign pins (both clean) — the merge’s boundary risk is only what it might ADD; keep the graphs closed. (GRA-3 confirmed: the newStellaOps.Graph.Persistencereferences onlyInfrastructure.Postgresand enters no deployable key’s closure, so the boundary report was byte-unchanged — 208 pairs, 0 unpinned, 0 grown.)- DC-26: delete the
graph+reachgraphcentral-migrator plugins in the same change as the fresh baseline. [GRA-3 deferral D-GRA3-4] Both plugins are still registered inPlatform.Database/MigrationModulePlugins.cs(GraphMigrationModulePlugin,ReachGraphMigrationModulePlugin), and GRA-3 did not delete them — following the JobEngine sibling (JOB-3), not the Findings/AdvisoryAI ones whose plugins PAC-6/PAC-6b had already removed. Deleting them while the legacy schema is still the system of record removes a live migration path for no benefit: the fresh baseline is staged in a new assembly the central migrator cannot reach, so there is nothing to race. The deletion rides the GRA-9 cutover. 2026-09-14 (SPRINT_20260722_021 PLT-4, DC-26): both plugins had already been removed at source (133c6a51f8), and the wholeIMigrationModulePluginmechanism — discovery, registry,ReleaseMigrationRunnerand thestella system migrations-*CLI — is now deleted, so path 5 below no longer exists.
[GRA-3 correction D-GRA3-3] — FIVE deployed discovery paths, not three. The staging invariant depends on enumerating every migrator that could pick up a new .sql, and graph-api turns out to be its own migration home:
graph-api→AddGraphIndexerPersistence→AddStartupMigrations(typeof(GraphIndexerDataSource).Assembly), no resource prefix (GraphIndexerPersistenceExtensions.cs:29,:54).graph-api→GraphSavedViewsMigrationHostedService→ a host overtypeof(GraphIndexerPersistenceExtensions).Assembly(ledger moduleGraph.Persistence), no prefix.graph-api→ the same hosted service → a host overtypeof(Program).Assembly(ledger moduleGraph.Api), no prefix. This is whyGraph.Api/Migrations/003_saved_views.sqlapplies at all, and it means adding any.sqlunder the graph-api host converges the shared platform database on the next rebuild.reachgraph-web→AddStartupMigrations(typeof(ReachGraphDataSource).Assembly), no prefix. During the GRA-9 two-role window the retained host explicitly declaresReachGraph:ConsolidatedTarget:Enabled=trueandReachGraph:StartupMigrations:Enabled=false. A guard registered before the migration host requires that pairing exactly when the connection database isstellaops_graph, so the predecessor cannot create a secondreachgraph.schema_migrationsledger on the target.platform-web→ AppDomain scan forIMigrationModulePlugin→ both plugins above. (Deleted 2026-09-14, PLT-4: the scan and the plugin contract no longer exist.)
Paths 1–4 bind their migrations assembly BY TYPE, so they can only ever scan the three legacy assemblies; path 5 needs a plugin, and the new assembly declares none. Four live ledger module rows collapse into one (graph.schema_migrations, module Graph.Consolidated) at the cutover.
- Hostname aliases INVENTORIED (2026-08-03):
cartographer.stella-ops.localis a backwards-compat network alias ONgraph-api(Slot 21 merge absorption; stella-services.yml only — the flagship compose has neither host). Both family containers RUN live (graph-api,reachgraph-web) — the merge consolidates two live services; the merged service keeps BOTH compat aliases (cartographer.…,reachgraph— the X20 publisher base-URL) through the transition per the GRA-1 contract notes.
5. What the S0 review must settle
- Which DDL lineage the live DB runs per overlapping table (§2) — the baseline-collapse input. SETTLED (live catalog query 2026-08-03 + the GRA-3 reader sweep): one physical lineage, three duplicate pairs and one column union, each resolved to the side with a named reader — §2.
ReachGraph’s zero-Postgres posture confirmation.ANSWERED THE OTHER WAY (GRA-3, D-GRA3-1): the posture is not zero-Postgres. ReachGraph owns three PostgreSQL tables under RLS and a live startup migrator. There was no zero-Postgres posture to confirm.asset_registry_eventsrelationship to the 010 family’s identically-named feed. SETTLED by 010’s round-75 sweep: this family is the producer/owner; findings holds a differently-shaped projection sink that reads ours cross-schema. That read becomes an event/API seam at the split (023×010). The GRA-3 baseline’s criterion sweep asserts the baseline never namesfindings.. Still a GRA-9 live NO-GO: source currently retains the directFROM graph.asset_registry_eventsreader, so the database repoint cannot execute until the successor seam (or an owner-approved fail-closed freeze) lands. This program does not hide the edge by granting cross-database access.- Fold order vs 012 (shared Cartographer contract) and 014 (retiring reader).
6. Gateway surface — the consolidated group (GRA-5, 2026-08-05)
Target group: ^/api/graph/v1(.*) → http://graph.stella-ops.local/api/graph/v1$1 (Microservice, regex), replacing ^/api/v1/reachability(.*) and ^/api/graph(.*). Prepared, not installed — DC-19 puts the swap in the GRA-9 window. The manifest (tools/scripts/gra5-graph-route-swap.json) is the one source of truth; the swap script and GraphConsolidatedRouteConfigTests both read it, and docs/runbooks/graph/graph-cutover-gra5-route-swap.md is the window procedure.
This family’s gateway surface is almost entirely absent, and that is the design-relevant fact. Two routes, against nine for Findings and ten for JobEngine. What the route table does not show:
- Both direct contracts (above) bypass the gateway.
- The reachability CAS and all nine CVE-mapping endpoints have never had a route.
/api/v1/reachgraphsand/api/v1/cve-mappings/*fall to^/api/v1/([^/]+)(.*)and targetreachgraphs.stella-ops.local/cve-mappings.stella-ops.local— microservice names nothing registers. The consolidated group gives them a route for the FIRST time. It does not retroactively fix the legacy paths, so consumers must move. ^/api/v1/reachability(.*)is a route to nowhere. It translates to/api/v1/reachability$1while the controller is mounted atv1/reachability(ReachabilityController.cs:13), and the gateway forwards the translated path verbatim. Live probe: every/api/v1/reachability/*form 404s on the real host; the un-prefixed forms answer.- Four CompatibilityEndpoints (
/search,/paths,/assets/{id}/snapshot,/nodes/{id}/adjacency) exist on the host and answer, but no external path reaches them, because^/api/graph(.*)can only produce targets beginning/graph.
Ordering hazard for GRA-9: /api is a non-regex ReverseProxy prefix route to platform-web (index 199) and it matches /api/graph/v1/*. The group must be inserted above it; appending it hands the whole plane to platform-web, which answers rather than failing visibly.
Owed by GRA-7 before the window — DISCHARGED (2026-08-05). Nothing is mapped under /api/graph/v1 today (probed: 404), and the two hosts collide under one prefix — graph-api’s /graphs versus the lifted v1/reachgraphs — so the sub-prefixes had to be chosen deliberately rather than left to registration order. The decision lives in the swap manifest under subPrefixMap (same file the swap script and the guards read, so the routing contract cannot disagree with itself): nine planes, each with a distinct first segment, reachgraphs a sibling of graphs rather than nested under it (nesting would put X20’s POST target behind /graphs/{graphId}, the capture the live probe already observed), no surface renamed at the fold, and the Cartographer pair excluded because it is a direct compose-alias contract. Guarded by GraphSubPrefixMapTests (6), which also greps the named sources so a deleted or renamed endpoint cannot leave a plane behind. The host-side re-mapping onto those tails is GRA-9’s; the deploy-side procedure is docs/runbooks/graph/graph-deploy-gra8-staged-stack.md.
Reachability-delta seam: ruled at GRA-5 and dropped (D-GRA5-1). IReachabilityDeltaService, InMemoryReachabilityDeltaService and Contracts/ReachabilityContracts.cs are deleted; the live lineage is Scanner’s (ReachabilityDriftEndpoints.cs), and forking it here would have violated the ledger’s evolve-don’t-fork rule. The two external TODOs naming the interface are retired at GRA-10.
