Neo4j Implementation Guide
Status
Canonical implementation guide. Version 0.2.
Companion to the Memory Runtime Integration Guide — that
guide fixes the NAMS/graph-tier binding mechanics (client configuration, bolt mode, Community Edition
constraints); this guide is the per-agent use-case and query-pattern catalog built on top of that binding. It
does not restate that guide’s §5.
Related specs: Synedrix OS Outcome Spec,
Synedrix OS Memory Fabric Spec,
Synedrix OS - Agent Registry, Synedrix OS ADR-OS-002.
Purpose
The Neo4j platform page establishes what the store is and why the OS uses it. The
Memory Fabric Spec and the Memory Runtime Integration
Guide fix the graph tier’s lifecycle and its NAMS
binding. The Outcome Spec fixes the node and relationship model. None of
them walks the Agent Registry end to end and shows what each of the
twelve OS agents actually does — or explicitly does not do — with the graph. This guide fills that gap.
A Neo4j or Cypher usage is non-compliant unless it stays inside the three-agent boundary the Agent Registry
already defines — os-agt-008 (Audit & Lineage Agent, read-only), os-agt-010 (Memory Curator Agent, sole
writer, via NAMS bolt mode), os-agt-011 (Consolidation & Reflection Agent, read-only) — and every graph
write is a projection of a prior Postgres write, never the reverse. A pattern that reads well but breaks
either rule is not a smaller version of compliance; it is a governance bypass that happens to run.
Scope
- The per-agent use-case catalog: which of the twelve OS agents touch the graph tier, with worked Cypher and NAMS-client examples for the three that do, and an honest accounting of the nine that do not
- The Postgres-first write pattern, taught through a real, already-flagged violation in the trading domain pack
- OS-level indexing guidance for the query shapes
os-agt-008actually issues - A compliance checklist for any new Neo4j or Cypher usage added to an OS-owned workflow or agent
Not in scope:
- NAMS client configuration, bolt-mode setup, and Community Edition constraint handling — owned by the Memory Runtime Integration Guide §5
- Community Edition licensing, backup, RBAC, and metrics limitations — owned by the Neo4j platform page’s “Deployment and licensing” section
- Domain-pack-specific Cypher schemas — the trading and art domain packs each own their own node types and query patterns; this guide references one trading example for teaching purposes and does not modify or resolve it
- Granting any agent Neo4j or NAMS access beyond the three the Agent Registry currently authorizes. This guide documents the existing authorization boundary; it proposes no new access for any of the other nine agents
- Neo4j installation and deployment mechanics — owned by the
synedrix-os-stack-self-hostedreference implementation per DOCS.md’s repository classification
Quick reference: OS concept → Neo4j / Cypher construct
| OS concept | Neo4j / Cypher construct |
|---|---|
outcome_id | A node, labelled by source type (agent or workflow outcome) |
agent_lineage_links (Supabase, write-authoritative) | The source the graph is projected from; never written to directly from Neo4j |
Relation types invoked · contributed-to · escalated-to · governed-by | Cypher relationship types. Hyphenated names are not valid unquoted identifiers and must be backtick-escaped, e.g. `contributed-to` |
correlation_id | An indexed node property used as the traversal scope filter — a partition key, not a graph element |
causation_id | Realised as a directed edge between two outcome nodes, typed as whichever of the four relation types applies — see §1.1.1 for the MERGE pattern that writes it |
| Escalation-path reachability (Agent Registry invariant) | A variable-length EXISTS { MATCH (a)-[:escalated-to*1..]->(t:Agent) WHERE t.agent_id IN [...] } query — see the platform page for the full form |
Audit read path (os-agt-008) | A read transaction over the Bolt driver — never a write transaction |
Graph-tier write (os-agt-010) | A NAMS MemoryClient async call in bolt mode — never hand-written Cypher issued from an agent node |
| POLE+O entity model (NAMS) | Node labels for Person / Organization / Location / Event, plus other typed objects |
| Reasoning-to-entity lineage (NAMS) | AgentStep / ToolCall nodes joined to the entities they affected via :TOUCHED edges |
Entity dedup candidates (os-agt-011) | A similarity read query — illustrative of what NAMS’s dedupe_entities performs internally, not a query the OS hand-writes in production |
| Tenant isolation under Community Edition’s one-standard-database limit | A tenant_id property on every node and edge, filtered in every query — never a per-tenant database |
| NAMS’s own vector index | Scoped to entity resolution only; Qdrant remains the single semantic retrieval substrate |
| The NAMS MCP server’s read-only Cypher tool | Governed by mcp_tool_policy; not exposed by default |
1. Per-agent use-case catalog
1.1 Agents with direct Neo4j or NAMS access
Exactly three agents. Each entry below shows the query shape the agent actually issues and states the benefit in terms of what a relational store would have cost.
1.1.1 os-agt-008 — Audit & Lineage Agent (read-only)
Its registry input contract accepts query_type: lineage_trace | decision_explanation | policy_audit | run_summary, entity_id?, workflow_id?, correlation_id, and time_range? — there is no outcome_id
field. For a lineage_trace query the outcome being traced is carried in entity_id; this guide’s Cypher
examples name that value $outcome_id purely for readability, and a client built from this guide must still
populate the contract’s actual entity_id field, not invent one. Two query shapes cover the great majority of
that surface. Both assume outcome nodes and typed edges already exist — the next subsection shows how they get
there before the two read shapes are covered.
How the lineage graph is structured before it can be queried
This write is deliberately not one of the three agent-mediated paths in this guide’s scope box. No agent
calls this Cypher as a tool. It is the outcome-persistence path itself — the same step that writes
agent_lineage_links in Supabase — projecting the fact it just committed into Neo4j immediately afterward,
fire-and-forget. This is what makes the Postgres-first rule in §2 a concrete mechanism rather than an
aspiration: the projection only ever writes a fact Postgres already has.
// Project one agent_lineage_links row whose endpoints are Outcome nodes.
// Parameters come from the Postgres row: source_id, target_id, relation,
// tenant_id, and optional context.correlation_id — NOT from causation_id
// alone (causation_id may name an event, which must never become an Outcome node).
MERGE (src:Outcome {outcome_id: $source_id})
ON CREATE SET src.tenant_id = $tenant_id
MERGE (tgt:Outcome {outcome_id: $target_id})
ON CREATE SET tgt.tenant_id = $tenant_id
SET tgt.correlation_id = coalesce($correlation_id, tgt.correlation_id),
tgt.tenant_id = $tenant_id
// Select one of four fixed patterns for relation (Cypher cannot parameterise
// relationship types). Example for relation = 'contributed-to':
MERGE (src)-[r:`contributed-to`]->(tgt)
ON CREATE SET r.tenant_id = $tenant_id // every relationship carries tenant_id, from the lineage rowWhen projecting a newly written outcome node (not an edge), upsert properties from the outcome row / envelope. Store causation_id as a node property only; never MERGE an Outcome keyed by causation_id unless that value is known to be an outcome_id from a committed lineage endpoint.
The example above is written for relation_type = 'contributed-to'. Cypher cannot bind a relationship type
as a query parameter — [:$relation_type] is not valid syntax — so the calling code selects between four
fixed query strings, one per canonical type, rather than interpolating a value into the pattern. Building the
relationship type from a string that isn’t one of the four fixed literals is a Cypher-injection surface, not a
convenience; if a single call site genuinely needs a dynamic type at runtime, APOC core’s
apoc.merge.relationship(origin, $relation_type, {}, {}, o, {}) does the same idempotent upsert with the type
name passed safely as a parameter, and it ships in Community Edition.
Because both the node upsert and the edge upsert use MERGE, running the same projection twice — the normal
behaviour of a fire-and-forget async step that may retry — leaves the graph unchanged rather than duplicating
nodes or edges.
Causal-chain reconstruction. Given an outcome_id, walk backward along the four canonical relation types
to the outcomes that led to it, scoped to a single flow:
// Reconstruct the causal chain leading to a given outcome, scoped to one
// flow and one tenant. Relation type names follow the Outcome Spec;
// hyphenated names require backtick-escaping.
//
// Collects every matching path rather than picking one: a fan-out/fan-in
// workflow contributes more than one upstream branch to the same target,
// and `ORDER BY length(path) DESC LIMIT 1` would silently keep only the
// longest branch and drop the rest -- exactly the kind of gap a policy
// audit or incident postmortem must not have.
MATCH path = (origin:Outcome)
-[:`invoked`|`contributed-to`|`escalated-to`|`governed-by`*1..20]->
(target:Outcome {outcome_id: $outcome_id})
WHERE ALL(n IN nodes(path) WHERE n.correlation_id = $correlation_id
AND n.tenant_id = $tenant_id)
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT {
outcome_id: n.outcome_id,
source_type: n.source_type,
source_id: n.source_id,
status: n.status
}) AS subgraph_nodes
RETURN subgraph_nodes,
[p IN paths | [r IN relationships(p) | type(r)]] AS branch_relation_sequencesThe benefit is concrete, not stylistic. In Supabase, the same reconstruction is a recursive self-join over
agent_lineage_links, and its cost grows with chain depth. In Cypher it is one MATCH over a variable-length
path, and correlation_id/tenant_id as per-node filters — pushed into the traversal rather than applied to
the result set — keep the walk inside one flow, for one tenant, instead of the whole graph. This is the same
principle the Memory Fabric
Spec §6.3 states for memory recall: filters belong
in the query, not after it.
Escalation-path reachability. The Neo4j platform
page gives the original illustrative query
that checks every agent’s declared escalation_path reaches os-agt-001 or os-agt-004; ADR-OS-007
and the Agent Graph Data Model since fixed the canonical shape as
one CAN_ESCALATE_TO relationship carrying declared/observed_count/last_observed_at, rather than
overloading the outcome graph’s escalated-to type for both the declared topology and the runtime record. What
is worth adding here is when os-agt-008 runs the reachability check: as part of WF-OS-INCIDENT-POSTMORTEM,
to distinguish “this agent’s escalation path is broken” from “this agent’s path is sound but nobody walked
it” — a question the declared/observed_count split now answers directly rather than by inference.
What Community Edition does not backstop. Community Edition has no role-based access control, so
os-agt-008’s read-only guarantee has no database-level enforcement — a point the platform page’s licensing
section already makes. It is restated here because it bears directly on this agent’s worked queries: the
guarantee holds because the registry invariant ("os-agt-008 must never appear in any agent’s tools_allowed
list") and the tool-authorization layer above it hold, not because the database refuses a write from this
agent’s driver session.
1.1.2 os-agt-010 — Memory Curator Agent (sole graph writer)
Every graph-tier write is a NAMS MemoryClient call, never hand-written Cypher issued from an agent node — the
Memory Runtime Integration Guide §5.3
already states why: a direct NAMS call from an arbitrary agent is a governance bypass even though the data
lands in the right store. The worked shape, inside WF-OS-MEMORY-CONSOLIDATE’s commit step, after
os-agt-012 has resolved scope:
# os-agt-010 (Memory Curator) — commit a consolidated entity assertion.
# Runs only inside WF-OS-MEMORY-CONSOLIDATE's commit step, after os-agt-012
# has validated scope. Never called from an arbitrary agent node.
result = await memory_client.upsert_entity(
user_identifier=f"{tenant_id}:{subject_id}", # tenant_id first, always
entity_type="Organization", # a POLE+O label
properties={"name": "Acme Logistics BV", "entity_ref": "org-2231"},
source_step_id=agent_step_id, # creates the :TOUCHED edge
)The :TOUCHED edge this call creates, from the AgentStep node to the entity node, is the reasoning-to-
entity lineage — the graph-tier counterpart of the memory record envelope’s provenance and derived_from
fields (Memory Fabric Spec §4). The
benefit is that a later question — “which reasoning step asserted this fact about this entity, and what did it
touch besides” — is a one-hop traversal from the entity node, rather than a search across every episodic record
that happens to mention it.
1.1.3 os-agt-011 — Consolidation & Reflection Agent (read-only entity dedup)
os-agt-011 recommends consolidations; it never writes. The dedup step it draws on is NAMS’s own
dedupe_entities primitive. The shape below is illustrative of what that primitive performs internally —
it is not Cypher the OS hand-writes in production, because the entity-resolution path belongs to NAMS, not to
OS code:
// Illustrative only. NAMS performs entity resolution internally via
// dedupe_entities(); os-agt-011 does not issue hand-written Cypher
// against the graph tier in production.
MATCH (e1:Organization), (e2:Organization)
WHERE e1.entity_ref <> e2.entity_ref
AND e1.tenant_id = e2.tenant_id
AND apoc.text.jaroWinklerDistance(e1.name, e2.name) > 0.92
RETURN e1, e2, apoc.text.jaroWinklerDistance(e1.name, e2.name) AS similarity
ORDER BY similarity DESC
LIMIT 20(APOC core, which ships the string-similarity functions, is available in Community Edition.) The benefit over a
purely relational fuzzy-match is that entity resolution here can also weigh relationship shape — two
organization nodes with near-identical names and overlapping :TOUCHED neighbourhoods are a stronger dedup
signal than name similarity alone, and that signal only exists because the relationships are first-class.
1.2 Agents without direct access — why not, and what relationship (if any) remains
The other nine agents have no Neo4j or NAMS entry in tools_allowed, systems_allowed, or memory_scope. That
is a deliberate governance boundary, not an oversight, and this table does not propose closing it. Each row
cites the agent’s actual systems_allowed from the Agent Registry.
| Agent | Why no direct access | Indirect relationship to the graph, if any |
|---|---|---|
Orchestrator Agent (os-agt-001) | systems_allowed: n8n, Redis, Supabase (read), agent registry (read) — no Neo4j | May trigger WF-OS-INCIDENT-POSTMORTEM, which invokes os-agt-008, but never reads the graph itself |
Policy Guard Agent (os-agt-002) | systems_allowed: Supabase policy store and audit trail, Redis — no Neo4j | The escalation-reachability query is a registry/CI validation check, not a runtime tool this agent calls |
Registry & Capability Agent (os-agt-003) | systems_allowed: Supabase registries (read), Redis capability cache — no Neo4j | None. Capability resolution is a registry lookup, not a graph traversal |
HITL Gateway Agent (os-agt-004) | systems_allowed: Supabase approval state, Hookdeck Outpost, Redis — no Neo4j | None |
Analytics Agent (os-agt-005) | systems_allowed: QuestDB, Supabase, PostHog, Langfuse (all read) — no Neo4j | None directly. A cross-domain correlation question that looks graph-shaped stays inside QuestDB/Supabase aggregation, or becomes a request to os-agt-008 for lineage evidence rather than a new tool binding |
Forecasting Agent (os-agt-006) | systems_allowed: QuestDB, Qdrant (read), Langfuse — no Neo4j | None |
Anomaly Detection Agent (os-agt-007) | systems_allowed: QuestDB, Redis, Prometheus, Supabase (write) — no Neo4j | None |
General Chat Agent (os-agt-009) | systems_allowed: Qdrant (read-only), LiteLLM, Langfuse — explicitly “no access to operational, conversation, or canonical relational memory” | None. Deliberately the smallest blast radius available for a standalone chat surface |
Memory Access Broker Agent (os-agt-012) | systems_allowed: Supabase (read), agent registry (read) — explicitly “no access to memory record content in any tier,” including graph content | The governance chokepoint: every graph-tier recall and write proposal is resolved through it before the store query runs, without it ever holding a Neo4j connection itself |
The registry invariant that os-agt-008 “must never appear in any agent’s tools_allowed list” is worth
stating plainly here: it means these nine agents have no indirect data path into the graph either, not
through the audit agent and not through any other route. Where a workflow needs graph-derived context for one
of these agents’ decisions, the correct shape is a governed recall or an explicit os-agt-008 query surfaced
into the workflow’s own outcome — never a new binding invented for the consuming agent.
2. The compliant-write pattern, taught through a real violation
The general rule, stated in the Agent DB Schema and repeated in the Memory Fabric Spec §10: lineage links are written to Postgres first, then synced to Neo4j. Postgres is write-authoritative; the graph is a rebuildable projection. Nothing blocks on the Neo4j half of that write.
2.1 Postgres → Neo4j lineage mapping (normative)
Agent DB Schema v1.4 and Outcome Spec v0.3 share one Outcome lineage vocabulary. Writers insert Postgres first; the async projector emits Cypher.
Postgres agent_lineage_links | Neo4j projection |
|---|---|
source_type = 'outcome', source_id = outcome_id | (:Outcome {outcome_id}) |
target_type = 'outcome', target_id = outcome_id | (:Outcome {outcome_id}) |
relation = 'invoked' | -[:`invoked`]-> |
relation = 'contributed-to' | -[:`contributed-to`]-> |
relation = 'escalated-to' | -[:`escalated-to`]-> |
relation = 'governed-by' | -[:`governed-by`]-> |
tenant_id, context.correlation_id (when present) | Node properties tenant_id, correlation_id |
Structural registry edges (depends-on, replaced-by with agent/workflow/tool/policy endpoints) project to the Agent Graph model (Agent Graph Data Model), not to :Outcome nodes. Do not invent a fifth Outcome relation type.
The trading domain pack documented the same failure shape (alignment C5). Remediation is now Specified as FILLED Step 5a/5b in Synedrix Trading — TRD DDL Spec §6 and recorded in Trading DB Schema Alignment v2.5; workflow-contract and synedrix-n8n skill updates remain build-phase. What it illustrates, generically, is the failure shape worth recognising anywhere in the OS:
NON-COMPLIANT
Step 5: write Neo4j causal-graph edge synchronously
(no prior Postgres write of the same fact — the graph
is now the only record, and it cannot be rebuilt from
Postgres because Postgres never had it)
COMPLIANT (the proposed remediation shape)
Step 5a: write the fact to Postgres (agent_lineage_links)
— synchronous, write-authoritative
Step 5b: project the same fact into Neo4j
— asynchronous, fire-and-forget, never on the hot pathThe test this protects is the one the Memory Runtime Integration Guide §2.3 already states for the memory fabric generally: wiping Neo4j and rebuilding it from Postgres must produce identical behaviour. A direct, synchronous Neo4j write with no Postgres counterpart fails that test by construction — there is nothing to rebuild from.
3. Indexing and performance guidance (OS level)
os-agt-008 queries by outcome_id, correlation_id, and causation_id (per the Outcome
Spec); the escalation-reachability query filters by
agent_id. All four should be indexed before any of the queries in §1.1.1 run against production volume:
outcome_id is a uniqueness constraint, not a plain index: the Outcome Spec requires outcome_id to be
globally unique, and a plain index does not enforce that — two concurrent, retrying fire-and-forget projections
can both find no matching node and each create one, MERGE notwithstanding, without a constraint backing the
lookup. correlation_id, causation_id, and agent_id remain plain property indexes, available in Community
Edition without restriction. The uniqueness constraint is the only constraint type Community Edition offers,
per the platform page’s licensing section; a constraint
supplies its own lookup index automatically, so outcome_id needs no separate index alongside it.
The trading domain’s own Neo4j price-prediction
doc applies the same
principle one level down — indexing its PriceCandle nodes on (asset, timestamp, regime_version) — for a
domain-specific node type this guide does not otherwise cover. The principle generalises; the schema does not.
4. Governance bridge (mandatory)
| Concern | Implementation |
|---|---|
| Read/write role separation | Among agents, only os-agt-008 and os-agt-011 hold read access; only os-agt-010 holds write access, and only via NAMS bolt mode. No other agent has any Neo4j entry in tools_allowed or systems_allowed. The outcome-lineage projection in §1.1.1 is a separate, non-agent-mediated write: it is system-level plumbing that only ever projects a fact Postgres already committed, not a fourth agent write path. |
| Commit authority | os-agt-010 is the only committer of graph-tier content, gated by os-agt-012’s scope resolution — the same rule the Memory Fabric Spec states for every other tier (§10). |
| Postgres-first lineage | Every graph write is a projection of a prior Postgres write (Agent DB Schema; see §2 above for the failure mode when this is skipped). |
| Audit read-only | os-agt-008 never appears in any agent’s tools_allowed, including as a memory tool — it is a query target, not a callable tool. |
| Tenant isolation | Enforced by a tenant_id property on every node and edge, filtered in every query — never by a per-tenant database, which Community Edition does not offer. |
| MCP governance | The NAMS MCP server’s read-only Cypher tool is not exposed by default; any exposure passes mcp_tool_policy. |
5. Observability
Graph-tier calls are measured with the metric families the Memory Runtime Integration Guide
§7 already defines for NAMS calls
generally — no new vocabulary is introduced here. os-agt-008’s read path adds two signals specific to this
guide’s queries:
| Signal | Source |
|---|---|
| Lineage-query latency | Langfuse span on the os-agt-008 read transaction |
Reconstructed chain length (length(path)) | Recorded alongside the query result; a chain that hits the *1..20 bound without reaching a terminal node is itself a signal worth alerting on |
6. Compliance checklist
Before shipping any new Neo4j or Cypher usage in an OS-owned workflow or agent:
- Only
os-agt-008,os-agt-010, andos-agt-011have Neo4j intools_allowed/systems_allowed; no other agent’s registry entry was changed. -
os-agt-008’s access is read-only, and it does not appear in any other agent’stools_allowed. -
os-agt-010is the only writer; every write is a NAMS bolt-mode call, never hand-written Cypher from an agent node;MEMORY_API_KEYis unset. -
os-agt-011’s access is read-only and scoped to entity deduplication. - Every Neo4j write is preceded by a Postgres write of the same fact — no direct-write pattern matching the
flagged trading
FILLEDstep-5 violation (§2) exists anywhere in an OS-owned path. - The outcome-lineage projection (§1.1.1) stays system-level plumbing, never wrapped as a callable tool bound
to any agent’s
tools_allowed— doing so would create a fourth, ungoverned write path into the graph. - Relation types used are exactly
invoked/contributed-to/escalated-to/governed-by, correctly backtick-escaped. - A uniqueness constraint exists on
outcome_id, and plain indexes exist oncorrelation_id,causation_id, andagent_id, before the query ships against production volume. - Tenant isolation is enforced by a
tenant_idproperty and application-layer filtering, not a per-tenant database. - The NAMS MCP server’s read-only Cypher tool remains unexposed by default, and is exposed only when
mcp_tool_policyexplicitly authorizes it.
Versioning
- Version: 0.2
- Supersedes: v0.1
- Related: Synedrix OS Memory Fabric Spec, Synedrix OS Memory Runtime Integration Guide, Synedrix OS ADR-OS-002
Related Documents
Core specs
- Synedrix OS Outcome Spec — The node and relationship model this guide’s Cypher examples implement.
- Synedrix OS Memory Fabric Spec — The
graphtier’s lifecycle, record envelope, and governance invariants. - Synedrix OS - Agent Registry — The three-agent access boundary this guide catalogs and does not extend.
- Synedrix OS ADR-OS-002 — The decision adopting NAMS as a library over Neo4j.
- Synedrix OS Agent Graph Data Model and Synedrix OS ADR-OS-007 —
The
Agent/Workflow/Toolnode types and theCAN_ESCALATE_TOrelationship that formalize the escalation-reachability query in §1.1.1; not yet reflected in this guide’s own worked examples (a follow-up, per ADR-OS-007 §6.3).
Orchestration and runtime
- Synedrix OS Memory Runtime Integration Guide — Companion guide; owns the NAMS binding mechanics this guide builds on without restating.
- Synedrix OS n8n Implementation Guide — Maps
graph_memory_accessto its n8n tool sub-node.
Platform reference
- Neo4j — Primary platform reference; the escalation-reachability query and the Community Edition licensing analysis this guide cross-links rather than repeats.
- Qdrant — The boundary this guide’s NAMS-vector-index note clarifies.
- Supabase PG17 — Write-authoritative source for every projection into Neo4j.
Domain reference (informative only, not normative for OS canon)
- Synedrix Trading Domain Pack, Trading DB Schema Alignment, and TRD DDL Spec — Teaching example in §2; C5 Step 5a/5b is Specified in the TRD DDL companion.
- Predicting Market Price Movement with Neo4j Pattern Matching — A domain-specific worked example of the indexing principle in §3.
Teaching material
- Synedrix OS Academy — Chapter 04.8 teaches the hands-on Cypher this guide documents normatively; chapters 02.8 and 04.4 cover the memory taxonomy and the store/state distinction it builds on. Teaching material, not normative: where a chapter disagrees with this guide, this guide wins.
Synedrix OS · August 2026