Skip to content

Mutation Engine

Verified against the code · 4d5c9b5781eaaa62048db19cd10dc7039e402c98 · 2026-10-20

956 D1b-2 (2026-10-20): 5 finding(s) fixed — build_overlay_relation writes one eidos:target field (WP4b #965), derive_overlay_relation_uuid takes idem_key (WP4 #953; caller graph_crud_engine.py:1535), NAMESPACE_EIDOS/_TRANSIENT_FIELDS live only in key_derivation, build_preview* return (diff, scope), the domain comes from resolve_this_domain() (EIDOS_DOMAIN, THIS_DOMAIN, DOMAIN_KEY). Re-verified against main 4d5c9b57.

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 and overlay document construction. UUID derivation itself is owned by infra/key_derivation.py (the identity-façade SSOT); mutation_engine re-exports derive_entity_uuid for backward-compatibility with legacy callers (G2 #929). IRI construction for @id fields is owned by infra/iri.py — the build functions call node_iri/relation_iri from the façade (G3 #930; WP1 #950 switched the emitted form from urn:eidos:(node|relation):{uuid} to the flat https://id.ontoteq.com/{uuid} namespace with rdf:type carrying the entity-type distinction) and never emit a raw IRI literal (bypasses are caught by the R2 ratchet tests/test_rdf_g1_r2_ratchet.py for the URN form and the R5 ratchet tests/test_rdf_wp1_r5_ratchet.py for the HTTPS form). The overlay @context dict is owned by infra/jsonld_context.py (WP1 #950). 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 (re-exported from infra.key_derivation)

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. Since G2 (#929) the function lives in infra/key_derivation.py (the canonical identity owner) and is re-exported from domain.mutation_engine so legacy imports (from domain.mutation_engine import derive_entity_uuid) keep working.

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 graph_crud_engine.create_node must receive identical arguments. For AddNode both sides build cmd with normalize_add_node_cmd (see below) and pass parent_context=parentNodeId, so the preview uuid is the committed uuid (#1016).

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 is optional: normalize_add_node_cmd supplies a fallback (#1016).
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 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.
TARGET_ID_IS_URI targetId contains a URI scheme (://) or a path separator (/). A target id is the node's uuid (the local part of its identity IRI; mutation commands are not parsed by parse_public_node_id), never a URL — cross-domain targets skip the existence check, so without this gate a …/node-by-ref?ref=… URL would be stored as the external UUID and rendered verbatim in the node header (DEV defect 2026-08-26). Checked on every channel, before the existence check.
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 comes from infra.config.resolve_this_domain(), which reads EIDOS_DOMAIN, then THIS_DOMAIN, then DOMAIN_KEY.

The function imports domain.relation_catalog lazily inside the function body to avoid a circular import.


normalize_add_node_cmd

def normalize_add_node_cmd(cmd: dict, *, idem_fallback: str) -> dict

Pure. Builds the canonical AddNode command that both the preview and the commit derive the node uuid from (CCR Rule 16, #1016). The result always has exactly the keys commandType ("AddNode"), parentNodeId, label, description (default ""), properties (default {}), idempotencyKey and productType (default None). Unknown keys are dropped. A missing or empty idempotencyKey becomes idem_fallback.

Callers:

  • POST /mutations/node/preview mints the previewId first and passes it as idem_fallback. It then calls build_preview with the normalised command and stores that command in pending/.
  • graph_crud_engine.create_node builds its command through the same helper. On the preview flow it receives the stored command, which already carries the resolved key, so normalising again changes nothing. Callers without a preview (Excel or domain import) get a fresh uuid4 fallback when they pass no key.
cmd = normalize_add_node_cmd(raw_body, idem_fallback=preview_id)
node_diff, scope = build_preview(cmd, eff)
# create_node(..., CreateNodePayload from cmd) derives node_diff["id"] again

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 (WP1 #950)

Field Source
@id infra.iri.node_iri(node_uuid) — https://id.ontoteq.com/{node_uuid}. Sole identifier; eidos:id is no longer emitted. The reader (infra.iri.id_from_envelope) keys on @id and falls back to eidos:id only for pre-WP1 legacy files.
@type "eidos:OverlayNode"
rdfs:label cmd["label"].strip() — writers emit rdfs:label; eidos:label is no longer emitted (_normalize_node reads rdfs:label first and falls back to eidos:label on legacy input).
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 "https://id.ontoteq.com/{audit_event_id}" when audit_event_id is non-empty, else "" — emitted via infra.iri.audit_iri (WP1 #950; shares the flat IRI space with nodes and relations, type carried by rdf:type). Overlay files written before WP1 that contain the legacy urn:eidos:audit:{audit_event_id} form continue to load unchanged — from_iri accepts both forms with a debug-level log on the legacy hit (rewrite deferred to #946).
eidos:productType Only written to the document when cmd["productType"] is not None
skos:notation infra.key_derivation.derive_slug_segment(label, is_root=not parent_id) — the node's ref-path segment (WP2 #951). is_root is derived from the real parent: when parentNodeId is absent (the bootstrap / domain-import root-creation path) the node is treated as root and keeps its sigil prefix such as +FFL; a child node (any truthy parentNodeId) uses the leading non-alphanumeric prefix stripped form. The segment is written on every create so the overlay carries a durable anchor; _build_ref prefers the stored notation and falls back to the label-derivation rule for any pre-WP2 node that lacks the field.

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".

skos:notation is recomputed every call from the effective label — the canonical generator is infra.key_derivation.derive_slug_segment(label, is_root=parent is None) (WP2 #951). A root node (no parent in parent_of) keeps its full label including the sigil prefix such as +FFL; a non-root uses the leading non-alphanumeric prefix stripped form. Full-path uniqueness is enforced in graph_crud_engine.update_node under the per-domain write lock — a rename whose new path collides inside the domain fails with V-SLUG_COLLISION and does not write.


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(idem_key) (WP4 #953: the UUID comes from the idempotency key, not the endpoints; graph_crud_engine.py:1535 passes idem_key). Since WP4b (#965) the target is written as one eidos:target field: the node IRI for an in-model target, an eidos:ExternalRef node (domain, ref, and eidos:node when the real remote id would not round-trip through the derived placeholder) for a cross-domain target, or an eidos:UnresolvedRef node (ref only) for a deferred one. Only externalSourceDomain is still written as a separate field. A command with externalTargetRef but no externalDomain (and no unresolvedTargetRef) raises InvalidTargetRefError.

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 (WP1 #950, WP2 #951):

Key Type Notes
@context dict {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#"} — sourced from infra.jsonld_context.overlay_context() (WP1 #950)
@id str infra.iri.node_iri(node_uuid) — https://id.ontoteq.com/{node_uuid} (flat IRI space, WP1 #950; was urn:eidos:node:{uuid} under G3 #930). Sole identifier — eidos:id is no longer emitted. The reader (id_from_envelope) keys on @id and falls back to eidos:id only for pre-WP1 legacy files; #946 will rewrite the on-disk legacy files.
@type str "eidos:OverlayNode"
rdfs:label str Stripped label — writers emit rdfs:label; _normalize_node reads rdfs:label first and falls back to eidos:label on legacy input.
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
skos:notation str infra.key_derivation.derive_slug_segment(label, is_root=not parent_id) — per-node ref-path anchor segment (WP2 #951). is_root is derived from the real parent: no parentNodeId means the node is a root and the sigil prefix is kept; a child node uses the stripped form. Always written for new nodes. Pre-WP2 overlay files lacking this field fall back to label-derivation at read time.

Module-level constants

NAMESPACE_EIDOS (uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479"), must never change) and _TRANSIENT_FIELDS ({"timestamp", "actor", "idempotencyKey"}, excluded from UUID canonicalisation) are defined only in infra/key_derivation.py (lines 24 and 50). mutation_engine.py neither defines nor imports them; from key_derivation it imports _canonicalize, derive_entity_uuid, derive_relation_uuid_for_unresolved_target and derive_slug_segment. Its own constants are:

_ADD_NODE_REQUIRED = {"commandType", "parentNodeId", "label"}
_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.