6. Folding and conflict rules

The result is the same on the host, in ud and in the webview, because they run the same core.

6.1 Reading a file#

  1. Parse the JSON (on failure, section 9.3). The tree is parsed with duplicate keys detected (9.4).
  2. Read designVersion and migrate if needed (section 10).
  3. Check the marker: exactly one wal.base, and its treeHash equals the hash of the parsed tree (6.9). A file that fails this is not folded (9.4).
  4. Normalise the tree (9.1): defaults, repairs.
  5. Fold every WAL entry in file order (5.2), skipping the marker. Each event is applied to the state left by the one before it.
  6. Repair the result (6.5 – 6.7) and collect the warnings.

The result is the same on the host, in ud and in the webview, because they run the same core.

6.2 Folding between saves (coalescing)#

Dragging a frame or typing a name produces a change at every step. Writing each as a line would bury the history in noise, so a writer merges a new event into the last event of the list when both belong to one continuous edit. All of these must hold:

  • the last event has not been saved to disk yet;
  • both have the same by and neither, or both with the same id, has a group;
  • the new event is a single-event change (not a group of several events);
  • and one of these applies:
    1. Two updates of the same thing. The same ….update op with the same target (and the same path for instance.override). The new set is merged into the old one by the merge patch rules; at becomes the new event’s time.
    2. A create followed by an update of what it created. node.create + node.update of one of its nodes, page.create + page.update, style.create + style.update, variable.create + variable.update, collection.create + collection.update, flow.create + flow.update, comment.add + comment.update. The set is merged into the created entity; at stays the time of the creation.
    3. Two moves or reorders of the same node. The later one replaces the earlier.

Only the last event of the list is looked at: an edit of another target in between ends the run. node.delete, page.delete, component.*, instance.detach, instance.swap, library.accept, version.save, comment.react and asset.add are never coalesced, and no event coalesces into them. devStatus changes keep their own line: a hand-off is history worth seeing.

A line that is on disk is never changed: not by coalescing, not by a repair (9.2), not after a merge. Coalescing shapes only unsaved lines, so it never shows up in a diff as a modified line. Saving ends a run: the next edit starts a new line, numbered max(seq) + 1 (5.1). Only consolidation (7) and a migration (10.3) rewrite lines.

6.3 Last in file wins, per property#

When two events set the same property of the same entity, the one lower in the file wins, whatever their at or seq. The unit is a leaf of the merge patch (5.9): constraints.horizontal and constraints.vertical are separate properties; fills is one property.

So edits of different properties of one node on two branches both survive a merge (COL-4): one branch sets itemSpacing, the other paddingTop, and after the union merge both lines apply. Edits of the same property keep the last line in the merged file, which in a union merge is the branch whose hunk git placed second.

6.4 Detecting and reporting conflicts#

A union merge of two branches that both appended to the WAL leaves two runs of events with overlapping sequence numbers: the first branch’s N+1 … N+a, then the second branch’s N+1 … N+b. A reader uses this to find conflicts (COL-4):

  1. Split the WAL into runs wherever an event’s seq is not greater than the previous event’s seq.
  2. Two adjacent runs whose seq ranges overlap came from a merge.
  3. A conflict is a property (as in 6.3) of one entity set by events in both runs, with different values. A ….delete in one run and any event on the same target in the other is also a conflict.

The later value wins (6.3). Each conflict is reported once:

text
Merge conflict on "Login" (nd-4c6e8a0b2d1f) itemSpacing: kept 24 by Grace (event 31), dropped 20 by Ada (event 29).

Conflicts are shown in the editor after a file change on disk (ARC-001 §10.4) and in the history panel, and are listed by ud validate. The report rests on the colliding numbers, and those stay: seq is never rewritten (5.1), and the events appended after the merge take max(seq) + 1, which starts no new run. Every reader of the merged file, now or in a month, therefore finds the same conflicts, and ud validate can be run at any time (a git post-merge hook is a good one). A conflict is settled, and no longer reported, when an event lower in the file than both runs sets the same property again (the next edit of it on the merged file), or when the WAL is consolidated.

6.5 Orphans#

A node is an orphan when its parent does not exist, typically because one branch deleted a frame and another added a child to it. While folding, the reader keeps a tombstone (id, page) for every node deleted so far. An orphan:

  • is moved to the top level of the page of its nearest ancestor that is a page, or whose tombstone names a page; else to the first page;
  • keeps its absolute position on the page when the deleted parent’s last geometry is known, else its stored x, y;
  • gets an index above its new siblings;
  • is reported: Node "Forgot password?" (nd-1c3e5a7b9d0f) lost its parent nd-4c6e8a0b2d1f, which was deleted by event 30, and was moved to page "Screens".

Slot content whose instance or slot no longer exists is handled the same way.

Which events the rule covers. The orphan rule applies to two cases only: a node.create whose parent does not exist when it is folded (applied with the rule rather than skipped, so no content is lost in a merge), and a node that is already in the document when its parent goes missing: a tree entry whose parent was dropped or never existed (9.1), or slot content whose slot left the main component (4.8). A node.move into a parent that does not exist is skipped (5.11.2): the node stays where it was, which is a place that exists, and the warning names the missing parent. Moving it to a page top level would silently undo one branch’s structure; leaving it is the smaller surprise.

The repair happens in memory; it reaches the file with the next change of the document, as node.move events signed by the reader’s actor and grouped with the note Repair orphans after merge.

6.6 Fractional index ties and invalid keys#

  • Siblings with equal index are ordered by id (4.3). No warning.
  • A missing or invalid index (empty, or a character outside 0-9A-Za-z) sorts after all valid keys of its siblings, ordered by id, with one warning for the file: <n> node(s) had no valid position among their siblings and were placed on top.

6.7 Other structural repairs#

Problem Repair Warning
A node.move would make a cycle The move is skipped (the earlier move in file order stands) Skipped event <seq> (node.move): the node would be inside itself.
A cycle already in the tree The node of the cycle with the lowest id moves to its page’s top level Node "<name>" (<id>) was part of a parent loop and moved to the top level.
A SECTION under a frame Moved to the page’s top level, keeping its absolute position Section "<name>" was inside a frame and moved to the page.
A child under a node that cannot have children Moved to that node’s parent Node "<name>" was inside a <type> and moved up.
A GROUP or BOOLEAN_OPERATION without children Removed Removed the empty group "<name>".
An INSTANCE whose componentId names nothing: no node, no imported copy, no snapshot in tree.deletedComponents (4.16) Renders as a placeholder with its stored size; kept Instance "<name>" refers to a missing component <id>.
An instance that contains its own main component (directly or nested) The innermost recursive instance renders as a placeholder Instance "<name>" contains itself.
A variable alias cycle Resolves to the type’s zero (4.10) Variable "<name>" is part of an alias cycle.
A reference to a missing style or variable Plain value used (4.4.5) listed by ud lint, no warning on open

6.8 A merge, worked#

On main, Ada changes the frame’s gap. On feature/link, Claude adds a link and Grace changes the frame’s gap to another value. The branches started from the file of section 12 at event 14.

diff
     ,{"seq":14,"at":"2026-10-06T09:20:04.118Z","by":"Ada","op":"node.update","id":"nd-4c6e8a0b2d1f","set":{"paddingTop":48}}
+    ,{"seq":15,"at":"2026-10-06T12:00:00.000Z","by":"Ada","op":"node.update","id":"nd-4c6e8a0b2d1f","set":{"itemSpacing":20}}
+    ,{"seq":15,"at":"2026-10-06T10:02:11.904Z","by":"Claude","op":"node.create","group":"tx-6d8f0a2c4e1b","note":"Add a forgot-password link","nodes":[{"id":"nd-1c3e5a7b9d0f","type":"TEXT","name":"Forgot password?","parent":"nd-4c6e8a0b2d1f","index":"a2","characters":"Forgot password?"}]}
+    ,{"seq":16,"at":"2026-10-06T12:05:00.000Z","by":"Grace","op":"node.update","id":"nd-4c6e8a0b2d1f","set":{"itemSpacing":24}}
   ]
 }

Reading the merged file: the link is created (no conflict); itemSpacing is 24 (last in file); paddingTop is 48. The second seq 15 starts a new run that overlaps the first, so the reader reports Merge conflict on "Login" (nd-4c6e8a0b2d1f) itemSpacing: kept 24 by Grace (event 16), dropped 20 by Ada (event 15). The two lines numbered 15 stay as they are; the next event appended to the merged file takes seq 17.

Consolidate on one branch only. Consolidation rewrites the tree and empties the list (section 7). Two branches that both consolidated cannot be merged by line: the union of their trees has duplicate keys and two markers, and the reader refuses to fold it (9.4). Consolidate on the main branch when no branch with unmerged design changes is open, and commit it on its own; a branch that was merged after all is repaired with ud rebase-branch.

6.9 The tree hash#

A state of the document is named by its tree hash: sha256: followed by the lowercase hexadecimal SHA-256 of the canonical JSON of the folded tree.

text
sha256:6f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0

Canonical JSON is the tree as consolidation would write it (section 7 steps 1, 3 and 4: repaired, without derived values, defaults and unused metadata; fractional keys are not shortened), without tree.seq and document.updatedAt, serialised as one line: no space or line break, members in the stable order of 3.1, strings escaped as JSON.stringify escapes them, numbers rounded to 6 significant decimals (4.7.1) and written in the shortest form that reads back to the same value (JavaScript’s Number#toString), encoded as UTF-8. The same document gives the same hash on every surface, because folding and the stable order are deterministic (2.1).

The hash, not seq, is what names a state wherever two branches may be involved:

Where Meaning
wal.base.treeHash (5.4) The tree as consolidation wrote it. Checked on read (9.4).
library.acceptedHash (4.11) The library state the imported copies came from. “Updates available” when the library’s current hash differs.
version.treeHash (4.14) The state a named version names.
--if-version <hash> (CLI-001, CMD-30) ud refuses to write when the document’s current hash differs: the document moved on, on any branch.

The colon tells a tree hash apart from an asset name, sha256-<hex> (8.2). ud prints the current hash of a file (CLI-001).