Agents Engine¶
Verified against the code · 19d408be · 2026-08-25
Every canonical operation, signature, agent-class step sequence, status/HTTP-code table, and layer claim in the doc matches backend/agents/{init,base,create_node_agent,create_relation_agent,update_node_agent}.py and the POST /command handler in backend/server.py (lines 3675-3723) exactly. AgentResult fields, check_idempotency/command_hash behaviour, per-agent execute() flows (idempotency check -> validate -> derive uuid/find subtree root -> lock -> write overlay + idempotency record -> invalidate -> audit -> return), the 200/201/422/409/400 status table, and the mutation_engine bridge functions (validate_add_node, validate_create_relation, validate_update_node, derive_entity_uuid, find_subtree_root, build_overlay_node, build_overlay_relation, build_overlay_update_node) all check out against the code on disk.
Layer: api
Overview — AI-Driven Graph Mutations¶
The backend/agents/ module is the command dispatch layer for all graph mutations that arrive through POST /command. It is the single enforced path for creating nodes, creating relations, and updating nodes — no code in the system writes these overlay documents directly.
The term "agent" here does not refer to an LLM-based autonomous agent. It refers to the structural pattern of a dedicated, self-contained handler class that owns one command's entire lifecycle: idempotency check → validation → lock acquisition → overlay document write → audit record. This isolates the logic for each command type and prevents the route handler in server.py from accumulating mutation details.
An agent differs from a direct API call in three material ways:
- Uniform idempotency. Every agent begins by checking whether the incoming
idempotencyKeyhas already been committed. If so, it returns the cached result without touching the graph. This is enforced at the base layer and cannot be bypassed by individual agent implementations. - Subtree locking. Agents acquire a subtree-scoped async lock before writing. Direct writes would race against concurrent commands on the same subtree.
- Audit trail. Every execution — whether committed or rejected — appends a structured audit record. Agents call
audit_service.make_rejectedoraudit_service.make_committed; the route handler is unaware of audit details.
Base Agent¶
backend/agents/base.py provides two shared primitives used by every agent class. There is no abstract base class with enforced subclassing; the shared contract is implemented through a consistent execute classmethod signature and direct use of these utilities.
AgentResult¶
@dataclass
class AgentResult:
status: str # "committed" | "rejected" | "conflict"
entity_id: str = ""
snapshot_id: str = ""
errors: list = field(default_factory=list)
warnings: list = field(default_factory=list)
http_status: int = 201
AgentResult is the single return type for every agent execution. The route handler in server.py calls result.to_dict() to build the HTTP response body and uses result.http_status as the status code. No agent returns raw dicts or raises HTTP exceptions directly — all outcomes are expressed through AgentResult.
to_dict() produces:
{
"result": "committed",
"entityId": "<uuid>",
"snapshotId": "<uuid>",
"warnings": []
}
errors and warnings keys are omitted from the output when the lists are empty.
command_hash¶
def command_hash(payload: dict) -> str
Computes a SHA-256 digest of the canonical JSON serialization of payload (keys sorted, UTF-8 encoded). This hash is stored in the idempotency record alongside the result so that a replayed key carrying a different payload can be detected as a conflict.
check_idempotency¶
def check_idempotency(idem_key: str, payload: dict, store) -> AgentResult | None
| Parameter | Type | Meaning |
|---|---|---|
idem_key |
str |
The client-supplied idempotency key. Empty string is treated as absent — the check is skipped. |
payload |
dict |
The raw request payload, used to compute the hash for conflict detection. |
store |
module | mutation_store, which exposes get_idempotency_record(key). |
Returns:
- None — key has not been seen before; the caller should proceed with execution.
- AgentResult(status="conflict", http_status=409) — key was seen before with a different payload hash. The client reused a key for a different request.
- AgentResult(status=<prior result>, http_status=200) — key was seen before with the same payload; the cached outcome is returned unchanged.
Every agent calls check_idempotency as its first action and returns immediately if the result is not None.
Agent Types¶
CreateNodeAgent¶
backend/agents/create_node_agent.py
Handles the CreateNode command. Translates the HTTP payload into the AddNode format expected by mutation_engine, runs validation, and writes an overlay node document.
Payload fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
label |
string | yes | Human-readable name for the new node |
parentId |
UUID string | yes | Parent node under which the new node is placed |
nodeType |
string | no | Ontological type of the node |
properties |
dict | no | Arbitrary key-value properties to attach |
description |
string | no | Free-text description |
_to_engine_cmd(payload, idem_key) -> dict
Translates the public HTTP payload shape into the internal AddNode command dict accepted by mutation_engine. If the caller did not supply an idempotencyKey, a new UUID v4 is generated so that the mutation engine always receives a non-empty key.
execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult
Step-by-step execution:
check_idempotency— return cached result if the key is known.- Translate payload via
_to_engine_cmd; computecommand_hash. - Load
EffectiveGraphfrommutation_store.get_effective_graph()and ownership assignments frommutation_store.load_ownership(). - Call
mutation_engine.validate_add_node(cmd, username, eff, assignments)→ValidationResult. - If
vr.has_errors: append a rejected audit record and returnAgentResult(status="rejected", http_status=422). - Derive the node's deterministic UUID via
mutation_engine.derive_entity_uuid(cmd, parent_context=parent_id). - Find the subtree root via
mutation_engine.find_subtree_root(parent_id, eff). - Acquire the subtree lock via
async with subtree_lock_fn(subtree_root). - Inside the lock: compute and write a snapshot, build the overlay node document via
mutation_engine.build_overlay_node(...), write it atomically toMUTATIONS_DIR/nodes/<uuid>.jsonld, write the idempotency record, and invalidate the effective graph cache. - Append a committed audit record.
- Return
AgentResult(status="committed", entity_id=node_uuid, snapshot_id=snap_id, http_status=201).
The origin="manual" parameter passed to build_overlay_node distinguishes this path from agent-driven updates (compare UpdateNodeAgent, which passes origin="agent").
CreateRelationAgent¶
backend/agents/create_relation_agent.py
Handles the CreateRelation command. Creates a directed, typed relation between two existing nodes.
Payload fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
sourceId |
UUID string | yes | UUID of the source node |
targetId |
UUID string | yes | UUID of the target node |
relationType |
string | yes | Type identifier for the relation (validated against the relation catalog) |
_to_engine_cmd(payload) -> dict
Produces a CreateRelation command dict. Note that this agent does not generate a fallback idempotency key — if no idem_key is provided, idempotency recording is skipped (the if idem_key: guard in step 9 below).
execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult
Step-by-step execution:
check_idempotency— return cached result if the key is known.- Translate payload via
_to_engine_cmd; computecommand_hash. - Load
EffectiveGraphfrommutation_store.get_effective_graph(). - Call
mutation_engine.validate_create_relation(cmd, username, eff)→ValidationResult. - If
vr.has_errors: append a rejected audit record and returnAgentResult(status="rejected", http_status=422). - Derive a deterministic UUID v5 for the relation via
key_derivation.derive_overlay_relation_uuid(sourceId, targetId, relationType). This is a content-addressed key: the same source/target/type triple always yields the same UUID (CCR Rule 16). - Find the subtree root via
mutation_engine.find_subtree_root(source_id, eff). - Acquire the subtree lock.
- Inside the lock: compute and write a snapshot, build the overlay relation document via
mutation_engine.build_overlay_relation(...), write it atomically toMUTATIONS_DIR/relations/<uuid>.jsonld, conditionally write the idempotency record (only ifidem_keywas provided), and invalidate the effective graph cache. - Append a committed audit record.
- Return
AgentResult(status="committed", entity_id=rel_uuid, snapshot_id=snap_id, http_status=201).
UpdateNodeAgent¶
backend/agents/update_node_agent.py
Handles the UpdateNode command. Merges incoming properties (and optionally a new label) onto an existing node's overlay document. The node's parent is not mutable through this command — structural integrity is preserved by keeping parent assignment frozen (CCR Rule 3).
Payload fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
UUID string | yes | UUID of the node to update |
properties |
dict | yes | Properties to merge into the existing overlay |
newLabel |
string | no | If present, replaces the node's current label |
_to_engine_cmd(payload) -> dict
Produces an UpdateNode command dict. The newLabel key is included only if the payload contains it — its absence tells mutation_engine.build_overlay_update_node to leave the existing label unchanged.
execute(payload, idem_key, username, ip, subtree_lock_fn) -> AgentResult
Step-by-step execution:
check_idempotency— return cached result if the key is known.- Translate payload via
_to_engine_cmd; computecommand_hash. - Load
EffectiveGraphand ownership assignments. - Call
mutation_engine.validate_update_node(cmd, username, eff, assignments)→ValidationResult. Ownership assignments are checked because only the assigned owner (or an admin) may update a node. - If
vr.has_errors: append a rejected audit record and returnAgentResult(status="rejected", http_status=422). - Find the subtree root via
mutation_engine.find_subtree_root(node_id, eff). - Acquire the subtree lock.
- Inside the lock: compute and write a snapshot, build the update overlay via
mutation_engine.build_overlay_update_node(..., origin="agent"), write it atomically toMUTATIONS_DIR/nodes/<node_id>.jsonld(overwriting any prior overlay for this node, which is the CCR Rule 8 replace-entire-overlay semantics), conditionally write the idempotency record, and invalidate the cache. - Append a committed audit record.
- Return
AgentResult(status="committed", entity_id=node_id, snapshot_id=snap_id, http_status=200).
Note the HTTP status code: UpdateNodeAgent returns 200 on success, while CreateNodeAgent and CreateRelationAgent return 201.
The Agent → Mutation Bridge¶
Agents do not have their own validation logic. All validation is delegated to mutation_engine:
mutation_engine.validate_add_node(cmd, username, eff, assignments)mutation_engine.validate_create_relation(cmd, username, eff)mutation_engine.validate_update_node(cmd, username, eff, assignments)
Each returns a ValidationResult with has_errors: bool, errors: list, and warnings: list. Agents inspect has_errors and branch accordingly.
There is no separate "preview" step in the agent path. The preview/commit two-phase pattern documented in mutation-engine.explanation.md belongs to a different call path (the legacy /preview and /commit routes). The agent path is single-phase: validate → lock → write → audit in one request.
The EffectiveGraph is the agent's view of the current graph state. It is loaded fresh at the start of each execute call via mutation_store.get_effective_graph(), which merges the base (read-only) graph with all committed overlay documents. Agents do not hold a reference to the graph across requests. After writing, they call mutation_store.invalidate() so that the next request sees the updated overlay.
API Integration¶
All agent commands enter through a single HTTP endpoint registered directly on the FastAPI app in backend/server.py:
POST /command
Authorization: Bearer <token> (principal resolved by the CurrentUser dependency, api/deps.py)
Content-Type: application/json
Request body:
{
"command": "CreateNode | UpdateNode | CreateRelation",
"payload": { ... },
"idempotencyKey": "<optional-uuid>"
}
The command_gateway route handler:
- Calls
_require_mutations()— raises if the mutations subsystem is disabled. - Calls
agents.dispatch(command)to look up the agent class. Returns400with a descriptive error if the command name is not registered. - Calls
agent_cls.execute(payload, idem_key, username, ip, subtree_lock_fn=_get_subtree_lock). - Logs the outcome via structured logging (
_slog). - Returns
JSONResponse(result.to_dict(), status_code=result.http_status).
Response body (success):
{
"result": "committed",
"entityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"snapshotId": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
Response body (validation failure):
{
"result": "rejected",
"entityId": null,
"snapshotId": null,
"errors": [
{ "code": "MISSING_FIELD", "message": "label is required", "field": "label" }
]
}
Response body (idempotency conflict):
{
"result": "conflict",
"entityId": null,
"snapshotId": null,
"errors": [
{
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency key reused with a different payload.",
"field": "idempotencyKey"
}
]
}
HTTP status codes:
| Outcome | Code |
|---|---|
| Node created | 201 |
| Relation created | 201 |
| Node updated | 200 |
| Idempotency replay (same payload) | 200 |
| Validation failure | 422 |
| Idempotency conflict (different payload) | 409 |
| Unknown command | 400 |
Configuration¶
The agents module has no LLM dependency and requires no AI provider configuration. The name "agents" reflects the structural dispatch pattern, not AI involvement.
Configuration that does affect agent behavior:
- Mutations subsystem toggle.
_require_mutations()inserver.pyreads an environment variable or config flag. If mutations are disabled (e.g., read-only deployment mode),POST /commandraises immediately before any agent is invoked. - Subtree lock implementation.
_get_subtree_lockis resolved inserver.pyand passed into each agent assubtree_lock_fn. Its implementation (in-process async lock, distributed lock, etc.) is transparent to the agents. - Mutation store paths.
mutation_store.MUTATIONS_DIRdetermines where overlay node and relation documents are written. This is configured at the persistence layer, not in the agents module.
Integration¶
EffectiveGraph as Input Context¶
Agents do not receive natural language input. Every execute call receives a structured payload dict that the HTTP client constructs. The EffectiveGraph is loaded internally by each agent as its authoritative view of current graph state. It provides:
- The set of existing nodes and their properties (used to validate
parentId,sourceId,targetId, and ownership). - The parent-child structure (used to find the subtree root for lock scoping).
- The current overlay state for a node being updated (so
build_overlay_update_nodecan carry forward existing fields).
The EffectiveGraph is a read-only snapshot from the agent's perspective. Agents never modify it directly — they write new overlay files and then call mutation_store.invalidate() to expire the cache.
IAM Governance¶
Authentication is enforced by the canonical CurrentUser dependency (api/deps.py) on POST /command — it accepts a Bearer user session or an internal X-Service-Token. An unauthenticated request never reaches the agent dispatch layer.
Authorization is enforced inside the mutation engine's validation functions:
validate_add_nodeandvalidate_update_nodereceive theassignmentsdict (loaded frommutation_store.load_ownership()). These functions check whetherusernamehas the right to write under the target parent or modify the target node.validate_create_relationreceives theEffectiveGraphand checks whether the source and target nodes exist and therelationTypeis valid per the relation catalog.
If the user does not have permission, vr.has_errors is True and the agent returns a rejected result with a 422 status before any write occurs. The rejection is recorded in the audit log with the username and IP address, providing a full trail of denied attempts.
The agent dispatch registry (_REGISTRY in backend/agents/__init__.py) is static. There is no runtime mechanism to register new commands or bypass the registry — adding a new command type requires a new agent class and an explicit entry in _REGISTRY.