Mutation Engine¶
Verified against the code · 19d408be · 2026-08-25
Every code-checkable claim in the YAML and compiled doc matches backend/domain/mutation_engine.py as it exists on disk: all listed canonical_operations (derive_entity_uuid, validate_add_node, build_preview, build_preview_update_node, build_preview_relation, build_overlay_node, build_overlay_relation) plus the additional documented functions (payload_hash, validate_update_node, validate_create_relation, build_overlay_update_node, resolve_owner, find_subtree_root) exist with the exact signatures, error codes, validation-stage ordering, and overlay-document field sets described. The 'does not write to disk' / pure-function boundary claim holds — no filesystem or network calls appear in the module. NAMESPACE_EIDOS, _TRANSIENT_FIELDS, and the required-field constants match verbatim. infra/key_derivation.py confirms NAMESPACE_EIDOS and derive_overlay_relation_uuid exist as referenced, and docs/architecture/key-derivation-audit-2026-05-13.md (the second sources.files entry) exists on disk.
Layer: domain
Purpose and Responsibilities¶
The mutation_engine module owns the validate → preview → build pipeline for all graph mutation commands. It is the authoritative source for validation logic, UUID derivation, and overlay document construction. It does not own:
- Disk I/O or file writes (that is
mutation_store/graph_crud_engine) - Preview storage or consumption (handled by
mutation_store) - Audit record creation (handled by
graph_crud_engine) - Idempotency record persistence (handled by
graph_crud_engine) - Lock acquisition or snapshot writes (both belong to
graph_crud_engine)
The module is a pure computation layer: every public function either returns a result object or raises — it never touches the filesystem, network, or cache.
Key Functions¶
derive_entity_uuid¶
def derive_entity_uuid(cmd: dict, parent_context: str | None = None) -> str
Computes the deterministic UUID v5 for a node mutation command. This is the implementation of CCR Rules 10 and 16: the same command payload always produces the same UUID, and the UUID returned during preview is identical to the UUID written at commit time.
Parameters
| Parameter | Type | Meaning |
|---|---|---|
cmd |
dict |
The raw mutation command dict. Must contain at least commandType and idempotencyKey. Transient fields (timestamp, actor, idempotencyKey in the outer envelope) are excluded from the canonical input before hashing. |
parent_context |
str \| None |
For node mutations, pass the parent node UUID. Included verbatim in the canonical string. For relation mutations, all three creation paths intentionally pass None (documented in the key-derivation audit, finding F-02 — sourceId in the payload is sufficient). |
Returns str — a UUID v5 string in standard hyphenated form, derived from NAMESPACE_EIDOS (f47ac10b-58cc-4372-a567-0e02b2c3d479).
Raises Nothing. Safe to call with any dict.
Invariant (CCR Rule 16): derive_entity_uuid(cmd, parent_context) called in build_preview and again in build_overlay_node must receive identical arguments. The calling layer (graph_crud_engine) is responsible for supplying the same parent_context both times.
from domain.mutation_engine import derive_entity_uuid
node_uuid = derive_entity_uuid(
{"commandType": "AddNode", "parentNodeId": "abc-123", "label": "Pump-01",
"idempotencyKey": "ik-xyz"},
parent_context="abc-123",
)
# node_uuid is stable — retry with identical inputs returns the same value
payload_hash¶
def payload_hash(cmd: dict) -> str
Computes a SHA-256 hex digest of the command payload, excluding idempotencyKey. Used for idempotency key integrity checks (CCR Rule 13) — callers can verify that the payload has not changed between the preview call and the commit call.
Parameters cmd — any command dict.
Returns str — 64-character lowercase hex digest.
h = payload_hash(cmd)
# Compare with stored hash from preview to detect tampering
validate_add_node¶
def validate_add_node(cmd: dict, username: str, eff: dict, assignments: list) -> ValidationResult
Runs all validation stages for an AddNode command, as specified in §4.6 of the backend spec. Stage 1 (schema) is a hard stop — if schema errors are found, the function returns immediately without running later stages. All other stages accumulate independently.
Parameters
| Parameter | Type | Meaning |
|---|---|---|
cmd |
dict |
The mutation command. Required keys: commandType, parentNodeId, label, idempotencyKey. |
username |
str |
The authenticated user performing the action. Checked against the ownership chain. |
eff |
dict |
The effective graph state. Keys used: nodes, parent_of, children_of. Produced by eidos_loader / graph_merge. |
assignments |
list |
Active ownership assignment records. Each entry is a dict with keys eidos:subtreeNodeId, eidos:assignedTo, and optionally eidos:revokedAt. Pass an empty list during bootstrapping — ownership checks are skipped when the list is empty. |
Returns ValidationResult — inspect .has_errors and call .to_dict() for the API response payload.
Validation stages and error codes
| Stage | Error code | Condition |
|---|---|---|
| Schema | FIELD_REQUIRED |
One of commandType, parentNodeId, label, idempotencyKey is absent. |
| Schema | INVALID_COMMAND_TYPE |
commandType != "AddNode". |
| Schema | NODE_LABEL_EMPTY |
label is blank. |
| Label format | LABEL_EMPTY |
label becomes empty after stripping whitespace. |
| Label format | LABEL_INVALID_CHAR |
Label does not match ^[^A-Za-z0-9]?[A-Za-z0-9][^.]*$ (one optional non-alphanumeric prefix, then a letter or digit, then anything except a dot). |
| Parent existence | NODE_PARENT_NOT_FOUND |
parentNodeId is not present in eff["nodes"]. |
| Prefix consistency | LABEL_INVALID_CHAR |
The label's leading prefix character does not match the parent's leading prefix character. Domain convention: all nodes in a subtree share the same sigil character (e.g. %). |
| Ownership | OWNERSHIP_DENIED |
The parent's subtree is owned by another user. |
| Label uniqueness | NODE_LABEL_DUPLICATE |
A sibling node exists with the same label (case-insensitive comparison). |
result = validate_add_node(cmd, username="alice", eff=eff, assignments=assignments)
if result.has_errors:
return {"status": "invalid", **result.to_dict()}, 422
validate_update_node¶
def validate_update_node(cmd: dict, username: str, eff: dict, assignments: list) -> ValidationResult
Validates an UpdateNode command. Properties and optionally newLabel may change; parent is frozen.
Parameters Same shape as validate_add_node. The cmd must contain commandType, nodeId, and properties. newLabel is validated only when the key is present — an absent key means "do not rename".
Error codes specific to UpdateNode
| Error code | Condition |
|---|---|
FIELD_REQUIRED |
Missing commandType, nodeId, or properties. |
INVALID_COMMAND_TYPE |
commandType != "UpdateNode". |
NODE_NOT_FOUND |
nodeId not in eff["nodes"]. |
OWNERSHIP_DENIED |
Subtree owned by another user. |
INVALID_PROPERTIES |
properties is not a dict. |
LABEL_EMPTY |
newLabel present but blank after stripping. |
LABEL_INVALID_CHAR |
newLabel contains a . character (the label separator in fully-qualified paths). |
LABEL_TOO_LONG |
newLabel exceeds 80 characters. |
LABEL_CONFLICT |
A sibling (excluding the node itself) already has newLabel. Note: case-sensitive comparison for updates, unlike NODE_LABEL_DUPLICATE in AddNode which is case-insensitive. |
ROOT_PREFIX_REQUIRED |
The node has class == "root" and its existing label starts with a non-alphanumeric character; newLabel must preserve that prefix character. |
NO_CHANGES |
None of label, properties, engineering, or productType would actually change. |
INVALID_ENGINEERING |
engineering key present but value is not a list. |
result = validate_update_node(cmd, username="alice", eff=eff, assignments=assignments)
if result.has_errors:
return result.to_dict(), 422
validate_create_relation¶
def validate_create_relation(cmd: dict, username: str, eff: dict) -> ValidationResult
Validates a CreateRelation command. Note: unlike the node validators this function does not take assignments — ownership is not checked for relation creation.
Parameters
| Parameter | Type | Meaning |
|---|---|---|
cmd |
dict |
Required keys: commandType, sourceId, targetId, relationType. Optional cross-domain keys: externalDomain, externalSourceDomain, externalTargetRef, unresolvedTargetRef, inheritSource. |
username |
str |
Authenticated user. Currently unused inside this function but passed for symmetry and future ownership checks. |
eff |
dict |
Effective graph. Keys used: nodes, relations. |
Error codes
| Error code | Condition |
|---|---|
FIELD_REQUIRED |
Missing commandType, sourceId, targetId, or relationType. |
INVALID_COMMAND_TYPE |
commandType != "CreateRelation". |
RELATION_TYPE_EMPTY |
relationType is blank. |
HIERARCHY_VIA_RELATION_ENDPOINT |
The predicate is a structural (Hierarchy-category) predicate according to relation_catalog.is_hierarchy(). Hierarchy predicates must go through the tree-edit flow. |
UNRESOLVED_NOT_ALLOWED |
unresolvedTargetRef is present but the predicate's catalog entry does not declare allow_unresolved=True. |
NODE_NOT_FOUND |
sourceId or targetId not in eff["nodes"] (skipped for cross-domain nodes where externalDomain / externalSourceDomain is set). |
CATALOG_VIOLATION |
The (relationType, source_class, target_class) triple violates a rule in relation_catalog. |
DOMAIN_NOT_ALLOWED_FOR_PREDICATE |
The source/target domain pair is not permitted for this predicate. Domain is read from EIDOS_DOMAIN or DOMAIN_KEY environment variables. |
The function imports domain.relation_catalog lazily inside the function body to avoid a circular import.
build_preview¶
def build_preview(cmd: dict, eff: dict) -> tuple[dict, dict]
Pure function. Computes the diff and scope that will be shown in the preview dialog for an AddNode command. Calls derive_entity_uuid internally so the returned node_diff["id"] is the exact UUID that will be written to disk at commit time (CCR Rule 16).
Parameters
| Parameter | Type | Meaning |
|---|---|---|
cmd |
dict |
Validated command dict. Must contain parentNodeId, label. Optional: nodeType, productType. |
eff |
dict |
Effective graph. |
Returns tuple[dict, dict] — (node_diff, scope). See Data Structures below.
No side effects. No file I/O. No cache access.
node_diff, scope = build_preview(cmd, eff)
# node_diff["id"] == the UUID that will be written at commit — never differs
build_preview_update_node¶
def build_preview_update_node(cmd: dict, eff: dict) -> tuple[dict, dict]
Pure. Computes the before/after diff for an UpdateNode command. Handles the newLabel normalisation rule: a newLabel that is None, absent, or blank after stripping is treated as "no label change" rather than an error — validation is expected to have caught the blank case before this function is called.
Returns tuple[dict, dict] — (diff, scope). The diff dict exposes labelBefore, labelAfter, labelChanged, propertiesBefore, propertiesAfter, productTypeBefore, productTypeAfter, productTypeChanged so the UI can render a full change summary row.
build_preview_relation¶
def build_preview_relation(cmd: dict, eff: dict) -> tuple[dict, dict]
Pure. Computes the diff for a CreateRelation command. Unlike build_preview, the returned diff dict does not contain a relation UUID — the relation UUID is derived at commit time by key_derivation.derive_overlay_relation_uuid, not here. This is a documented asymmetry (finding F-07 in the key-derivation audit).
build_overlay_node¶
def build_overlay_node(
cmd: dict, node_uuid: str, username: str, eff: dict,
audit_event_id: str = "", origin: str = "manual"
) -> dict
Constructs the JSON-LD overlay node document that is ready to be passed to write_atomic. The caller (typically graph_crud_engine.create_node) is responsible for supplying node_uuid — it must be derived via derive_entity_uuid with the same parent_context that was used during preview.
Key fields in the returned document
| Field | Source |
|---|---|
eidos:id |
node_uuid parameter |
eidos:label |
cmd["label"].strip() |
eidos:parent |
cmd["parentNodeId"] |
eidos:properties |
Merged via _merge_properties(cmd) — combines cmd["properties"] dict with cmd["description"] string, both stored under the properties key |
eidos:rev |
Always 1 for new nodes (BL-ARCH-004) |
eidos:origin |
origin parameter — use "agent" for agent-created nodes, "excel-import" for Excel import, "manual" (default) for UI |
eidos:createdAuditRef |
"urn:eidos:audit:{audit_event_id}" when audit_event_id is non-empty, else "" |
eidos:productType |
Only written to the document when cmd["productType"] is not None |
build_overlay_update_node¶
def build_overlay_update_node(
cmd: dict, username: str, eff: dict,
audit_event_id: str = "", origin: str = "manual"
) -> dict
Constructs the overlay node document for an update. The overlay completely replaces the base (CCR Rule 8), so all existing fields are carried forward from eff["nodes"][cmd["nodeId"]] and the command's changes are merged on top.
eidos:rev is bumped by 1 from the existing node's rev. A legacy node that has never been updated and therefore has no rev field starts at rev = 1 (int(existing.get("rev", 0) or 0) + 1).
eidos:engineering is only written to the document when "engineering" is present as a key in cmd — absence means "do not change", not "clear the list".
build_overlay_relation¶
def build_overlay_relation(
cmd: dict, rel_uuid: str, username: str, audit_event_id: str = "",
created_by_id: str = ""
) -> dict
Constructs the JSON-LD overlay relation document. rel_uuid must be supplied by the caller — derive it with key_derivation.derive_overlay_relation_uuid(source_id, target_id, relation_type). Cross-domain fields (externalDomain, externalSourceDomain, externalTargetRef) are written to the document only when present in cmd. unresolvedTargetRef is also conditional.
The document records the creator twice: eidos:createdBy is the mutable display username, and eidos:createdById is the stable user_id (written only when created_by_id is supplied). Both are provenance/display only — authorship does NOT gate deletion (issue #561). create_relation passes ctx.user_id.
Relation delete authority. Authorization is role + IAM node-scope, never authorship (issue #561): the node-scoped write gate on the relation's source node (trin 0.9/#531, _iam_reject_if_locked at the router) is the single canonical authority. An editor or admin with write access to the source node may delete any relation on it, regardless of who created it — there is no creator-only gate. delete_relation additionally enforces the structural inherited-relation guard (context_node_id == relation.source): only the owning node may delete an inherited relation (V-INHERITED_RELATION_DELETE_FORBIDDEN). The atomic-replace path (a CreateRelation carrying existingRelId, used to re-target a single-valued coordinate such as Location/Type) passes context_node_id = the edited node so the prior edge on your own node is removed as part of the re-target; the inherited guard keeps it scoped to that node.
resolve_owner¶
def resolve_owner(node_id: str, eff: dict, assignments: list) -> str | None
Walks the ancestor chain of node_id (up to 50 levels) and returns the username of the deepest (most specific) active owner. Returns None when the subtree is unowned. Returns None immediately when assignments is an empty list — this is the bootstrapping escape hatch.
Parameters
| Parameter | Type | Meaning |
|---|---|---|
node_id |
str |
The node whose ownership to resolve. |
eff |
dict |
Must contain parent_of mapping. |
assignments |
list |
Each entry must have eidos:subtreeNodeId, eidos:assignedTo; optionally eidos:revokedAt (non-falsy = revoked). |
find_subtree_root¶
def find_subtree_root(node_id: str, eff: dict) -> str
Walks the parent_of chain to the root. Returns the first ancestor that has no parent entry. Caps traversal at 50 hops to prevent infinite loops on corrupt data.
Data Structures¶
ValidationResult¶
@dataclass
class ValidationResult:
status: str = "valid" # "valid" | "invalid"
errors: list = field(default_factory=list)
warnings: list = field(default_factory=list)
Each entry in errors and warnings is a dict with three keys:
{"code": str, "message": str, "field": str}
code— machine-readable constant (e.g."OWNERSHIP_DENIED","NODE_LABEL_DUPLICATE"). Use this in tests and frontend branching logic.message— human-readable string suitable for display.field— the command field that triggered the error (empty string when the error is not field-specific).
The status field transitions from "valid" to "invalid" the first time add_error is called. It never transitions back.
Methods
result.has_errors # bool — shortcut for bool(result.errors)
result.add_error(code, message, field_name="")
result.add_warning(code, message, field_name="")
result.to_dict() # {"status": ..., "errors": [...], "warnings": [...]}
node_diff dict (from build_preview)¶
{
"id": str, # node UUID — identical to commit UUID
"label": str,
"nodeType": str,
"productType": Any, # None when not supplied
"treeId": str,
"parentId": str,
"subtreePathDisplay": str, # e.g. "Root / K02 / Pump-01"
}
diff dict (from build_preview_update_node)¶
{
"id": str,
"label": str,
"labelBefore": str,
"labelAfter": str,
"labelChanged": bool,
"propertiesBefore": dict,
"propertiesAfter": dict,
"descriptionBefore": str,
"descriptionAfter": str,
"productTypeBefore": Any,
"productTypeAfter": Any,
"productTypeChanged": bool,
}
scope dict (returned by all three build_preview_* functions)¶
{
"scopeNodeId": str, # root of the subtree containing the affected node
"scopePathDisplay": str, # human-readable path to the scope root
"nodeCount": int, # number of nodes in the subtree (including root)
"relationCount": int, # relations where source is in the subtree
}
Overlay node document (from build_overlay_node)¶
The returned dict is a JSON-LD document with the eidos: context. Top-level keys:
| Key | Type | Notes |
|---|---|---|
@context |
dict | Always {"eidos": "https://ontoteq.com/ns/eidos#"} |
@id |
str | "urn:eidos:node:{node_uuid}" |
@type |
str | "eidos:OverlayNode" |
eidos:id |
str | Plain UUID, used as the file stem |
eidos:label |
str | Stripped label |
eidos:nodeType |
str | |
eidos:treeId |
str | Inherited from parent |
eidos:parent |
str | parentNodeId |
eidos:properties |
dict | Merged from cmd["properties"] and cmd["description"] |
eidos:created |
str | ISO 8601 UTC timestamp |
eidos:createdBy |
str | username |
eidos:idempotencyKey |
str | |
eidos:createdAuditRef |
str | Empty string when audit_event_id is not supplied |
eidos:origin |
str | "manual" | "agent" | "excel-import" | "domain-import" |
eidos:rev |
int | 1 for new nodes |
eidos:productType |
Any | Only present when cmd["productType"] is not None |
Module-level constants¶
NAMESPACE_EIDOS = key_derivation.NAMESPACE_EIDOS
# uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
# Must NEVER be changed — all overlay node files on disk are keyed to this namespace.
_TRANSIENT_FIELDS = {"timestamp", "actor", "idempotencyKey"}
# Excluded from UUID canonicalisation. Adding a field here changes no existing UUIDs.
_ADD_NODE_REQUIRED = {"commandType", "parentNodeId", "label", "idempotencyKey"}
_UPDATE_NODE_REQUIRED = {"commandType", "nodeId", "properties"}
_CREATE_RELATION_REQUIRED = {"commandType", "sourceId", "targetId", "relationType"}
Design Constraints¶
1. Always validate before building.
Call validate_add_node / validate_update_node / validate_create_relation and confirm result.has_errors is False before calling any build_* function. The build functions do not re-validate and will silently produce documents from invalid input.
2. Pass identical parent_context to both build_preview and build_overlay_node.
Both calls must use parent_context=cmd["parentNodeId"]. Any mismatch breaks CCR Rule 16 — the preview UUID and the commit UUID will differ, and the preview stored in pending/ will no longer match what gets written to disk.
3. Never mutate the dict returned by build_overlay_node or build_overlay_update_node.
These dicts are passed directly to write_atomic. Mutating them after the fact will produce a document on disk that does not match what derive_entity_uuid would re-derive, breaking idempotency.
4. Never use uuid.uuid4() for entity IDs.
CCR Rules 10 and 16 prohibit random UUIDs for entity identity. Finding F-01 in the key-derivation audit documents the consequences of the one case where this rule was violated. Always use derive_entity_uuid for nodes and key_derivation.derive_overlay_relation_uuid for relations.
5. The preview → commit pipeline is mandatory (Invariant I-6).
build_overlay_node and build_overlay_update_node must only be called from within a commit handler that has consumed a stored preview. Direct commits without a prior preview are an architectural defect.
6. assignments=[] skips ownership checks.
An empty assignments list is the bootstrapping escape hatch. Never pass an empty list in production paths where ownership should be enforced.
7. eidos:rev on build_overlay_update_node is not thread-safe on its own.
The caller (graph_crud_engine) must hold the subtree lock before reading eff and calling this function. The rev is read from eff["nodes"][node_id], which can be stale if a concurrent update races between the eff snapshot and the lock acquisition.
Common Pitfalls¶
Forgetting parent_context in derive_entity_uuid.
The default is None, which canonicalises to "". If you call derive_entity_uuid(cmd) during a preview and derive_entity_uuid(cmd, parent_context=parent_id) during commit (or vice versa), the UUIDs will differ and the commit will write a file under a UUID the preview never announced. This silently creates a duplicate node.
Checking result.errors directly instead of result.has_errors.
ValidationResult.errors is a list. if result.errors is equivalent, but result.has_errors is the documented API and is the form used internally. Use it consistently.
Passing the full effective graph eff object as mutable state.
build_preview, build_preview_update_node, and build_preview_relation read from eff but do not write to it. However, the scope computation in all three functions calls _all_descendants, which performs a BFS over eff["children_of"]. If the calling layer modifies eff["children_of"] between the preview call and the commit call, the scope counts will diverge. Do not modify eff between calls.
Assuming build_preview_relation returns a relation UUID.
Unlike build_preview for nodes (which sets node_diff["id"] to the future UUID), build_preview_relation returns no UUID in its diff dict. The relation UUID is derived only at commit time. This is documented as finding F-07 in the key-derivation audit and is not a bug — but it means clients cannot pre-validate the relation UUID from the preview response.
Passing productType conditionally and getting silent no-ops.
In validate_update_node, a productType key that is absent from cmd means "do not change". But a productType key whose value equals the existing value also does not count as a change, and will trigger the NO_CHANGES error if that is the only field in the command. Pass productType only when the user has staged an explicit change.
Using newLabel with a blank string to mean "clear the label".
The build_preview_update_node function normalises a blank newLabel to None, treating it as "no change". Setting newLabel to "" or " " does not clear the label — it is a no-op at the preview level and will trigger LABEL_EMPTY at the validation level. There is no supported path to clear a node label.
Expecting eidos:productType to always be present in overlay documents.
Both build_overlay_node and build_overlay_update_node only write eidos:productType to the document when the value is not None. Code that reads overlay documents must treat a missing eidos:productType key as equivalent to None, not as an error.