Deterministic UUID¶
Verified against the code · 19d408be · 2026-08-25
The KM entity's sources.files lists only two reference markdown docs (no code files), but the compiled doc page makes extensive, precisely verifiable code claims about backend/domain/mutation_engine.py and backend/infra/key_derivation.py — all confirmed accurate: _canonicalize (mutation_engine.py:52-64, exact _TRANSIENT_FIELDS set and four-key envelope), derive_entity_uuid (mutation_engine.py:67-72, exact uuid.uuid5(NAMESPACE_EIDOS, ...) call), payload_hash (mutation_engine.py:75-78), the build_preview call site cited as 'line 257' matches exactly (node_uuid = derive_entity_uuid(cmd, parent_context=parent_id)), NAMESPACE_EIDOS/NAMESPACE_SEEDED_RELATION/NAMESPACE_BOOTSTRAP constants and ownership comments (key_derivation.py:24-36) match verbatim, and the CreateRelation/parent_context=None claim is confirmed by derive_overlay_relation_uuid (key_derivation.py:47-64), which builds a (sourceId, targetId, relationType) cmd and delegates to mutation_engine.derive_entity_uuid with no parent_context, making it truly 'the single point of UUID derivation for all overlay nodes and overlay relations' as claimed.
Every entity written to the EIDOS overlay — every node, every relation — carries a UUID that is computed, not generated at random. Given the same mutation command, the same UUID is always produced. This is not a convenience feature; it is a load-bearing invariant that makes the entire overlay safe to replay, import multiple times, and retry without accumulating duplicate records.
The Guarantee¶
For any given AddNode or CreateRelation mutation command, the UUID derived from that command is always the same. Re-submitting the identical command tomorrow, next week, or after a server crash produces the exact same UUID as the first submission. This property is called deterministic identity derivation and it is encoded in CCR Rules 10 and 16.
The practical consequence is that mutations are idempotent at the identity level: the second submission of a command will attempt to write a node file whose name is identical to the one already on disk. Combined with the idempotency record system (CCR Rule 13), the second write is detected and harmlessly rejected before any file is touched.
The Excel importer depends on this guarantee directly. When an Excel file is re-imported after a partial failure, the importer re-derives commands from the file's content hash (CCR 16). Because the UUIDs are deterministic, previously committed nodes receive the same ID they already have on disk. Only genuinely new rows produce new UUIDs, and the commit for those rows can proceed without risk of creating ghost duplicates of what was already written.
Where It Is Enforced¶
The invariant is enforced in two functions inside backend/domain/mutation_engine.py, with a supporting constant from backend/infra/key_derivation.py.
_canonicalize¶
def _canonicalize(cmd: dict, parent_context: str | None = None) -> str:
| Param | Type | Description |
|---|---|---|
cmd |
dict |
The raw mutation command dictionary |
parent_context |
str \| None |
UUID of the parent node; required for child nodes to prevent cross-subtree collisions |
Returns: a compact JSON string with no whitespace, keys sorted alphabetically.
_canonicalize produces the name string that will be fed into UUID v5. It does three things:
-
Strips transient fields. The set
_TRANSIENT_FIELDS = {"timestamp", "actor", "idempotencyKey"}is removed from the payload before it is serialised. These fields change between submissions (timestamps differ, actor may differ on retry) but they do not define the identity of the entity. Including them would make the UUID non-deterministic across retries. -
Builds a fixed-structure envelope. The output JSON always contains exactly four top-level keys —
commandType,payload,parentContext,idempotencyKey— in that sort order. Thepayloadvalue is the filtered command dict (transient fields removed).parentContextis theparent_contextargument, or an empty string ifNone.idempotencyKeyis taken from the original command. -
Serialises with
sort_keys=Trueand compact separators. The separators(",", ":")eliminate all optional whitespace so the string is byte-for-byte identical regardless of how the original dict was constructed or which Python version is running.
The result is a deterministic, human-readable string that uniquely identifies the intent of the mutation command.
derive_entity_uuid¶
def derive_entity_uuid(cmd: dict, parent_context: str | None = None) -> str:
| Param | Type | Description |
|---|---|---|
cmd |
dict |
The raw mutation command dictionary |
parent_context |
str \| None |
UUID of the parent node; passed through to _canonicalize |
Returns: a UUID v5 string in canonical hyphenated form, e.g. "3d6f4d2a-...".
Reads: NAMESPACE_EIDOS from infra.key_derivation.
Mutates: nothing. Pure function with no side effects.
Error conditions: none raised explicitly. If cmd is missing expected keys, dict.get returns empty strings rather than raising — the canonical string will still be valid but may produce an unintended UUID if required fields are absent. Schema validation in validate_add_node must run before this function is called in any commit path.
This function is the single point of UUID derivation for all overlay nodes and overlay relations. It is called in build_preview (line 257) and must be called by the commit handler with exactly the same cmd and parent_context arguments. Because the canonical string is identical in both calls, the preview UUID is guaranteed to equal the commit UUID (CCR Rule 16: "preview UUID == commit UUID").
payload_hash¶
def payload_hash(cmd: dict) -> str:
| Param | Type | Description |
|---|---|---|
cmd |
dict |
The raw mutation command dictionary |
Returns: a 64-character lowercase hex string (SHA-256 digest).
Reads: all fields in cmd except idempotencyKey.
payload_hash is a companion function, not the UUID derivation itself. It is used for idempotency key integrity checks (CCR Rule 13). When a client submits a mutation, it includes an idempotencyKey in the command. The server uses payload_hash to verify that the payload has not changed between the submission that created the idempotency record and the current retry. If the hash differs, the server rejects the request — the same idempotencyKey cannot be reused for a different payload.
The serialisation in payload_hash is simpler than _canonicalize: it sorts the remaining keys alphabetically and emits compact JSON, then hashes the UTF-8 bytes with SHA-256. It does not impose the same four-key envelope that _canonicalize uses, so the two functions are not interchangeable.
UUID v5 vs UUID v4¶
Why UUID v4 is wrong here¶
UUID v4 is generated by drawing 122 bits from a cryptographically random source. No two calls return the same value. If the mutation engine used uuid.uuid4() for entity IDs, every invocation of derive_entity_uuid — even with identical inputs — would produce a different UUID. Re-importing the same Excel file would create duplicate nodes. Retrying a failed HTTP request would create a second copy of the same node. The overlay would accumulate garbage with no way to distinguish legitimate new nodes from accidental duplicates.
This failure mode is not hypothetical. The violated_by entry in deterministic-uuid.yaml documents exactly this: the retired write-engine used uuid.uuid4() and its IDs were irreconcilable with the mutation engine. Finding 11 in ARCHITECTURE_REVIEW.md describes the consequences.
How UUID v5 works¶
UUID v5 is defined in RFC 4122. It takes two inputs: a namespace UUID and a name string. It computes SHA-1(namespace_bytes + name_utf8_bytes), then formats the first 128 bits of that digest as a UUID with version bits 0101 and variant bits set. The result is fully deterministic: the same namespace and name always produce the same UUID.
The Python call in derive_entity_uuid is:
str(uuid.uuid5(NAMESPACE_EIDOS, _canonicalize(cmd, parent_context)))
NAMESPACE_EIDOS is the fixed constant f47ac10b-58cc-4372-a567-0e02b2c3d479, defined in backend/infra/key_derivation.py and imported at module load time. The module docstring in key_derivation.py states explicitly: "Namespaces are fixed forever once data has been written using them. Do not change a namespace value without a full data migration." Changing NAMESPACE_EIDOS would cause every node ever written to receive a different UUID on the next derivation — every existing file would become orphaned and every idempotency record would be invalidated.
NAMESPACE_EIDOS is reserved exclusively for overlay node and relation UUIDs. Other namespaces serve other purposes: NAMESPACE_SEEDED_RELATION is used by the base-graph loader, NAMESPACE_BOOTSTRAP by the bootstrap and deploy scripts. No other module may define its own namespace UUID constants; key_derivation.py is the single source of truth.
The parent_context Parameter¶
Why child nodes need it¶
Consider two separate subtrees, each containing a node with the label "Pump-01" under a parent called "Hydraulics". If UUID derivation depended only on commandType, label, and parentNodeId, these two nodes would receive identical UUIDs — a collision that would cause the second node to overwrite the first on disk.
The parent_context parameter breaks this symmetry. For AddNode commands, the caller passes parent_context=parent_id (the UUID of the parent node). Because parent_id differs across subtrees, the canonical string differs, and the derived UUID differs.
In build_preview the call is:
node_uuid = derive_entity_uuid(cmd, parent_context=parent_id)
The commit handler must use the same call pattern. Using parent_context=None in one path and parent_context=parent_id in the other would break the "preview UUID == commit UUID" guarantee.
What happens without it¶
If parent_context is omitted (None), _canonicalize substitutes an empty string for parentContext. Two nodes with the same label under different parents would collide. The collision would be silent: the second commit would overwrite the first node's file on disk with the new node's content, corrupting the graph without raising an exception. The only observable symptom would be that the first node's data disappears and its children become orphaned.
For CreateRelation commands, parent_context is typically None because the uniqueness of a relation is determined by the (sourceId, targetId, relationType) triple already present in the command payload. The canonical string for a relation is already globally unique without an additional context.
Idempotency Guarantee This Enables¶
Same mutation, same UUID¶
When a client submits an AddNode command and the network drops before the response arrives, the client retries. The retry carries the same commandType, parentNodeId, label, and idempotencyKey fields. _canonicalize produces the same string. uuid.uuid5 produces the same UUID. The commit handler attempts to write a node file whose name is <uuid>.jsonld. That file already exists from the first (successful) write.
Without an idempotency record system, the second write would silently overwrite the first — harmless for identical content but dangerous if any mutable field (like eidos:created) differs between attempts. The idempotency record system (CCR Rule 13) detects the duplicate before the write: it looks up the idempotencyKey in the idempotency store, finds a record from the first submission, and returns the stored success response without touching the file system.
The deterministic UUID is what makes this system coherent: the client can always recompute the UUID of the entity it tried to create, and the server can always find the correct file on disk using that UUID even if the idempotency store entry has expired.
Double-submission rejection¶
If a user clicks "Save" twice in quick succession, two AddNode requests arrive at the server. The first acquires a lock on the idempotencyKey, writes the node file, writes the idempotency record, and releases the lock. The second request, upon acquiring the lock, finds the idempotency record and returns the first request's success response. The node file is written exactly once. The overlay contains exactly one copy of the node.
Testing¶
Core test pattern¶
A test for the deterministic UUID invariant has exactly this shape:
cmd = {
"commandType": "AddNode",
"parentNodeId": "some-parent-uuid",
"label": "Pump-01",
"idempotencyKey": "key-abc",
}
uuid_first = derive_entity_uuid(cmd, parent_context="some-parent-uuid")
uuid_second = derive_entity_uuid(cmd, parent_context="some-parent-uuid")
assert uuid_first == uuid_second
A second test confirms that different inputs produce different UUIDs:
cmd_b = {**cmd, "label": "Pump-02"}
assert derive_entity_uuid(cmd, parent_context="some-parent-uuid") != \
derive_entity_uuid(cmd_b, parent_context="some-parent-uuid")
The edge case: same label, different parents¶
The most important edge case is two nodes with the same label under different parent UUIDs. Without parent_context propagation, these would collide:
cmd = {
"commandType": "AddNode",
"label": "Pump-01",
"idempotencyKey": "key-abc",
"parentNodeId": "parent-A",
}
uuid_under_A = derive_entity_uuid(cmd, parent_context="parent-A")
cmd_b = {**cmd, "parentNodeId": "parent-B"}
uuid_under_B = derive_entity_uuid(cmd_b, parent_context="parent-B")
assert uuid_under_A != uuid_under_B # must differ — different subtrees
This test verifies that parent_context is actually flowing into _canonicalize. If someone refactors derive_entity_uuid and forgets to pass parent_context through, this test will catch the regression before any data is written.
Testing transient field exclusion¶
A third category of test verifies that timestamp and actor do not affect the UUID:
cmd_with_timestamp = {**cmd, "timestamp": "2026-01-01T00:00:00Z", "actor": "alice"}
cmd_without = {k: v for k, v in cmd.items() if k not in ("timestamp", "actor")}
assert derive_entity_uuid(cmd_with_timestamp, parent_context="parent-A") == \
derive_entity_uuid(cmd_without, parent_context="parent-A")
If this test fails, a retry that includes a fresh timestamp in the command would produce a different UUID than the original submission — breaking idempotency entirely.
Regression guard: namespace must not change¶
The namespace constant NAMESPACE_EIDOS = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479") should be pinned in a test:
from infra.key_derivation import NAMESPACE_EIDOS
import uuid
assert NAMESPACE_EIDOS == uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
This test prevents a well-intentioned developer from changing the namespace constant without realising they are invalidating every UUID ever written to the overlay.