Graph Architecture

Derived from Epic 5 – SBOM Graph Explorer.

The Graph module is the generic SBOM dependency/service graph store for the Stella Ops release control plane. It ingests SBOMs, advisories, VEX statements, and asset observations into a content-addressed node/edge model, then serves query, path, diff, lineage, overlay, and export surfaces to the Console, CLI, and SDK. This document captures the core model, ingestion pipelines, API surface, storage, and observability as implemented.

Scope note. This module (src/Graph/) owns the generic dependency/service graph store — nodes, edges, overlays, lineage, and the asset registry — and, since SPRINT_20260722_023 GRA-10 (2026-09-08), the reachability subgraph CAS as well. src/ReachGraph/ was a separate deployable (reachgraph-web) that consumed Graph data; GRA-4 lifted its HTTP surface into src/Graph/__Libraries/StellaOps.ReachGraph.Application, GRA-9 composed it onto graph-api and moved the family onto one database, and GRA-10 deleted the old host, its compose service block and its publish key. One deployable, one database (stellaops_graph, schemas graph + reachgraph), and the direct reachgraph.stella-ops.local alias now resolves to graph-api. The module dossier for the reachability model itself is docs/modules/reach-graph/. The graph:simulate scope and AI-assisted recommendations referenced below are roadmap items not yet enforced by this service.

0) Components

Shipped as a single container (stellaops-graph-api, slot 20). Cartographer (formerly slot 21) has been merged in; cartographer.stella-ops.local is now a network alias on this container. The module is composed of:

1) Core model

2) Pipelines

  1. Ingestion: SBOM Service emits SBOM snapshots (sbom_snapshot events) captured by the Graph Indexer (now hosted inside graph-api; Cartographer merged). Ledger lineage references become SBOM_VERSION_OF + SBOM_LINEAGE_* edges. Advisories/VEX from Concelier/Excititor generate edge updates, policy runs attach overlay metadata. Asset source observations from SBOM scans, integration profiles, runtime services, and plugin catalogs normalize through the Graph-owned asset indexer pipeline into asset nodes, append-only asset events, and ASSET_VERSION lineage edges. Production asset source subscriptions poll normalized *.asset-source-batch.json files from Graph:AssetSourceSubscriptions:SpoolDirectory on a default and capped 30-minute interval, then process them through AssetIndexProcessor so source services do not write Graph tables directly.
  2. ETL: Normalises nodes/edges into canonical IDs (GraphIdentity.ComputeNodeId/ComputeEdgeId — tenant-scoped, kind-prefixed, Base32-Crockford SHA-256 of the sorted identity tuple; gn:/ge: prefixes), deduplicates, enforces tenant partitions, and writes document-JSON adjacency rows to PostgreSQL (graph.graph_nodes/graph.graph_edges). A graph-DB backend is a roadmap option, not shipped (see §4).
  3. Overlay computation: Roadmap. The intended design has batch workers build materialised views for frequently used queries (impact lists, saved queries, policy overlays) and store them as immutable blobs for Offline Kit exports. Today only inline, per-request overlays exist (Testing only — see §1 and §3.1); there is no durable overlay materialisation worker yet.
  4. Diffing: graph_diff jobs compare two snapshots (e.g., pre/post deploy) and generate signed diff manifests for UI/CLI consumption.
  5. Analytics (Runtime & Signals 140.A): background workers run Louvain-style clustering + degree/betweenness approximations on ingested graphs, emitting overlays per tenant/snapshot and writing cluster ids back to nodes when enabled.

3) APIs

3.1) Current runtime posture (Sprint 20260416_003)

3.2) Tenant and auth resolution contract (Sprint 20260222.058)

3.3) Edge Metadata Contracts

The edge metadata system provides explainability for graph relationships:

3.4) Localization runtime contract (Sprint 20260224_002)

3.5) Authorization scopes

The authorization policies use the scope sets in Security/GraphPolicies.cs and the typed GraphScopeRequirement; a request is admitted if it is authenticated and carries any scope in the set. RequiredScopes plus RequireAllScopes=false give Router discovery the same OR requirement rather than an opaque assertion that cannot be published:

PolicyAccepted scopesUsed by
Graph.ReadOrQuerygraph:read or graph:querysearch, lineage, edge metadata (single/batch/by-reason/by-evidence), asset list/query/get, compatibility GETs
Graph.Querygraph:queryquery, paths, diff, edges-on-path, compatibility /paths, saved-view create/delete
Graph.Exportgraph:exportexport (start + download), asset export, compatibility /graphs/{id}/export
Graph.AssetRegistryProjectionReadgraph:asset-registry:read-allcross-tenant asset-registry projection feed; ordinary Graph scopes do not admit this feed
Graph.HealthReadops.healthDoctor operational-health checks

The RAR-7 source prerequisite is verified against 2942a54b194b177a5c2762db84b9b89736410ce2 by GraphRouterAuthorizationTests in src/Graph/__Tests/StellaOps.Graph.Api.Tests: it invokes the production Router dispatcher, proves denial before repository access for anonymous, insufficient-scope, missing-tenant and conflicting-tenant requests, verifies both accepted read scopes independently, and checks discovery plus direct-only Cartographer exclusions. Re-run pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj -Class '*GraphRouterAuthorizationTests' -BuildProjectReferences -Restore. The landing and runtime acceptance belong to the RAR-7 sprint receipt; local conformance does not retire the gateway rewrite or establish live acceptance.

4) Storage considerations

4.1) Canonical local replay boundary (RAR-7)

The opt-in local Graph chain is docker-compose.stella-services.yml → docker-compose.graph.yml → docker-compose.local-graph.yml → existing networks. It keeps the Graph-owned database variables, preserves the external network aliases and pins the exact rollback image. A docker-compose.local-graph-reset.yml used to sit second, directly after the base file, to !reset the inherited generic connection — the position mattered because Compose ignores a !reset placed after an intermediate environment merge. It is gone: GRA-9 deleted the key it reset, and GRA-10 retired the file on 2026-09-08 once graph-api had been recreated on a chain that no longer names it. Guarding the ABSENCE of a generic connection replaced resetting it (GraphConsolidationConformanceTests.ComposeOverlay_IsWiredByDesign_AndAnOverlayLessBringUpFailsClosed). The manifest local-graph-runtime.json remains fail-closed until two later facts exist: a clean exact-main candidate image, and a stopped-container capture of the current DataProtection key ring at its existing /var/lib/stella/.aspnet/DataProtection-Keys destination. No filename, payload or inventory is invented in source preparation.

Process-local Cartographer job loss is owner-accepted for this replacement. The acceptance does not include DataProtection keys, PostgreSQL rows or mounted inputs. The replacement is also the host’s first move onto its owner database (stellaops_graph, owner ruling 2026-09-02): the manifest’s ownerDatabaseAdoption record plus a live zero-row proof over every graph.* table in the shared stellaops_platform database are the only way the controller admits that connection change. The preservation/replay helper therefore refuses candidate and recovery phases until both immutable image generations and the key-ring inventory are bound. /api/graphs/builds and /api/graphs/overlays stay direct-only SDK contracts: the host fails startup if configuration attempts to republish excluded paths, and the canonical profile does not weaken that guard.

The execution controller fails closed beyond aggregate counts: the committed capture must name the sorted, fixed-shape DataProtection XML entries and their individual hashes, and every pre-stop, stopped-copy, ACL, restart and post-recreate observation must match exactly. Candidate admission binds immutable image ID and RepoDigest, linux/amd64, uid10001, clean buildinfo whose source is HEAD or an ancestor of HEAD with unchanged candidate build inputs, the rendered model, protected .env, exact owner DSN/Redis continuity and Doctor-disabled posture. Process/security/health/ports/capabilities, target aliases, physical networks and every peer network attachment are compared before and after. The only Docker authority is the explicit local npipe; ambient context/host/TLS configuration is rejected. Mutation receipts are reserved and fsynced before execution as separate immutable intent/executing/helper-ready/final records; a failed final write cannot erase executing intent. DataProtection capture rechecks source/estate/target/inventory after that intent and immediately before stop. Candidate uid10001 access is not part of normal preflight: a dedicated reviewed access phase verifies and removes an exact networkless candidate helper and bounded marker, then normal preflight consumes its hash-bound completion without launching anything. Rollback accepts a successful forward or a failed forward that positively installed the exact candidate still present with unchanged immutable identity/configuration. Lifecycle drift is ignored; a now-missing but positively recorded candidate needs an additional explicit recovery flag. Baseline/no-replacement receipts never authorize rollback.

Ambiguous helper creation is not absence: timeout/nonzero/no-output paths resolve the unique helper name and returned ID, validate its complete security/mount/image identity and remove only that exact object. Marker cleanup requires one regular, single-link byte equal to literal x by SHA-256. Forward receipts embed the access-proof hash, so delayed rollback remeasures current host custody/image without requiring the old baseline-only access receipt to remain fresh. Crash recovery from a surviving .executing.json is a separate explicit mode and only proceeds while the exact candidate is installed and engine, peers, model/environment, both DP inventories, the baseline-bound runtime settings and physical network IDs, and the manifest-derived candidate Compose chain still match; it cannot stand in for the missing-container guard.

For the later ready pin, build identity and deployment-manifest identity are separate only when a machine-recomputed digest proves the complete explicit Graph image build-input closure byte-identical between the clean candidate SHA and descendant manifest HEAD. The intervening diff is restricted to the exact canonical Graph pin/evidence/docs set; no Dockerfile, project, product/shared source or build helper may differ. Both SHAs and the closure digest are receipt-bound. Pending state continues to require literal sourceSha == HEAD and does not claim this exception yet.

5) Offline & export

6) Observability

7) Rollout notes

Local testing note

Set STELLAOPS_TEST_POSTGRES_CONNECTION to a reachable PostgreSQL instance before running tests/Graph/StellaOps.Graph.Indexer.Tests. The test harness falls back to Host=127.0.0.1;Port=5432;Database=stellaops_test, then Testcontainers for PostgreSQL, but the CI workflow requires the environment variable to be present to ensure upsert coverage runs against a managed database. Use STELLAOPS_GRAPH_SNAPSHOT_DIR (or the AddSbomIngestPipeline options callback) to control where graph snapshot artefacts land during local runs.

Refer to the module README and implementation plan for immediate context, and update this document once component boundaries and data flows are finalised.