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, and Tool node schemas and their write-authoritative sources
  • The PRODUCED, CAN_ESCALATE_TO, AUTHORIZED_FOR, and OWNS_WORKFLOW relationship types
  • MERGE write 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

LabelKey propertiesWrite-authoritative source
Agentagent_id, name, family, owner, decision_scope, statusAgent Registry — applies to any registered agent, OS-owned or domain-pack-owned
Workflowworkflow_id, name, domain, versionWorkflow Contract Spec’s registry
Tooltool_id, nameEach 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

RelationshipShapePropertiesPurpose
PRODUCED(Agent)-[:PRODUCED]->(Outcome)noneReplaces 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: timestampOne 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)noneFrom tools_allowed.
OWNS_WORKFLOW(Agent)-[:OWNS_WORKFLOW]->(Workflow)noneFrom 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: timestampIdempotency 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
}
Illustrative example, not a production implementation. Review and test before use.

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 = false
Illustrative example, not a production implementation. Review and test before use.

If $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)
Illustrative example, not a production implementation. Review and test before use.

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_at
Illustrative example, not a production implementation. Review and test before use.

Because 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 50
Illustrative example, not a production implementation. Review and test before use.

Undeclared 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_at
Illustrative example, not a production implementation. Review and test before use.

Declared 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_id
Illustrative example, not a production implementation. Review and test before use.

Escalation-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_terminal
Illustrative example, not a production implementation. Review and test before use.

with $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, and Tool nodes are written only by the system-level projection step, never bound as a callable tool to any agent’s tools_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.declared is written only by the registry-sync query (§3.2); observed_count and last_observed_at are written only by the outcome-sync query (§3.4). Neither query resets the other’s properties.
  • §3.2’s registry sync clears declared = true on any edge whose target is no longer in the current escalation_path, and §3.1 deletes AUTHORIZED_FOR/OWNS_WORKFLOW edges no longer in the current tools/owned_workflows lists — a registry removal is reflected in the graph, not only an addition.
  • escalation_path entries 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-Agent nodes.
  • §3.4’s observed_count increment is idempotent under retry of the same source/target outcome pair (the :OBSERVED marker edge, not a bare SET r.observed_count = r.observed_count + 1).
  • The escalation-reachability query (§4) excludes os-agt-001/os-agt-004 from the outer candidate set — a control terminal is not required to reach itself in zero hops.
  • Uniqueness constraints exist on agent_id, workflow_id, and tool_id before either write pattern in §3 ships against production volume.
  • Wiping Agent/Workflow/Tool nodes 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


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 Outcome node and four relationship types this document extends without restating.
  • Synedrix OS - Agent Registry — The write-authoritative source for every Agent/Workflow/Tool node.
  • Synedrix OS Memory Fabric Spec — The materialized-projection invariant this document’s write patterns must satisfy.

Implementation

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