Overlay¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 3 finding(s) fixed — the merge caller is eidos_loader.current_graph(); malformed overlay files are logged via load_overlay_{node,relation,tombstone,node_tombstone}_parse_failed (mutation_store.py:357-437) and then skipped; nodes.update at graph_merge.py:152, engineering/productType carry-over 146-151, DomainStore.write_atomic 246-260. Re-verified against main 4996d1ce. #935 S1 (2026-10-21): line pin moved (nodes.update at graph_merge.py:167). Checked against archon/issue-935. #935 S7 (2026-10-22): the page describes the overlay-only model (Effective Graph = overlay minus tombstones; root and WORLD are overlay files); the sparse-patch-on-an-immutable-base model and the Immutability Contract section are marked as history; the write-ahead-layer wording is dropped. Checked against eidos_loader.py and graph_merge.py at a11d2636. #1102 S8a (2026-10-22): an eidos:UnresolvedRef may carry eidos:node (explicit placeholder, never derived), and the loader reads an IRI-form eidos:source as its bare uuid (infra.iri.relation_source_id). Checked against target_ref.py and mutation_store.py on the #1102 branch (base 538bb015).¶
What the Overlay Is¶
The overlay is the mutable layer of the EIDOS data model and, since #935 S5a, its only store. It is a directory of JSON-LD files that holds every node and relation of the domain — the domain root and WORLD included — plus tombstone markers for entities that have been deleted. The Effective Graph — the merged view that API callers and the frontend consume — is built from the overlay alone.
The mental model is the overlay-only store:
Effective Graph = overlay (minus tombstoned entities)
The backend never reads a .eidos file: the root and WORLD are overlay files, written by bootstrap, or by python -m tools.migration convert for a domain that still held them in data.eidos. The merge in graph_merge.py still starts from a base graph, but that base is empty, so "overlay wins over the base on identical IDs" no longer selects between two stores.
History (before #935 S5a). The overlay was a sparse patch layer on top of an immutable base
.eidosfile: it held only the nodes and relations created or modified since the base was last published, plus tombstones, and everything else was read from the base. The base/overlay wording further down this page describes that historical merge, whose code path is unchanged.
The overlay lives at backend/mutations/ and is governed by backend/persistence/mutation_store.py. The merge that combines the base with the overlay is performed by backend/domain/graph_merge.py, called by eidos_loader.current_graph().
What the overlay contains¶
The overlay directory holds four kinds of data relevant to the merge:
- New and updated nodes — every node created at runtime, plus any base node that was subsequently modified. These live in
mutations/nodes/. - New relations — every relation created at runtime. These live in
mutations/relations/. - Relation tombstones — soft-delete markers for seeded relations. These live in
mutations/tombstones/. - Node tombstones — soft-delete markers for nodes. These live in
mutations/node_tombstones/.
There are additional subdirectories (ownership/, idempotency/, audit/, snapshots/, pending/) that support other subsystems but are not part of the overlay merge path.
On-Disk Layout¶
mutations/
nodes/ one .jsonld file per created or updated node
relations/ one .jsonld file per created relation
tombstones/ one .jsonld file per soft-deleted seeded relation
node_tombstones/ one .jsonld file per soft-deleted node
ownership.jsonld all ownership assignments (single shared file)
idempotency/ one .jsonld file per committed idempotency key
snapshots/ one .jsonld file per snapshot
audit/ append-only audit log, one file per calendar day
pending/ short-lived preview commands (one .json per preview)
_cache_version monotonic integer counter for cross-process cache invalidation
File naming convention¶
Every node and relation file is named after its UUID (UUID v5):
mutations/nodes/{uuid}.jsonld
mutations/relations/{uuid}.jsonld
mutations/tombstones/{uuid}.jsonld
mutations/node_tombstones/{uuid}.jsonld
The filename stem must equal the UUID carried on the envelope's @id
(https://id.ontoteq.com/{uuid}), read via infra.iri.id_from_envelope.
load_overlay() detects and logs discrepancies between the filename and the
embedded id (Contract 06-R28), skipping any file where they do not match. For
pre-WP1 files that still carry only eidos:id, id_from_envelope falls back
to that key — the reader accepts both shapes.
What a node overlay entry looks like¶
A file at mutations/nodes/abc-123.jsonld has this shape (WP1 #950, WP2 #951):
{
"@context": {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#"},
"@id": "https://id.ontoteq.com/abc-123",
"@type": "eidos:OverlayNode",
"rdfs:label": "My Component",
"skos:notation": "MyComponent",
"eidos:nodeType": "Component",
"eidos:treeId": "tree-xyz",
"eidos:parent": "parent-456",
"eidos:productType": "hardware",
"eidos:properties": {"description": "A new component added at runtime"},
"eidos:created": "2026-06-01T10:00:00+00:00",
"eidos:updatedAt": "2026-06-15T09:30:00+00:00",
"eidos:createdBy": "user@example.com",
"eidos:rev": 3
}
The skos:notation field (WP2 #951) is the node's per-segment ref-path
anchor. It is written by mutation_engine.build_overlay_node and
mutation_engine.build_overlay_update_node using the canonical generator
infra.key_derivation.derive_slug_segment(label, is_root=...). The full
dotted ref path is assembled from the chain of stored segments; a pre-WP2
overlay file that still lacks skos:notation falls back to deriving the
segment from rdfs:label at read time (no file rewrite), so existing ref
paths are byte-stable.
Writers emit @id (via infra.iri.node_iri) and rdfs:label; eidos:id
and eidos:label are no longer written. The reader keys on @id through
id_from_envelope, which falls back to eidos:id only for pre-WP1 overlay
files; _normalize_node() likewise reads rdfs:label first and falls back
to eidos:label on legacy input.
After reading this file, _normalize_node() in mutation_store.py converts the JSON-LD fields into the flat dict format used throughout the codebase:
| JSON-LD field | Flat dict key | Description |
|---|---|---|
@id (legacy fallback: eidos:id) |
id |
UUID of this node, parsed via id_from_envelope |
rdfs:label (legacy fallback: eidos:label) |
label |
Display label |
eidos:nodeType |
class |
Node type (compat key with base format) |
eidos:treeId |
treeId |
Tree this node belongs to |
eidos:parent |
parent |
UUID of the parent node |
eidos:productType |
productType |
Product classification |
eidos:properties |
properties |
Arbitrary key-value pairs; description lives here |
eidos:created |
created |
ISO timestamp of creation |
eidos:updatedAt |
modified |
ISO timestamp of last modification |
eidos:createdBy |
createdBy |
Identity of creator |
eidos:rev |
rev |
Monotonic version token; absent on legacy/base nodes resolves to 0 |
eidos:engineering |
engineering |
Optional list of engineering data dicts |
skos:notation |
notation |
Per-node ref-path segment (WP2 #951); absent on pre-WP2 overlay files — reader derives from label as a fallback |
| (always set) | _overlay |
Boolean True — marks this as an overlay-originated node |
The _overlay: True marker is important: merge_overlay() uses it in tombstone filtering to distinguish overlay relations (which are never tombstoned by tombstones) from base relations (which are).
What a relation overlay entry looks like¶
A file at mutations/relations/def-456.jsonld has this shape (WP4b #965):
{
"@context": {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#", "eidos:target": {"@id": "https://ontoteq.com/ns/eidos#target", "@type": "@id"}},
"@id": "https://id.ontoteq.com/def-456",
"@type": "eidos:OverlayRelation",
"eidos:source": "abc-123",
"eidos:target": "https://id.ontoteq.com/ghi-789",
"eidos:relationType": "implements",
"eidos:inheritSource": false
}
Writers emit the IRI on @id (via infra.iri.relation_iri); eidos:id is
no longer written. The reader keys on @id through id_from_envelope and
falls back to eidos:id only for pre-WP1 overlay files.
eidos:target — one field, three status forms (WP4b #965)¶
Before WP4b, a relation carried its target across four separate keys
(eidos:toNodeId, eidos:externalDomain, eidos:externalTargetRef,
eidos:unresolvedTargetRef) and the reader status-dispatched on which were
set. WP4b collapses that into a single eidos:target field whose shape
encodes the status directly. The writer (build_overlay_relation) emits
exactly one of these three shapes; the reader (read_target_ref_jsonld)
status-dispatches on shape alone and returns a typed TargetRef.
-
In-model — target is a node in this domain.
eidos:targetis the node's canonical IRI (string), declared with@type: @idin the@contextso a JSON-LD processor expands it as a node reference:"eidos:target": "https://id.ontoteq.com/ghi-789" -
External — target lives in another domain.
eidos:targetis aneidos:ExternalRefnode carrying the domain and the ref. The placeholder UUID is NOT written as an IRI — writing it as a target string would read back as a broken in-model relation to a node that does not exist. The reader re-derives the placeholder from (eidos:source,eidos:ref,eidos:relationType) viaderive_relation_uuid_for_unresolved_target:"eidos:target": { "@type": "eidos:ExternalRef", "eidos:domain": "signals", "eidos:ref": "+SIG.CH01.PT100" }
An eidos:ExternalRef may carry just eidos:domain with an empty or
absent eidos:ref — the cross-domain migration path writes this shape
when it cannot ask the remote instance for a ref at write time
(documents-migration DOC_A). The reader returns an external
TargetRef whose ref is empty and whose placeholder UUID is empty
unless eidos:node carries the real remote id.
eidos:node — the real remote node IRI — is embedded in the
eidos:ExternalRef whenever a real remote target UUID is known AND
the derived placeholder would NOT round-trip to it. That is: either
the ref is empty (no placeholder can be derived at all — the DOC_A
case) or the real targetId differs from the placeholder derived
from (sourceId, externalTargetRef, relationType) — the
UI-created cross-domain path. When eidos:node is present the reader
parses the IRI back to a bare UUID and uses it as the placeholder;
only the Excel-imported path where targetId equals the derived
placeholder omits eidos:node (the reader re-derives the same
placeholder on read):
```json
"eidos:target": {
"@type": "eidos:ExternalRef",
"eidos:domain": "signals",
"eidos:ref": "+SIG.CH01.PT100",
"eidos:node": "https://id.ontoteq.com/<real-remote-uuid>"
}
```
-
Unresolved — target is a deferred LOCAL reference that will bind when the matching node is created.
eidos:targetis aneidos:UnresolvedRefnode carrying the ref only:"eidos:target": { "@type": "eidos:UnresolvedRef", "eidos:ref": "local.DEFERRED.X" }
An eidos:UnresolvedRef may also carry eidos:node (#1102 S8a): the
migration import writes it when a legacy file's eidos:toNodeId was
set beside eidos:unresolvedTargetRef, and the reader returns that uuid.
Without eidos:node the placeholder is empty — nothing is derived for
an unresolved target.
The @context declares the two ref node types (eidos:UnresolvedRef,
eidos:ExternalRef) and the two ref-node payload keys (eidos:domain,
eidos:ref) so every shape round-trips through a JSON-LD processor.
Malformed targets fail typed, never silently. An unparseable IRI,
an eidos:UnresolvedRef with no eidos:ref, an eidos:ExternalRef
missing eidos:domain, an unknown @type on a target node, or an
unparseable eidos:node IRI inside an eidos:ExternalRef or
eidos:UnresolvedRef, raises
InvalidTargetRefError. The overlay loader catches that error, logs a
structured warning (load_overlay_relation_bad_target), and skips the
offending file so one bad relation never crashes the whole load.
The two WP4b-era catch sites in mutations_router (delete preview and
heal scan) likewise log a structured warning and continue, never
leaking the exception text into an API response.
Legacy target form refused (#1102 S8b). Overlay files written
before WP4b carried the four pre-WP4b target keys (eidos:toNodeId,
eidos:externalDomain, eidos:externalTargetRef,
eidos:unresolvedTargetRef) instead of eidos:target. The migration
import (tools.migration, #1102 S8a) rewrites them into eidos:target;
since S8b read_target_ref_jsonld no longer reads them. A relation with
any of the four keys and no eidos:target raises
LegacyFormRefusedError (an InvalidIRIError subclass, infra.iri)
naming tools.migration, and the overlay load fails on it instead of
dropping the file. tools.migration is the one remaining reader of the
form (infra.target_ref.legacy_target_ref).
What a tombstone entry looks like¶
A relation tombstone at mutations/tombstones/def-456.jsonld (WP1 #950):
{
"@context": {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#"},
"@type": "eidos:RelationTombstone",
"@id": "https://id.ontoteq.com/def-456",
"eidos:created": "2026-06-20T14:00:00+00:00",
"eidos:createdBy": "user@example.com"
}
A node tombstone at mutations/node_tombstones/abc-123.jsonld (WP1 #950):
{
"@context": {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#"},
"@type": "eidos:NodeTombstone",
"@id": "https://id.ontoteq.com/abc-123",
"eidos:deletedAt": "2026-06-20T15:00:00+00:00",
"eidos:deletedBy": "user@example.com",
"eidos:auditRef": "https://id.ontoteq.com/some-audit-id"
}
Both tombstone writers now emit @id only; the reader keys on @id via
id_from_envelope, falling back to eidos:id for pre-WP1 tombstone files.
Legacy urn ids refused (#1102 S8b). Overlay files written before WP1 carried
@id,eidos:sourceand audit-reference values in the oldurn:eidos:(node|relation|audit):{uuid}form. The migration import (tools.migration, #946 / #1102 S8a) rewrites them tohttps://id.ontoteq.com/{uuid}. Since S8binfra.iri.from_iri,id_from_envelopeandrelation_source_idrefuse aurn:eidos:node:/urn:eidos:relation:value withLegacyFormRefusedErrornamingtools.migration(aneidos:idduplicate does not answer it), andload_overlayfails naming the file. Theurn:eidos:audit:{uuid}form is still read (the append-only audit log keeps it), and the empty legacy audit refurn:eidos:audit:stays the #1071 exception.
Overlay vs Base — The Immutability Contract (history)¶
History. This section describes the base
.eidos+ overlay model retired by #935 S5a. Today there is no base: the overlay is the only store and the invariant below reduces to "Effective Graph = overlay".
The base .eidos file was a ZIP archive that was never modified at runtime; eidos_loader loaded it read-only until #935 S5a retired that reader (a legacy file is now never opened, and convert archives it). The reasons for this constraint are practical and safety-related:
- ZIP files are not append-friendly. Modifying a ZIP in place requires rewriting the central directory, which is not atomic. A crash mid-rewrite corrupts the entire base.
- The base represents a published snapshot. Mutating it would destroy the ability to reconstruct the exact state that was reviewed and released.
- The overlay is the crash-safe write path. Every write to the overlay uses the atomic pattern described below. A crash can leave a partial
.tmpfile but can never corrupt the base or an existing overlay file.
The invariant is:
Effective Graph = immutable base + mutable overlay, where overlay wins on identical IDs.
This means: any node or relation in the base can be superseded at runtime by writing a file with the same ID to mutations/nodes/ or mutations/relations/. The base entry is not deleted; it is simply shadowed. If the overlay entry is later removed or its ID no longer appears in the overlay, the base entry becomes visible again.
A developer who misunderstands this and tries to write directly into the .eidos ZIP, or who removes an overlay file expecting "undo" semantics without considering the merge order, will get incorrect Effective Graph output. The right way to suppress a base node is to write a node tombstone, not to remove or modify the base.
Writing to the Overlay¶
All writes go through write_atomic(data: dict, path: Path) in DomainStore (also available as the module-level wrapper mutation_store.write_atomic).
write_atomic — the temp/fsync/rename pattern¶
def write_atomic(self, data: dict, path: Path) -> None:
| Parameter | Type | Description |
|---|---|---|
data |
dict |
JSON-serialisable data to write |
path |
Path |
Absolute destination path inside mutations/ |
The function implements CCR Rule 5: temp file → fsync → atomic rename. In detail:
- A
tempfile.mkstemp()call creates a uniquely named.tmpfile in the same directory aspath. Using the same directory is essential:os.replace()(step 4) requires that the source and destination be on the same filesystem. - The data is written as indented JSON with
json.dump(..., ensure_ascii=False, indent=2)and the file handle is flushed withf.flush(). os.fsync(f.fileno())is called. This forces the kernel to push the data to the storage device before the rename. Withoutfsync, a crash after the rename could leave a zero-byte or partially written file that appears complete toos.replace.os.replace(tmp, path)performs an atomic rename. On POSIX systems, this is guaranteed to be atomic by the kernel: any reader ofpathsees either the complete old content or the complete new content — never a partial write.- If any step fails, the
.tmpfile is deleted withcontextlib.suppress(OSError)and the original exception is re-raised. The destination file is never corrupted.
The method is serialised by self._write_lock (a threading.Lock), which prevents two coroutines in the same process from interleaving writes to the same path. For the single shared files (ownership.jsonld) an additional cross-process advisory fcntl lock is acquired by the callers before calling write_atomic.
What happens if a write is interrupted¶
If the process crashes between the fsync and the os.replace, the only artefact is an orphaned .tmp file in the same directory as the intended target. The target file (if it already existed) is unchanged. The .tmp file is inert: load_overlay() globs *.jsonld and will never pick up *.tmp files. The next successful write to that path will replace the .tmp or the surviving old file, whichever applies.
Reading from the Overlay¶
load_overlay() — what it assembles¶
def load_overlay(self) -> dict:
Returns:
{
"nodes": {uuid: normalized_node_dict, ...},
"relations": [rel_dict, ...],
"tombstones": {uuid, ...}, # set of soft-deleted seeded relation UUIDs
"node_tombstones": {uuid, ...}, # set of soft-deleted node UUIDs
}
The function scans four directories in order:
mutations/nodes/*.jsonld— each file is parsed, UUID-checked (id_from_envelopereads@id; falls back toeidos:idon pre-WP1 files), and passed through_normalize_node(). The result is keyed by UUID in thenodesdict.mutations/relations/*.jsonld— each file is parsed, UUID-checked the same way, and the relevant fields (eidos:source,eidos:target,eidos:relationType, etc.) are extracted into a flat relation dict with_overlay: True. The runtimefromis the bare source uuid: aneidos:sourcestill in a node-IRI form (https://id.ontoteq.com/…or…/node/…) is read as its uuid throughinfra.iri.relation_source_id(#1102 S8a), so a migration import that writes the bare uuid leaves the effective graph unchanged. The target is routed throughread_target_ref_jsonldso one of the three WP4b (#965) shapes — in-model IRI,eidos:ExternalRef, oreidos:UnresolvedRef— resolves to the runtime (to,external_domain,external_ref,unresolved_ref) fields the merger expects. A malformed new-format target raisesInvalidTargetRefError; the loader logs a structuredload_overlay_relation_bad_targetwarning and skips the offending file so one bad relation never crashes the whole overlay load.mutations/tombstones/*.jsonld— each file's UUID (viaid_from_envelope) is added to thetombstonesset.mutations/node_tombstones/*.jsonld— each file's UUID (viaid_from_envelope) is added to thenode_tombstonesset.
Malformed files (JSON parse errors, missing fields) are logged and skipped: each scan loop catches Exception, emits a structured warning (load_overlay_node_parse_failed, load_overlay_relation_parse_failed, load_overlay_tombstone_parse_failed, load_overlay_node_tombstone_parse_failed — mutation_store.py:357-437) naming the file, and continues. This is intentional: a single corrupted overlay file must not prevent the rest of the overlay from loading. The one exception is a file in a legacy form (#1102 S8b) — a urn:eidos:node: / urn:eidos:relation: @id or eidos:source, or a relation with the four pre-WP4b target keys and no eidos:target: the loop re-raises LegacyFormRefusedError prefixed with the file (relations/<uuid>.jsonld: …), naming tools.migration, so the whole load fails instead of dropping the file. At the API the module-level mutation_store.load_overlay() surfaces it as the typed LegacyOverlayFileRefusedError (HTTP 500, legacy_overlay_file_refused): the client message is generic, and the file, the refusal and tools.migration ride on the domain_error log record only (log_fields file, reason, migration_tool). A legacy id nested elsewhere in a loaded file (an inheritedFrom, a relation origin/target, an id-index lookup) is not read either: the read helpers re-raise LegacyFormRefusedError instead of returning "" or the raw string. The migration import converts such files; the backend no longer reads them. The UUID-mismatch case additionally carries rule="C-01" (load_overlay_uuid_mismatch / load_overlay_rel_uuid_mismatch) so it can be found in audit tooling.
Caching and cross-process invalidation¶
load_overlay() caches its result in self._overlay_cache. The cache is valid as long as the monotonic counter in mutations/_cache_version matches the value recorded when the cache was last built (self._overlay_cache_sentinel_mtime). Every call to invalidate() bumps the counter via bump_version_counter(), which uses os.replace internally (same atomic-rename guarantee). Because the counter is stored on disk, other uvicorn worker processes see the change on their next load_overlay() call and discard their own warm caches. This replaces the old st_mtime comparison, which had whole-second resolution and could miss two invalidations in the same second.
Concrete Example¶
Suppose the base .eidos file contains this node:
{
"id": "node-001",
"label": "Engine Mount",
"class": "Mechanical",
"treeId": "tree-A",
"properties": {"description": "Original description from base"}
}
A user updates the label and description at runtime. The system writes mutations/nodes/node-001.jsonld (WP1 #950):
{
"@context": {"eidos": "https://ontoteq.com/ns/eidos#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "skos": "http://www.w3.org/2004/02/skos/core#"},
"@id": "https://id.ontoteq.com/node-001",
"@type": "eidos:OverlayNode",
"rdfs:label": "Engine Mount (Rev B)",
"eidos:nodeType": "Mechanical",
"eidos:treeId": "tree-A",
"eidos:properties": {"description": "Updated description after engineering review"},
"eidos:updatedAt": "2026-06-23T08:00:00+00:00",
"eidos:rev": 1
}
When eidos_loader.current_graph() rebuilds the cache, it:
- Starts from the base graph — empty since #935 S5a; in the historical base+overlay setup shown here
nodes["node-001"]held the original data. - Calls
load_overlay(). The overlay returnsnodes = {"node-001": <normalized overlay entry>}. - Passes both to
merge_overlay(). Insidemerge_overlay(), line 152:nodes.update(overlay["nodes"])— the overlay entry for"node-001"overwrites the base entry.
The Effective Graph now contains:
{
"id": "node-001",
"label": "Engine Mount (Rev B)",
"class": "Mechanical",
"treeId": "tree-A",
"properties": {"description": "Updated description after engineering review"},
"modified": "2026-06-23T08:00:00+00:00",
"rev": 1,
"_overlay": True,
# engineering and productType are preserved from base (see graph_merge.py lines 146-151)
}
The base file is untouched. If mutations/nodes/node-001.jsonld were deleted (do not do this directly — use a node tombstone), the next load would show the original base entry again.
Tombstones¶
Why tombstones exist instead of deleting files¶
The overlay follows an additive invariant: files are never deleted from it. Instead, a separate tombstone file is written to signal deletion. This design has two motivations:
- Audit trail. The layer stack of
mutations/nodes/{uuid}.jsonld+mutations/node_tombstones/{uuid}.jsonldtells the full history: "this entity existed, then was deleted." Deleting the overlay file would erase that evidence. - Atomic semantics. Deleting a file and ensuring that no concurrent reader sees a half-deleted state is harder than writing a new file.
write_atomicgives the same crash-safe guarantee for tombstones as for all other overlay writes.
write_node_tombstone — soft-deleting a node¶
def write_node_tombstone(
self, node_id: str, deleted_by: str = "", audit_id: str = ""
) -> None:
| Parameter | Type | Description |
|---|---|---|
node_id |
str |
UUID of the node to soft-delete |
deleted_by |
str |
Identity of the user requesting deletion |
audit_id |
str |
Reference to the audit log entry for this deletion |
Writes a eidos:NodeTombstone document to mutations/node_tombstones/{node_id}.jsonld via write_atomic. The node's overlay file at mutations/nodes/{node_id}.jsonld (if any) is intentionally left on disk. The operation is idempotent: re-writing the same tombstone is an overwrite with no duplicate-file error.
write_tombstone — soft-deleting a seeded relation¶
def write_tombstone(self, rel_uuid: str, created_by: str = "") -> None:
| Parameter | Type | Description |
|---|---|---|
rel_uuid |
str |
UUID of the seeded relation to soft-delete |
created_by |
str |
Identity of the user requesting deletion |
Writes a eidos:RelationTombstone document to mutations/tombstones/{rel_uuid}.jsonld via write_atomic. Only base (seeded) relations use this mechanism. Overlay relations (those in mutations/relations/) are deleted by removing their file — but again, do not do this manually; use the appropriate API endpoint which manages invalidation.
How merge_overlay handles tombstones¶
merge_overlay() in graph_merge.py applies tombstones in a strict order to prevent re-introduction of deleted entities:
Step 1 (BL-FE-037): Before any merge work, node_tombstones are used to filter overlay["nodes"]. If a node has both an overlay file and a node tombstone, the overlay entry is removed from the working copy of the overlay dict. This prevents the subsequent nodes.update() call from silently resurrecting a deleted node. The original _overlay_cache dict is not mutated; a shallow copy is made (overlay = dict(overlay)) so the cache remains valid.
Step 2: Relation tombstones are applied to the base relations list and to rels_by_node. The filter keeps overlay relations (identified by r.get("_overlay")) because overlay relations have a separate deletion path and are never in the tombstones set.
Step 3 (BL-DM-001): Node tombstones are applied to the assembled graph. For each tombstoned UUID:
- The node is removed from nodes.
- Every relation where from or to equals the tombstoned UUID is removed from relations and rels_by_node.
- The node is removed from parent_of and children_of.
- The node's ref path is removed from ref_index and ref_by_uid via key_derivation.unindex_ref(), which is careful not to drop a colliding ref entry that belongs to another node (BL-DM-010).
- The tombstoned UUID is removed from the children list of its parent in children_of.
Step 4: The remaining overlay nodes and relations are merged into the base graph. nodes.update(overlay["nodes"]) applies the overlay-wins rule. Structural indexes (parent_of, children_of, trees[i]["children"], ref_index, ref_by_uid, rels_by_node) are rebuilt for all overlay nodes in two passes so that children of overlay parents resolve correctly regardless of the order files were scanned.
After merge_overlay() returns, base_graph["relations"] has been reassigned to the filtered and extended list. Callers must read from base_graph["relations"] after the call, not from any reference they captured before it.
Invariants¶
The two invariants named in overlay.yaml map directly to code:
- overlay-wins-on-conflict: enforced by
nodes.update(overlay["nodes"])inmerge_overlay()(line 167 ofgraph_merge.py). Becausedict.updateoverwrites existing keys, any base entry for a UUID that appears in the overlay is replaced unconditionally. - atomic-write: enforced by
write_atomic()inDomainStore(lines 246–260 ofmutation_store.py). Every overlay file write — nodes, relations, tombstones, relation tombstones — goes through this function. There is no code path that writes overlay JSON withoutfsync+os.replace.
A developer who bypasses write_atomic — for example, by calling path.write_text() directly — risks leaving a partially written file on disk if the process crashes mid-write. load_overlay() would then fail to parse the malformed JSON, log a load_overlay_*_parse_failed warning and skip the file, and the entity would disappear from the Effective Graph until the file was manually repaired or overwritten. This is the primary blast radius of violating the atomic-write invariant.