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#
- Parse the JSON (on failure, section 9.3). The tree is parsed with duplicate keys detected (9.4).
- Read
designVersionand migrate if needed (section 10). - Check the marker: exactly one
wal.base, and itstreeHashequals the hash of the parsed tree (6.9). A file that fails this is not folded (9.4). - Normalise the tree (9.1): defaults, repairs.
- 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.
- 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
byand neither, or both with the same id, has agroup; - the new event is a single-event change (not a group of several events);
- and one of these applies:
- Two updates of the same thing. The same
….updateop with the same target (and the samepathforinstance.override). The newsetis merged into the old one by the merge patch rules;atbecomes the new event’s time. - A create followed by an update of what it created.
node.create+node.updateof 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. Thesetis merged into the created entity;atstays the time of the creation. - Two moves or reorders of the same node. The later one replaces the earlier.
- Two updates of the same thing. The same
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):
- Split the WAL into runs wherever an event’s
seqis not greater than the previous event’sseq. - Two adjacent runs whose
seqranges overlap came from a merge. - A conflict is a property (as in 6.3) of one entity set by events in both runs, with different values.
A
….deletein 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:
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
indexabove 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
indexare ordered by id (4.3). No warning. - A missing or invalid
index(empty, or a character outside0-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.
,{"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.
sha256:6f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0Canonical 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).