Skip to main content

AD-006: Graph Concept Storage

Summary

Bowrain stores concept relationships, brand vocabulary networks, and billing metadata links as a graph. The GraphStore interface has one server backend: plain-SQL adjacency tables on standard PostgreSQL (SQLGraphStore). The graph is optional and derived — a GraphSyncer rebuilds it from relational events — and no consumer issues raw Cypher, so the backend runs on stock PostgreSQL with no extension. Edges carry temporal validity and tag-based scoping.

Context

Terminology is conceptually a graph. Concepts relate to broader and narrower concepts, to related concepts, to deprecated synonyms, to competitor terms, and to terms designated for specific brand voices. Brand voice rules form another graph: brand → preferred terms, brand → forbidden terms, brand → competitor terms. Billing metadata links workspaces, plans, feature flags, and quotas through relationship chains.

A flat relational model can store edges, but traversal queries (find all narrower concepts, shortest path between concepts, scoped neighbors at a specific point in time) become unnecessarily complex. A native graph representation makes these queries natural and fast.

The graph also needs temporal semantics: relationships change over time (term supersession, seasonal terminology, product lifecycle), and they scope to tag dimensions (market, product line, channel). A single model has to support both dimensions without hard-coding vocabulary.

Decision

GraphStore Interface

The GraphStore interface in core/graph/store.go is a backend-agnostic graph API:

type GraphStore interface {
// Node CRUD
CreateNode(ctx context.Context, node *Node) error
GetNode(ctx context.Context, id string) (*Node, error)
UpdateNode(ctx context.Context, node *Node) error
DeleteNode(ctx context.Context, id string) error

// Node queries
FindNodes(ctx context.Context, label string, properties map[string]string) ([]*Node, error)
FindNodesScoped(ctx context.Context, label string, properties map[string]string, scope Scope) ([]*Node, error)

// Edge CRUD + queries
CreateEdge(ctx context.Context, edge *Edge) error
GetEdge(ctx context.Context, id string) (*Edge, error)
UpdateEdge(ctx context.Context, edge *Edge) error
DeleteEdge(ctx context.Context, id string) error
FindEdges(ctx context.Context, label string, properties map[string]string) ([]*Edge, error)

// Traversal
Neighbors(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Node, error)
NeighborsScoped(ctx context.Context, nodeID string, direction Direction, scope Scope, labels ...string) ([]*Node, error)
EdgesOf(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Edge, error)
ShortestPath(ctx context.Context, fromID, toID string, maxDepth int) (*Path, error)

// Bulk
BulkCreateNodes(ctx context.Context, nodes []*Node) error
BulkCreateEdges(ctx context.Context, edges []*Edge) error

Close() error
}

Direction supports Outgoing, Incoming, and Both for edge traversal.

Data Types

type Node struct {
ID string `json:"id"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type Edge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
Validity *Validity `json:"validity,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type Path struct {
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
}

Temporal Validity

Edges carry optional Validity combining temporal bounds and tag-based scoping:

type Validity struct {
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
}

type Scope struct {
At time.Time `json:"at"`
Tags map[string]string `json:"tags,omitempty"`
}

Matching rules (Validity.Matches(Scope)):

  • Nil validity always matches (unbounded edge).
  • Time: half-open interval [ValidFrom, ValidTo).
  • Tags: all scope tags must be present in validity tags with matching values (open-world — extra validity tags are ignored).

Tag dimensions are workspace-configurable via brand.TagDimension. The graph itself has no hard-coded dimensions, so customers can introduce market, product, channel, locale-family, or any other axis without schema changes.

Helper functions: Now(), ScopeAt(t), ScopeWithTags(tags), IsExpired(), IsActive().

SKOS-Aligned Edge Labels

Edge labels in core/graph/labels.go align with W3C SKOS vocabulary for terminology interoperability:

LabelSKOS / Semantic OriginPurpose
BROADERskos:broaderParent concept
NARROWERskos:narrowerChild concept
RELATEDskos:relatedAssociative link
PART_OFmeronymyComponent of
HAS_PARTholonymyContains component
HAS_TERMterminologicalConcept to term designation
USE_INSTEADterminologicalDeprecated to preferred term
REPLACED_BYterminologicalSuperseded concept to replacement
EXACT_MATCHskos:exactMatchCross-scheme equivalence
CLOSE_MATCHskos:closeMatchApproximate equivalence
FORBIDDENbrand voiceBrand to forbidden term
PREFERREDbrand voiceBrand to preferred term
COMPETITORbrand voiceBrand to competitor term

InverseLabel() returns the inverse of directional labels (BROADER/NARROWER, PART_OF/HAS_PART) so callers can navigate in either direction.

SQL Backend

The server backend, SQLGraphStore in bowrain/graph/sql.go, implements GraphStore on standard PostgreSQL using plain relational adjacency tables (graph_nodes, graph_edges) — no extension and no Cypher. It is a dialect port of the framework's SQLite reference (host/storage/graph/sqlite.go): the same node/edge CRUD, jsonb property containment, temporal Scope/Validity filtering (evaluated in Go via Validity.Matches), directional neighbours, and a bounded, cycle-safe recursive-CTE ShortestPath.

Because the graph is optional and derived and needs no native traversal, plain SQL means both local development and managed RDS/Aurora run on the same stock postgres:16 image. CypherQuery / CypherExec return core/graph.ErrCypherNotSupported. EnsureGraph creates the schema at wiring time (bowrain/server/postgres.go), under a transaction-scoped advisory lock so replicas cold-starting against one fresh database serialise their DDL rather than race. A schema-setup failure degrades to a nil GraphStore (brand-graph features disabled) rather than failing server startup.

A contract suite (bowrain/graph/parity_test.go) pins the operations bowrain uses against a real PostgreSQL testcontainer. See bowrain/graph/NOTES.md for behavior notes.

CypherQuery / CypherExec stay on the core/graph.GraphStore interface as the escape hatch for a workload that ever needs native graph traversal — deep multi-hop Cypher, path algorithms. Such a workload is served by adding a native graph engine behind the same interface, not by extending the SQL backend.

SQLite reference (framework)

The SQLGraphStore schema is a dialect port of the framework's SQLite reference, host/storage/graph/sqlite.go, which the CLI and single-instance tooling use with no external database. Both implement the same GraphStore with adjacency tables (graph_nodes, graph_edges), JSON/jsonb-encoded properties, nullable RFC3339 validity bounds, Go-side tag filtering for scoped queries, and a recursive-CTE cycle-safe ShortestPath. The one behavioural difference: the SQL/Postgres backend cascades edge deletes (ON DELETE CASCADE), whereas the SQLite reference errors on deleting a node that still has incident edges.

Event-Driven Graph Sync

The server-side GraphSyncer in bowrain/graph/sync.go subscribes to the event bus and keeps the graph in sync with relational content changes. It is backend-agnostic — it takes a core/graph.GraphStore:

EventAction
EventBlockCreatedCreate Concept node with project_id and name properties
EventBlockUpdatedUpdate node properties
EventBlockDeletedDelete node (incident edges cascade via ON DELETE CASCADE)

The syncer uses a 10-second context timeout per event and logs errors without failing — graph inconsistency is recoverable; blocking event processing is not. Because the graph is a derived projection, it can be rebuilt from the relational stores at any time.

Terminology Integration

The ConceptRelation type in the terms package bridges the terms store and the graph:

type ConceptRelation struct {
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
RelationType string `json:"relation_type"` // graph.Label* constants
}

TermDesignation pairs a Term with a Validity for the status-on-edge model, where term lifecycle status (approved, pending, deprecated) can be time-bounded or tag-scoped.

Terminology updates emit events consumed by the GraphSyncer, keeping concept relationships reflected in the graph without direct terms → graph coupling.

Brand Voice and Billing

Brand voice vocabulary projects onto the graph: preferred terms (PREFERRED), forbidden terms (FORBIDDEN), and competitor mentions (COMPETITOR) are edges from brand nodes. Scoped queries (Scope with market/product tags) resolve the effective brand vocabulary at a point in time. The relational brand store remains the source of truth; the graph is the derived traversal projection.

Billing metadata uses the graph to link workspaces to plans, plans to feature flags, and features to quotas. Temporal validity on edges models plan transitions without destructive updates.

Implementation Files

FilePurpose
core/graph/types.goNode, Edge, Path, Direction types
core/graph/store.goGraphStore interface
core/graph/validity.goValidity, Scope, matching logic
core/graph/labels.goSKOS-aligned edge label constants
host/storage/graph/sqlite.goSQLite adjacency-table reference (framework / CLI)
bowrain/graph/sql.goSQLGraphStore — plain-SQL backend on PostgreSQL
bowrain/server/postgres.goStore wiring (EnsureGraph at pool-open time)
bowrain/graph/sync.goEvent-driven graph sync
bowrain/graph/NOTES.mdBackend and behavior notes

Consequences

  • Concept relationships are first-class graph edges; navigation, broader/narrower traversal, and shortest-path queries are natural.
  • The SQL backend runs on stock PostgreSQL (including managed RDS/Aurora), so development and production share one image with no extension dependency.
  • Temporal validity models relationships that change over time (term supersession, seasonal terminology, product lifecycle).
  • SKOS-aligned labels ensure interoperability with standard terminology interchange formats.
  • Event-driven sync keeps the graph consistent with relational data without manual intervention.
  • Backend substitution stays open: callers depend only on core/graph.GraphStore, so a native graph engine can be introduced behind the same interface if scaling ever demands native traversal; the parity_test.go contract suite is the behavioral spec a new backend must pass.