Agent Graph Data Model
Status
Canonical spec.
Implements: ADR-OS-007.
Authority for: The graph tier’s node and relationship taxonomy for agents, workflows, and tools. Extends,
and does not replace, the Outcome Spec’s Outcome node and its four
relationship types — this document adds to that model; it does not restate it.
Related: Neo4j Implementation Guide, Agent
Registry, Memory Fabric Spec.
Purpose
Before this document, the graph tier had exactly one node type — Outcome — and agent identity existed only as
a property on it. Any agent-centric question (“what has this agent produced,” “what can it escalate to, and has
it”) required a property filter over every outcome in the graph rather than a traversal from the agent itself.
ADR-OS-007 decided to close that gap by making
Agent, Workflow, and Tool first-class nodes, written by the same non-agent-mediated projection that
already writes Outcome nodes. This document is that decision’s implementation: the full schema, the write
patterns, and the queries it newly makes possible.
Nothing here changes the three-agent access boundary. The Neo4j Implementation
Guide remains authoritative for which agents touch the graph
(os-agt-008 read-only, os-agt-010 sole NAMS writer, os-agt-011 read-only) and for the Postgres-first write
rule. This document extends what the projection writes, not who is allowed to write it.
Scope
- The
Agent,Workflow, andToolnode schemas and their write-authoritative sources - The
PRODUCED,CAN_ESCALATE_TO,AUTHORIZED_FOR, andOWNS_WORKFLOWrelationship types MERGEwrite patterns for all of the above- Example queries the model newly makes possible
- Constraints and indexing
Not in scope: the Outcome node and its four relationship types (owned by the Outcome
Spec); which agents may query or write the graph (owned by the Neo4j
Implementation Guide); the NAMS-owned POLE+O entity/reasoning
tier (owned by the Memory Runtime Integration Guide
§5); domain-pack-specific node types (each pack’s own documentation).
1. Node types
| Label | Key properties | Write-authoritative source |
|---|---|---|
Agent | agent_id, name, family, owner, decision_scope, status | Agent Registry — applies to any registered agent, OS-owned or domain-pack-owned |
Workflow | workflow_id, name, domain, version | Workflow Contract Spec’s registry |
Tool | tool_id, name | Each agent’s tools_allowed declaration |
Outcome | (unchanged — see the Outcome Spec) | Outcome Spec |
agent_id, workflow_id, and tool_id are each expected to be unique within a tenant, mirroring the
outcome_id uniqueness the Outcome Spec already assumes.
2. Relationship types
| Relationship | Shape | Properties | Purpose |
|---|---|---|---|
PRODUCED | (Agent)-[:PRODUCED]->(Outcome) | none | Replaces filtering Outcome.source_id; “what has this agent done” becomes a one-hop traversal. |
CAN_ESCALATE_TO | (Agent)-[:CAN_ESCALATE_TO]->(Agent) | declared: boolean, observed_count: integer, last_observed_at: timestamp | One edge carries both the declared registry topology and the observed runtime record — see ADR-OS-007 §3.2 for why this is one relationship type, not two. |
AUTHORIZED_FOR | (Agent)-[:AUTHORIZED_FOR]->(Tool) | none | From tools_allowed. |
OWNS_WORKFLOW | (Agent)-[:OWNS_WORKFLOW]->(Workflow) | none | From owner_workflows. |
invoked / contributed-to / escalated-to / governed-by | (Outcome)-[...]->(Outcome) | (unchanged) | Owned by the Outcome Spec; escalated-to here is the runtime record CAN_ESCALATE_TO.observed_count is derived from. |
OBSERVED | (Outcome)-[:OBSERVED]->(Outcome) | recorded_at: timestamp | Idempotency bookkeeping only (§3.4) — marks that a given source/target outcome pair’s escalated-to edge has already been counted toward CAN_ESCALATE_TO.observed_count, so a retried projection cannot double-count it. Not part of the model’s semantics; not traversed by any query in §4. |
The node types and relationship types, with the write pattern that feeds each:
flowchart LR
AGENT(("Agent"))
WF(("Workflow"))
TOOL(("Tool"))
OUT(("Outcome"))
AGENT -->|"AUTHORIZED_FOR"| TOOL
AGENT -->|"OWNS_WORKFLOW"| WF
AGENT -->|"PRODUCED"| OUT
AGENT -->|"CAN_ESCALATE_TO"| AGENT
OUT -->|"invoked, contributed-to, escalated-to, governed-by"| OUT
OUT -->|"OBSERVED"| OUT
P31["3.1 Registry projection"]
P32["3.2 Escalation topology: declared half"]
P33["3.3 PRODUCED"]
P34["3.4 Observed half of CAN_ESCALATE_TO"]
P31 -.->|"writes"| AGENT
P31 -.->|"writes"| WF
P31 -.->|"writes"| TOOL
P32 -.->|"writes declared edges"| AGENT
P33 -.->|"links an existing outcome to the agent that produced it"| OUT
P34 -.->|"counts observed escalations"| AGENT
P34 -.->|"writes the OBSERVED marker that makes a retry a no-op"| OUT
3. Write patterns
As with the Outcome projection in the Neo4j Implementation Guide
§1.1.1, none of the writes below are agent-mediated. No
agent calls this Cypher as a tool; it is system-level plumbing that only ever projects a fact the Agent Registry
or an outcome event already holds.
3.1 Registry projection — Agent, Workflow, Tool, AUTHORIZED_FOR, OWNS_WORKFLOW
// Upsert an agent node and its static registry-derived edges. Idempotent.
//
// $tools and $owned_workflows are lists of objects, not bare ID lists --
// [{tool_id, name}, ...] and [{workflow_id, name, domain, version}, ...]
// -- so the Tool and Workflow nodes actually get the properties §1
// declares for them, rather than being created with an id and nothing
// else.
//
// Each list runs inside its own CALL (a) { ... } subquery rather than a
// bare UNWIND chained by WITH: UNWIND on an empty list produces zero
// rows, and a bare UNWIND would silently drop the outer `a` row (and
// every projection after it) for any agent with an empty tools_allowed
// list -- a legal registry state, since a domain agent can own workflows
// without being authorized for any tool. A CALL subquery runs once per
// outer row as a unit of work, independent of how many rows its own
// UNWIND produces, so an empty list skips only its own projection.
MERGE (a:Agent {agent_id: $agent_id})
SET a.name = $name,
a.family = $family,
a.owner = $owner,
a.decision_scope = $decision_scope,
a.status = $status
WITH a
CALL (a) {
UNWIND $tools AS tool
MERGE (t:Tool {tool_id: tool.tool_id})
SET t.name = tool.name
MERGE (a)-[:AUTHORIZED_FOR]->(t)
}
WITH a
CALL (a) {
UNWIND $owned_workflows AS wf
MERGE (w:Workflow {workflow_id: wf.workflow_id})
SET w.name = wf.name,
w.domain = wf.domain,
w.version = wf.version
MERGE (a)-[:OWNS_WORKFLOW]->(w)
}
// Reconciliation: a registry edit that removes a tool or an owned
// workflow must delete the corresponding edge, the same reason §3.2
// clears a removed escalation declaration -- otherwise a revoked
// authorization or a reassigned workflow stays represented in the graph
// indefinitely.
WITH a
CALL (a) {
MATCH (a)-[r:AUTHORIZED_FOR]->(t:Tool)
WHERE NOT t.tool_id IN [tool IN $tools | tool.tool_id]
DELETE r
}
WITH a
CALL (a) {
MATCH (a)-[r:OWNS_WORKFLOW]->(w:Workflow)
WHERE NOT w.workflow_id IN [wf IN $owned_workflows | wf.workflow_id]
DELETE r
}3.2 Escalation topology — declared half of CAN_ESCALATE_TO
// Sets the declared flag from the registry's escalation_path, filters out
// non-agent terminals, and clears declared=true on any edge whose target
// has since been removed from the registry's list. Does not touch
// observed_count -- that half is updated only by §3.4.
MATCH (a:Agent {agent_id: $agent_id})
// Registry escalation_path entries are not all agent IDs -- the terminal
// of a human-in-the-loop path is a string like "human operator" or
// "Human compliance owner", not a registered agent. Only entries matching
// the agent-ID naming convention (<prefix>-agt-NNN, per the Naming
// Convention Spec) are projected as Agent nodes; anything else is
// filtered out inside this subquery rather than becoming a pseudo-agent
// with no registry backing. Wrapped in CALL (a) { ... } for the same
// row-preservation reason as §3.1: an empty or fully-filtered list must
// not drop the outer `a` row and skip the reconciliation step below.
// `$escalation_path` is coalesced to [] up front: a null list would make
// the reconciliation predicate below evaluate to null and silently revoke
// nothing.
WITH a, coalesce($escalation_path, []) AS escalation_path
CALL (a, escalation_path) {
UNWIND escalation_path AS target_agent_id
WITH target_agent_id WHERE target_agent_id =~ '.+-agt-[0-9]+'
MERGE (t:Agent {agent_id: target_agent_id})
MERGE (a)-[r:CAN_ESCALATE_TO]->(t)
ON CREATE SET r.observed_count = 0
SET r.declared = true
}
// Reconciliation: a registry edit that removes a target from
// escalation_path must revoke that declaration, not just fail to renew
// it -- otherwise the graph keeps treating a removed path as authorized
// indefinitely, and an escalation over it can never be flagged as
// undeclared (§4).
WITH a, escalation_path
MATCH (a)-[stale:CAN_ESCALATE_TO]->(t:Agent)
WHERE stale.declared = true AND NOT t.agent_id IN escalation_path
SET stale.declared = falseIf $agent_id has no Agent node the initial MATCH returns no rows and the query writes nothing — §3.1 must
have projected the agent first. The target MERGEs rely on the agent_id uniqueness constraint in §6 to stay
race-free under concurrent runs.
An edge that exists only because of an observed escalation (§3.4 below) and was never declared keeps
declared = false — ON CREATE SET in §3.4 must not set declared to any value, so this query remains the
only writer that ever sets it true, and the reconciliation step above is the only writer that sets it back to
false.
3.3 PRODUCED — linking an outcome to the agent that produced it
MATCH (a:Agent {agent_id: $agent_id})
MATCH (o:Outcome {outcome_id: $outcome_id})
MERGE (a)-[:PRODUCED]->(o)Runs alongside the existing Outcome projection in the Neo4j Implementation Guide
§1.1.1 — same trigger, same fire-and-forget timing, one more
MERGE in the same projection step.
3.4 Observed half of CAN_ESCALATE_TO
// Runs when an outcome-to-outcome `escalated-to` edge is projected between
// two outcomes produced by two different agents. Increments the counter;
// never sets `declared`.
//
// The outcome projector is explicitly fire-and-forget and retryable
// (§1.1.1 of the Neo4j Implementation Guide), so this write must be
// idempotent under replay of the *same* source/target outcome pair --
// blindly incrementing observed_count on every call would inflate the
// count on a transient retry that observed nothing new. A dedicated
// :OBSERVED marker edge, keyed on the same source/target outcome pair
// this call was triggered by, is what makes "have I already counted
// this exact observation" a MERGE lookup instead of an assumption: the
// increment sits in ON CREATE, so it only fires the first time this pair
// is seen, however many times the call is retried.
MATCH (source:Outcome {outcome_id: $source_outcome_id})<-[:PRODUCED]-(from:Agent)
MATCH (target:Outcome {outcome_id: $target_outcome_id})<-[:PRODUCED]-(to:Agent)
MERGE (from)-[r:CAN_ESCALATE_TO]->(to)
ON CREATE SET r.declared = false, r.observed_count = 0
MERGE (source)-[obs:OBSERVED]->(target)
ON CREATE SET r.observed_count = r.observed_count + 1,
r.last_observed_at = $observed_at,
obs.recorded_at = $observed_atBecause both queries use MERGE on the same edge pattern, whichever runs first creates it and whichever runs
second only updates its own half of the properties — the declared-flag query never resets observed_count, and
the observed-count query never resets declared. The :OBSERVED marker is bookkeeping only, not a semantic
addition to the model in §2 — it exists solely to make repeated delivery of the same observation a no-op.
4. Example queries this model makes possible
What has this agent ever produced:
MATCH (a:Agent {agent_id: $agent_id})-[:PRODUCED]->(o:Outcome)
RETURN o.outcome_id, o.status, o.correlation_id
ORDER BY o.created_at DESC
LIMIT 50Undeclared escalations — an escalation happened that the registry never authorized:
MATCH (a:Agent)-[r:CAN_ESCALATE_TO]->(b:Agent)
WHERE r.declared = false AND r.observed_count > 0
RETURN a.agent_id, b.agent_id, r.observed_count, r.last_observed_atDeclared but never exercised — a registered escalation path with no runtime record:
MATCH (a:Agent)-[r:CAN_ESCALATE_TO]->(b:Agent)
WHERE r.declared = true AND r.observed_count = 0
RETURN a.agent_id, b.agent_idEscalation-path reachability, rewritten against this model — the same question the Neo4j platform
page answers illustratively against a
bare Agent/escalated-to shape, now scoped correctly to the declared topology:
MATCH (a:Agent)
WHERE NOT a.agent_id IN $terminal_ids
AND NOT EXISTS {
MATCH (a)-[:CAN_ESCALATE_TO*1.. {declared: true}]->(t:Agent)
WHERE t.agent_id IN $terminal_ids
}
RETURN a.agent_id AS agent_without_reachable_terminal
ORDER BY agent_without_reachable_terminalwith $terminal_ids = ['os-agt-001', 'os-agt-004']. The property map on the variable-length relationship applies
to every edge along the path, so only declared edges are ever followed and the engine never enumerates paths
through undeclared ones — a path in which any edge is merely present, not declared, does not count. Passing the
terminals as a parameter keeps the control-terminal list in one place instead of repeating a literal.
The outer WHERE NOT a.agent_id IN $terminal_ids guard is load-bearing, not defensive styling. *1.. requires at least
one hop, so a control terminal can never reach itself in zero hops — without the guard, os-agt-001 and
os-agt-004 would always fail their own reachability check, and os-agt-004 in particular escalates onward to
a human reviewer rather than back to another control agent, so it would fail every time regardless. The
terminals are the destination the check is for; they are not themselves required to have one.
This document specifies the schema CAN_ESCALATE_TO requires; a fuller worked treatment belongs in the Neo4j
Implementation Guide as a follow-up (see §6), alongside its
existing causal-chain and escalation-reachability examples.
5. Sync triggers — and the open item ADR-OS-007 left unresolved
Outcome projection triggers on an outcome event: something happens, a fact is written to Postgres, it gets
projected. Agent Registry facts are comparatively static — they change by registry edit, not by invocation. This
document does not resolve what triggers §3.1 and §3.2 on a registry change, per ADR-OS-007
§6.2 and
§8. Two candidates, neither adopted:
- Run the registry projection whenever the Agent Registry itself is written to (requires a registry-mutation event that does not yet exist in the canon).
- Run it as a scheduled reconciliation job that diffs the registry against the graph — simpler to build, adds the same kind of eventual-consistency lag the memory control plane already accepts for background consolidation, but is not free of drift risk during the gap.
Until this is decided, an implementer should treat Agent/Workflow/Tool node freshness as best-effort,
not a compliance guarantee — a stale node is a bug to fix, not yet a governance violation to escalate, because
no canonical document has committed to a sync latency for it.
6. Constraints and indexing
These are uniqueness constraints, the one constraint type Community Edition offers (per the Neo4j platform
page’s licensing section) — consistent with the rest of this
graph tier, which already relies on uniqueness constraints rather than the existence/type constraints only
Enterprise Edition provides. No additional index is needed for agent_id/workflow_id/tool_id lookups: a
uniqueness constraint backs an index automatically.
7. Compliance checklist
-
Agent,Workflow, andToolnodes are written only by the system-level projection step, never bound as a callable tool to any agent’stools_allowed. - The three-agent access boundary from the Neo4j Implementation Guide is unchanged; no agent’s registry entry gained a Neo4j or NAMS reference because of this document.
-
CAN_ESCALATE_TO.declaredis written only by the registry-sync query (§3.2);observed_countandlast_observed_atare written only by the outcome-sync query (§3.4). Neither query resets the other’s properties. - §3.2’s registry sync clears
declared = trueon any edge whose target is no longer in the currentescalation_path, and §3.1 deletesAUTHORIZED_FOR/OWNS_WORKFLOWedges no longer in the currenttools/owned_workflowslists — a registry removal is reflected in the graph, not only an addition. -
escalation_pathentries that do not match the agent-ID naming convention (human/organizational terminals such as “human operator”) are filtered out before projection, never merged as pseudo-Agentnodes. - §3.4’s
observed_countincrement is idempotent under retry of the same source/target outcome pair (the:OBSERVEDmarker edge, not a bareSET r.observed_count = r.observed_count + 1). - The escalation-reachability query (§4) excludes
os-agt-001/os-agt-004from the outer candidate set — a control terminal is not required to reach itself in zero hops. - Uniqueness constraints exist on
agent_id,workflow_id, andtool_idbefore either write pattern in §3 ships against production volume. - Wiping
Agent/Workflow/Toolnodes and rebuilding from the Agent Registry produces identical behaviour — the same correctness test the Memory Runtime Integration Guide §2.3 already requires for the rest of the graph tier. - Registry-derived node freshness is treated as best-effort per §5, not silently assumed to be real-time.
Versioning
- Version: 0.2
- Supersedes: synedrix-os-agent-graph-data-model-v0.1.md
- Related: ADR-OS-007, Synedrix OS Outcome Spec, Synedrix OS Neo4j Implementation Guide
Related Documents
Decision and core specs
- ADR-OS-007 — The decision this document implements, including why Neo4j Virtual Graph’s zero-copy architecture was evaluated and rejected.
- Synedrix OS Outcome Spec — The
Outcomenode and four relationship types this document extends without restating. - Synedrix OS - Agent Registry — The write-authoritative source for every
Agent/Workflow/Toolnode. - Synedrix OS Memory Fabric Spec — The materialized-projection invariant this document’s write patterns must satisfy.
Implementation
- Synedrix OS Neo4j Implementation Guide — The three-agent access boundary and the
Outcomeprojection pattern this document’s writes run alongside; the escalation-reachability query this document’s §4 rewrites against the new schema. - Synedrix OS Memory Runtime Integration Guide — The NAMS-owned entity/reasoning tier this document’s node types are explicitly separate from.
Platform reference
- Neo4j — The “node taxonomy beyond the outcome” open item this document resolves; the Community Edition constraint types §6 relies on.
Synedrix OS · August 2026