Graph Architecture
Verified against commit
942a18727con 2026-09-26 (UTC). Re-verify:git grep -n "AddHostedService\|AddReachGraphApplication\|SnapshotGraphChangeSource" -- src/Graphandls src/Graph/__Libraries/StellaOps.Graph.Persistence/Migrations.
Graph owns the tenant-scoped dependency and service graph, the asset registry and the reachability store. src/ is the source of truth; where this document and the code disagree, the code wins.
1. Components
src/Graph/
StellaOps.Graph.Api/ # the one host (graph-api): Program.cs, Endpoints/, Hosting/, Security/, Services/
StellaOps.Graph.Indexer/ # library composed by graph-api: Ingestion/, Analytics/, Incremental/, Assets/, Retention/
__Libraries/
StellaOps.Graph.Contracts/ # wire contracts (asset-registry event feed)
StellaOps.Graph.Indexer.Persistence/# Postgres repositories for the Indexer (no migrations registered by graph-api)
StellaOps.Graph.Persistence/ # the consolidated migration authority (graph + reachgraph schemas)
StellaOps.Graph.Operations/ # schema topology and Doctor checks
StellaOps.ReachGraph.Application/ # reachability controllers and services lifted from reachgraph-web
__Tests/
src/__Libraries/StellaOps.ReachGraph*/ # shared reachability model, hashing, serialization, cache, persistence
graph-api composes everything in src/Graph/StellaOps.Graph.Api/Program.cs. There is no separate Indexer or ReachGraph process.
2. Request path, identity and scopes
- Requests arrive through the Router (
AddRouterMicroservice, consumer groupgraphin the bundle) or directly on the service aliases. Security/GraphHeaderAuthenticationHandler.csauthenticates only from an already-authenticated principal (the Router dispatch path) or a gateway-signed identity envelope verified with the shared signing key. Raw tenant or scope headers are never an identity source; a request with neither source receives401 GRAPH_UNAUTHORIZED.- Tenant resolution is
GraphRequestContextResolver; a missing tenant claim is rejected. - Scope policies (
Security/GraphPolicies.cs, registered inProgram.cs):
| Policy | Scopes |
|---|---|
ReadOrQuery | graph:read or graph:query |
Query | graph:query |
Export | graph:export |
AssetRegistryProjectionRead | graph:asset-registry:read-all (the cross-tenant asset-registry feed only) |
HealthRead | ops.health (service Doctor endpoints) |
Authenticated | any authenticated principal (reachability controllers) |
- Reachability deletion (
DELETE /v1/reachgraphs/{digest}) additionally requiresgraph:admin(ReachGraphDestructiveActionAuthorizer). - Rate limits: the graph query routes use
RateLimiterService(120 requests per window, per tenant partition); the reachability controllers use thereachgraph-read(100 per minute) andreachgraph-write(20 per minute) fixed-window policies (Hosting/ReachGraphApplicationHostingExtensions.cs).
3. API surface
| Family | Routes | Source |
|---|---|---|
| Graph queries | POST /graph/search, /graph/query, /graph/paths, /graph/diff, /graph/lineage, /graph/export; GET /graph/export/{jobId} | Program.cs |
| Edge metadata | POST /graph/edges/metadata; GET /graph/edges/{edgeId}/metadata, /graph/edges/path/{source}/{target}, /graph/edges/by-reason/{reason}, /graph/edges/by-evidence | Program.cs |
| Asset registry | GET /graph/assets, POST /graph/assets/query, GET /graph/assets/{assetId}, POST /graph/assets/export | Endpoints/AssetRegistryEndpoints.cs |
| Asset-registry event feed | GET /graph/asset-registry/events (cross-tenant catch-up feed read by Findings) | Endpoints/AssetRegistryEventFeedEndpoints.cs, StellaOps.Graph.Contracts/AssetRegistry/AssetRegistryEventFeedContract.cs |
| Console compatibility | GET /graphs, /graphs/{graphId}, /graphs/{graphId}/tiles, /graphs/{graphId}/export, /search, /paths, /assets/{assetId}/snapshot, /nodes/{nodeId}/adjacency; saved views GET/POST /graphs/{graphId}/saved-views, DELETE /graphs/{graphId}/saved-views/{viewId} | Endpoints/CompatibilityEndpoints.cs |
| Scheduler build and overlay contract | POST/GET /api/graphs/builds[/{jobId}], POST/GET /api/graphs/overlays[/{jobId}] | Endpoints/CartographerEndpoints.cs |
| Reachability store | POST /v1/reachgraphs, GET /v1/reachgraphs/{digest}, GET /v1/reachgraphs/{digest}/slice, POST /v1/reachgraphs/replay, GET /v1/reachgraphs/by-artifact/{artifactDigest}, DELETE /v1/reachgraphs/{digest} | StellaOps.ReachGraph.Application/Controllers/ReachGraphController.cs |
| Unified reachability query | POST /v1/reachability/static, /runtime, /hybrid, /batch | Controllers/ReachabilityController.cs |
| CVE-to-symbol mapping | GET /v1/cve-mappings/{cveId}, /by-package, /by-symbol, /stats; POST /v1/cve-mappings, /analyze-patch, /{cveId}/enrich | Controllers/CveMappingController.cs |
| Operations | GET /healthz, build info, service Doctor endpoints | Program.cs |
Endpoints/GraphConsolidatedRouteAliases.cs publishes every family above a second time under the consolidated root /api/graph/v1 (for example /graph becomes /api/graph/v1/graph, /v1/reachgraphs becomes /api/graph/v1/reachgraphs) as real route endpoints, so the Router discovers them.
Behaviour notes verified in source:
- Graph builds (SPRINT_20261018_017 CCP-093): a build reads one SBOM version from SbomService’s tenant-scoped exact-document route for the authenticated principal’s tenant, verifies SbomService’s ledger digest (SHA-256 of the document’s compact JSON re-serialization, as SbomService derives
ledger_versions.digest; the route returns the stored first-writer bytes, whose plain hash is a different identity), maps the CycloneDX JSON (CycloneDxSbomSnapshotReader) and runs the SBOM ingest (SbomIngestProcessor), which writesartifact,sbomandcomponentnodes tograph.graph_nodes. The body carriessbomId(the SbomService subject reference),sbomVersionId(the ledger version id) andsbomDigest(the ledger digest, which the upload response returns asdigest); a body that namestenantIdortenantis refused with 400. The route requiresgraph:write, runs inside the request and answers with the terminal status:completedonly after the write, otherwisefailedwith a stable error code listed in the API reference. Another tenant’s SBOM is not visible to the owner read and failssbom_not_found. SPDX documents failsbom_format_unsupported. Build records are process-local and bounded to the 1,024 most recent builds; after a restart the status read answers 404 and the caller submits the build again (the ingest upserts the same node and edge ids). WithoutGraph:SbomOwnera build answers 501GRAPH_FEATURE_UNAVAILABLE. - The direct build paths stay excluded from Router publication and are reached on the
cartographer.stella-ops.localalias with a gateway-signed identity envelope. Their canonical aliases under/api/graph/v1/buildsare published, so an operator reaches a build through the gateway with a bearer token. The JobEngine scheduler worker still calls the direct path without an identity and with a body tenant, so its builds are refused until it authenticates (open follow-up in SPRINT_20261018_017). - The overlay endpoints are unchanged: they record a process-local job as
completedwithout running an overlay and take the tenant from the request body (open defect in SPRINT_20261018_017). - Outside the Testing environment, overlay, export and edge-metadata services without a Postgres-backed implementation are replaced by
Unsupported*services that answer with explicit unavailability instead of fabricated data (Program.cs,GraphFeatureUnavailableResults.cs). A tile read withincludeOverlays=truetherefore answers501 GRAPH_FEATURE_UNAVAILABLE(details.feature = "overlays") on those hosts, while the same tile without overlays answers. - Console consumer (Graph Explorer,
/security/sbom/graph; SPRINT_20261007_031 CCP-024): the explorer readsGET /graphs, then the first listed graph’s tile with overlays. On the501overlay answer above it reads the tile again without overlays and states the owner’s overlay message; the graph itself is not reported as unavailable. A401/403or a session withoutgraph:readrenders as denied (no retry); no response or a5xxrenders as unavailable with retry; an empty graph list, a graph whosestatusis notready, and a tile with no nodes each render their own stated empty state, never a blank canvas. The tile’shasExceptionattribute is shown as a flag only; the tile carries no exception record. The SBOM entry/security/sbomis the artifact-digest subject selection that opens SbomService’s lineage viewer (/security/lineage/<digest>); it no longer states that graph visualization is coming soon. - Console exception controls (CCP-077): Graph Explorer applies
policy:author, the scope of the Policy Engine exception create route, to both the Create Exception control and direct handler (corrected 2026-10-19 fromexceptions:write, which no create route enforces). The shared Full Wizard action and both Explorer placeholder handlers are removed; inline context and Cancel remain. See Explorer exception draft controls for the actual served-route boundary, verification source and explicitly retained limitations. - The committed OpenAPI document
openapi/v1.jsonand the generatedapi-reference.mdare captured from the host in process. They cover the minimal-API routes, the reachability controllers and the/api/graph/v1aliases of the minimal-API routes; the/api/graph/v1aliases of the reachability controllers are not in them. The retiredreachgraph-webspec is archived atdocs-archive/modules/reach-graph/openapi/v1.json.
4. Indexer pipeline (in-process)
| Stage | Registration | Notes |
|---|---|---|
| SBOM ingestion | AddSbomIngestPipeline; driven by graph builds (AddGraphSbomBuilds, section 3) | The file snapshot export is opt-in: it runs only when STELLAOPS_GRAPH_SNAPSHOT_DIR or SbomIngestOptions.SnapshotRootDirectory names a root; there is no default root. Without one, the ingest writes the graph rows, skips the export and logs that it did. With one, an export that fails fails the build, and every ingest overwrites the same snapshot file names under that root, whatever the tenant. |
| Inspector ingestion | AddInspectorIngestPipeline | Contract: contracts/graph.inspect.v1.md. |
| Analytics | AddGraphAnalyticsPipeline registers GraphAnalyticsHostedService | Writes graph.centrality_scores and graph.cluster_assignments. GraphAnalyticsOptions carry code defaults; no configuration section binds them. |
| Change stream and backfill | AddGraphChangeStreamProcessor registers GraphChangeStreamProcessor and GraphChangeStreamRuntimeGuardHostedService | See section 5. GraphChangeStreamOptions carry code defaults. |
| Asset-source subscriptions | AddGraphIndexerPersistence (via AddAssetSourceSubscriptionBridge) registers AssetSourceSubscriptionHostedService | Polls *.asset-source-batch.json batches from Graph:AssetSourceSubscriptions:SpoolDirectory. See asset-registry.md. |
| Ephemeral retention | AddGraphEphemeralRetention registers GraphEphemeralRetentionService | Section 7. |
| Configuration guard | GraphRuntimeConfigurationGuardHostedService (Services/GraphRuntimeFallbackServices.cs) | Fails startup when the Graph connection is missing outside Testing, and refuses further unsupported runtime configuration outside Development and Testing. |
5. Change source (CIC-2)
The change and backfill source is SnapshotGraphChangeSource, which reads the persisted snapshot queue graph.pending_snapshots in Graph’s own database. Program.cs registers it for both IGraphChangeEventSource and IGraphBackfillSource whenever the Graph connection resolves. The consolidated baseline creates the queue empty, so the same source serves a fresh install. Without the connection the processor falls back to a no-op source, which the runtime guard refuses outside Development and Testing. The Doctor check doctor.graph.indexer.change-source (Services/GraphChangeSourceDoctorCheck.cs) reports the composed source, whether the queue exists and its depth, and is Critical when the no-op source is composed.
6. Persistence
- Database
stellaops_graph; schemasgraphandreachgraph; connectionSTELLAOPS_POSTGRES_GRAPH_CONNECTIONorPostgres:Graph:ConnectionString(StellaOps.Graph.Operations/GraphSchemaTopology.cs).ConnectionStrings:Defaultis not a source. Outside Testing a missing connection fails the host. - Migration authority:
StellaOps.Graph.Persistenceonly (AddConsolidatedGraphPersistence, startup migrations).graph-apiregisters the Indexer repositories withregisterMigrations: false, and the ReachGraph composition registers no migrator.
| Table | Purpose |
|---|---|
graph.graph_nodes, graph.graph_edges | Graph documents; tenant carried in document_json (no row-level security in this schema) |
graph.pending_snapshots | Snapshot queue feeding the change stream |
graph.centrality_scores, graph.cluster_assignments | Analytics output |
graph.idempotency_tokens | Ingest deduplication |
graph.asset_registry_events | Append-only asset lifecycle events |
graph.saved_views | Console saved views |
reachgraph.subgraphs | Content-addressed reachability subgraphs; row-level security keyed on app.tenant_id |
reachgraph.replay_log | Replay verification audit |
Migrations (src/Graph/__Libraries/StellaOps.Graph.Persistence/Migrations/):
| File | Effect |
|---|---|
001_graph_consolidated_baseline.sql | Both schemas |
002_force_reachgraph_tenant_rls.sql | Forces tenant row-level security on the reachability tables |
003_graph_asset_registry_events_retention_class.sql | Retention class of the asset-registry events |
004_graph_idempotency_token_horizon.sql | Dedup horizon routine (RFC-15) |
005_graph_pending_snapshot_age_cap.sql | Snapshot-queue age cap routine (RFC-15) |
006_reachgraph_replay_log_window.sql | Replay-log audit window routine (RFC-15) |
007_graph_topology_retention_classes.sql | Topology retention classes |
008_reachgraph_retire_slice_cache.sql | Retires the unused reachgraph.slice_cache table (RFC-15, owner ruling Q-39); refuses if the table holds rows |
The reachability hot-path cache is Valkey through ConnectionStrings:Redis (ReachGraphValkeyCache, options section ReachGraphCache); outside Development and Testing a missing Redis connection string fails startup.
7. Retention
GraphEphemeralRetentionService (StellaOps.Graph.Indexer/Retention/), options section Graph:EphemeralRetention:
| Key | Default | Bounds |
|---|---|---|
IdempotencyHorizonHours | 24 | graph.idempotency_tokens |
PendingSnapshotAgeCapHours | 24 | graph.pending_snapshots rows the drain never reached |
ReplayLogRetentionDays | 365 | reachgraph.replay_log, keeping the newest row per tenant and subgraph digest |
BatchSize, MaxBatchesPerSweep, SweepInterval | 10000, 10, 1 hour | sweep limits |
It is registered only when the Graph connection resolves. The Doctor check doctor.graph.retention.ephemeral-backlog reports rows past these bounds.
8. Integrations
- Signals: the reachability query API reads runtime facts over HTTP (
SignalsHttpAdapter), base addressSignals:BaseUrlorSTELLAOPS_SIGNALS_URL, defaulthttp://signals.stella-ops.local. - Findings: reads the asset-registry catch-up feed with the
graph:asset-registry:read-allscope (HttpGraphAssetRegistryEventSourceinsrc/Findings). - Timeline: audit emission through
AddAuditEmission(bundleAuditEmission__TimelineBaseUrl). - Scheduler: calls the build and overlay contract on the
cartographer.stella-ops.localalias. Its build calls carry no identity and are refused (section 3). - SbomService: graph builds read the exact-document route
/api/v1/sbom/subject/{subjectRef}/document(queryversion) with a client-credentials token of the confidential clientstellaops-graph-sbom-reader(scopesbom:read), requested per build for the caller’s tenant. SettingsGraph:SbomOwner:Enabled(defaultfalse),BaseAddress,Authority,TokenEndpoint,ClientId,MaximumDocumentBytes(default 32 MiB),RequestTimeoutSeconds(default 60). The client secret comes only fromGRAPH_SBOM_READER_CLIENT_SECRET; an enabled host without it fails at startup. - Doctor registration with Platform is opt-in (
Doctor:Registration:Enabled); when enabled,Authority,ClientIdandClientSecretare required and must not be blank.
9. Doctor checks
| Check | Source |
|---|---|
doctor.graph.database.own-database-cutover | StellaOps.Graph.Operations/Doctor/GraphDoctorChecks.cs |
doctor.graph.reachgraph.cas-tenant-isolation | same |
doctor.graph.retention.ephemeral-backlog | same |
doctor.graph.indexer.change-source | StellaOps.Graph.Api/Services/GraphChangeSourceDoctorCheck.cs |
The standard service checks come from AddServiceDoctorChecks("graph-api"). The Doctor endpoints require ops.health.
10. Observability
Meter StellaOps.Graph.Api (Services/GraphMetrics.cs): graph_tile_latency_seconds, graph_export_latency_seconds, graph_query_budget_denied_total, graph_overlay_cache_hits_total, graph_overlay_cache_misses_total. The Indexer stages define their own meters (GraphAnalyticsMetrics, GraphBackfillMetrics, SbomIngestMetrics, PolicyOverlayMetrics, AdvisoryLinksetMetrics).
11. Retired surfaces
reachgraph-web: merged intograph-api(SPRINT_20260722_023 GRA-4, GRA-9, GRA-10). Host-era dossier archived atdocs-archive/modules/reach-graph/.cartographer: merged intograph-api; the alias remains on the container.src/__Obsoleted/Graph/: predecessor saved-view migration authority (EST-9) and the orphanedStellaOps.Graph.Coreprototype with its tests (GRA-10).
