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:

IDWasIs
D-GRA3-1“ReachGraph zero-Postgres posture CONFIRMED … vestigial Npgsql PackageReference with zero source usage … no persistence project existssrc/__Libraries/StellaOps.ReachGraph.Persistence is a full EF Core + Npgsql persistence library with Migrations/001_reachgraph_store.sql (3 tables + RLS), referenced by the WebService, migrated on its startup and registered in the central migrator. The S0 dig searched src/ReachGraph/; the library lives under src/__Libraries/.
D-GRA3-2two duplicate pairs enumerated (graph_idempotency/idempotency_tokens, graph_snapshots/pending_snapshots)threegraph_analytics is the unread twin of the typed centrality_scores + cluster_assignments pair. Plus graph_nodes/graph_edges, which are not a pair but a column union.
D-GRA3-3migration homes described as three, with graph-api merely calling the Indexer migratorgraph-api is itself a migration home: GraphSavedViewsMigrationHostedService stands up a startup-migration host over typeof(Program).Assembly, which is the only reason Graph.Api/Migrations/003_saved_views.sql applies. Five deployed discovery paths, not three.

1. Current → target shape

CurrentTarget
Deployablesgraph-api (+ indexer role), reachgraph-webone Graph family service (S1 already approved the merge, 2026-07-22; Signals stays SEPARATE — restated)
PersistenceTWO 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-10one fresh baseline plus forward RLS hardening, own database + fail-closed env per the recipe
ReachGraph storagereachability 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 hostCAS 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.

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 relationFateWhy
graph_idempotencyDROPidempotency_tokens is what PostgresIdempotencyStore and PostgresGraphDocumentWriter write (INSERT INTO graph.idempotency_tokens).
graph_snapshotsDROPpending_snapshots is what PostgresGraphSnapshotProvider upserts, drains and deletes from, and is the canonical change feed PostgresGraphRuntimeRepository reads.
graph_analyticsDROP — not enumerated by the approved designthe 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_edgescolumn union, resolved to the EF-repository shapenot 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)

  1. Cartographer compatCartographerWebhookClient.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 at Scheduler.WebService/GraphJobs/ — 012’s own S4 moved it to src/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.
  2. ReachGraph readersAdvisoryAI/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: CartographerWebhookClient calls graph-api directly at Scheduler__Worker__Graph__Cartographer__BaseAddress: "http://graph.stella-ops.local" (docker-compose.stella-services.yml:2298), and HttpReachGraphPublisher calls reachgraph-web directly at STELLAOPS_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/builds onto /graphs/builds, which the real host resolves to MapGet("/graphs/{graphId}") with graphId="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 by GraphConsolidatedRouteConfigTests.CartographerCompat_IsNotGatewayServedToday_SoTheSwapCannotBreakIt and 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.

  1. ReachGraph WRITER (X20)Scanner.Worker/HttpReachGraphPublisher posts ReachGraphMinimal to POST /v1/reachgraphs (gated by PublishToReachGraph + base URL). The merged service preserves the POST contract VERBATIM and keeps the reachgraph compose 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-only src/__Libraries/StellaOps.ReachGraph.Contracts project: Scanner compiles the contract, while ReachGraph hashing, canonical serialization, deduplication and signing stay in the implementation project. The original StellaOps.ReachGraph.Schema namespace is unchanged. Retired 2026-09-14 (SPRINT_20260914_001 VRP-5): the writer was measured unarmed in every estate (PublishToReachGraph unset, default false; no reachgraph container; host deleted at GRA-10) and deleted at source together with its options, its analysis key and the Worker’s ReachGraph.Contracts reference. The reachgraph alias now serves only the STELLAOPS_REACHGRAPH_URL pointer; X20 in the ownership matrix is CLOSED.

4. Build-boundary + platform hygiene

[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:

  1. graph-apiAddGraphIndexerPersistenceAddStartupMigrations(typeof(GraphIndexerDataSource).Assembly), no resource prefix (GraphIndexerPersistenceExtensions.cs:29, :54).
  2. graph-apiGraphSavedViewsMigrationHostedService → a host over typeof(GraphIndexerPersistenceExtensions).Assembly (ledger module Graph.Persistence), no prefix.
  3. graph-api → the same hosted service → a host over typeof(Program).Assembly(ledger module Graph.Api), no prefix. This is why Graph.Api/Migrations/003_saved_views.sql applies at all, and it means adding any .sql under the graph-api host converges the shared platform database on the next rebuild.
  4. reachgraph-webAddStartupMigrations(typeof(ReachGraphDataSource).Assembly), no prefix. During the GRA-9 two-role window the retained host explicitly declares ReachGraph:ConsolidatedTarget:Enabled=true and ReachGraph:StartupMigrations:Enabled=false. A guard registered before the migration host requires that pairing exactly when the connection database is stellaops_graph, so the predecessor cannot create a second reachgraph.schema_migrations ledger on the target.
  5. platform-web → AppDomain scan for IMigrationModulePlugin → 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.

5. What the S0 review must settle

  1. 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.
  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.
  3. asset_registry_events relationship 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 names findings.. Still a GRA-9 live NO-GO: source currently retains the direct FROM graph.asset_registry_events reader, 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.
  4. 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:

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.