From c17ffb6d59d04033b21b82d0c746471884f5d249 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 17:35:20 -0700 Subject: [PATCH 1/7] refactor: retire Go neighborhood reads --- .../web/scripts/testdata/grc-e2e-seed/main.go | 11 +- cmd/cerebro/aws_github_neo4j_live_e2e_test.go | 2 +- cmd/cerebro/github_findings_live_e2e_test.go | 16 +- cmd/cerebro/neo4j_live_test.go | 81 +++++ internal/attackpath/engine.go | 4 +- internal/findingdsl/test_suite.go | 2 +- internal/findings/service.go | 4 +- internal/graphstore/neo4j/store.go | 312 ------------------ internal/graphstore/neo4j/store_test.go | 42 --- internal/knowledge/service.go | 2 +- .../rust_organizational_platform_test.go | 18 + 11 files changed, 117 insertions(+), 377 deletions(-) diff --git a/apps/web/scripts/testdata/grc-e2e-seed/main.go b/apps/web/scripts/testdata/grc-e2e-seed/main.go index 131510cba5..f40add550d 100644 --- a/apps/web/scripts/testdata/grc-e2e-seed/main.go +++ b/apps/web/scripts/testdata/grc-e2e-seed/main.go @@ -88,9 +88,14 @@ func seedNeo4j(ctx context.Context) { for _, link := range links { must(store.UpsertProjectedLink(ctx, link)) } - neighborhood, err := store.GetEntityNeighborhood(ctx, adminURN, 10) + rows, err := store.ExecuteReadCypher(ctx, ports.CypherQueryRequest{ + Query: `MATCH (root:Entity {urn: $root_urn})-[relation:RELATION]-(neighbor:Entity) +RETURN count(DISTINCT neighbor) AS neighbors, count(relation) AS relations`, + Params: map[string]any{"root_urn": adminURN}, + RowLimit: 1, + }) must(err) - if neighborhood.Root == nil || len(neighborhood.Neighbors) < 2 || len(neighborhood.Relations) < 2 { - panic("seeded graph neighborhood is incomplete") + if len(rows) != 1 || fmt.Sprint(rows[0].Values["neighbors"]) == "0" || fmt.Sprint(rows[0].Values["relations"]) == "0" { + panic("seeded graph relations are incomplete") } } diff --git a/cmd/cerebro/aws_github_neo4j_live_e2e_test.go b/cmd/cerebro/aws_github_neo4j_live_e2e_test.go index c9f6bbae99..e4cc25706e 100644 --- a/cmd/cerebro/aws_github_neo4j_live_e2e_test.go +++ b/cmd/cerebro/aws_github_neo4j_live_e2e_test.go @@ -85,7 +85,7 @@ func TestAWSGitHubNeo4jSharedIdentityLiveE2E(t *testing.T) { t.Fatalf("graph counts = %#v, want non-zero nodes and relations", counts) } identityURN := "urn:cerebro:" + tenantID + ":identity:email:" + strings.ToLower(sharedEmail) - neighborhood, err := store.GetEntityNeighborhood(ctx, identityURN, 50) + neighborhood, err := readNeo4jLiveNeighborhood(ctx, store, identityURN, 50) if err != nil { t.Fatalf("GetEntityNeighborhood(%q) error = %v", identityURN, err) } diff --git a/cmd/cerebro/github_findings_live_e2e_test.go b/cmd/cerebro/github_findings_live_e2e_test.go index 0b2ef44dec..b9d47a1687 100644 --- a/cmd/cerebro/github_findings_live_e2e_test.go +++ b/cmd/cerebro/github_findings_live_e2e_test.go @@ -14,7 +14,6 @@ import ( cerebrov1 "github.com/writer/cerebro/gen/cerebro/v1" "github.com/writer/cerebro/internal/findings" - "github.com/writer/cerebro/internal/graphquery" "github.com/writer/cerebro/internal/ports" "github.com/writer/cerebro/internal/sourceops" "github.com/writer/cerebro/internal/sourceprojection" @@ -119,10 +118,7 @@ func TestGitHubDependabotFindingsEndToEndWithGHCLI(t *testing.T) { t.Fatalf("ResolveFinding().Status = %q, want resolved", resolved.Status) } - alertNeighborhood, err := graphquery.New(graphStore).GetEntityNeighborhood(ctx, graphquery.NeighborhoodRequest{ - RootURN: primaryResourceURN, - Limit: 20, - }) + alertNeighborhood, err := readNeo4jLiveNeighborhood(ctx, graphStore, primaryResourceURN, 20) if err != nil { t.Fatalf("GetEntityNeighborhood(%q) error = %v", primaryResourceURN, err) } @@ -136,10 +132,7 @@ func TestGitHubDependabotFindingsEndToEndWithGHCLI(t *testing.T) { } findingURN := "urn:cerebro:" + finding.TenantID + ":finding:" + finding.ID - findingNeighborhood, err := graphquery.New(graphStore).GetEntityNeighborhood(ctx, graphquery.NeighborhoodRequest{ - RootURN: findingURN, - Limit: 20, - }) + findingNeighborhood, err := readNeo4jLiveNeighborhood(ctx, graphStore, findingURN, 20) if err != nil { t.Fatalf("GetEntityNeighborhood(%q) error = %v", findingURN, err) } @@ -260,10 +253,7 @@ func TestGitHubAuditFindingsGraphPreviewWithGHCLI(t *testing.T) { neighborhoods := make([]graphPreviewNeighborhood, 0, len(previewFindings)) neighborhoodsByFindingURN := map[string]*ports.EntityNeighborhood{} for _, finding := range allPreviewFindings { - neighborhood, err := graphquery.New(graphStore).GetEntityNeighborhood(ctx, graphquery.NeighborhoodRequest{ - RootURN: finding.FindingURN, - Limit: 50, - }) + neighborhood, err := readNeo4jLiveNeighborhood(ctx, graphStore, finding.FindingURN, 50) if err != nil { t.Fatalf("GetEntityNeighborhood(%q) error = %v", finding.FindingURN, err) } diff --git a/cmd/cerebro/neo4j_live_test.go b/cmd/cerebro/neo4j_live_test.go index 194c25aa89..c6b2f1e42f 100644 --- a/cmd/cerebro/neo4j_live_test.go +++ b/cmd/cerebro/neo4j_live_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "encoding/json" + "fmt" "os" "strings" "testing" @@ -10,6 +12,7 @@ import ( configpkg "github.com/writer/cerebro/internal/config" graphstoreneo4j "github.com/writer/cerebro/internal/graphstore/neo4j" + "github.com/writer/cerebro/internal/ports" ) func openNeo4jLiveGraphStore(t *testing.T, ctx context.Context) *graphstoreneo4j.Store { @@ -38,6 +41,84 @@ func openNeo4jLiveGraphStore(t *testing.T, ctx context.Context) *graphstoreneo4j return store } +// readNeo4jLiveNeighborhood keeps legacy Neo4j-only integration fixtures +// working through the retained raw-Cypher compatibility boundary. Product +// neighborhood reads must use the Rust authority adapter. +func readNeo4jLiveNeighborhood(ctx context.Context, store ports.RawCypherQueryStore, rootURN string, limit int) (*ports.EntityNeighborhood, error) { + rootRows, err := store.ExecuteReadCypher(ctx, ports.CypherQueryRequest{ + Query: `MATCH (root:Entity {urn: $root_urn}) +RETURN root.urn AS urn, root.entity_type AS entity_type, root.label AS label +LIMIT 1`, + Params: map[string]any{"root_urn": rootURN}, + RowLimit: 1, + }) + if err != nil { + return nil, err + } + if len(rootRows) == 0 { + return nil, fmt.Errorf("%w: %s", ports.ErrGraphEntityNotFound, rootURN) + } + neighborhood := &ports.EntityNeighborhood{ + Root: &ports.NeighborhoodNode{ + URN: liveCypherString(rootRows[0].Values["urn"]), + EntityType: liveCypherString(rootRows[0].Values["entity_type"]), + Label: liveCypherString(rootRows[0].Values["label"]), + }, + Neighbors: []*ports.NeighborhoodNode{}, + Relations: []*ports.NeighborhoodRelation{}, + } + if limit <= 0 { + return neighborhood, nil + } + rows, err := store.ExecuteReadCypher(ctx, ports.CypherQueryRequest{ + Query: `MATCH (root:Entity {urn: $root_urn})-[relation:RELATION]-(neighbor:Entity) +RETURN neighbor.urn AS neighbor_urn, + neighbor.entity_type AS neighbor_type, + neighbor.label AS neighbor_label, + startNode(relation).urn AS from_urn, + relation.relation AS relation_type, + endNode(relation).urn AS to_urn, + coalesce(relation.attributes_json, '{}') AS attributes_json +ORDER BY neighbor.urn, relation.relation +LIMIT $limit`, + Params: map[string]any{"root_urn": rootURN, "limit": limit}, + RowLimit: limit, + }) + if err != nil { + return nil, err + } + neighbors := make(map[string]*ports.NeighborhoodNode, len(rows)) + for _, row := range rows { + neighbor := &ports.NeighborhoodNode{ + URN: liveCypherString(row.Values["neighbor_urn"]), + EntityType: liveCypherString(row.Values["neighbor_type"]), + Label: liveCypherString(row.Values["neighbor_label"]), + } + neighbors[neighbor.URN] = neighbor + attributes := map[string]string{} + if err := json.Unmarshal([]byte(liveCypherString(row.Values["attributes_json"])), &attributes); err != nil { + return nil, fmt.Errorf("decode live graph relation attributes: %w", err) + } + neighborhood.Relations = append(neighborhood.Relations, &ports.NeighborhoodRelation{ + FromURN: liveCypherString(row.Values["from_urn"]), + Relation: liveCypherString(row.Values["relation_type"]), + ToURN: liveCypherString(row.Values["to_urn"]), + Attributes: attributes, + }) + } + for _, neighbor := range neighbors { + neighborhood.Neighbors = append(neighborhood.Neighbors, neighbor) + } + return neighborhood, nil +} + +func liveCypherString(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + func resetNeo4jLiveGraph(t *testing.T, ctx context.Context, cfg configpkg.GraphStoreConfig) { t.Helper() driver, err := neo4jdriver.NewDriverWithContext( diff --git a/internal/attackpath/engine.go b/internal/attackpath/engine.go index 28f96cb0be..3052746bf6 100644 --- a/internal/attackpath/engine.go +++ b/internal/attackpath/engine.go @@ -27,7 +27,7 @@ var ( ) type Engine struct { - store ports.GraphQueryStore + store ports.RawCypherQueryStore depth int } @@ -103,7 +103,7 @@ type NodeRef struct { Label string `json:"label"` } -func New(store ports.GraphQueryStore) *Engine { +func New(store ports.RawCypherQueryStore) *Engine { return &Engine{store: store, depth: DefaultDepth} } diff --git a/internal/findingdsl/test_suite.go b/internal/findingdsl/test_suite.go index 7f465c8010..97f0c02524 100644 --- a/internal/findingdsl/test_suite.go +++ b/internal/findingdsl/test_suite.go @@ -61,7 +61,7 @@ type PolicyGraphFixtureEdge struct { } type PolicyGraphTestStore interface { - ports.GraphQueryStore + ports.RawCypherQueryStore ports.ProjectionGraphStore ports.ProjectionEntityDeleter } diff --git a/internal/findings/service.go b/internal/findings/service.go index 9813f6d244..f87692aed6 100644 --- a/internal/findings/service.go +++ b/internal/findings/service.go @@ -72,7 +72,7 @@ type Service struct { evidenceStore ports.FindingEvidenceStore candidateStore ports.FindingCandidateStore claimStore ports.ClaimStore - graphQuery ports.GraphQueryStore + graphQuery ports.RawCypherQueryStore graphRunStore GraphIngestRunStore requireTrustedResolution bool graph ports.ProjectionGraphStore @@ -248,7 +248,7 @@ func (s *Service) WithGraphStore(graph ports.ProjectionGraphStore) *Service { } // WithGraphQueryStore wires one optional graph query boundary used by workflow bridges. -func (s *Service) WithGraphQueryStore(graphQuery ports.GraphQueryStore) *Service { +func (s *Service) WithGraphQueryStore(graphQuery ports.RawCypherQueryStore) *Service { if s == nil { return nil } diff --git a/internal/graphstore/neo4j/store.go b/internal/graphstore/neo4j/store.go index 88644f294d..898f3e75d8 100644 --- a/internal/graphstore/neo4j/store.go +++ b/internal/graphstore/neo4j/store.go @@ -1912,143 +1912,6 @@ func endpointOwnerIDCleanupPrefixes(tenantID string, sources []string) ([]string return stalePrefixes, replacementPrefixes } -// GetEntityNeighborhood returns one bounded root-centered graph neighborhood. -func (s *Store) GetEntityNeighborhood(ctx context.Context, rootURN string, limit int) (*ports.EntityNeighborhood, error) { - normalizedRootURN := strings.TrimSpace(rootURN) - if normalizedRootURN == "" { - return nil, errors.New("root urn is required") - } - if err := s.requireConfigured(); err != nil { - return nil, err - } - neighborhood := &ports.EntityNeighborhood{ - Neighbors: []*ports.NeighborhoodNode{}, - Relations: []*ports.NeighborhoodRelation{}, - } - if _, err := s.read(ctx, func(ctx context.Context, tx neo4jdriver.ManagedTransaction) (any, error) { - neighborhood = &ports.EntityNeighborhood{ - Neighbors: []*ports.NeighborhoodNode{}, - Relations: []*ports.NeighborhoodRelation{}, - } - root, err := lookupNeighborhoodNode(ctx, tx, normalizedRootURN) - if err != nil { - return nil, err - } - neighborhood.Root = root - if limit <= 0 { - return nil, nil - } - neighbors := make(map[string]*ports.NeighborhoodNode) - relations := make(map[string]*ports.NeighborhoodRelation) - remaining, err := collectNeighborhoodRows(ctx, tx, `MATCH (root:Entity {urn: $root_urn})-[r:RELATION]->(neighbor:Entity) -RETURN neighbor.urn AS neighbor_urn, neighbor.entity_type AS neighbor_type, neighbor.label AS neighbor_label, - root.urn AS from_urn, r.relation AS relation_type, neighbor.urn AS to_urn, coalesce(r.attributes_json, '{}') AS attributes_json -ORDER BY neighbor.urn, r.relation LIMIT $limit`, map[string]any{"root_urn": normalizedRootURN, "limit": limit}, limit, neighbors, relations) - if err != nil { - return nil, err - } - if remaining > 0 { - if _, err := collectNeighborhoodRows(ctx, tx, `MATCH (neighbor:Entity)-[r:RELATION]->(root:Entity {urn: $root_urn}) -RETURN neighbor.urn AS neighbor_urn, neighbor.entity_type AS neighbor_type, neighbor.label AS neighbor_label, - neighbor.urn AS from_urn, r.relation AS relation_type, root.urn AS to_urn, coalesce(r.attributes_json, '{}') AS attributes_json -ORDER BY neighbor.urn, r.relation LIMIT $limit`, map[string]any{"root_urn": normalizedRootURN, "limit": remaining}, remaining, neighbors, relations); err != nil { - return nil, err - } - } - neighborhood.Neighbors = neighborhoodNodes(neighbors) - neighborhood.Relations = neighborhoodRelations(relations) - return nil, nil - }); err != nil { - return nil, err - } - return neighborhood, nil -} - -// GetEntityNeighborhoods returns bounded root-centered graph neighborhoods for -// a batch of roots. It preserves the same per-root edge ordering as -// GetEntityNeighborhood while collapsing frontier expansion into three read -// queries instead of three queries per root. -func (s *Store) GetEntityNeighborhoods(ctx context.Context, rootURNs []string, limit int) (map[string]*ports.EntityNeighborhood, error) { - roots := normalizeNeighborhoodRootURNs(rootURNs) - if len(roots) == 0 { - return map[string]*ports.EntityNeighborhood{}, nil - } - if err := s.requireConfigured(); err != nil { - return nil, err - } - neighborhoods := make(map[string]*ports.EntityNeighborhood, len(roots)) - if _, err := s.read(ctx, func(ctx context.Context, tx neo4jdriver.ManagedTransaction) (any, error) { - accumulators := make(map[string]*neighborhoodAccumulator, len(roots)) - result, err := tx.Run(ctx, `MATCH (e:Entity) -WHERE e.urn IN $root_urns -RETURN e.urn, e.entity_type, e.label`, map[string]any{"root_urns": roots}) - if err != nil { - return nil, fmt.Errorf("query graph roots: %w", err) - } - for result.Next(ctx) { - record := result.Record() - root := &ports.NeighborhoodNode{ - URN: stringValue(record.Values[0]), - EntityType: stringValue(record.Values[1]), - Label: stringValue(record.Values[2]), - } - accumulators[root.URN] = &neighborhoodAccumulator{ - neighborhood: &ports.EntityNeighborhood{ - Root: root, - Neighbors: []*ports.NeighborhoodNode{}, - Relations: []*ports.NeighborhoodRelation{}, - }, - neighbors: make(map[string]*ports.NeighborhoodNode), - relations: make(map[string]*ports.NeighborhoodRelation), - remaining: limit, - } - } - if err := result.Err(); err != nil { - return nil, fmt.Errorf("query graph roots: %w", err) - } - presentRoots := make([]string, 0, len(roots)) - for _, rootURN := range roots { - if accumulators[rootURN] != nil { - presentRoots = append(presentRoots, rootURN) - } - } - if limit > 0 && len(presentRoots) > 0 { - params := map[string]any{"root_urns": presentRoots, "limit": limit} - if err := collectBatchedNeighborhoodRows(ctx, tx, outgoingNeighborhoodBatchQuery, params, accumulators); err != nil { - return nil, err - } - incomingRootsByLimit := make(map[int][]string) - for _, rootURN := range presentRoots { - remaining := accumulators[rootURN].remaining - if remaining > 0 { - incomingRootsByLimit[remaining] = append(incomingRootsByLimit[remaining], rootURN) - } - } - incomingLimits := make([]int, 0, len(incomingRootsByLimit)) - for remaining := range incomingRootsByLimit { - incomingLimits = append(incomingLimits, remaining) - } - slices.Sort(incomingLimits) - for _, remaining := range incomingLimits { - params := map[string]any{"root_urns": incomingRootsByLimit[remaining], "limit": remaining} - if err := collectBatchedNeighborhoodRows(ctx, tx, incomingNeighborhoodBatchQuery, params, accumulators); err != nil { - return nil, err - } - } - } - for _, rootURN := range presentRoots { - accumulator := accumulators[rootURN] - accumulator.neighborhood.Neighbors = neighborhoodNodes(accumulator.neighbors) - accumulator.neighborhood.Relations = neighborhoodRelations(accumulator.relations) - neighborhoods[rootURN] = accumulator.neighborhood - } - return nil, nil - }); err != nil { - return nil, err - } - return neighborhoods, nil -} - // ExecuteReadCypher runs one bounded read-only Cypher query and returns its rows. // // The store enforces a row cap to keep graph rules from accidentally pulling unbounded result @@ -2952,138 +2815,6 @@ RETURN count(a)`, params) return updated == 1, nil } -func lookupNeighborhoodNode(ctx context.Context, tx neo4jdriver.ManagedTransaction, rootURN string) (*ports.NeighborhoodNode, error) { - result, err := tx.Run(ctx, "MATCH (e:Entity {urn: $urn}) RETURN e.urn, e.entity_type, e.label", map[string]any{"urn": rootURN}) - if err != nil { - return nil, fmt.Errorf("query graph root %q: %w", rootURN, err) - } - if !result.Next(ctx) { - if err := result.Err(); err != nil { - return nil, fmt.Errorf("query graph root %q: %w", rootURN, err) - } - return nil, fmt.Errorf("%w: %s", ports.ErrGraphEntityNotFound, rootURN) - } - record := result.Record() - return &ports.NeighborhoodNode{ - URN: stringValue(record.Values[0]), - EntityType: stringValue(record.Values[1]), - Label: stringValue(record.Values[2]), - }, result.Err() -} - -type neighborhoodAccumulator struct { - neighborhood *ports.EntityNeighborhood - neighbors map[string]*ports.NeighborhoodNode - relations map[string]*ports.NeighborhoodRelation - remaining int -} - -const outgoingNeighborhoodBatchQuery = `UNWIND $root_urns AS root_urn -MATCH (root:Entity {urn: root_urn}) -CALL { - WITH root - MATCH (root)-[r:RELATION]->(neighbor:Entity) - RETURN neighbor.urn AS neighbor_urn, neighbor.entity_type AS neighbor_type, neighbor.label AS neighbor_label, - root.urn AS from_urn, r.relation AS relation_type, neighbor.urn AS to_urn, coalesce(r.attributes_json, '{}') AS attributes_json - ORDER BY neighbor.urn, r.relation - LIMIT $limit -} -RETURN root_urn, neighbor_urn, neighbor_type, neighbor_label, from_urn, relation_type, to_urn, attributes_json -ORDER BY root_urn, neighbor_urn, relation_type` - -const incomingNeighborhoodBatchQuery = `UNWIND $root_urns AS root_urn -MATCH (root:Entity {urn: root_urn}) -CALL { - WITH root - MATCH (neighbor:Entity)-[r:RELATION]->(root) - RETURN neighbor.urn AS neighbor_urn, neighbor.entity_type AS neighbor_type, neighbor.label AS neighbor_label, - neighbor.urn AS from_urn, r.relation AS relation_type, root.urn AS to_urn, coalesce(r.attributes_json, '{}') AS attributes_json - ORDER BY neighbor.urn, r.relation - LIMIT $limit -} -RETURN root_urn, neighbor_urn, neighbor_type, neighbor_label, from_urn, relation_type, to_urn, attributes_json -ORDER BY root_urn, neighbor_urn, relation_type` - -func normalizeNeighborhoodRootURNs(rootURNs []string) []string { - roots := make([]string, 0, len(rootURNs)) - seen := make(map[string]bool, len(rootURNs)) - for _, raw := range rootURNs { - rootURN := strings.TrimSpace(raw) - if rootURN == "" || seen[rootURN] { - continue - } - seen[rootURN] = true - roots = append(roots, rootURN) - } - return roots -} - -func collectNeighborhoodRows(ctx context.Context, tx neo4jdriver.ManagedTransaction, query string, params map[string]any, remaining int, neighbors map[string]*ports.NeighborhoodNode, relations map[string]*ports.NeighborhoodRelation) (int, error) { - result, err := tx.Run(ctx, query, params) - if err != nil { - return remaining, fmt.Errorf("query graph neighborhood: %w", err) - } - for result.Next(ctx) { - record := result.Record() - neighbor := &ports.NeighborhoodNode{ - URN: stringValue(record.Values[0]), - EntityType: stringValue(record.Values[1]), - Label: stringValue(record.Values[2]), - } - attributes, err := decodeGraphAttributes(stringValue(record.Values[6])) - if err != nil { - return remaining, fmt.Errorf("decode graph neighborhood relation attributes: %w", err) - } - relation := &ports.NeighborhoodRelation{ - FromURN: stringValue(record.Values[3]), - Relation: stringValue(record.Values[4]), - ToURN: stringValue(record.Values[5]), - Attributes: attributes, - } - neighbors[neighbor.URN] = neighbor - relations[relation.FromURN+"|"+relation.Relation+"|"+relation.ToURN] = relation - remaining-- - if remaining == 0 { - break - } - } - return remaining, result.Err() -} - -func collectBatchedNeighborhoodRows(ctx context.Context, tx neo4jdriver.ManagedTransaction, query string, params map[string]any, accumulators map[string]*neighborhoodAccumulator) error { - result, err := tx.Run(ctx, query, params) - if err != nil { - return fmt.Errorf("query graph neighborhoods: %w", err) - } - for result.Next(ctx) { - record := result.Record() - rootURN := stringValue(record.Values[0]) - accumulator := accumulators[rootURN] - if accumulator == nil || accumulator.remaining <= 0 { - continue - } - neighbor := &ports.NeighborhoodNode{ - URN: stringValue(record.Values[1]), - EntityType: stringValue(record.Values[2]), - Label: stringValue(record.Values[3]), - } - attributes, err := decodeGraphAttributes(stringValue(record.Values[7])) - if err != nil { - return fmt.Errorf("decode graph neighborhood relation attributes: %w", err) - } - relation := &ports.NeighborhoodRelation{ - FromURN: stringValue(record.Values[4]), - Relation: stringValue(record.Values[5]), - ToURN: stringValue(record.Values[6]), - Attributes: attributes, - } - accumulator.neighbors[neighbor.URN] = neighbor - accumulator.relations[relation.FromURN+"|"+relation.Relation+"|"+relation.ToURN] = relation - accumulator.remaining-- - } - return result.Err() -} - func graphAttributesJSON(attributes map[string]string) (string, error) { if len(attributes) == 0 { return `{}`, nil @@ -3202,49 +2933,6 @@ func mergeAttributeValue(key, existing, incoming string) string { return incoming } -func decodeGraphAttributes(payload string) (map[string]string, error) { - trimmed := strings.TrimSpace(payload) - if trimmed == "" || trimmed == "{}" { - return nil, nil - } - attributes := map[string]string{} - if err := json.Unmarshal([]byte(trimmed), &attributes); err != nil { - return nil, err - } - return attributes, nil -} - -func neighborhoodNodes(values map[string]*ports.NeighborhoodNode) []*ports.NeighborhoodNode { - nodes := make([]*ports.NeighborhoodNode, 0, len(values)) - for _, node := range values { - nodes = append(nodes, node) - } - slices.SortFunc(nodes, func(left *ports.NeighborhoodNode, right *ports.NeighborhoodNode) int { - switch { - case left.URN < right.URN: - return -1 - case left.URN > right.URN: - return 1 - default: - return 0 - } - }) - return nodes -} - -func neighborhoodRelations(values map[string]*ports.NeighborhoodRelation) []*ports.NeighborhoodRelation { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - slices.Sort(keys) - relations := make([]*ports.NeighborhoodRelation, 0, len(keys)) - for _, key := range keys { - relations = append(relations, values[key]) - } - return relations -} - func ingestRunReturnQuery(prefix string) string { return prefix + ` RETURN r.id, coalesce(r.runtime_id, ''), diff --git a/internal/graphstore/neo4j/store_test.go b/internal/graphstore/neo4j/store_test.go index 15372e8c40..fa554ffaa6 100644 --- a/internal/graphstore/neo4j/store_test.go +++ b/internal/graphstore/neo4j/store_test.go @@ -39,41 +39,6 @@ func TestProjectedEntityMergePreservesExistingLabelsForFallbackLabels(t *testing } } -func TestNeighborhoodRelationsOrdersByStoredRelationKey(t *testing.T) { - relations := map[string]*ports.NeighborhoodRelation{ - "urn:cerebro:writer:asset:b|depends_on|urn:cerebro:writer:asset:a": { - FromURN: "urn:cerebro:writer:asset:b", - Relation: "depends_on", - ToURN: "urn:cerebro:writer:asset:a", - }, - "urn:cerebro:writer:asset:a|owns|urn:cerebro:writer:asset:c": { - FromURN: "urn:cerebro:writer:asset:a", - Relation: "owns", - ToURN: "urn:cerebro:writer:asset:c", - }, - "urn:cerebro:writer:asset:a|depends_on|urn:cerebro:writer:asset:b": { - FromURN: "urn:cerebro:writer:asset:a", - Relation: "depends_on", - ToURN: "urn:cerebro:writer:asset:b", - }, - } - - result := neighborhoodRelations(relations) - - if len(result) != 3 { - t.Fatalf("neighborhoodRelations() len = %d, want 3", len(result)) - } - if result[0].FromURN != "urn:cerebro:writer:asset:a" || result[0].Relation != "depends_on" || result[0].ToURN != "urn:cerebro:writer:asset:b" { - t.Fatalf("neighborhoodRelations()[0] = %#v, want asset:a depends_on asset:b", result[0]) - } - if result[1].FromURN != "urn:cerebro:writer:asset:a" || result[1].Relation != "owns" || result[1].ToURN != "urn:cerebro:writer:asset:c" { - t.Fatalf("neighborhoodRelations()[1] = %#v, want asset:a owns asset:c", result[1]) - } - if result[2].FromURN != "urn:cerebro:writer:asset:b" || result[2].Relation != "depends_on" || result[2].ToURN != "urn:cerebro:writer:asset:a" { - t.Fatalf("neighborhoodRelations()[2] = %#v, want asset:b depends_on asset:a", result[2]) - } -} - func TestScanIngestRunRecordIncludesCheckpointTerminalState(t *testing.T) { record := &neo4jdriver.Record{Values: []any{ "run-1", "runtime-1", "github", "writer", "checkpoint-1", "page-2", false, @@ -515,13 +480,6 @@ func TestNeo4jDockerProjectionAndQueries(t *testing.T) { if relationCounts["maintains"] != 1 || relationCounts["tracks"] != 1 || relationCounts["missing"] != 0 { t.Fatalf("RelationCounts() = %#v, want maintains=1 tracks=1 missing=0", relationCounts) } - neighborhood, err := store.GetEntityNeighborhood(ctx, user.URN, 5) - if err != nil { - t.Fatalf("GetEntityNeighborhood() error = %v", err) - } - if neighborhood.Root == nil || neighborhood.Root.URN != user.URN || len(neighborhood.Neighbors) != 1 || len(neighborhood.Relations) != 1 { - t.Fatalf("GetEntityNeighborhood() = %#v", neighborhood) - } target := &ports.ProjectedEntity{URN: "urn:cerebro:writer:grc_target:target-1", TenantID: "writer", SourceID: "grc", EntityType: "grc.target", Label: "target-1"} source := &ports.ProjectedEntity{URN: "urn:cerebro:writer:source:grc", TenantID: "writer", SourceID: "grc", EntityType: "source", Label: "grc"} finding := &ports.ProjectedEntity{URN: "urn:cerebro:writer:finding:finding-1", TenantID: "writer", SourceID: "grc", EntityType: "finding", Label: "finding-1"} diff --git a/internal/knowledge/service.go b/internal/knowledge/service.go index 56d5131b2d..4f4e5533f7 100644 --- a/internal/knowledge/service.go +++ b/internal/knowledge/service.go @@ -148,7 +148,7 @@ type OutcomeWriteResult struct { } // New constructs one platform knowledge write service. -func New(query ports.GraphQueryStore, graph ports.ProjectionGraphStore) *Service { +func New(query ports.RawCypherQueryStore, graph ports.ProjectionGraphStore) *Service { _ = query // Retained in the constructor for compatibility; graph lookup is not a durability precondition. return &Service{graph: graph, durabilityMode: DurabilityRequired} } diff --git a/tools/archtests/rust_organizational_platform_test.go b/tools/archtests/rust_organizational_platform_test.go index fa634c934d..c50ef9a9e8 100644 --- a/tools/archtests/rust_organizational_platform_test.go +++ b/tools/archtests/rust_organizational_platform_test.go @@ -185,6 +185,24 @@ func TestRustOrganizationalPlatformBoundary(t *testing.T) { } } + goNeo4jStore := readText(t, filepath.Join(root, "internal/graphstore/neo4j/store.go")) + for _, forbidden := range []string{ + "func (s *Store) GetEntityNeighborhood(", + "func (s *Store) GetEntityNeighborhoods(", + } { + if strings.Contains(goNeo4jStore, forbidden) { + t.Errorf("Go Neo4j store restored retired product-read authority %q", forbidden) + } + } + for _, retained := range []string{ + "func (s *Store) ExecuteReadCypher(", + "func (s *Store) ExplainReadCypher(", + } { + if !strings.Contains(goNeo4jStore, retained) { + t.Errorf("Go Neo4j store removed raw-Cypher compatibility %q", retained) + } + } + replacementWorkflow := readText(t, filepath.Join(root, ".github/workflows/rust-graph-replacement.yml")) for _, required := range []string{ "name: Rust-only persisted product read", From 1eb52343648742f96f6f9600564149975ed017f7 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 18:48:00 -0700 Subject: [PATCH 2/7] test: qualify retired graph path in Rust authority --- .github/workflows/ephemeral-cerebro.yml | 2 +- apps/web/scripts/local-grc-e2e.mjs | 74 +++++++-- apps/web/scripts/local-grc-e2e.test.mjs | 9 ++ scripts/qualify-rust-graph.sh | 199 +++--------------------- 4 files changed, 91 insertions(+), 193 deletions(-) diff --git a/.github/workflows/ephemeral-cerebro.yml b/.github/workflows/ephemeral-cerebro.yml index b44fb2f697..0da6f671c5 100644 --- a/.github/workflows/ephemeral-cerebro.yml +++ b/.github/workflows/ephemeral-cerebro.yml @@ -17,7 +17,7 @@ on: - source - published soak_seconds: - description: Sustained shadow-read exercise duration (60-1800 seconds) + description: Sustained Rust-authority read exercise duration (60-1800 seconds) required: false default: 60 type: number diff --git a/apps/web/scripts/local-grc-e2e.mjs b/apps/web/scripts/local-grc-e2e.mjs index 45f1975899..357e9d35b2 100644 --- a/apps/web/scripts/local-grc-e2e.mjs +++ b/apps/web/scripts/local-grc-e2e.mjs @@ -44,6 +44,7 @@ let workDir; let logDir; let failed = false; let backendProcess = null; +let rustGraphProcess = null; let webProcess = null; let neo4jStopped = false; let postgresStarted = false; @@ -250,38 +251,71 @@ async function main(options) { const binarySuffix = process.platform === "win32" ? ".exe" : ""; const apiBinary = path.join(workDir, `cerebro${binarySuffix}`); const eventAdmissionBinary = path.join(backendRoot, "target", "release", `cerebro-event-admission-worker${binarySuffix}`); + const rustGraphBinary = path.join(backendRoot, "target", "release", `cerebro-platform${binarySuffix}`); + const rustGraphSharedSecret = `grc-e2e-${randomBytes(32).toString("base64url")}`; const apiBuild = Promise.all([ run("go", ["-C", backendRoot, "build", "-o", apiBinary, "./cmd/cerebro"], { env: portableChildEnvironment(), quiet: true, }), - run("cargo", ["build", "--locked", "--release", "-p", "cerebro-sourceruntime-eventadmission", "--bin", "cerebro-event-admission-worker"], { + run("cargo", [ + "build", "--locked", "--release", + "-p", "cerebro-sourceruntime-eventadmission", + "-p", "cerebro-platform", + "--bins", + ], { cwd: backendRoot, env: portableChildEnvironment(), quiet: true, }), ]); - backendProcess = await startAfterPrerequisite(apiBuild, async () => { - const reservation = await reserveDistinctLoopbackPort(usedPorts, { control: activeRunControl }); - apiPort = reservation.port; - usedPorts.add(apiPort); - apiBase = `http://127.0.0.1:${apiPort}`; - return handoffLoopbackReservation(reservation, () => spawnLogged("cerebro-api", apiBinary, ["serve"], { + await apiBuild; + const rustGraphReservation = await reserveDistinctLoopbackPort(usedPorts, { control: activeRunControl }); + const rustGraphPort = rustGraphReservation.port; + usedPorts.add(rustGraphPort); + const rustGraphBase = `http://127.0.0.1:${rustGraphPort}`; + rustGraphProcess = await handoffLoopbackReservation(rustGraphReservation, () => spawnLogged( + "cerebro-rust-graph", + rustGraphBinary, + ["serve-neo4j-readonly"], + { + cwd: backendRoot, env: portableChildEnvironment(process.env, { - CEREBRO_HTTP_ADDR: `127.0.0.1:${apiPort}`, - CEREBRO_STATE_STORE_DRIVER: "postgres", - CEREBRO_POSTGRES_DSN: postgresDSN, - CEREBRO_GRAPH_STORE_DRIVER: "neo4j", CEREBRO_NEO4J_URI: neo4jURI, CEREBRO_NEO4J_USERNAME: neo4jUser, CEREBRO_NEO4J_PASSWORD: neo4jCredential, - CEREBRO_EVENT_ADMISSION_WORKER: eventAdmissionBinary, - CEREBRO_API_AUTH_ENABLED: "false", - CEREBRO_DEV_MODE: "1", - CEREBRO_DEV_MODE_ACK: "1", + CEREBRO_ORGANIZATIONAL_GRAPH_SHARED_SECRET: rustGraphSharedSecret, + CEREBRO_RUST_BIND: `127.0.0.1:${rustGraphPort}`, }), - }), activeRunControl, "Cerebro API spawn"); - }, activeRunControl, "Cerebro API port reservation"); + }, + ), activeRunControl, "Rust graph spawn"); + await waitFor("Rust graph readiness", async () => { + const readiness = await request(`${rustGraphBase}/readyz`); + expect(readiness.status === 200, `Rust graph readiness status ${readiness.status}`); + }, Math.min(120_000, remainingValidationMs()), rustGraphProcess); + + const apiReservation = await reserveDistinctLoopbackPort(usedPorts, { control: activeRunControl }); + apiPort = apiReservation.port; + usedPorts.add(apiPort); + apiBase = `http://127.0.0.1:${apiPort}`; + backendProcess = await handoffLoopbackReservation(apiReservation, () => spawnLogged("cerebro-api", apiBinary, ["serve"], { + env: portableChildEnvironment(process.env, { + CEREBRO_HTTP_ADDR: `127.0.0.1:${apiPort}`, + CEREBRO_STATE_STORE_DRIVER: "postgres", + CEREBRO_POSTGRES_DSN: postgresDSN, + CEREBRO_GRAPH_STORE_DRIVER: "neo4j", + CEREBRO_NEO4J_URI: neo4jURI, + CEREBRO_NEO4J_USERNAME: neo4jUser, + CEREBRO_NEO4J_PASSWORD: neo4jCredential, + CEREBRO_ORGANIZATIONAL_GRAPH_READ_URL: rustGraphBase, + CEREBRO_ORGANIZATIONAL_GRAPH_READ_MODE: "authority", + CEREBRO_ORGANIZATIONAL_GRAPH_SHARED_SECRET: rustGraphSharedSecret, + CEREBRO_EVENT_ADMISSION_WORKER: eventAdmissionBinary, + CEREBRO_API_AUTH_ENABLED: "false", + CEREBRO_DEV_MODE: "1", + CEREBRO_DEV_MODE_ACK: "1", + }), + }), activeRunControl, "Cerebro API spawn"); await waitFor("Cerebro API readiness", async () => { const health = await requestJSON(`${apiBase}/health`); expect(health.status === 200, `health status ${health.status}`); @@ -1134,6 +1168,11 @@ async function cleanup() { } catch (error) { errors.push(error); } + try { + await stopChild(rustGraphProcess); + } catch (error) { + errors.push(error); + } if (neo4jStarted) { try { await run("docker", ["rm", "-f", neo4jContainer], { deadlineAt: overallDeadlineAt, quiet: true, signal: null }); @@ -1203,6 +1242,7 @@ export async function runLocalGrcE2E(options = {}) { activeRunControl = createRunControl(validationDeadlineAt); failed = false; backendProcess = null; + rustGraphProcess = null; webProcess = null; delayRelay = null; workDir = undefined; diff --git a/apps/web/scripts/local-grc-e2e.test.mjs b/apps/web/scripts/local-grc-e2e.test.mjs index 7c0ce10454..9190e72491 100644 --- a/apps/web/scripts/local-grc-e2e.test.mjs +++ b/apps/web/scripts/local-grc-e2e.test.mjs @@ -144,6 +144,15 @@ describe("real-service E2E isolation", () => { expect(runner).toContain("CEREBRO_EVENT_ADMISSION_WORKER: eventAdmissionBinary"); }); + it("routes product graph reads through the Rust authority", async () => { + const runnerPath = fileURLToPath(new URL("./local-grc-e2e.mjs", import.meta.url)); + const runner = await readFile(runnerPath, "utf8"); + expect(runner).toContain('"serve-neo4j-readonly"'); + expect(runner).toContain("CEREBRO_ORGANIZATIONAL_GRAPH_READ_URL: rustGraphBase"); + expect(runner).toContain('CEREBRO_ORGANIZATIONAL_GRAPH_READ_MODE: "authority"'); + expect(runner).toContain("CEREBRO_ORGANIZATIONAL_GRAPH_SHARED_SECRET: rustGraphSharedSecret"); + }); + it("requires visible seeded data and a successful API response in Chromium", () => { expect(browserDataContract("/risk-inbox")).toEqual({ apiPath: "/api/cerebro/grc/findings", diff --git a/scripts/qualify-rust-graph.sh b/scripts/qualify-rust-graph.sh index 3a12b68197..c7f564986e 100755 --- a/scripts/qualify-rust-graph.sh +++ b/scripts/qualify-rust-graph.sh @@ -19,26 +19,6 @@ readonly organizational_receipt="${output_dir}/organizational-receipt.json" readonly qualification_receipt="${output_dir}/receipt.json" readonly service_logs="${output_dir}/service-logs.txt" readonly compose_files=(-f "${repository_root}/docker-compose.yml" -f "${repository_root}/docker-compose.rust.yml") -read -r rust_canary_tenant go_canary_tenant < <( - python3 - <<'PY' -import hashlib - -rust_tenant = "" -go_tenant = "" -for index in range(10000): - tenant = f"rust-canary-{index}" - bucket = int.from_bytes(hashlib.sha256(tenant.encode()).digest()[:4], "big") % 100 - if bucket < 50 and not rust_tenant: - rust_tenant = tenant - if bucket >= 50 and not go_tenant: - go_tenant = tenant - if rust_tenant and go_tenant: - print(rust_tenant, go_tenant) - break -else: - raise SystemExit("unable to construct stable canary tenants") -PY -) if ! [[ "${CEREBRO_QUALIFICATION_SHA}" =~ ^[0-9a-f]{40}$ ]]; then echo "CEREBRO_QUALIFICATION_SHA must be an exact 40-character commit" >&2 @@ -52,11 +32,10 @@ fi mkdir -p "${output_dir}" export COMPOSE_PROJECT_NAME="${compose_project}" export CEREBRO_RUST_COMMAND=serve-neo4j-consumer -export CEREBRO_RUST_READ_MODE=shadow -export CEREBRO_RUST_SHADOW_PERCENT=100 +export CEREBRO_RUST_READ_MODE=authority +export CEREBRO_RUST_SHADOW_PERCENT=0 export CEREBRO_RUST_AUTHORITY_PERCENT=0 export CEREBRO_RUST_GRAPH_SECRET="${graph_secret}" -export CEREBRO_RUST_CANARY_API_KEYS=",rust-canary-key:local:${rust_canary_tenant},go-canary-key:local:${go_canary_tenant}" cleanup() { local exit_code=$? @@ -160,32 +139,17 @@ assert_status() { fi } -assert_not_status() { - local rejected="$1" - local label="$2" - local output="$3" - local url="$4" - local api_key="${5:-local-dev-key}" - local observed - observed="$(request_status "${output}" "${url}" "${api_key}" || true)" - echo "${label}: ${observed}" - if [[ "${observed%% *}" = "${rejected}" ]]; then - cat "${output}" >&2 || true - return 1 - fi -} - assert_status 200 readiness-before "${output_dir}/readiness.json" http://127.0.0.1:8080/health assert_status 200 graph-before "${output_dir}/graph-response.json" "${graph_url}" jq -e --arg root "${root_urn}" ' .root.urn == $root and ([.relations[] | select(.relation == "represents")] | length) >= 1 ' "${output_dir}/graph-response.json" >/dev/null -jq -S . "${output_dir}/graph-response.json" >"${output_dir}/shadow-graph-response.json" +jq -S . "${output_dir}/graph-response.json" >"${output_dir}/authority-graph-response.json" deadline=$((SECONDS + soak_seconds)) request_count=0 -: >"${output_dir}/shadow-statuses.txt" +: >"${output_dir}/authority-statuses.txt" while ((SECONDS < deadline)); do seq 1 2 | xargs -P 2 -I{} curl \ --max-time 10 \ @@ -194,37 +158,18 @@ while ((SECONDS < deadline)); do --output /dev/null \ --write-out '%{http_code}\n' \ --header "${auth_header_name}: ${auth_scheme} local-dev-key" \ - "${graph_url}" >>"${output_dir}/shadow-statuses.txt" || true + "${graph_url}" >>"${output_dir}/authority-statuses.txt" || true request_count=$((request_count + 2)) sleep 1 done -observed_count="$(wc -l <"${output_dir}/shadow-statuses.txt" | tr -d ' ')" -matching_count="$(grep -c '^200$' "${output_dir}/shadow-statuses.txt" || true)" +observed_count="$(wc -l <"${output_dir}/authority-statuses.txt" | tr -d ' ')" +matching_count="$(grep -c '^200$' "${output_dir}/authority-statuses.txt" || true)" test "${observed_count}" -eq "${request_count}" test "${matching_count}" -eq "${request_count}" rust_container="$(docker compose "${compose_files[@]}" ps -q rust-platform)" test -n "${rust_container}" -docker pause "${rust_container}" >/dev/null -: >"${output_dir}/shadow-paused-statuses.txt" -seq 1 96 | xargs -P 32 -I{} curl \ - --max-time 2 \ - --silent \ - --show-error \ - --output /dev/null \ - --write-out '%{http_code} %{time_total}\n' \ - --header "${auth_header_name}: ${auth_scheme} local-dev-key" \ - "${graph_url}" >>"${output_dir}/shadow-paused-statuses.txt" || true -paused_count="$(wc -l <"${output_dir}/shadow-paused-statuses.txt" | tr -d ' ')" -test "${paused_count}" -eq 96 -awk '$1 != 200 || $2 >= 1.0 { exit 1 }' "${output_dir}/shadow-paused-statuses.txt" -docker unpause "${rust_container}" >/dev/null - -docker stop "${rust_container}" >/dev/null -assert_status 200 readiness-shadow-without-rust "${output_dir}/readiness.json" http://127.0.0.1:8080/health -assert_status 200 graph-shadow-without-rust "${output_dir}/graph-response.json" "${graph_url}" - -docker start "${rust_container}" >/dev/null +docker restart "${rust_container}" >/dev/null for attempt in $(seq 1 36); do if docker inspect --format '{{.State.Health.Status}}' "${rust_container}" | grep -qx healthy; then break @@ -233,7 +178,11 @@ for attempt in $(seq 1 36); do sleep 5 done assert_status 200 readiness-after-restart "${output_dir}/readiness.json" http://127.0.0.1:8080/health -assert_status 200 graph-after-restart "${output_dir}/graph-response.json" "${graph_url}" +assert_status 200 graph-after-restart "${output_dir}/authority-graph-response-after-restart.json" "${graph_url}" +jq -S . "${output_dir}/authority-graph-response-after-restart.json" \ + >"${output_dir}/authority-graph-response-after-restart.canonical.json" +cmp "${output_dir}/authority-graph-response.json" \ + "${output_dir}/authority-graph-response-after-restart.canonical.json" run_harness verify test "$(jq -r .schema_version "${organizational_receipt}")" = \ @@ -241,138 +190,38 @@ test "$(jq -r .schema_version "${organizational_receipt}")" = \ test "$(jq -r .status "${organizational_receipt}")" = passed test "$(jq '[.checks[] | select(.status == "passed")] | length' "${organizational_receipt}")" -eq 14 -rust_canary_urn="urn:cerebro:${rust_canary_tenant}:organizational_entity:canary" -go_canary_urn="urn:cerebro:${go_canary_tenant}:organizational_entity:canary" -docker exec "${neo4j_container}" cypher-shell -u neo4j -p local-password \ - --param "rust_urn => '${rust_canary_urn}'" \ - --param "rust_tenant => '${rust_canary_tenant}'" \ - --param "go_urn => '${go_canary_urn}'" \ - --param "go_tenant => '${go_canary_tenant}'" \ - 'MERGE (rust:Entity:OrganizationalEntity {urn: $rust_urn}) - SET rust.tenant_id = $rust_tenant, - rust.entity_id = "rust-canary-entity", - rust.external_id = $rust_urn, - rust.entity_type = "resource", - rust.entity_kind = "resource", - rust.authority_json = "\"qualification\"", - rust.properties_json = "{\"entity_urn\":\"" + $rust_urn + "\"}", - rust.label = "Rust canary" - MERGE (legacy:Entity:OrganizationalEntity {urn: $go_urn}) - SET legacy.tenant_id = $go_tenant, - legacy.entity_id = "go-canary-entity", - legacy.external_id = $go_urn, - legacy.entity_type = "resource", - legacy.entity_kind = "resource", - legacy.authority_json = "\"qualification\"", - legacy.properties_json = "{\"entity_urn\":\"" + $go_urn + "\"}", - legacy.label = "Go canary"' -rust_canary_url="http://127.0.0.1:8080/platform/graph/neighborhood?root_urn=$(jq -rn --arg value "${rust_canary_urn}" '$value|@uri')&limit=10" -go_canary_url="http://127.0.0.1:8080/platform/graph/neighborhood?root_urn=$(jq -rn --arg value "${go_canary_urn}" '$value|@uri')&limit=10" - -export CEREBRO_RUST_READ_MODE=canary -export CEREBRO_RUST_SHADOW_PERCENT=0 -export CEREBRO_RUST_AUTHORITY_PERCENT=50 -export CEREBRO_RUST_CANARY_VERIFY_PERCENT=100 -docker compose "${compose_files[@]}" up -d --force-recreate --wait cerebro -assert_status 200 graph-canary-rust "${output_dir}/canary-rust-response.json" "${rust_canary_url}" rust-canary-key -assert_status 200 graph-canary-go "${output_dir}/canary-go-response.json" "${go_canary_url}" go-canary-key - -docker compose "${compose_files[@]}" restart cerebro -docker compose "${compose_files[@]}" up -d --wait cerebro -assert_status 200 graph-canary-rust-after-restart \ - "${output_dir}/canary-rust-response.json" "${rust_canary_url}" rust-canary-key -assert_status 200 graph-canary-go-after-restart \ - "${output_dir}/canary-go-response.json" "${go_canary_url}" go-canary-key - -rust_container="$(docker compose "${compose_files[@]}" ps -q rust-platform)" -docker stop "${rust_container}" >/dev/null -assert_status 200 readiness-canary-without-rust \ - "${output_dir}/readiness.json" http://127.0.0.1:8080/health -assert_status 200 graph-canary-go-without-rust \ - "${output_dir}/canary-go-response.json" "${go_canary_url}" go-canary-key -assert_not_status 200 graph-canary-rust-without-rust \ - "${output_dir}/canary-rust-response.json" "${rust_canary_url}" rust-canary-key -docker start "${rust_container}" >/dev/null -for attempt in $(seq 1 36); do - if docker inspect --format '{{.State.Health.Status}}' "${rust_container}" | grep -qx healthy; then - break - fi - test "${attempt}" -lt 36 - sleep 5 -done - -export CEREBRO_RUST_READ_MODE=authority -export CEREBRO_RUST_SHADOW_PERCENT=0 -export CEREBRO_RUST_AUTHORITY_PERCENT=0 -export CEREBRO_RUST_CANARY_VERIFY_PERCENT=0 -docker compose "${compose_files[@]}" up -d --force-recreate --wait cerebro -assert_status 200 readiness-authority "${output_dir}/readiness.json" http://127.0.0.1:8080/health -assert_status 200 graph-authority "${output_dir}/authority-graph-response.json" "${graph_url}" -jq -S . "${output_dir}/authority-graph-response.json" \ - >"${output_dir}/authority-graph-response.canonical.json" -cmp "${output_dir}/shadow-graph-response.json" \ - "${output_dir}/authority-graph-response.canonical.json" - rust_container="$(docker compose "${compose_files[@]}" ps -q rust-platform)" test -n "${rust_container}" docker stop "${rust_container}" >/dev/null assert_status 503 readiness-authority-without-rust \ "${output_dir}/readiness.json" http://127.0.0.1:8080/health +assert_status 503 graph-authority-without-rust \ + "${output_dir}/graph-response.json" "${graph_url}" jq \ --arg schema_version "cerebro.pr-rust-graph/v1" \ - --argjson shadow_requests "${request_count}" \ + --argjson authority_requests "${request_count}" \ '.schema_version = $schema_version - | .shadow_requests = $shadow_requests + | .authority_requests = $authority_requests | .checks += [ { - name: "go_shadow_graph", - status: "passed", - evidence: (($shadow_requests | tostring) + " projected graph reads returned 200") - }, - { - name: "shadow_failure_isolation", - status: "passed", - evidence: "Go readiness and graph reads stayed available while Rust was stopped" - }, - { - name: "shadow_backpressure_isolation", + name: "rust_authority_soak", status: "passed", - evidence: "96 concurrent graph reads stayed below one second while the Rust process was paused" + evidence: (($authority_requests | tostring) + " Rust-authority product graph reads returned 200") }, { - name: "go_rust_authority_parity", + name: "rust_authority_restart", status: "passed", - evidence: "shadow and Rust authority product responses were identical" + evidence: "the Rust-authority product response remained identical after a Rust process restart" }, { name: "authority_fail_closed", status: "passed", - evidence: "Go readiness returned 503 when Rust authority was stopped" - }, - { - name: "stable_authority_canary", - status: "passed", - evidence: "stable 50 percent sampling served one typed read from Rust and one from Go" - }, - { - name: "verified_canary_restart", - status: "passed", - evidence: "100 percent Go-oracle verification was enabled and both stable cohorts survived an API restart" - }, - { - name: "canary_legacy_isolation", - status: "passed", - evidence: "Go readiness and the non-sampled Go read stayed available while Rust was stopped" - }, - { - name: "canary_rust_fail_closed", - status: "passed", - evidence: "the sampled Rust read did not fall back to Go while Rust was stopped" + evidence: "API readiness and product graph reads returned 503 when Rust authority was stopped" } ]' "${organizational_receipt}" >"${qualification_receipt}" test "$(jq -r .schema_version "${qualification_receipt}")" = "cerebro.pr-rust-graph/v1" test "$(jq -r .status "${qualification_receipt}")" = passed -test "$(jq '[.checks[] | select(.status == "passed")] | length' "${qualification_receipt}")" -eq 23 -echo "Rust PR graph qualification passed with ${request_count} shadow reads" +test "$(jq '[.checks[] | select(.status == "passed")] | length' "${qualification_receipt}")" -eq 17 +echo "Rust PR graph qualification passed with ${request_count} authority reads" From 756a31a790f6ffd43ae7c512770ce66086104a3d Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 19:06:32 -0700 Subject: [PATCH 3/7] test: seed Rust graph schema for web integration --- apps/web/scripts/local-grc-e2e.test.mjs | 2 + .../web/scripts/testdata/grc-e2e-seed/main.go | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/apps/web/scripts/local-grc-e2e.test.mjs b/apps/web/scripts/local-grc-e2e.test.mjs index 9190e72491..a4b14a4ebb 100644 --- a/apps/web/scripts/local-grc-e2e.test.mjs +++ b/apps/web/scripts/local-grc-e2e.test.mjs @@ -126,6 +126,8 @@ describe("real-service E2E isolation", () => { const seed = await readFile(seedPath, "utf8"); expect(seed).toContain("e2e-local"); expect(seed).toContain("example.org"); + expect(seed).toContain(":OrganizationalEntity"); + expect(seed).toContain(":ORGANIZATIONAL_RELATION"); expect(seed).not.toMatch(/\breplace\s|GOPRIVATE|example\.com|unused-local/); }); diff --git a/apps/web/scripts/testdata/grc-e2e-seed/main.go b/apps/web/scripts/testdata/grc-e2e-seed/main.go index f40add550d..aae653c1ee 100644 --- a/apps/web/scripts/testdata/grc-e2e-seed/main.go +++ b/apps/web/scripts/testdata/grc-e2e-seed/main.go @@ -2,10 +2,12 @@ package main import ( "context" + "encoding/json" "fmt" "os" "time" + neo4jdriver "github.com/neo4j/neo4j-go-driver/v5/neo4j" cerebrov1 "github.com/writer/cerebro/gen/cerebro/v1" "github.com/writer/cerebro/internal/config" findinganalysis "github.com/writer/cerebro/internal/findings" @@ -88,6 +90,7 @@ func seedNeo4j(ctx context.Context) { for _, link := range links { must(store.UpsertProjectedLink(ctx, link)) } + seedRustAuthorityGraph(ctx, entities, links) rows, err := store.ExecuteReadCypher(ctx, ports.CypherQueryRequest{ Query: `MATCH (root:Entity {urn: $root_urn})-[relation:RELATION]-(neighbor:Entity) RETURN count(DISTINCT neighbor) AS neighbors, count(relation) AS relations`, @@ -99,3 +102,99 @@ RETURN count(DISTINCT neighbor) AS neighbors, count(relation) AS relations`, panic("seeded graph relations are incomplete") } } + +func seedRustAuthorityGraph(ctx context.Context, entities []*ports.ProjectedEntity, links []*ports.ProjectedLink) { + driver, err := neo4jdriver.NewDriverWithContext( + os.Getenv("CEREBRO_NEO4J_URI"), + neo4jdriver.BasicAuth( + os.Getenv("CEREBRO_NEO4J_USERNAME"), + os.Getenv("CEREBRO_NEO4J_PASSWORD"), + "", + ), + ) + must(err) + defer func() { _ = driver.Close(ctx) }() + + entityIDs := map[string]string{ + adminURN: "identity-privileged", + appURN: "application-admin-console", + repoURN: "repository-public-protected", + guestURN: "identity-external-collaborator", + } + entityRows := make([]map[string]any, 0, len(entities)) + for _, entity := range entities { + properties, err := json.Marshal(map[string]string{"entity_urn": entity.URN}) + must(err) + entityRows = append(entityRows, map[string]any{ + "authority_json": "{}", + "entity_id": entityIDs[entity.URN], + "entity_kind": entity.EntityType, + "external_id": entity.URN, + "label": entity.Label, + "properties_json": string(properties), + }) + } + linkRows := make([]map[string]any, 0, len(links)) + for index, link := range links { + linkRows = append(linkRows, map[string]any{ + "assertion_id": fmt.Sprintf("grc-e2e-%d", index+1), + "from_entity_id": entityIDs[link.FromURN], + "relation": link.Relation, + "source_runtime_id": link.RuntimeID, + "to_entity_id": entityIDs[link.ToURN], + }) + } + + session := driver.NewSession(ctx, neo4jdriver.SessionConfig{}) + defer func() { _ = session.Close(ctx) }() + _, err = session.ExecuteWrite(ctx, func(tx neo4jdriver.ManagedTransaction) (any, error) { + result, err := tx.Run(ctx, `UNWIND $entities AS row +MATCH (entity:Entity {urn: row.external_id}) +SET entity:OrganizationalEntity, + entity.tenant_id = $tenant_id, + entity.entity_id = row.entity_id, + entity.entity_kind = row.entity_kind, + entity.authority_json = row.authority_json, + entity.label = row.label, + entity.properties_json = row.properties_json, + entity.external_id = row.external_id +WITH count(entity) AS projected +MERGE (revision:OrganizationalGraphRevision {tenant_id: $tenant_id}) +SET revision.graph_revision = 1 +RETURN projected`, map[string]any{"entities": entityRows, "tenant_id": tenantID}) + if err != nil { + return nil, err + } + record, err := result.Single(ctx) + if err != nil { + return nil, err + } + projected, ok := record.Values[0].(int64) + if !ok || projected != int64(len(entityRows)) { + return nil, fmt.Errorf("projected Rust authority entities = %v, want %d", record.Values[0], len(entityRows)) + } + result, err = tx.Run(ctx, `UNWIND $links AS row +MATCH (source:OrganizationalEntity {tenant_id: $tenant_id, entity_id: row.from_entity_id}) +MATCH (target:OrganizationalEntity {tenant_id: $tenant_id, entity_id: row.to_entity_id}) +MERGE (source)-[relation:ORGANIZATIONAL_RELATION { + tenant_id: $tenant_id, + assertion_id: row.assertion_id +}]->(target) +SET relation.relation = row.relation, + relation.source_runtime_id = row.source_runtime_id +RETURN count(relation) AS projected`, map[string]any{"links": linkRows, "tenant_id": tenantID}) + if err != nil { + return nil, err + } + record, err = result.Single(ctx) + if err != nil { + return nil, err + } + projected, ok = record.Values[0].(int64) + if !ok || projected != int64(len(linkRows)) { + return nil, fmt.Errorf("projected Rust authority relations = %v, want %d", record.Values[0], len(linkRows)) + } + return nil, nil + }) + must(err) +} From 9b3bd1cc4187486a0a4932b46322cb52ef45a77f Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 20:17:30 -0700 Subject: [PATCH 4/7] fix: report retired graph authority outages --- apps/web/scripts/local-grc-e2e.mjs | 15 ++------------- internal/bootstrap/grc.go | 1 + internal/bootstrap/grc_test.go | 6 ++++++ internal/ports/graphquery.go | 4 ++++ internal/sourcehttp/organizationalgraph/query.go | 8 ++++++-- .../sourcehttp/organizationalgraph/query_test.go | 15 +++++++++++++++ 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/web/scripts/local-grc-e2e.mjs b/apps/web/scripts/local-grc-e2e.mjs index 357e9d35b2..9c6d524235 100644 --- a/apps/web/scripts/local-grc-e2e.mjs +++ b/apps/web/scripts/local-grc-e2e.mjs @@ -46,7 +46,6 @@ let failed = false; let backendProcess = null; let rustGraphProcess = null; let webProcess = null; -let neo4jStopped = false; let postgresStarted = false; let neo4jStarted = false; let overallDeadlineAt = 0; @@ -1007,7 +1006,8 @@ async function validateFailureModes() { const warmDashboard = await requestJSON(staleDashboardUrl, proxyCacheProbeOptions); expect(warmDashboard.status === 200, `warm dashboard status ${warmDashboard.status}`); await abortableSleep(proxyCacheTtlMs + 500, activeRunControl?.signal, "proxy cache expiry"); - await stopNeo4j(); + await stopChild(rustGraphProcess); + rustGraphProcess = null; const staleImpact = await requestJSON(staleImpactUrl, proxyCacheProbeOptions); expect(staleImpact.status === 200, `stale impact status ${staleImpact.status}`); expect(staleImpact.headers.get("x-cerebro-cache") === "stale", `stale impact cache ${staleImpact.headers.get("x-cerebro-cache")}`); @@ -1025,16 +1025,6 @@ async function validateFailureModes() { expect(nonCacheableDown.status === 502, `non-cacheable backend-down status ${nonCacheableDown.status}`); } -async function stopNeo4j() { - if (neo4jStopped || !neo4jStarted) return; - neo4jStopped = true; - await run("docker", ["stop", "--time", "5", neo4jContainer], { - deadlineAt: Math.min(overallDeadlineAt, Date.now() + 10_000), - quiet: true, - }); - neo4jStarted = false; -} - async function stopChild(child) { if (!child) return; if (child.stopPromise) return child.stopPromise; @@ -1249,7 +1239,6 @@ export async function runLocalGrcE2E(options = {}) { logDir = undefined; apiBase = undefined; webBase = undefined; - neo4jStopped = false; postgresStarted = false; neo4jStarted = false; diff --git a/internal/bootstrap/grc.go b/internal/bootstrap/grc.go index aaa5712e04..fd8b7d6f31 100644 --- a/internal/bootstrap/grc.go +++ b/internal/bootstrap/grc.go @@ -1408,6 +1408,7 @@ func grcHTTPStatusCode(err error) int { case errors.Is(err, sourceruntime.ErrRuntimeUnavailable), errors.Is(err, sourcecoverage.ErrEvaluatorUnavailable), errors.Is(err, findings.ErrRuntimeUnavailable), + errors.Is(err, ports.ErrGraphRuntimeUnavailable), errors.Is(err, graphagent.ErrLLMAuthenticationFailed), errors.Is(err, graphagent.ErrRuntimeUnavailable), errors.Is(err, graphquery.ErrRuntimeUnavailable), diff --git a/internal/bootstrap/grc_test.go b/internal/bootstrap/grc_test.go index 32a4f7a1d6..19255ec7d2 100644 --- a/internal/bootstrap/grc_test.go +++ b/internal/bootstrap/grc_test.go @@ -40,6 +40,12 @@ func TestGRCSourceCoverageEvaluatorUnavailableMapsToServiceUnavailable(t *testin } } +func TestGRCGraphRuntimeUnavailableMapsToServiceUnavailable(t *testing.T) { + if got := grcHTTPStatusCode(ports.ErrGraphRuntimeUnavailable); got != http.StatusServiceUnavailable { + t.Fatalf("status code = %d, want %d", got, http.StatusServiceUnavailable) + } +} + func TestGRCScopeDoesNotReadQuestionnaireVendorURN(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/grc/control-packets?tenant_id=writer&vendor_urn=urn:cerebro:writer:vendor:okta", nil) scope, err := grcScopeFromRequest(req) diff --git a/internal/ports/graphquery.go b/internal/ports/graphquery.go index a5a84e53d1..4763da76db 100644 --- a/internal/ports/graphquery.go +++ b/internal/ports/graphquery.go @@ -8,6 +8,10 @@ import ( // ErrGraphEntityNotFound indicates that the requested graph root entity does not exist. var ErrGraphEntityNotFound = errors.New("graph entity not found") +// ErrGraphRuntimeUnavailable indicates that the configured graph read authority +// could not serve the request. +var ErrGraphRuntimeUnavailable = errors.New("graph runtime unavailable") + // ErrGraphTypedOperationRequired indicates that a caller still depends on raw // Cypher and must move to a bounded Rust graph operation before strict // replacement mode can serve it. diff --git a/internal/sourcehttp/organizationalgraph/query.go b/internal/sourcehttp/organizationalgraph/query.go index c5bdb36b44..b28fc5aca9 100644 --- a/internal/sourcehttp/organizationalgraph/query.go +++ b/internal/sourcehttp/organizationalgraph/query.go @@ -814,10 +814,14 @@ func (s *QueryStore) MigrateProjectedLinkAssertions(ctx context.Context, request } func graphRPCError(operation string, err error) error { - if connect.CodeOf(err) == connect.CodeNotFound { + switch connect.CodeOf(err) { + case connect.CodeNotFound: return ports.ErrGraphEntityNotFound + case connect.CodeUnavailable, connect.CodeDeadlineExceeded: + return fmt.Errorf("%w: rust graph %s: %v", ports.ErrGraphRuntimeUnavailable, operation, err) + default: + return fmt.Errorf("rust graph %s: %w", operation, err) } - return fmt.Errorf("rust graph %s: %w", operation, err) } func productNeighborhood(rootKey string, rust *cerebrographv1.ExpandResponse) (*ports.EntityNeighborhood, error) { diff --git a/internal/sourcehttp/organizationalgraph/query_test.go b/internal/sourcehttp/organizationalgraph/query_test.go index 4927842b9c..c521024e18 100644 --- a/internal/sourcehttp/organizationalgraph/query_test.go +++ b/internal/sourcehttp/organizationalgraph/query_test.go @@ -925,6 +925,21 @@ func TestQueryStoreStrictReplacementRejectsUntypedCompatibilityCalls(t *testing. } } +func TestAuthorityReportsUnavailableRustRuntime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + store, err := NewQueryStore(nil, server.URL, testSharedSecret, time.Second) + if err != nil { + t.Fatalf("NewQueryStore() error = %v", err) + } + if _, err := store.GetEntityNeighborhood(context.Background(), "urn:cerebro:tenant-a:resource:one", 10); !errors.Is(err, ports.ErrGraphRuntimeUnavailable) { + t.Fatalf("GetEntityNeighborhood() error = %v, want %v", err, ports.ErrGraphRuntimeUnavailable) + } +} + func TestQueryStorePreservesProjectionAssertionCapabilities(t *testing.T) { compatibility := &assertionQueryStoreStub{} store, err := NewQueryStore(compatibility, "http://127.0.0.1:1", testSharedSecret, time.Second) From 31b5b1669f46c9bc0ec321fbabdb5a40547cbe53 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 20:24:43 -0700 Subject: [PATCH 5/7] refactor: preserve bootstrap surface budget --- internal/bootstrap/grc.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/bootstrap/grc.go b/internal/bootstrap/grc.go index fd8b7d6f31..2555dfd8ac 100644 --- a/internal/bootstrap/grc.go +++ b/internal/bootstrap/grc.go @@ -1392,9 +1392,7 @@ func writeGRCError(w http.ResponseWriter, err error) { func grcHTTPStatusCode(err error) int { statusCode := http.StatusInternalServerError switch { - case errors.Is(err, errTenantForbidden): - statusCode = http.StatusForbidden - case errors.Is(err, errScopeForbidden): + case errors.Is(err, errTenantForbidden), errors.Is(err, errScopeForbidden): statusCode = http.StatusForbidden case errors.Is(err, ports.ErrSourceRuntimeNotFound), errors.Is(err, ports.ErrFindingNotFound), From 4af5acac0a0d2db395a10268753e4efdcf6d2415 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 20:45:54 -0700 Subject: [PATCH 6/7] fix: preserve graph transport error chain --- internal/sourcehttp/organizationalgraph/query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/sourcehttp/organizationalgraph/query.go b/internal/sourcehttp/organizationalgraph/query.go index b28fc5aca9..5256a2d3cc 100644 --- a/internal/sourcehttp/organizationalgraph/query.go +++ b/internal/sourcehttp/organizationalgraph/query.go @@ -818,7 +818,7 @@ func graphRPCError(operation string, err error) error { case connect.CodeNotFound: return ports.ErrGraphEntityNotFound case connect.CodeUnavailable, connect.CodeDeadlineExceeded: - return fmt.Errorf("%w: rust graph %s: %v", ports.ErrGraphRuntimeUnavailable, operation, err) + return fmt.Errorf("%w: rust graph %s: %w", ports.ErrGraphRuntimeUnavailable, operation, err) default: return fmt.Errorf("rust graph %s: %w", operation, err) } From eee0c75015031f148a1cb0ee47f97d27f039e3ac Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Wed, 29 Jul 2026 21:21:27 -0700 Subject: [PATCH 7/7] fix: map Rust graph outages to unavailable --- internal/bootstrap/app.go | 2 +- internal/bootstrap/app_test.go | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 8a1549c441..c16837f709 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -1854,7 +1854,7 @@ var knowledgeErrorMappings = []bootstrapErrorMapping{ var graphQueryErrorMappings = []bootstrapErrorMapping{ {match: matchesAnyError(ports.ErrGraphEntityNotFound), httpStatus: http.StatusNotFound, code: connect.CodeNotFound}, - {match: matchesAnyError(graphquery.ErrRuntimeUnavailable), httpStatus: http.StatusServiceUnavailable, code: connect.CodeUnavailable}, + {match: matchesAnyError(graphquery.ErrRuntimeUnavailable, ports.ErrGraphRuntimeUnavailable), httpStatus: http.StatusServiceUnavailable, code: connect.CodeUnavailable}, {match: matchesAnyError(graphquery.ErrInvalidRequest, errInvalidHTTPRequest), httpStatus: http.StatusBadRequest, code: connect.CodeInvalidArgument}, } var graphIngestErrorMappings = []bootstrapErrorMapping{ diff --git a/internal/bootstrap/app_test.go b/internal/bootstrap/app_test.go index 5deffd0182..e807942185 100644 --- a/internal/bootstrap/app_test.go +++ b/internal/bootstrap/app_test.go @@ -170,6 +170,7 @@ func TestConnectErrorHelpersUseSpecificCodes(t *testing.T) { {name: "workflow replay unknown", err: workflowReplayConnectError(errors.New("replay failed")), code: connect.CodeInternal}, {name: "graph query entity not found", err: graphQueryConnectError(ports.ErrGraphEntityNotFound), code: connect.CodeNotFound}, {name: "graph query unavailable", err: graphQueryConnectError(graphquery.ErrRuntimeUnavailable), code: connect.CodeUnavailable}, + {name: "graph query Rust runtime unavailable", err: graphQueryConnectError(ports.ErrGraphRuntimeUnavailable), code: connect.CodeUnavailable}, {name: "graph query invalid", err: graphQueryConnectError(graphquery.ErrInvalidRequest), code: connect.CodeInvalidArgument}, {name: "graph ingest run not found", err: graphIngestConnectError(graphingest.ErrRunNotFound), code: connect.CodeNotFound}, {name: "graph ingest source not found", err: graphIngestConnectError(sourceops.ErrSourceNotFound), code: connect.CodeNotFound}, @@ -489,6 +490,14 @@ func TestWriteKnowledgeErrorMapsInvalidRequestToBadRequest(t *testing.T) { } } +func TestWriteGraphQueryErrorMapsRustRuntimeUnavailable(t *testing.T) { + recorder := httptest.NewRecorder() + writeGraphQueryError(recorder, ports.ErrGraphRuntimeUnavailable) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable) + } +} + func TestWriteHTTPErrorHelpersDoNotExposeInternalMessages(t *testing.T) { for _, tt := range []struct { name string