Skip to content

Overlay Wins on Conflict

Verified — minor divergences from the code — 2 finding(s) · 19d408be · 2026-08-25

The invariant, its enforcement point (merge_overlay in backend/domain/graph_merge.py), the Step 4a/4b node-merge logic, the tombstone-ordering fix, and the BL-FE-037 regression test all match the code precisely, including exact line 134 for nodes.update(overlay["nodes"]) and near-verbatim code quotes for the Step-1 tombstone pre-filter. The one divergence is in the KM YAML itself: sources.files cites a nonexistent path for the enforcement file.

Divergences from the code — details
Sev Where Doc says Code does Evidence
MEDIUM sources.files / enforced_by: graph-merge sources.files lists the enforcement file as backend/graph_merge.py No such file exists; the module is at backend/domain/graph_merge.py (the compiled doc page itself correctly uses this path throughout, so only the KM YAML's own sources.files entry is stale) docs/manual/knowledge/invariants/overlay-wins-on-conflict.yaml:23
LOW For relations the mechanism is different Relations: relations = relations + overlay["relations"] The actual code first filters overlay relations against node_tombstones into overlay_rels, then does relations = relations + overlay_rels (graph_merge.py:176-181) — the doc's quoted snippet is a simplification that omits the tombstone-gating step (which is otherwise correctly described in prose elsewhere in the same page) backend/domain/graph_merge.py:176-181

The Guarantee

When a node or relation exists in both the base .eidos ZIP and the overlay (mutations/ directory), the overlay version is the authoritative value. For any given node UUID, once an overlay entry exists the base is never consulted for that ID again — not merged, not preferred, not blended.

In concrete terms: if the base graph contains node faaa4f34-… with label "OldLabel" and the overlay contains the same UUID with label "NewLabel", the effective graph presents "NewLabel". The base entry for that UUID is completely shadowed.

This applies equally to the node entry as a whole. If the overlay entry omits fields that the base contained, those fields disappear from the effective node (with two narrowly-scoped exceptions for engineering and productType — see the Step 4a discussion below). The overlay entry is not patched on top of the base entry field-by-field; it replaces it.

The same guarantee extends to relations. Overlay relations are appended to the effective relation list; they do not require a matching base relation, and a base relation can be hidden by a tombstone entry in the overlay. The rule governing relation ordering is append-only rather than replacement, but the directional authority runs the same way: overlay assertions always win.

Where It Is Enforced

The invariant is enforced in a single function:

# backend/domain/graph_merge.py

def merge_overlay(base_graph: dict, overlay: dict) -> dict:

The function is the only path through which the effective graph is produced. eidos_loader._load() calls it after assembling the base graph and scanning the mutations/ directory. No other call site generates an EffectiveGraph from a base-plus-overlay combination.

The Core Decision — nodes.update()

The overlay-wins rule for nodes is implemented in Step 4b of merge_overlay, at line 134 of backend/domain/graph_merge.py:

nodes.update(overlay["nodes"])

nodes is a reference to base_graph["nodes"] — a dict[str, dict] mapping node UUID → node entry. dict.update() overwrites any existing key with the value from the incoming dict and inserts keys that were absent. Because overlay["nodes"] is passed as the argument, overlay entries land on top of base entries for identical UUIDs. There is no conditional: the update is unconditional for every key in overlay["nodes"].

Immediately before this line, Step 4a runs a narrow preservation pass:

for uid, onode in overlay["nodes"].items():
    if "engineering" not in onode and uid in nodes:
        onode["engineering"] = nodes[uid].get("engineering", [])
    if "productType" not in onode and uid in nodes and nodes[uid].get("productType") is not None:
        onode["productType"] = nodes[uid]["productType"]

This copies engineering and productType from the base into the overlay entry only when the overlay entry does not contain those keys at all. It is not an exception to the invariant — it is a field-level back-fill for two specific metadata fields that the overlay authoring process does not write. The overlay entry still wins for every other field. After this back-fill, nodes.update(overlay["nodes"]) runs and the overlay entry (now enriched with the back-filled fields) replaces the base entry in full.

For relations the mechanism is different — overlay relations are appended rather than merged by UUID — but the authority direction is the same:

relations = relations + overlay["relations"]

Base relations that conflict with overlay intent are removed via tombstones (see the Tombstones section below). An overlay relation is never suppressed by a base relation.

Why It Must Be Unconditional

The User-Edit Use Case

The primary reason the rule has no exceptions is the user-edit lifecycle.

When a user modifies a node that originated in the base .eidos ZIP, the mutation engine writes a new overlay file for that node UUID in mutations/nodes/. The overlay entry holds the user's new values. From that point forward, the only way the user's changes can survive across reloads is if the overlay entry unconditionally wins when the same UUID appears in both layers.

If the rule had an exception — for example, "overlay wins unless the base entry has a higher version number" — then a base ZIP update could silently overwrite user edits with no warning and no recovery path. The user edited "NewLabel", the base ZIP was replaced with a new version that still carries the node but now calls it "UpdatedBaseLabel", and the user's work is gone.

The unconditional rule eliminates this entire class of defect. User-authored overlay data is durable against base replacements.

What Would Break if the Rule Had Exceptions

Conditional merge by field type. If the code attempted to merge base and overlay entries field-by-field rather than replacing wholesale, the result would depend on which fields the overlay happened to write. Overlay authoring does not guarantee completeness — an overlay file for a node may contain only the fields the user explicitly changed. A field-level merge would mix base and overlay data in ways neither layer intended, producing composite node entries that correspond to no real authored state.

Version-gated overlay. If a newer base version were allowed to override an older overlay, the effective graph would silently change on base ZIP updates with no user action and no notification. The user would see a node revert to a previous state with no explanation.

Per-field ownership tracking. Any scheme that routes individual fields to either base or overlay depending on provenance metadata would require that metadata to be consistent across all mutation paths. A missing or incorrect provenance tag would silently mis-route a field. The dict.update() approach requires no such metadata; the overlay file's existence is the only signal needed.

Tombstones as Overlay Assertions

The overlay-wins invariant also governs deletions. A node tombstone — a file at mutations/node_tombstones/{uuid}.jsonld — is an overlay-layer assertion that the node with that UUID must not exist in the effective graph, regardless of whether it exists in the base.

This is the same invariant applied to the delete case: if the base contains a node and the overlay says that node is deleted, the overlay wins and the node is absent from the effective graph.

In merge_overlay, node tombstones are applied during Step 3:

node_tombstones: set = overlay.get("node_tombstones", set())
if node_tombstones:
    for uid in list(nodes):
        if uid in node_tombstones:
            del nodes[uid]
    relations = [r for r in relations
                 if r.get("from") not in node_tombstones
                 and r.get("to") not in node_tombstones]
    # ... plus parent_of, children_of, ref_index, ref_by_uid, rels_by_node cleanup

The tombstone removes the node from every index structure. The base file and overlay file remain on disk — the on-disk layer stack preserves the full lifecycle — but the effective graph projection does not contain the node.

Relation tombstones follow the same pattern via the tombstones set (Step 2):

tombstones: set = overlay.get("tombstones", set())
if tombstones:
    relations = [r for r in relations
                 if r.get("_overlay") or r.get("source") not in tombstones]

A relation tombstone targets a seeded (base-graph) relation by its source UUID. Overlay relations carry "_overlay": True and are never targeted by relation tombstones — they have no base source to reference.

The important design consequence is that tombstones must be applied before the overlay-add merge. If tombstones were applied after nodes.update(), a node that was created in the overlay and later tombstoned would be re-introduced from overlay["nodes"] before the tombstone filter ran. This was the exact mechanism behind BL-FE-037.

Testing for Violation

A test that catches a violation of the overlay-wins invariant must construct a scenario where base and overlay both define the same node, then assert that the effective graph reflects only the overlay value.

Minimal test structure:

def test_overlay_wins_for_conflicting_node():
    base_graph = {
        "nodes": {
            "uid-A": {"label": "BaseLabel", "engineering": [], "productType": None},
        },
        "relations": [],
        "trees": [],
        "parent_of": {},
        "children_of": {},
        "ref_index": {},
        "ref_by_uid": {},
        "rels_by_node": {},
    }
    overlay = {
        "nodes": {
            "uid-A": {"label": "OverlayLabel"},
        },
        "relations": [],
        "tombstones": set(),
        "node_tombstones": set(),
    }
    result = merge_overlay(base_graph, overlay)
    assert result["nodes"]["uid-A"]["label"] == "OverlayLabel"

A test that catches the tombstone variant of the invariant (the BL-FE-037 scenario) must place the same UUID in both overlay["nodes"] and overlay["node_tombstones"] and assert that the node is absent from every output structure:

def test_overlay_tombstone_wins_over_overlay_node():
    uid = "uid-created-then-deleted"
    base_graph = {
        "nodes": {},
        "relations": [],
        "trees": [],
        "parent_of": {},
        "children_of": {},
        "ref_index": {},
        "ref_by_uid": {},
        "rels_by_node": {},
    }
    overlay = {
        "nodes": {uid: {"label": "CreatedViaOverlay"}},
        "relations": [],
        "tombstones": set(),
        "node_tombstones": {uid},
    }
    result = merge_overlay(base_graph, overlay)
    # Node must be absent from every output structure
    assert uid not in result["nodes"]
    assert uid not in result["parent_of"]
    assert uid not in result["children_of"]
    assert uid not in result["ref_by_uid"]
    assert uid not in result["rels_by_node"]

This second test is the pattern used in tests/test_node_tombstone.py::test_merge_removes_tombstoned_overlay_node_from_effective_graph, which was added as the regression test for BL-FE-037.

To test the base-tombstone variant (a base-graph node deleted via overlay), place the UUID in base_graph["nodes"] and in overlay["node_tombstones"] and assert the same absence.

BL-FE-037 — Historical Violation

What Went Wrong

On 2026-05-20, users reported that deleting a node appeared to succeed — the delete dialog closed without error — but the node remained visible in the tree and detail panel. A hard browser refresh did not help; the defect was server-side.

Investigation confirmed (via SSH to the DEV deployment) that the tombstone file mutations/node_tombstones/{uuid}.jsonld was present on disk. The tombstone had been written correctly. The defect was in backend/graph_merge.py::merge_overlay.

At that point the function applied node_tombstones filtering to base structures — removing the tombstoned node from nodes, parent_of, children_of, and so on — but then unconditionally ran nodes.update(overlay["nodes"]) with the raw, unfiltered overlay["nodes"] dict. For any node that existed both as an overlay file (because it was created via overlay) and as a tombstone (because it was subsequently deleted), the tombstone filter's work was immediately undone by the dict.update() call. The overlay-add pass re-inserted the deleted node into every structure that the tombstone filter had just cleaned.

The defect affected any node that went through the create-in-overlay then delete lifecycle, which is the normal lifecycle for any user-created node. Base-graph nodes that had never been written as overlay files were not affected — their tombstones removed them from the base structures, and there was no overlay entry to re-introduce them.

The Fix

The fix was applied in commit e0df3a4 (2026-05-20). It added Step 1 to merge_overlay: filter overlay["nodes"] against overlay["node_tombstones"] before any other processing runs. The filtered dict replaces overlay["nodes"] for the remainder of the function. Because overlay is potentially the cached mutation_store._overlay_cache dict shared across requests, a shallow copy is made before mutating:

_node_tombstones_pre: set = overlay.get("node_tombstones", set())
if _node_tombstones_pre and overlay.get("nodes"):
    _filtered_overlay_nodes = {
        uid: n for uid, n in overlay["nodes"].items()
        if uid not in _node_tombstones_pre
    }
    if len(_filtered_overlay_nodes) != len(overlay["nodes"]):
        overlay = dict(overlay)           # shallow copy — do not mutate cached dict
        overlay["nodes"] = _filtered_overlay_nodes

After Step 1 runs, no subsequent step in the function can see the tombstoned UUID in overlay["nodes"]. The nodes.update(overlay["nodes"]) call in Step 4b never encounters it. The invariant — tombstone wins over overlay node entry — is enforced at the input boundary of the overlay layer, before the first write to any output structure.

Blast Radius of Violation

If the order of operations in merge_overlay ever drifts back to the pre-fix shape — tombstone applied to output structures before the nodes.update() call — the consequences are:

  • Every node with the create-via-overlay-then-delete lifecycle will be permanently visible in the effective graph, with no server-side mechanism to hide it. Hard reloads do not help.
  • Users see deleted nodes in tree responses, detail panels, and search results. Subsequent deletes of the same node are idempotent (the tombstone already exists), so the delete appears to succeed while the node persists.
  • The defect accumulates silently. Each overlay-created-then-deleted node adds a permanent phantom entry. There is no self-correcting mechanism.

The invariant is also checked by GCF Contract 06 §3.11 R37–R38 (added after BL-FE-037), which requires a regression test covering the composite-view tombstone ordering and a canonical flow diagram at docs/architecture/flow-merge-overlay.md. Any change to the order of operations in merge_overlay must be accompanied by a regression test update before merge.

  • backend/domain/graph_merge.py — the sole enforcement point for this invariant.
  • docs/manual/knowledge/invariants/overlay-wins-on-conflict.yaml — the KM entity for this invariant.
  • docs/manual/knowledge/workflows/composite-view-assembly.explanation.md — full explanation of the four-step merge, data structures, and all pitfalls.
  • docs/architecture/flow-merge-overlay.md — canonical Mermaid flow diagram showing the strict order of operations.
  • tests/test_node_tombstone.py — regression suite including the create-then-delete test added for BL-FE-037.
  • backlog/items/frontend/BL-FE-037.md — full triage, root-cause analysis, and resolution record for the historical violation.
  • backlog/items/data-model/BL-DM-001.md — node tombstone contract.