5. The write-append list
wal is an array.
Esta página está em inglês; a tradução está a caminho.
wal is an array. Its first entry is the wal.base marker; every other entry is one event.
5.1 The envelope#
Every event has the same envelope, followed by the payload of its operation:
| Member | Type | Meaning | When it is missing or wrong |
|---|---|---|---|
seq |
integer, 1 or more | A branch-local counter: a writer gives a new event the largest seq found anywhere in the file (tree.seq, the marker and every event) plus one. It is not unique: two branches give the same number to different events, and after a merge the list holds both. That is expected, is how merges are detected (6.4), and is never repaired: seq is never rewritten. It is not a state id either; states are named by the tree hash (6.9). |
Read as the previous event’s seq + 1 for reports; the line is left as it is (9.2). |
at |
timestamp | When the change was made. | The at of the previous event; for the first, document.updatedAt. |
by |
string | The actor (5.8). | Unknown. |
op |
string | The operation (5.11). | The entry is dropped with a warning. |
group |
tx- id |
Optional. The group the event belongs to (5.7). | No group. |
note |
string | Optional, on the first event of a group: the group’s description. | No description. |
5.2 Order#
Events are applied in the order of their lines, from the top of the list to the bottom. The position in the
file decides, not seq and not at: a reader never sorts the list. at does not have to grow from line to
line; two merged branches interleave their times.
5.3 One event per line, with a leading comma#
Every event stands on its own line and the comma that separates it from the entry before stands at the start of that line:
{"seq":12,"at":"2026-10-05T16:40:00.000Z","op":"wal.base","epoch":"ep-9b7d5f3a1c0e","treeHash":"sha256:6f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"}
,{"seq":13,"at":"2026-10-06T09:12:30.412Z","by":"Ada","op":"node.update","id":"nd-9f1b3d5c7e2a","set":{"characters":"Sign in to SoftForge"}}
,{"seq":14,"at":"2026-10-06T09:20:04.118Z","by":"Ada","op":"node.update","id":"nd-4c6e8a0b2d1f","set":{"paddingTop":48}}With trailing commas, appending to a JSON array changes two lines: the new one and the previous last one,
which needs a comma. Two branches that both append would then conflict in git. With leading commas the previous
lines are never touched: an append is one added line, and two appends on two branches are two added lines at
the same place, which merge=union keeps both.
An agent that writes by hand inserts a new line before the line ] that closes the list, starting with
four spaces and a comma, and does not change any other line.
5.4 The wal.base marker#
{"seq":12,"at":"2026-10-05T16:40:00.000Z","op":"wal.base","epoch":"ep-9b7d5f3a1c0e","treeHash":"sha256:6f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0"}The first entry of an array cannot have a comma in front of it, so the first slot is taken by an entry that is never an event. Every event then has the same shape. The marker also says which tree the events were written against, so that a reader can tell a merged file whose two sides do not share a tree.
| Member | Meaning |
|---|---|
seq |
Copy of tree.seq when the marker was written. Informative; readers take tree.seq. |
at |
When the list was last consolidated. Absent in a file never consolidated. |
op |
Always wal.base. |
epoch |
An ep- id (Appendix A) made when the tree was written: at the creation of the file and at every consolidation. Two files with the same epoch have the same tree. ud rebase-branch uses it to find, in git history, the tree a run of events was written against (9.4). |
treeHash |
The tree hash (6.9) of tree as written. A reader computes the hash of the tree it parsed and compares; a difference means the tree was changed by something other than a consolidation (9.4). |
The marker has no by. Readers do not apply it and skip it wherever it stands. A file has exactly one marker;
a second one is the sign of a merge across a consolidation, and the reader does not fold such a file (9.4). A
marker without epoch or treeHash (written by hand) is accepted with the warning The event list marker has no treeHash; the tree is not checked., and the members are written at the next consolidation.
5.5 Identifiers#
Entities refer to each other by id, never by name or position. Ids are made by the writer of the event that
creates the entity; a reader never invents one. Writers generate a prefix, a hyphen and 12 random lowercase
hexadecimal digits (Appendix A): nd-3f9a0c1e7b2d. The extension derives them from a per-request seed so a
dry run and the real run produce the same ids (ARC-001 §3.6); agents writing by hand use any random source.
A reader treats an id as an opaque non-empty string without /, so ids of another shape (from an import)
work too. An id is never reused, not even after its entity was deleted, because events on another branch
may still refer to it.
nd:3f9a0c1e7b2d, with a colon, is accepted as input by ud and the Plugin API (CMD-3) and is never written
to the file.
5.6 Times#
Timestamps are UTC texts with milliseconds, YYYY-MM-DDTHH:MM:SS.sssZ. Readers compare them as text, which
orders them correctly only in this one form: do not write an offset or leave out the milliseconds.
5.7 Groups: transactions and undo#
A change that takes several events (a paste, a use_figma script, ud apply, an AI proposal, a flatten) is
a group: every event carries the same group id, and the first carries the note.
,{"seq":15,"at":"2026-10-06T10:02:11.904Z","by":"Claude","op":"node.create","group":"tx-6d8f0a2c4e1b","note":"Add a forgot-password link","nodes":[…]}
,{"seq":16,"at":"2026-10-06T10:02:11.904Z","by":"Claude","op":"node.update","group":"tx-6d8f0a2c4e1b","id":"nd-4c6e8a0b2d1f","set":{"paddingBottom":32}}- The events of a group are consecutive lines with the same
byand the sameat, written in one edit. Writers never write part of a group (CMD-12, CMD-28, PLT-5). - A group is one undo step in the editor and one entry in the history panel (COL-1), labelled by
note. Foruse_figmathe note is the script’sdescription. - A group is not a transaction for readers. Each event of a group is applied like any other; if one cannot apply (its target was deleted on another branch), it is skipped with a warning and the rest of the group still applies. Atomicity is guaranteed when the group is written, which is the only time all its events are known to be valid together.
- A single event needs no
group.
Why a group member and not txn.begin / txn.end lines. A union merge, a hand edit or a truncated write
can separate a begin line from its end line, leaving a transaction that is open forever. A group member on each
line is self-contained: every line says which group it belongs to, whatever happens to the lines around it.
5.8 Actor#
Every event is signed with by (CMD-29), the display name of whoever made the change:
--as <name>on theudcommand line;- else the
UD_ACTORenvironment variable; - else
ClaudewhenCLAUDECODEis set (inside Claude Code); - else the git user name (
git config user.name); - else
Unknown.
The editor signs with the git user name. by is a claim, not a cryptographic signature; authenticity is that
of the git commit that carries the line (signed commits, where the repository requires them). Readers use by
for history, comments and conflict reports, and never for access decisions.
5.9 Property patches#
node.update and every other ….update event carry set, a merge patch of the target’s stored
properties, as in RFC 7396:
- a member with a value sets that property;
- a member with
nullunsets the property: it returns to its default (4.4); - a member whose value and current value are both objects is merged recursively, by the same rules;
- an array is replaced as a whole;
- a property not in
setkeeps its value.
{"op": "node.update", "id": "nd-4c6e8a0b2d1f", "set": {
"itemSpacing": 20,
"constraints": {"horizontal": "STRETCH"},
"boundVariables": {"itemSpacing": null},
"fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}],
"pluginData": {"ud": {"generatedFrom": "REQ-12"}}
}}This sets itemSpacing, changes constraints.horizontal and keeps constraints.vertical, removes the
itemSpacing binding and keeps other bindings, replaces the fills, and sets one plugin data key.
Which objects merge and which are replaced. Objects that are maps of independent entries merge:
constraints, boundVariables, explicitVariableModes, componentPropertyReferences,
componentPropertyDefinitions, componentProperties, overrides, pluginData, sharedPluginData,
accessibility, valuesByMode, codeSyntax, modes, openTypeFeatures, fontVariations. Objects that are
one value are replaced whole, as if they were arrays: colours, fontName, letterSpacing, lineHeight,
arcData, vectorNetwork, devStatus, hyperlink, codeMap, anchor, imported entries. The JSON Schema
marks each (section 11).
The unit of a conflict (6.3) is a leaf of the merge patch: a property, or one member of a merging object
(constraints.horizontal, pluginData.ud.generatedFrom, valuesByMode.md-…).
Properties that a set may not change are type, parent and index (use node.move and node.reorder) and
the id. They are ignored in set with a warning.
5.10 Rules for all events#
- An event carries every generated value: the ids of what it creates, its time, its actor, and any value computed when the change was made (the copies of a library update, the layers of a detached instance, the remapped overrides of a swap). Folding the same list twice gives the same document.
- Create events carry the new entity in one member (
node,nodes,page,style, …) with its id. Members that are left out take their defaults. - Update events carry the target’s id and
set(5.9). - Members a reader does not know are kept, in the payload and inside
set: they are written into the entity and survive consolidation (PLT-4). - An event that cannot be applied is skipped with the warning
Skipped event <seq> (<op>): <reason>.Reading continues with the next line; the skipped event stays in the list. The reasons are given with each operation below. - After every event, applied or skipped, the document’s
seqbecomes the event’sseqwhen that is greater, anddocument.updatedAtbecomes the event’satwhen that is later.
5.11 Event catalogue#
Each table gives the payload, the effect, and the reasons the event is skipped. “Target” is the entity named
by id. All example lines continue the file of section 12.
5.11.1 Document and pages#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
document.update |
set |
Merge patch of document (not id). |
never |
page.create |
page: a page with id and index |
Adds the page. | the id exists |
page.update |
id, set |
Merge patch of the page. | no such page |
page.reorder |
id, index |
Moves the page. | no such page |
page.delete |
id |
Removes the page, its nodes (with descendants), its flows; comment threads anchored on it are removed. | no such page; it is the last page |
,{"seq":19,"at":"2026-10-06T11:00:00.000Z","by":"Ada","op":"page.create","page":{"id":"pg-3d5f7b9a1c2e","name":"Onboarding","index":"a2"}}5.11.2 Nodes#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
node.create |
nodes: array of nodes, each with id, type, parent, index |
Adds the nodes in order. A node’s parent exists already or is earlier in the same array. A node whose parent does not exist when the event is folded (deleted on another branch) is not skipped: it is placed by the orphan rule (6.5), so no content is lost in a merge. | Per node, with the warning naming the id: the id exists; type is missing. Other nodes of the array still apply. |
node.update |
id, set |
Merge patch of the node (5.9). | no such node |
node.move |
id, parent, index; optional x, y |
Gives the node a new parent and position. x, y are its position in the new parent’s coordinates, computed by the writer. When skipped, the node stays where it was; the orphan rule does not apply to a move (6.5). |
no such node; no such parent; the move would put the node inside itself; the parent cannot have children (4.3) |
node.reorder |
id, index |
New position among the same siblings: z-order and flow order (DOC-3). | no such node |
node.delete |
ids: array of node ids; optional components: map of component id to {nodes} |
Removes each node and all its descendants. Deleting a main component that has instances: the writer puts, for every COMPONENT or COMPONENT_SET among the deleted nodes that at least one instance of the document uses, a snapshot of its subtree in components (the shape of nodes in 4.16); the reader adds each one to tree.deletedComponents with the event’s at and by, and the instances keep rendering from it (4.16). A reader that finds an instance’s component deleted without a snapshot (hand-written event) makes the snapshot from the state it has folded so far, with the warning Event <seq> (node.delete) deleted component "<name>" (<id>) without a snapshot; one was taken while reading. |
Per id: no such node (no warning when it was already deleted by an earlier event of the same group). |
,{"seq":20,"at":"2026-10-06T11:05:12.000Z","by":"Ada","op":"node.move","id":"nd-1c3e5a7b9d0f","parent":"nd-4c6e8a0b2d1f","index":"a1V"}
,{"seq":21,"at":"2026-10-06T11:06:40.000Z","by":"Ada","op":"node.reorder","id":"nd-9f1b3d5c7e2a","index":"a3"}
,{"seq":22,"at":"2026-10-06T11:07:02.000Z","by":"Ada","op":"node.delete","ids":["nd-1c3e5a7b9d0f"]}
,{"seq":27,"at":"2026-10-08T09:00:00.000Z","by":"Grace","op":"node.delete","ids":["nd-8a3f1c5e7b92"],"components":{"nd-8a3f1c5e7b92":{"nodes":{"nd-8a3f1c5e7b92":{"type":"COMPONENT","name":"Button","parent":"pg-6f7e8d9c0b1a","index":"a0","…":"…"},"nd-2b7d9e4f6a10":{"type":"TEXT","name":"Label","parent":"nd-8a3f1c5e7b92","index":"a0","…":"…"}}}}}The last line deletes the Button component of section 12 while the “Sign in” instance still uses it, so it carries the snapshot; the instance goes on rendering the button.
5.11.3 Components#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
component.create |
id; optional set |
Turns a FRAME into a COMPONENT with the same id (CMP-1); set adds properties in the same step. Other selections are first wrapped in a frame by a node.create and node.move events of the same group. |
no such node; not a FRAME |
component.combine |
node: a new COMPONENT_SET node; ids: components |
Creates the set and moves the components into it, keeping their order. Instances of them keep working. | the set’s id exists; any id is not a COMPONENT |
component.property.set |
id (component or set), key, definition |
Adds or replaces one property definition (CMP-5). | no such node; not a component or set; a VARIANT key with # or another key without |
component.property.delete |
id, key |
Removes the definition. References to it in layers and values in instances stay and have no effect. | no such node |
component.restore |
id: a key of tree.deletedComponents; optional parent, index |
Puts the snapshot’s nodes back into tree.nodes with their ids (4.16) and removes the entry. The component goes to parent and index when given; else to its old parent when that still exists; else to the top level of its old page by the orphan rule (6.5). Instances need no change: their componentId already names it. |
no such deleted component; an id of the snapshot exists in tree.nodes; parent cannot have children |
,{"seq":23,"at":"2026-10-06T11:20:00.000Z","by":"Grace","op":"component.property.set","id":"nd-8a3f1c5e7b92","key":"Show icon#5d02","definition":{"type":"BOOLEAN","defaultValue":false}}5.11.4 Instances#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
instance.override |
id, path, set |
Merge patch of overrides[path] (CMP-3). |
no such instance; empty path (use node.update for the instance itself) |
instance.reset |
id; optional path, keys |
Removes overrides: all of them; or those at path; or only keys at path. Also removes the instance’s own stored properties named in keys when path is absent. |
no such instance |
instance.swap |
id, componentId; optional path, overrides |
Without path: the instance shows another component, and its overrides become the given ones (the writer keeps those whose layer names match, CMP-6). With path: swaps a nested instance, as an override with componentId. |
no such instance |
instance.detach |
id, set, nodes |
Turns the instance into a FRAME with the same id, removes componentId, componentProperties and overrides, applies set (the main component’s properties copied by the writer) and adds nodes, the sublayers as real nodes with new ids. Slot content moves under the new nodes. |
no such instance |
,{"seq":24,"at":"2026-10-06T11:24:31.000Z","by":"Grace","op":"instance.override","id":"nd-e7a9c1b3d5f0","path":"nd-2b7d9e4f6a10","set":{"textDecoration":"UNDERLINE"}}
,{"seq":25,"at":"2026-10-06T11:25:03.000Z","by":"Grace","op":"node.update","id":"nd-e7a9c1b3d5f0","set":{"componentProperties":{"Show icon#5d02":true}}}5.11.5 Styles#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
style.create |
style with id, type, name, index |
Adds the style. | the id exists |
style.update |
id, set |
Merge patch; index may be set to reorder. |
no such style; set.type differs |
style.delete |
id |
Removes the style. Nodes that refer to it fall back to their plain values (4.4.5); ud lint lists them. |
no such style |
5.11.6 Variables, collections and modes#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
collection.create |
collection with id, name, index, modes (at least one) |
Adds the collection. | the id exists; no modes |
collection.update |
id, set |
Merge patch (name, defaultModeId, hiddenFromPublishing, …). Modes change through mode.*. |
no such collection |
collection.delete |
id |
Removes the collection and its variables. Bindings fall back to plain values. | no such collection |
mode.create |
collectionId, mode: {id, name, index} |
Adds the mode. Variables without a value for it use the default mode’s value (4.10). | no collection; the mode id exists |
mode.update |
collectionId, id, set |
Renames or reorders the mode. | no collection or mode |
mode.delete |
collectionId, id |
Removes the mode and the values for it. explicitVariableModes naming it fall back to the default mode. |
no collection or mode; it is the last mode; it is the defaultModeId |
variable.create |
variable with id, name, variableCollectionId, resolvedType, index |
Adds the variable. | the id exists; no such collection; the name is taken in the collection |
variable.update |
id, set |
Merge patch; valuesByMode merges per mode. |
no such variable; set.resolvedType differs; an alias in set would make a cycle |
variable.delete |
id |
Removes the variable. Bindings and aliases fall back to plain values or the type’s zero. | no such variable |
,{"seq":26,"at":"2026-10-06T11:40:00.000Z","by":"Ada","op":"variable.update","id":"vr-7b9d1f3a5c7e","set":{"valuesByMode":{"md-6e5d4c3b2a10":{"r":0.6,"g":0.67,"b":1,"a":1}}}}5.11.7 Libraries#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
library.add |
library with id, path, documentId; optional imported, acceptedHash |
Adds a library reference. | the id exists |
library.update |
id, set |
Merge patch (path after a move, enabled, name). |
no such library |
library.accept |
id, acceptedHash, imported |
Accepts an update (VAR-8): acceptedHash is the library’s tree hash (6.9) the copies were taken from; imported is a merge patch of the copies: each entry given replaces the copy, null removes it. Also used when this file starts using another item of the library. |
no such library |
library.remove |
id |
Removes the reference and its copies. References that still use it fall back to plain values; instances show “Missing component”. | no such library |
5.11.8 Prototype flows#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
flow.create |
flow with id, pageId, name, index, nodeId |
Adds a flow starting point (PRO-6). | the id exists; no such page |
flow.update |
id, set |
Merge patch. | no such flow |
flow.delete |
id |
Removes the flow. | no such flow |
Interactions are node properties: they change with node.update of reactions.
5.11.9 Comments#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
comment.add |
comment with id, message, and anchor or parentId; optional mentions |
Adds a thread or a reply. by and at come from the envelope. |
the id exists; parentId names no thread |
comment.update |
id, set (message, anchor, mentions, resolved) |
Merge patch; sets editedAt to the event’s at when message changes. |
no such comment |
comment.react |
id, reaction, on (boolean) |
Adds (on: true) or removes the event’s by from reactions[reaction]. Separate from comment.update so that reactions of two people on two branches both survive. |
no such comment |
comment.delete |
id |
Removes the comment; for a thread, also its replies. | no such comment |
,{"seq":17,"at":"2026-10-06T10:30:45.120Z","by":"Grace","op":"comment.add","comment":{"id":"cn-8b0d2f4a6c1e","message":"Should this button be full width on desktop too? @Ada","anchor":{"nodeId":"nd-e7a9c1b3d5f0","offset":{"x":120,"y":24}},"mentions":["Ada"]}}5.11.10 Versions and assets#
| Op | Payload | Effect | Skipped when |
|---|---|---|---|
version.save |
version: {id, title, description, treeHash} |
Adds a named version at this event’s place in the file (COL-2). treeHash is the tree hash (6.9) of the document just before this event, computed by the writer; a reader that finds it missing computes it while folding, with a warning. |
the id exists |
version.update |
id, set (title, description) |
Merge patch. | no such version |
version.delete |
id |
Removes the named version (not the changes). | no such version |
asset.add |
asset: {hash, kind, mime, ext, bytes, …} (4.15) |
Registers the metadata of a file in the assets folder. The writer writes the file before the event (8.4). | never; a second asset.add of the same hash replaces the metadata |
There is no event that removes an asset: unused metadata is dropped by consolidation and unused files by the asset garbage collection (8.5).
5.12 Operations a reader does not know#
A later format version can add operations. A reader that meets one it does not know skips it:
,{"seq":48,"at":"2026-11-02T08:00:00.000Z","by":"Ada","op":"node.animate","id":"nd-4c6e8a0b2d1f","keyframes":[]}Skipped event 48 (node.animate): this build does not know the operation.The document’s seq still advances, and the line stays in the list: the writer writes it back unchanged with
every later change, so a newer build can still apply it. It is lost when a build that does not know it
consolidates the list (section 7); such a build warns before consolidating.