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:

text
    {"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#

text
    {"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.

text
    ,{"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 by and the same at, 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. For use_figma the note is the script’s description.
  • 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:

  1. --as <name> on the ud command line;
  2. else the UD_ACTOR environment variable;
  3. else Claude when CLAUDECODE is set (inside Claude Code);
  4. else the git user name (git config user.name);
  5. 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 null unsets 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 set keeps its value.
json
{"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 seq becomes the event’s seq when that is greater, and document.updatedAt becomes the event’s at when 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
text
    ,{"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).
text
    ,{"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
text
    ,{"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
text
    ,{"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
text
    ,{"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
text
    ,{"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:

text
,{"seq":48,"at":"2026-11-02T08:00:00.000Z","by":"Ada","op":"node.animate","id":"nd-4c6e8a0b2d1f","keyframes":[]}
text
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.