Project Board file format 2.1
A .pb file holds one planning board: issues, milestones, labels, a Kanban workflow, requirements with
traceability links, baselines, attachments and the links to remote trackers. The file is plain JSON and lives in a
git repository next to the code it plans.
This document is the reference of the format. It is written for a developer or an AI agent who has never seen a
.pb file. Two companions:
schemas/pb-2.1.schema.json: the same format as a JSON Schema (draft-07), with a description on every field.docs/ai-agents.md: a one-page instruction sheet for AI agents that read and edit a board.
Everything stated here is what the code under src/pb does: document.ts (file layout, parsing, consolidation),
events.ts (the event reducer), normalize.ts (tolerant reading of the tree), session.ts (coalescing),
query.ts (Kanban and outline rules) and versions/ (version detection and upgrades).
Contents
- Purpose and design goals
- File layout
- Reading a board
- The tree
- The write-append list
- Events
- Tolerance rules
- Coalescing
- Consolidation
- Kanban semantics
- Requirements
- Remotes and connections
- Attachments
- Versions and upgrades
- Using the file with git
1. Purpose and design goals
Format 1.0 was a single JSON object that was rewritten on every change. That is simple, but in git every change touched lines all over the file, two branches that both edited the board always conflicted, and the file kept no record of who changed what. Format 2.1 splits the file into two parts:
tree, a snapshot of the whole board, andwal, the write-append list: the changes made since that snapshot, one event per line.
The board is the tree with every event of the list applied in order. Four goals follow from that split.
Git-diffable. A saved change adds one line to the file and touches no other line. git diff, git log -p and
git blame show who changed what and when without any tool.
Mergeable. Lines are only added at the end of the list, and no line depends on the text of another line. Two branches that both changed the board merge by keeping the lines of both (section 15).
Self-describing. Every file states its format version, links to its schema and carries a note that tells a
reader with no documentation how to treat it. Operations and fields have plain names (issue.create, dueDate).
Recoverable. A reader does not refuse a file because one entry is wrong. Missing fields take defaults, broken references are dropped, an event that cannot be applied is skipped with a warning, colliding sequence numbers are renumbered (section 7). The only files that are not opened are files that are not a JSON object and files of a format version the build does not know.
2. File layout
A complete file. It is the example board used in the rest of this document: a project called Lighthouse with one milestone and two labels, consolidated once, with three events written after the consolidation.
{
"pbVersion": "2.1",
"$schema": "https://raw.githubusercontent.com/arthurafarias/vscode-extension-project-board/master/schemas/pb-2.1.schema.json",
"$comment": "Project Board file, format 2.1. The board is `tree` with every `wal` event applied in order. To change the board, append one event per line to `wal` (leading comma, next `seq`) and leave `tree` alone; the extension folds the list into the tree when asked to consolidate. Event reference: https://github.com/arthurafarias/vscode-extension-project-board/blob/master/docs/pb-file-format.md",
"tree": {
"seq": 3,
"project": {
"id": "0b9d6c1e-4f6a-4f0e-9a57-3c2d8e5f7a10",
"name": "Lighthouse",
"description": "Example board of the Project Board file format reference.",
"visibility": "private",
"createdAt": "2026-03-01T09:00:00.000Z",
"updatedAt": "2026-03-02T16:40:12.000Z"
},
"owner": {
"name": "Ada",
"email": "ada@example.com",
"bio": "",
"avatarColor": "#2f81f7"
},
"workflow": [
{
"id": "todo",
"name": "To do",
"category": "todo"
},
{
"id": "doing",
"name": "In progress",
"category": "doing"
},
{
"id": "review",
"name": "In review",
"category": "doing"
},
{
"id": "done",
"name": "Done",
"category": "done"
}
],
"labels": [
{
"id": "lbl-5e001f110423",
"name": "bug",
"color": "#e11d48",
"description": "Something isn't working"
},
{
"id": "lbl-ea660db94990",
"name": "feature",
"color": "#2f81f7",
"description": "New capability or request"
}
],
"milestones": [
{
"id": "ms-1f6a0c93b2d4",
"title": "v1.0",
"description": "First public release.",
"startDate": "2026-03-01",
"dueDate": "2026-04-30",
"state": "open",
"createdAt": "2026-03-01T09:05:00.000Z",
"closedAt": null
}
],
"issues": [
{
"id": "iss-9a3b5c7d1e2f",
"number": 1,
"kind": "task",
"title": "Crash when the board file is empty",
"body": "Opening a zero-byte `.pb` file throws instead of starting a new board.",
"state": "open",
"author": "Ada",
"assignee": "Linus",
"labelIds": [
"lbl-5e001f110423"
],
"milestoneId": "ms-1f6a0c93b2d4",
"status": "doing",
"startDate": null,
"dueDate": null,
"rank": 1024,
"parentId": null,
"order": 1024,
"attrs": {},
"links": [],
"rev": 1,
"createdAt": "2026-03-01T09:12:30.000Z",
"updatedAt": "2026-03-02T16:40:12.000Z",
"closedAt": null,
"comments": [
{
"id": "c-4d5e6f708192",
"author": "Linus",
"body": "Reproduced. The parser has to treat an empty file as a new board.",
"createdAt": "2026-03-02T16:40:12.000Z",
"updatedAt": null
}
]
}
],
"attributes": [],
"baselines": [],
"connections": [],
"attachments": [],
"activity": [
{
"id": "act-3",
"type": "comment_added",
"message": "Linus commented on #1 Crash when the board file is empty",
"actor": "Linus",
"timestamp": "2026-03-02T16:40:12.000Z",
"seq": 3,
"ref": "iss-9a3b5c7d1e2f"
},
{
"id": "act-2",
"type": "issue_opened",
"message": "Ada opened #1 Crash when the board file is empty",
"actor": "Ada",
"timestamp": "2026-03-01T09:12:30.000Z",
"seq": 2,
"ref": "iss-9a3b5c7d1e2f"
},
{
"id": "act-1",
"type": "milestone_created",
"message": "Ada created milestone v1.0",
"actor": "Ada",
"timestamp": "2026-03-01T09:05:00.000Z",
"seq": 1,
"ref": "ms-1f6a0c93b2d4"
}
]
},
"wal": [
{"seq":3,"at":"2026-03-02T17:00:00.000Z","op":"wal.base"}
,{"seq":4,"at":"2026-03-03T08:15:00.000Z","by":"Grace","op":"issue.create","issue":{"id":"iss-2b4d6f8a0c1e","number":2,"kind":"task","title":"Export the board as Markdown","body":"One file per milestone, one section per issue.","labelIds":["lbl-ea660db94990"],"milestoneId":"ms-1f6a0c93b2d4","status":null,"rank":1024,"parentId":null,"order":2048,"attrs":{},"assignee":""}}
,{"seq":5,"at":"2026-03-03T08:20:41.000Z","by":"Ada","op":"comment.add","issueId":"iss-2b4d6f8a0c1e","comment":{"id":"c-7a8b9c0d1e2f","body":"Yes please. Keep the output diff-friendly."}}
,{"seq":6,"at":"2026-03-03T10:02:09.000Z","by":"Linus","op":"issue.update","id":"iss-9a3b5c7d1e2f","set":{"state":"closed","status":"done"}}
]
}
The top-level members:
| Member | Meaning |
|---|---|
pbVersion |
Version of the file format, the text "2.1". Section 14 describes how other values are treated. |
$schema |
Address of the JSON Schema of the format. Written on every save, never read. |
$comment |
A fixed note for a reader who opens the file with no documentation. Written on every save, never read. |
tree |
The consolidated board: the state after the event numbered tree.seq (section 4). |
wal |
The write-append list: every change made after the tree was written, oldest first (section 5). |
How to read the example:
tree.seqis 3. Three events were folded into the tree at the last consolidation; they produced the milestone, issue #1, its comment and the three history entriesact-1toact-3.- The first line of
walis thewal.basemarker. It repeatstree.seqand records when the list was folded. - Line
seq4: Grace opened issue #2 in milestone v1.0. Line 5: Ada commented on it. Line 6: Linus closed issue #1 and moved its card to the Done column. - So the tree alone is not the board. In the tree, issue #1 is open and sits in the column
doing. On the board it is closed, sits indoneand hasclosedAt2026-03-03T10:02:09.000Z, and issue #2 exists with one comment.
Layout on disk
The extension writes the file in one fixed layout (serializeDocument in src/pb/document.ts):
- the five members in the order shown, indented by two spaces, the tree pretty printed;
- the list with one entry per line: first the
wal.basemarker, indented by four spaces, then every event as four spaces, a comma, and the event as compact JSON without any space or line break (line breaks inside texts are written as\nby JSON); - a line break at the end of the file.
Saving a change therefore produces this diff, and nothing else:
--- a/planning.pb
+++ b/planning.pb
@@ -139,5 +139,6 @@
{"seq":3,"at":"2026-03-02T17:00:00.000Z","op":"wal.base"}
,{"seq":4,"at":"2026-03-03T08:15:00.000Z","by":"Grace","op":"issue.create","issue":{"id":"iss-2b4d6f8a0c1e","number":2,"kind":"task","title":"Export the board as Markdown","body":"One file per milestone, one section per issue.","labelIds":["lbl-ea660db94990"],"milestoneId":"ms-1f6a0c93b2d4","status":null,"rank":1024,"parentId":null,"order":2048,"attrs":{},"assignee":""}}
,{"seq":5,"at":"2026-03-03T08:20:41.000Z","by":"Ada","op":"comment.add","issueId":"iss-2b4d6f8a0c1e","comment":{"id":"c-7a8b9c0d1e2f","body":"Yes please. Keep the output diff-friendly."}}
+ ,{"seq":6,"at":"2026-03-03T10:02:09.000Z","by":"Linus","op":"issue.update","id":"iss-9a3b5c7d1e2f","set":{"state":"closed","status":"done"}}
]
}
The layout is a convention for git, not a requirement for reading: a reader parses the file as ordinary JSON and accepts any spacing. When the extension next changes a board it writes the whole file again in the layout above. For a file that already has that layout only the new lines differ; a file that was edited by hand, or whose content had to be repaired (section 7), is brought back to the canonical form at that moment.
An empty file (zero bytes or only white space) is a board that has not been written yet: the extension opens it as a new board with the default workflow and labels and writes the file with the first change.
3. Reading a board
To get the current board from a file (parseDocumentObject and materialize in src/pb/document.ts):
- Parse the file as JSON. It has to be an object.
- Read
treetolerantly: every missing or mistyped field takes its default and broken references are dropped (sections 4 and 7). - Go through
walfrom the first line to the last. Skip thewal.basemarker. Apply every other entry as an event (section 6). An event that cannot be applied is skipped with a warning; reading goes on. - Repair references once more: dangling ids are dropped and duplicate issue numbers are reassigned (section 7).
The result is the board. Nothing else is stored anywhere: the Kanban columns, the requirements outline, the milestone progress and the history are all computed from this state.
4. The tree
tree is the state of the board after the event numbered tree.seq. The extension writes it when a file is
created, upgraded or consolidated and otherwise leaves it byte for byte alone. It is not meant to be edited by
hand: a hand edit is not recorded in the history, it is rewritten in canonical form by the next save, and it
conflicts in git.
The tables give, for every field, the default a reader uses when the field is missing or has the wrong JSON type.
A reader rebuilds every object with exactly the fields listed here; a member it does not know is not kept and
disappears the next time the extension writes the tree. Timestamps are ISO 8601 texts in UTC as written by
JavaScript’s toISOString(), for example 2026-03-04T10:00:00.000Z.
4.1 tree
| Field | Type | Meaning | Default |
|---|---|---|---|
seq |
integer, 0 or more | seq of the last event folded into the tree. The first event of wal has this number plus one. |
0 |
project |
object | Name and settings of the board (4.2). | all defaults |
owner |
object | Profile of the board’s owner (4.3). | all defaults |
workflow |
array | The Kanban columns, left to right (4.4). | the default workflow |
labels |
array | The labels issues can carry (4.5). | empty |
milestones |
array | The milestones, in creation order (4.6). | empty |
issues |
array | Tasks, requirements and headings together, in creation order (4.7). | empty |
attributes |
array | The attribute definitions (4.11). | empty |
baselines |
array | Frozen copies of the requirements document (4.12). | empty |
connections |
array | The remote trackers the board synchronizes with (4.13). | empty |
attachments |
array | The files stored inside the board (4.10). | empty |
activity |
array | The history people read, newest entry first (4.15). | empty |
In labels, milestones, issues, attributes, baselines, connections and attachments, an entry that
reuses the id of an earlier entry of the same list is dropped with the warning
Dropped a <what> that reused the id <id>.
4.2 project
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Stable identifier of the board, a UUID. No event changes it. | a new random UUID |
name |
string | Display name of the board. | Untitled Project |
description |
string | What the board is for. | empty |
visibility |
public or private |
A declaration for people and tools; the format attaches no behaviour to it. | private |
createdAt |
timestamp | When the board was created. Also the fallback creation time of tree entries that have none. | the time of reading |
updatedAt |
timestamp | Derived: the latest at of the events applied so far (section 6.1). |
createdAt |
4.3 owner
The person or team the board belongs to. It is not the identity of whoever edits the file: that is the by of
each event. The owner’s name is the fallback author of tree entries that have none.
| Field | Type | Meaning | Default |
|---|---|---|---|
name |
string | Display name. | Unknown |
email |
string | Contact address. | empty |
bio |
string | A short free text. | empty |
avatarColor |
string | Avatar colour as a CSS hex colour. | #2f81f7 |
4.4 Workflow column
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier that issues refer to in status. |
a new random col-… id |
name |
string | Title shown above the column. | Column <position> |
category |
todo, doing or done |
What the column means, whatever it is called (section 10). | doing |
The default workflow is todo (To do, category todo), doing (In progress, doing), review (In review,
doing) and done (Done, done). A reader makes sure a board has somewhere to start and somewhere to finish:
- a column that repeats the
idof an earlier column is dropped; - an empty list becomes the default workflow;
- when no column has the category
done, the last column becomesdone; - when every column is
done, a columnTo dowith the categorytodoand a random id is put in front.
A column without an id gets a different random id on every read, so no issue can stay in it: always give ids.
4.5 Label
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier that issues refer to in labelIds. |
a new random lbl-… id |
name |
string | The text of the label. | label-<position> |
color |
string | Background colour as a CSS hex colour. | #6b7280 |
description |
string | When to use the label. | empty |
4.6 Milestone
A target the team works towards: a release, a sprint. Its issues are the issues whose milestoneId names it.
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier that issues refer to in milestoneId. |
a new random ms-… id |
title |
string | Name, for example v1.0. |
Milestone <position> |
description |
string | What the milestone delivers, in Markdown. | empty |
startDate |
string or null | Planned first day, YYYY-MM-DD. |
null |
dueDate |
string or null | Planned last day, YYYY-MM-DD. Due dates are compared as text. |
null |
state |
open or closed |
Whether the milestone is still worked on. Any value other than closed reads as open. |
open |
createdAt |
timestamp | When the milestone was created. | the project’s createdAt |
closedAt |
timestamp or null | Derived: the at of the event that closed it; null while open. |
when closed: the project’s createdAt |
remotes |
object | Counterparts in remote trackers (4.14). Absent when there are none. | absent |
4.7 Issue
The main item of a board. One list holds the three kinds: a task is a unit of work, a requirement is a
statement the product has to satisfy, a heading is a section title of the requirements outline.
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier that events and other issues refer to. Never changes, never shown to people. | a new random iss-… id |
number |
integer, 1 or more | The number people use: #12, REQ-12. Unique in the file. |
position in the list |
kind |
task, requirement or heading |
What the issue is. | task |
title |
string | One line that names the issue. | Untitled issue <number> |
body |
string | The description, in Markdown. | empty |
state |
open or closed |
Any value other than closed reads as open. |
open |
author |
string | Name of who opened the issue. | the owner’s name |
assignee |
string | Name of who works on it; empty when nobody does. | empty |
labelIds |
array of strings | Ids of the labels on the issue, without repeats. | empty |
milestoneId |
string or null | Id of its milestone; null means the backlog. | null |
status |
string or null | Id of the workflow column of its card; null means “derive the column from state” (section 10). |
null |
startDate |
YYYY-MM-DD or null |
Planned first day of the work, for the Gantt chart. Headings carry no dates. New in 2.1. | null |
dueDate |
YYYY-MM-DD or null |
Planned last day of the work. A writer never puts it before startDate; a reader keeps such a file as written and draws the bar with the dates swapped. With only one of the two dates the issue is a one-day bar on that day. New in 2.1. |
null |
rank |
number | Position of the card inside its column, ascending. | 1024 × position in the list |
parentId |
string or null | Id of the issue it sits under in the outline; null means the top level (section 11). | null |
order |
number | Position among the issues with the same parent, ascending. | 1024 × position in the list |
attrs |
object | Key-value tags. Values are texts; a number or boolean is converted to text, anything else and any blank key is dropped. | empty |
links |
array | Traceability links that start at this issue (4.9). | empty |
rev |
integer, 1 or more | Derived revision counter of the content: grows by one with every issue.update event that changes title, body, kind or attrs. |
1 |
createdAt |
timestamp | When the issue was opened. | the project’s createdAt |
updatedAt |
timestamp | Derived: the at of the last event that changed the issue (see issue.update, the comment and link events). |
createdAt |
closedAt |
timestamp or null | Derived: the at of the event that closed it; null while open. |
when closed: updatedAt |
comments |
array | The discussion, oldest first (4.8). | empty |
remotes |
object | Counterparts in remote trackers (4.14). Absent when there are none. | absent |
4.8 Comment
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier, unique within its issue. | a new random c-… id |
author |
string | Name of who wrote it. | the owner’s name |
body |
string | The text, in Markdown. | empty |
createdAt |
timestamp | When it was written. | the issue’s createdAt |
updatedAt |
timestamp or null | Derived: the at of the last comment.update that changed the text. |
null |
remotes |
object | Counterparts in remote trackers (4.14). | absent |
4.9 Link
A typed, directed link from the issue that holds it to another issue (section 11).
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier, unique within its issue. | a new random lnk-… id |
type |
satisfies, derives, verifies, refines, depends, conflicts or relates |
What the link claims, read from the holder to the target (section 11). | relates |
targetId |
string | Id of the issue the link points to. A link without it is dropped. | none |
targetRev |
integer | rev of the target when the link was created or last reviewed. |
1 |
createdAt |
timestamp | The at of the link.add event. |
the issue’s createdAt |
by |
string | The by of the link.add event. |
the owner’s name |
4.10 Attachment
A file stored inside the board file (section 13).
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier used in attachment:<id> references. |
a new random att-… id |
name |
string | File name shown to people. | attachment |
mime |
string | Media type, for example image/png. |
application/octet-stream |
size |
integer | Size of the content in bytes. | 3/4 of the length of data, rounded down |
kind |
image, sketch or file |
A picture, a drawing of the built-in sketch editor, or anything else. | file |
data |
string | The content, base64 encoded. | empty |
scene |
any JSON value | Only for sketches: the editable source of the drawing. | absent |
createdAt |
timestamp | When the file was attached. | the project’s createdAt |
by |
string | Name of who attached it. | the owner’s name |
4.11 Attribute definition
Gives a key of the attrs of issues a name, a type and, for enum, its choices (section 11).
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier of the definition. | a new random attr-… id |
key |
string | The key under which issues store the value in attrs. No event changes it. |
attribute-<position> |
name |
string | Column title shown to people. | the key |
type |
text, number, enum, date or boolean |
Which input an editor offers. Values are stored as text whatever the type. | text |
options |
array of strings | The choices when type is enum. |
empty |
description |
string | What the attribute means. | empty |
4.12 Baseline
A named, frozen copy of the requirements document at one moment (section 11).
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier of the baseline. | a new random bl-… id |
name |
string | Name given to the baseline. | Baseline <position> |
description |
string | Why it was taken. | empty |
createdAt |
timestamp | The at of the baseline.create event. |
the project’s createdAt |
by |
string | The by of the baseline.create event. |
the owner’s name |
items |
array | The copied issues, in file order. | empty |
Each item is a copy of these fields of one issue as they were when the baseline was taken: id, number, kind,
title, body, state, parentId, order, attrs, rev. An item is never changed after that, whatever
happens to the issue it was copied from.
4.13 Connection
A link between the board and a project in a remote tracker (section 12). It never holds a password or a token.
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | Identifier; the key under which entities record their counterpart in remotes. |
a new random conn-… id |
kind |
string | Which connector talks to the tracker, for example github or gitlab. |
github |
name |
string | Name shown to people. | <kind> <position> |
baseUrl |
string | Address of the tracker; empty for the connector’s default. | empty |
project |
string | Which project in the tracker: owner/repo, a GitLab path or id, a Redmine identifier, a Confluence space. |
empty |
options |
object | Connector-specific settings. | {} |
createdAt |
timestamp | When the connection was set up. | the project’s createdAt |
lastSyncAt |
timestamp or null | When the last synchronization finished. | null |
4.14 remotes
Issues, comments and milestones can carry remotes: an object keyed by connection id whose values say where the
entity lives in that tracker. The member is absent when the entity has no counterpart.
| Field | Type | Meaning |
|---|---|---|
id |
string | Identifier of the counterpart in the tracker. A number is converted to text. An entry without id is dropped. |
number |
integer | The human-facing number in the tracker, when it has one. |
url |
string | Web address of the counterpart. |
base |
object of strings | Short hashes, one per tracked field, of the content as of the last synchronization (section 12). |
4.15 Activity entry
One line of the history that people read. Entries are derived: the reader writes them while it applies events.
| Field | Type | Meaning | Default |
|---|---|---|---|
id |
string | act-<seq> for an entry produced by an event. |
a new random act-… id |
type |
string | Machine-readable kind of the entry, for example issue_closed (section 6.2). |
note |
message |
string | The sentence shown to people. | empty |
actor |
string | The by of the event. |
the owner’s name |
timestamp |
timestamp | The at of the event. |
the project’s createdAt |
seq |
integer | The seq of the event that produced the entry. Absent on entries carried over from format 1.0. |
absent |
ref |
string | Id of the entity the entry is about, when there is one. | absent |
5. The write-append list
wal is an array. Its first entry is the wal.base marker; every other entry is one event: one change of the
board, made by one person or tool at one moment.
5.1 The envelope of an event
Every event has the same four members, followed by the payload of its operation:
| Member | Type | Meaning | When it is missing or wrong |
|---|---|---|---|
seq |
integer, 1 or more | Sequence number: one more than the seq of the entry before it. The first event after a consolidation has tree.seq + 1. |
The event gets the previous number plus one (section 7). |
at |
timestamp | When the change was made. Feeds every derived time: updatedAt, closedAt, the time of the history entry. |
The at of the previous event is used; for the first event, the tree’s project.updatedAt. |
by |
string | Display name of the person or tool that made the change. Feeds author, the actor of the history entry and the sentences of the history. |
Unknown |
op |
string | The operation, for example issue.create (section 6). |
The entry is dropped with a warning. |
The extension writes the members in this order: seq, at, by, op, then the payload. A reader does not
depend on the order.
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 branches that were merged interleave their times); project.updatedAt is simply the latest at seen.
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, not at the end of the previous one:
{"seq":3,"at":"2026-03-02T17:00:00.000Z","op":"wal.base"}
,{"seq":4,"at":"2026-03-03T08:15:00.000Z","by":"Grace","op":"issue.create","issue":{"id":"iss-2b4d6f8a0c1e","number":2,"kind":"task","title":"Export the board as Markdown","body":"One file per milestone, one section per issue.","labelIds":["lbl-ea660db94990"],"milestoneId":"ms-1f6a0c93b2d4","status":null,"rank":1024,"parentId":null,"order":2048,"attrs":{},"assignee":""}}
,{"seq":5,"at":"2026-03-03T08:20:41.000Z","by":"Ada","op":"comment.add","issueId":"iss-2b4d6f8a0c1e","comment":{"id":"c-7a8b9c0d1e2f","body":"Yes please. Keep the output diff-friendly."}}
With the usual trailing commas, adding an entry to a JSON array changes two lines: the new one, and the previous last one, which needs a comma. Git would then report a conflict whenever two branches add an entry, because both changed the same line. With leading commas the previous lines are never touched: an append is exactly one added line, and two appends made on two branches are two added lines at the same place, which git can keep both (section 15).
The same reason explains the wal.base marker: 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, including the first, then has the same
shape, and the list can be folded and started again without changing how a line looks.
5.4 The wal.base marker
{"seq":3,"at":"2026-03-02T17:00:00.000Z","op":"wal.base"}
| Member | Type | Meaning |
|---|---|---|
seq |
integer, 0 or more | Copy of tree.seq at the moment the marker was written. Informative: a reader takes the number from tree.seq. |
at |
timestamp | When the list was last folded into the tree. Absent in a file that was never consolidated. |
op |
string | Always wal.base. |
The marker has no by. A reader does not apply it: it only remembers its at as the time of the last
consolidation, and it skips the marker wherever it stands in the list.
5.5 Identifiers
Entities refer to each other by id, never by number, name or title. Ids are made by whoever writes the event
that creates the entity; a reader never invents one for an event. The extension generates ids as a prefix, a
hyphen and 12 random hexadecimal digits:
| Prefix | Entity | Unique within |
|---|---|---|
iss- |
issue (task, requirement, heading) | the file |
c- |
comment | its issue |
lnk- |
link | its issue |
lbl- |
label | the file |
ms- |
milestone | the file |
att- |
attachment | the file |
attr- |
attribute definition | the file |
bl- |
baseline | the file |
conn- |
connection | the file |
col- |
workflow column added by a person | the workflow |
The columns of the default workflow have the plain ids todo, doing, review and done. History entries get
act-<seq> from the event that produced them. The project id is a UUID. A reader treats an id as an opaque,
non-empty text, so ids of another shape (those of an imported file, for example) work as well; what matters is
that an id is never reused, not even after the entity was deleted, because events written on another branch may
still refer to it.
5.6 Times and dates
Timestamps (at, createdAt, updatedAt, closedAt, lastSyncAt, timestamp) 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 a time zone offset or leave out the milliseconds. Calendar dates of milestones
(startDate, dueDate) are YYYY-MM-DD texts; they are stored as given and also compared as text.
6. Events
6.1 Rules for all events
- An event carries every generated value: the id of what it creates, its time, its author. Applying the same list twice gives the same board.
- Create events carry the new entity in one member (
issue,label,comment, …) whoseidis required. Fields that are left out take the defaults given below. - Update events carry
idandset.setholds only the fields to change; a field that is not insetkeeps its value, and a field with a value of the wrong type is ignored. A missingsetchanges nothing. - Members a reader does not know are ignored, in the payload and inside
set. - An event that cannot be applied is skipped, with the warning
Skipped event <seq> (<op>): <reason>. The reasons are listed with each operation. Reading continues with the next line; the skipped event stays in the list. - After every event, applied or skipped, the board’s
seqbecomes the event’sseqandproject.updatedAtbecomes the event’satwhen that is later. - History. Many events put one entry at the front of
activity:idact-<seq>,actorthe event’sby,timestampthe event’sat,seqthe event’sseq, and thetype,messageandrefgiven below. An event never produces more than one entry.
6.2 Overview
| Operation | What it does | History entry |
|---|---|---|
project.update |
changes name, description or visibility of the board | project_updated |
owner.update |
changes the owner profile | owner_updated |
workflow.set |
replaces the Kanban columns | workflow_updated |
label.create |
adds a label | label_created |
label.update |
changes a label | none |
label.delete |
removes a label, also from its issues | label_deleted |
milestone.create |
adds a milestone | milestone_created |
milestone.update |
changes, closes or reopens a milestone | milestone_closed, milestone_reopened, or none |
milestone.delete |
removes a milestone; its issues go back to the backlog | milestone_deleted |
issue.create |
opens a task, a requirement or a heading | issue_opened |
issue.update |
changes, closes, reopens or moves an issue | one of nine types, or none |
issue.delete |
removes an issue | issue_deleted |
comment.add |
adds a comment to an issue | comment_added |
comment.update |
changes a comment | none |
comment.delete |
removes a comment | comment_deleted |
link.add |
links an issue to another one | link_added |
link.update |
marks a link as reviewed or changes its type | none |
link.delete |
removes a link | none |
attachment.add |
stores a file in the board | attachment_added |
attachment.update |
renames a file or replaces its content | none |
attachment.delete |
removes a file | attachment_deleted |
attribute.create |
defines an attribute | attribute_created |
attribute.update |
changes an attribute definition | none |
attribute.delete |
removes an attribute definition | attribute_deleted |
baseline.create |
freezes a copy of the requirements | baseline_created |
baseline.delete |
removes a baseline | baseline_deleted |
connection.create |
connects the board to a remote tracker | connection_created |
connection.update |
changes a connection or records a synchronization | none |
connection.delete |
removes a connection and every trace of it | connection_deleted |
activity.note |
writes a line into the history | the given type, note by default |
The example lines below continue the example file of section 2. Applied in the order of their seq, 7 to 47,
they are one consistent story, and the effects described for each line are what a reader computes.
6.3 Board
project.update
| Member | Type | Required | Meaning |
|---|---|---|---|
set |
object | yes | The fields to change. |
set.name |
string | no | New name of the board. |
set.description |
string | no | New description. |
set.visibility |
public or private |
no | New visibility. Any other value is ignored. |
,{"seq":7,"at":"2026-03-04T09:00:00.000Z","by":"Ada","op":"project.update","set":{"description":"Planning board of the Lighthouse desktop client.","visibility":"public"}}
Effects: the given fields are set. History: project_updated, “Ada updated the project settings”, written even
when set changes nothing. Never skipped.
owner.update
| Member | Type | Required | Meaning |
|---|---|---|---|
set |
object | yes | The fields to change. |
set.name, set.email, set.bio, set.avatarColor |
string | no | New values of the owner profile. |
,{"seq":8,"at":"2026-03-04T09:01:10.000Z","by":"Ada","op":"owner.update","set":{"bio":"Maintainer of Lighthouse."}}
Effects: the given fields are set. History: owner_updated, “Ada updated the owner profile”, written even when
set changes nothing. Never skipped.
workflow.set
| Member | Type | Required | Meaning |
|---|---|---|---|
columns |
array of columns | yes | The complete new list of columns, left to right. At least one. |
columns[].id |
string | yes | Id of the column. Keep the id of a column that stays, so its cards stay in it. |
columns[].name |
string | no | Title of the column. Default Column <position>. |
columns[].category |
todo, doing or done |
no | Meaning of the column. Default doing. |
,{"seq":9,"at":"2026-03-04T09:05:00.000Z","by":"Ada","op":"workflow.set","columns":[{"id":"todo","name":"To do","category":"todo"},{"id":"doing","name":"In progress","category":"doing"},{"id":"col-b7e2a9d04c13","name":"Blocked","category":"doing"},{"id":"review","name":"In review","category":"doing"},{"id":"done","name":"Done","category":"done"}]}
Effects: the workflow is replaced as a whole; there is no event that changes one column. The rules of 4.4 apply
to the new list (a repeated id is dropped, the last column becomes done when none is, a To do column is put in
front when all are done). Every issue whose status names a column that no longer exists gets status null and
shows in the column derived from its state (section 10); its updatedAt does not change. History:
workflow_updated, “Ada changed the workflow columns”. The example adds a column Blocked between In progress and
In review.
A column without id is accepted but gets a new random id every time the file is read, so nothing can refer to
it until the list is consolidated. Always give ids.
Skipped when columns is missing, not an array or empty: a workflow needs at least one column.
6.4 Labels
label.create
| Member | Type | Required | Meaning |
|---|---|---|---|
label |
object | yes | The new label. |
label.id |
string | yes | Its id. |
label.name |
string | no | Text of the label. Default label-1. |
label.color |
string | no | CSS hex colour. Default #6b7280. |
label.description |
string | no | When to use it. Default empty. |
,{"seq":10,"at":"2026-03-04T09:10:00.000Z","by":"Ada","op":"label.create","label":{"id":"lbl-c3d4e5f6a7b8","name":"needs-design","color":"#f59e0b","description":"Needs a design decision first"}}
Effects: the label is added at the end of labels. History: label_created, “Ada created label needs-design”,
ref the label id.
Skipped: the label has no id. · the label id <id> already exists.
label.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the label. |
set |
object | yes | The fields to change. |
set.name, set.color, set.description |
string | no | New values. |
,{"seq":11,"at":"2026-03-04T09:11:30.000Z","by":"Ada","op":"label.update","id":"lbl-c3d4e5f6a7b8","set":{"name":"design","color":"#d97706"}}
Effects: the given fields are set. Issues refer to the label by id, so they show the new name at once. No history entry.
Skipped: the label does not exist.
label.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the label. |
,{"seq":42,"at":"2026-03-09T09:00:00.000Z","by":"Ada","op":"label.delete","id":"lbl-c3d4e5f6a7b8"}
Effects: the label is removed, and its id is removed from the labelIds of every issue (their updatedAt does
not change). History: label_deleted, “Ada deleted label design”, without ref. In the story, issue #3 keeps
its other label.
Skipped: the label does not exist.
6.5 Milestones
milestone.create
| Member | Type | Required | Meaning |
|---|---|---|---|
milestone |
object | yes | The new milestone. |
milestone.id |
string | yes | Its id. |
milestone.title |
string | no | Default Untitled milestone. |
milestone.description |
string | no | Markdown. Default empty. |
milestone.startDate, milestone.dueDate |
string or null | no | YYYY-MM-DD. Default null. |
milestone.state |
open or closed |
no | Default open. |
milestone.createdAt |
timestamp | no | Default: the event’s at. Importers give the original time. |
milestone.closedAt |
timestamp | no | Only used when state is closed. Default then: the event’s at. |
milestone.remotes |
object | no | Counterparts in remote trackers (4.14). |
,{"seq":12,"at":"2026-03-04T09:20:00.000Z","by":"Ada","op":"milestone.create","milestone":{"id":"ms-8c2e4a6b0d1f","title":"v1.1","description":"Import and export.","startDate":"2026-05-01","dueDate":"2026-06-15"}}
Effects: the milestone is added at the end of milestones. History: milestone_created, “Ada created milestone
v1.1”, ref the milestone id.
Skipped: the milestone has no id. · the milestone id <id> already exists.
milestone.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the milestone. |
set |
object | yes | The fields to change. |
set.title, set.description |
string | no | New values. |
set.startDate, set.dueDate |
string or null | no | New date; null or an empty text removes the date. |
set.state |
open or closed |
no | Closes or reopens the milestone. |
set.remotes |
object or null | no | Replaces all remote counterparts; null removes them. |
,{"seq":13,"at":"2026-03-04T09:21:00.000Z","by":"Ada","op":"milestone.update","id":"ms-8c2e4a6b0d1f","set":{"dueDate":"2026-06-30"}}
,{"seq":36,"at":"2026-03-08T17:00:00.000Z","by":"Ada","op":"milestone.update","id":"ms-1f6a0c93b2d4","set":{"state":"closed"}}
Effects: the given fields are set. When state differs from the current state, closedAt becomes the event’s
at (closing) or null (reopening), with the history entry milestone_closed, “Ada closed milestone v1.0”, or
milestone_reopened, “… reopened milestone …”, ref the milestone id. Any other change writes no history. The
first example moves the due date and leaves no history entry; the second closes v1.0 and sets its closedAt to
2026-03-08T17:00:00.000Z. Closing a milestone does not touch its issues: the two open issues of v1.0 stay open.
Skipped: the milestone does not exist.
milestone.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the milestone. |
,{"seq":43,"at":"2026-03-09T09:01:00.000Z","by":"Ada","op":"milestone.delete","id":"ms-8c2e4a6b0d1f"}
Effects: the milestone is removed and every issue in it goes back to the backlog (milestoneId null, updatedAt
unchanged). Issues are not deleted. History: milestone_deleted, “Ada deleted milestone v1.1”, without ref.
Skipped: the milestone does not exist.
6.6 Issues
issue.create
| Member | Type | Required | Meaning |
|---|---|---|---|
issue |
object | yes | The new issue. |
issue.id |
string | yes | Its id. |
issue.number |
integer | no | The number to give it. Used when it is 1 or more and no issue has it; otherwise, and when it is left out, the issue gets the highest number in use plus one. |
issue.kind |
task, requirement or heading |
no | Default task. |
issue.title |
string | no | Default Untitled issue <number>. |
issue.body |
string | no | Markdown. Default empty. |
issue.state |
open or closed |
no | Default open. closed records an issue that is already done. |
issue.author |
string | no | Default: the event’s by. |
issue.assignee |
string | no | Default empty. |
issue.labelIds |
array of strings | no | Ids of labels. Ids of labels that do not exist and repeats are dropped. |
issue.milestoneId |
string or null | no | Id of a milestone. Null, and the id of a milestone that does not exist, mean the backlog. |
issue.status |
string or null | no | Id of a workflow column. Null, and the id of a column that does not exist, mean “derive from state”. |
issue.startDate |
YYYY-MM-DD or null |
no | Planned first day. Default null. |
issue.dueDate |
YYYY-MM-DD or null |
no | Planned last day, not before startDate. Default null. |
issue.rank |
number | no | Position of the card in its column. Default 1024 × (number of issues on the board + 1). |
issue.parentId |
string or null | no | Id of the parent in the outline. The id of an issue that does not exist means the top level. |
issue.order |
number | no | Position among its siblings. Default 1024 × (number of issues on the board + 1). |
issue.attrs |
object | no | Key-value tags. Default empty. |
issue.createdAt |
timestamp | no | Default: the event’s at. Importers give the original time. |
issue.updatedAt |
timestamp | no | Default: createdAt. |
issue.closedAt |
timestamp | no | Only used when state is closed. Default then: the event’s at. |
issue.remotes |
object | no | Counterparts in remote trackers (4.14). |
,{"seq":14,"at":"2026-03-04T10:00:00.000Z","by":"Linus","op":"issue.create","issue":{"id":"iss-5e7a9c1b3d5f","number":3,"kind":"task","title":"Import issues from a CSV file","body":"","labelIds":[],"milestoneId":null,"status":null,"rank":1024,"parentId":null,"order":3072,"attrs":{},"assignee":""}}
,{"seq":23,"at":"2026-03-05T09:10:00.000Z","by":"Ada","op":"issue.create","issue":{"id":"iss-a0b1c2d3e4f5","number":4,"kind":"heading","title":"Import and export","body":"","labelIds":[],"milestoneId":null,"status":null,"rank":1024,"parentId":null,"order":4096,"attrs":{},"assignee":""}}
,{"seq":24,"at":"2026-03-05T09:12:00.000Z","by":"Ada","op":"issue.create","issue":{"id":"iss-b1c2d3e4f5a6","number":5,"kind":"requirement","title":"The board shall import issues from CSV files","body":"Columns are matched by header name.","labelIds":[],"milestoneId":null,"status":null,"rank":1024,"parentId":"iss-a0b1c2d3e4f5","order":1024,"attrs":{"priority":"must"},"assignee":""}}
Effects: the issue is added at the end of issues with rev 1 and no comments and no links; links,
comments and rev in the payload are ignored (comments and links are added by their own events). History:
issue_opened, “Linus opened #3 Import issues from a CSV file”, ref the issue id.
The examples are lines as the extension writes them, with every field spelled out: a task, the heading #4, and
the requirement #5 placed under that heading. Only id is required; a line as short as
"issue":{"id":"…","title":"…"} creates an open task in the backlog with the next free number.
The number in the event is a proposal. When two branches both created “the next issue” and were merged, the
second line finds its number taken and gets the next one (section 15); a person’s #6 in a commit message can
then point at the other issue, which is one more reason to refer to issues by id inside the file.
Skipped: the issue has no id. · the issue id <id> already exists.
issue.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the issue. |
set |
object | yes | The fields to change. |
set.title |
string | no | New title. Content: counts for rev. |
set.body |
string | no | New description. Content. |
set.kind |
task, requirement or heading |
no | New kind. Content. |
set.attrs |
object | no | Replaces all key-value tags of the issue. Content. |
set.assignee |
string | no | New assignee; an empty text removes the assignee. |
set.labelIds |
array of strings | no | Replaces the whole list of labels. To add one label, send the current list plus the new id. Unknown ids and repeats are dropped. |
set.milestoneId |
string or null | no | Moves the issue into that milestone; null, or the id of a milestone that does not exist, moves it to the backlog. |
set.status |
string or null | no | Moves the card into that workflow column; null, or the id of a column that does not exist, clears the column. |
set.state |
open or closed |
no | Closes or reopens the issue. |
set.startDate |
YYYY-MM-DD or null |
no | New planned first day; null or an empty text clears it. Not content: does not count for rev. |
set.dueDate |
YYYY-MM-DD or null |
no | New planned last day; null or an empty text clears it. Not content. |
set.parentId |
string or null | no | Moves the issue under that parent; null moves it to the top level. Ignored when the parent does not exist, is the issue itself or sits below the issue. |
set.rank |
number | no | New position of the card in its column. |
set.order |
number | no | New position among its siblings in the outline. |
set.remotes |
object or null | no | Replaces all remote counterparts; null removes them. |
,{"seq":15,"at":"2026-03-04T10:07:42.000Z","by":"Linus","op":"issue.update","id":"iss-5e7a9c1b3d5f","set":{"title":"Import issues from CSV","body":"Map the columns `title`, `body`, `state` and `labels`.\n\n- [ ] detect the delimiter\n- [ ] preview before applying"}}
,{"seq":16,"at":"2026-03-04T10:09:00.000Z","by":"Linus","op":"issue.update","id":"iss-5e7a9c1b3d5f","set":{"assignee":"Linus","labelIds":["lbl-ea660db94990","lbl-c3d4e5f6a7b8"]}}
,{"seq":17,"at":"2026-03-04T10:15:00.000Z","by":"Linus","op":"issue.update","id":"iss-5e7a9c1b3d5f","set":{"status":"doing","rank":1024,"milestoneId":"ms-8c2e4a6b0d1f"}}
,{"seq":18,"at":"2026-03-04T11:30:00.000Z","by":"Ada","op":"issue.update","id":"iss-9a3b5c7d1e2f","set":{"state":"open","status":"todo"}}
,{"seq":27,"at":"2026-03-05T14:00:00.000Z","by":"Ada","op":"issue.update","id":"iss-b1c2d3e4f5a6","set":{"body":"Columns are matched by header name, ignoring case."}}
,{"seq":34,"at":"2026-03-07T09:30:00.000Z","by":"Ada","op":"issue.update","id":"iss-5e7a9c1b3d5f","set":{"remotes":{"conn-4c6e8a0b2d1f":{"id":"2847561093","number":17,"url":"https://github.com/lighthouse-app/lighthouse/issues/17","base":{"title":"3f2a9c1d0b","body":"a81d0e77c2"}}}}}
Effects. A field counts as changed only when the new value differs from the current one.
revgrows by one whentitle,body,kindorattrschanged, once per event however many of them changed.updatedAtbecomes the event’satwhen any oftitle,body,kind,attrs,assignee,labelIds,milestoneId,status,startDate,dueDateorstatechanged.- Planned dates are scheduling, not content: rescheduling never raises
rev, so it never makes a link suspect. closedAtbecomes the event’satwhenstatechanged toclosed, and null when it changed toopen.parentId,rank,orderandremotesare applied silently: norev, noupdatedAt, no history.- The reducer changes exactly the fields in
set. Closing an issue does not move its card and moving a card does not close the issue; how the two fit together is described in section 10.
History: at most one entry per event, with ref the issue id. A change of state comes first. Otherwise the
entry describes the first field that changed, in the order of this table:
| Changed | Type | Message |
|---|---|---|
state |
issue_closed, issue_reopened |
<by> closed #<number> <title>, <by> reopened #<number> <title> |
title |
issue_renamed |
<by> renamed #<number> to <new title> |
body |
issue_edited |
<by> edited the description of #<number> <title> |
kind |
issue_kind |
<by> marked #<number> <title> as <kind> |
attrs |
issue_tagged |
<by> changed the tags of #<number> <title> |
assignee |
issue_assigned |
<by> assigned #<number> <title> to <assignee>, or <by> unassigned #<number> <title> |
labelIds |
issue_labeled |
<by> changed the labels of #<number> <title> |
milestoneId |
issue_milestoned |
<by> moved #<number> <title> to <milestone title>, or <by> moved #<number> <title> back to the backlog |
status |
issue_moved |
<by> moved #<number> <title> to <column name>; no entry when the column is cleared |
startDate, dueDate |
issue_scheduled |
<by> scheduled #<number> <title> from <start> to <due> (a missing date is the other one), or <by> unscheduled #<number> <title> |
The six example lines, in order:
seq15 changes title and body of #3:revbecomes 2; historyissue_renamed, “Linus renamed #3 to Import issues from CSV”.seq16 assigns #3 and gives it two labels:revstays 2; historyissue_assigned, “Linus assigned #3 Import issues from CSV to Linus”.seq17 moves #3 into milestone v1.1 and into the columndoing: historyissue_milestoned, “Linus moved #3 Import issues from CSV to v1.1”.seq18 reopens #1 and puts its card intotodo:closedAtbecomes null; historyissue_reopened, “Ada reopened #1 Crash when the board file is empty”.seq27 rewords requirement #5:revbecomes 2, which makes the link from #3 suspect (section 11); historyissue_edited.seq34 records the GitHub counterpart of #3: no history,updatedAtunchanged.
Skipped: the issue does not exist.
issue.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the issue. |
,{"seq":44,"at":"2026-03-09T09:02:00.000Z","by":"Ada","op":"issue.delete","id":"iss-a0b1c2d3e4f5"}
Effects: the issue is removed with its comments and its links. Its children in the outline move up to its own
parent. Links of other issues that point to it are removed. Baselines keep their copy. History: issue_deleted,
“Ada deleted #4 Import and export”, without ref. In the story the heading #4 is deleted and requirement #5,
which sat under it, moves to the top level.
Numbers are not reserved: the next issue.create gets the highest number still in use plus one, so the number of
a deleted issue can come back when it was the highest.
Skipped: the issue does not exist.
6.7 Comments
comment.add
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue the comment belongs to. |
comment |
object | yes | The new comment. |
comment.id |
string | yes | Its id, unique within the issue. |
comment.body |
string | no | The text, in Markdown. Default empty. |
comment.author |
string | no | Default: the event’s by. |
comment.createdAt |
timestamp | no | Default: the event’s at. Importers give the original time. |
comment.remotes |
object | no | Counterparts in remote trackers (4.14). |
,{"seq":19,"at":"2026-03-04T13:02:00.000Z","by":"Grace","op":"comment.add","issueId":"iss-5e7a9c1b3d5f","comment":{"id":"c-1a2b3c4d5e6f","body":"Please support semicolon separated files too."}}
,{"seq":31,"at":"2026-03-06T10:06:00.000Z","by":"Grace","op":"comment.add","issueId":"iss-5e7a9c1b3d5f","comment":{"id":"c-9f8e7d6c5b4a","body":"Mock-up of the preview step:\n\n"}}
Effects: the comment is added at the end of the issue’s comments with updatedAt null, and the issue’s
updatedAt becomes the event’s at. History: comment_added, “Grace commented on #3 Import issues from CSV”,
ref the issue id. The second example shows a picture stored in the board (section 13).
Skipped: the issue does not exist. · the comment has no id. · the comment id <id> already exists.
comment.update
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue. |
id |
string | yes | Id of the comment. |
set |
object | yes | The fields to change. |
set.body |
string | no | New text. |
set.remotes |
object or null | no | Replaces all remote counterparts; null removes them. |
,{"seq":20,"at":"2026-03-04T13:05:30.000Z","by":"Grace","op":"comment.update","issueId":"iss-5e7a9c1b3d5f","id":"c-1a2b3c4d5e6f","set":{"body":"Please support semicolon and tab separated files too."}}
Effects: when the text differs, the comment’s body is replaced and the updatedAt of the comment and of the
issue become the event’s at. No history entry.
Skipped: the comment does not exist. (also when the issue does not exist)
comment.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue. |
id |
string | yes | Id of the comment. |
,{"seq":21,"at":"2026-03-04T15:00:00.000Z","by":"Linus","op":"comment.delete","issueId":"iss-9a3b5c7d1e2f","id":"c-4d5e6f708192"}
Effects: the comment is removed and the issue’s updatedAt becomes the event’s at. History:
comment_deleted, “Linus deleted a comment on #1 Crash when the board file is empty”, ref the issue id.
Skipped: the comment does not exist.
6.8 Links
link.add
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue the link starts at and is stored in. |
link |
object | yes | The new link. |
link.id |
string | yes | Its id, unique within the issue. |
link.targetId |
string | yes | Id of the issue the link points to. It has to exist and be another issue. |
link.type |
satisfies, derives, verifies, depends or relates |
no | Default relates. |
link.targetRev |
integer | no | The rev of the target the author looked at. Default: the target’s rev when the event is applied. |
,{"seq":25,"at":"2026-03-05T11:00:00.000Z","by":"Linus","op":"link.add","issueId":"iss-5e7a9c1b3d5f","link":{"id":"lnk-6f5e4d3c2b1a","type":"satisfies","targetId":"iss-b1c2d3e4f5a6","targetRev":1}}
,{"seq":26,"at":"2026-03-05T11:02:00.000Z","by":"Linus","op":"link.add","issueId":"iss-5e7a9c1b3d5f","link":{"id":"lnk-0a9b8c7d6e5f","targetId":"iss-2b4d6f8a0c1e"}}
Effects: the link is added at the end of the issue’s links with createdAt the event’s at and by the
event’s by, and the issue’s updatedAt becomes the event’s at. The target is not changed. History:
link_added, “Linus linked #3 satisfies #5”, ref the id of the issue that holds the link. The second example
leaves out type and targetRev: it becomes a relates link at the target’s current revision.
The reducer accepts a second link of the same type to the same target. The editor refuses to create one.
Skipped: the link needs an id and two different existing issues. · the link id <id> already exists.
link.update
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue that holds the link. |
id |
string | yes | Id of the link. |
set |
object | yes | The fields to change. |
set.targetRev |
integer | no | The rev of the target that was reviewed. |
set.type |
link type | no | New type of the link. |
,{"seq":28,"at":"2026-03-05T15:20:00.000Z","by":"Linus","op":"link.update","issueId":"iss-5e7a9c1b3d5f","id":"lnk-6f5e4d3c2b1a","set":{"targetRev":2}}
Effects: the given fields are set; nothing else changes and there is no history entry. The example is how a
suspect link is marked as reviewed: targetRev is set to the current rev of requirement #5.
Skipped: the link does not exist.
link.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
issueId |
string | yes | Id of the issue that holds the link. |
id |
string | yes | Id of the link. |
,{"seq":40,"at":"2026-03-09T08:20:00.000Z","by":"Linus","op":"link.delete","issueId":"iss-5e7a9c1b3d5f","id":"lnk-0a9b8c7d6e5f"}
Effects: the link is removed and the issue’s updatedAt becomes the event’s at. No history entry.
Skipped: the link does not exist.
6.9 Attachments
attachment.add
| Member | Type | Required | Meaning |
|---|---|---|---|
attachment |
object | yes | The new file. |
attachment.id |
string | yes | Its id. |
attachment.name |
string | no | File name. Default attachment. |
attachment.mime |
string | no | Media type. Default application/octet-stream. |
attachment.kind |
image, sketch or file |
no | Default file. |
attachment.data |
string | no | The content, base64 encoded. Default empty. |
attachment.size |
integer | no | Size in bytes. Default: 3/4 of the length of data, rounded down. |
attachment.scene |
any JSON value | no | Only for sketches: the editable source of the drawing. |
attachment.createdAt |
timestamp | no | Default: the event’s at. |
attachment.by |
string | no | Default: the event’s by. |
,{"seq":30,"at":"2026-03-06T10:05:00.000Z","by":"Grace","op":"attachment.add","attachment":{"id":"att-3b5d7f9a1c2e","name":"csv-preview.png","mime":"image/png","size":72,"kind":"image","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="}}
,{"seq":32,"at":"2026-03-06T10:20:00.000Z","by":"Grace","op":"attachment.add","attachment":{"id":"att-6d8f0a2c4e1b","name":"sample.csv","mime":"text/csv","size":24,"kind":"file","data":"dGl0bGUsc3RhdGUKRG9jcyxvcGVuCg=="}}
Effects: the file is added at the end of attachments. History: attachment_added, “Grace attached
csv-preview.png”, ref the attachment id. The content travels inside the event line, so the line is as long as
the file; that is the price of keeping the board in one file.
Skipped: the attachment has no id. · the attachment id <id> already exists.
attachment.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the attachment. |
set |
object | yes | The fields to change. |
set.name, set.mime |
string | no | New file name, new media type. |
set.data |
string | no | New content, base64 encoded. |
set.size |
integer | no | Size of the new content. Only read together with set.data; default 3/4 of its length, rounded down. |
set.scene |
any JSON value or null | no | New source of a sketch; null removes it. |
,{"seq":38,"at":"2026-03-09T08:00:00.000Z","by":"Grace","op":"attachment.update","id":"att-3b5d7f9a1c2e","set":{"name":"csv-import-preview.png"}}
Effects: the given fields are set. id, kind, createdAt and by cannot be changed. No history entry.
Skipped: the attachment does not exist.
attachment.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the attachment. |
,{"seq":41,"at":"2026-03-09T08:30:00.000Z","by":"Grace","op":"attachment.delete","id":"att-6d8f0a2c4e1b"}
Effects: the file is removed. Texts that refer to it with attachment:<id> are not changed (section 13).
History: attachment_deleted, “Grace removed sample.csv”, without ref.
Skipped: the attachment does not exist.
6.10 Attribute definitions
attribute.create
| Member | Type | Required | Meaning |
|---|---|---|---|
attribute |
object | yes | The new definition. |
attribute.id |
string | yes | Its id. |
attribute.key |
string | yes | The key in the attrs of issues. Not blank; spaces around it are removed. |
attribute.name |
string | no | Column title. Default: the key. |
attribute.type |
text, number, enum, date or boolean |
no | Default text. |
attribute.options |
array of strings | no | The choices of an enum. Default empty. |
attribute.description |
string | no | Default empty. |
,{"seq":22,"at":"2026-03-05T09:00:00.000Z","by":"Ada","op":"attribute.create","attribute":{"id":"attr-0f1e2d3c4b5a","key":"priority","name":"Priority","type":"enum","options":["must","should","could","wont"],"description":"MoSCoW priority"}}
Effects: the definition is added at the end of attributes. Issues are not changed: a definition only describes
values that issues store, or will store, under its key. History: attribute_created, “Ada added the attribute
Priority”, without ref.
Skipped: the attribute needs an id and a key. · the attribute <key> already exists. (the id or the key is
already used)
attribute.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the definition. |
set |
object | yes | The fields to change. |
set.name, set.description |
string | no | New values. |
set.type |
attribute type | no | New type. |
set.options |
array of strings | no | Replaces the whole list of choices. |
,{"seq":39,"at":"2026-03-09T08:10:00.000Z","by":"Ada","op":"attribute.update","id":"attr-0f1e2d3c4b5a","set":{"name":"MoSCoW","options":["must","should","could"]}}
Effects: the given fields are set. The key cannot be changed. Values that issues already store are not checked
against the new type or choices. No history entry.
Skipped: the attribute does not exist.
attribute.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the definition. |
,{"seq":46,"at":"2026-03-09T09:04:00.000Z","by":"Ada","op":"attribute.delete","id":"attr-0f1e2d3c4b5a"}
Effects: the definition is removed. The values that issues store under its key stay in their attrs. History:
attribute_deleted, “Ada removed the attribute MoSCoW”, without ref.
Skipped: the attribute does not exist.
6.11 Baselines
baseline.create
| Member | Type | Required | Meaning |
|---|---|---|---|
baseline |
object | yes | The new baseline. |
baseline.id |
string | yes | Its id. |
baseline.name |
string | no | Default Baseline <count + 1>. |
baseline.description |
string | no | Default empty. |
baseline.scope |
string | no | all copies every issue. Any other value, or none, copies the requirements and headings and leaves the tasks out. |
,{"seq":29,"at":"2026-03-06T09:00:00.000Z","by":"Ada","op":"baseline.create","baseline":{"id":"bl-2a4c6e8f0b1d","name":"v1.1 requirements","description":"Agreed in the planning meeting of 2026-03-06."}}
Effects: a baseline is added at the end of baselines with createdAt the event’s at, by the event’s by
and, as items, a copy of the issues in scope as they are when the event is applied (4.12). The event does not
carry the copy, which keeps it one short line: the reader takes it from the state the event meets. History:
baseline_created, “Ada created baseline v1.1 requirements (2 items)”, ref the baseline id. The two items of
the example are heading #4 and requirement #5, the requirement at rev 2.
Skipped: the baseline has no id. · the baseline id <id> already exists.
baseline.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the baseline. |
,{"seq":45,"at":"2026-03-09T09:03:00.000Z","by":"Ada","op":"baseline.delete","id":"bl-2a4c6e8f0b1d"}
Effects: the baseline is removed. History: baseline_deleted, “Ada deleted baseline v1.1 requirements”, without
ref. There is no event that changes a baseline.
Skipped: the baseline does not exist.
6.12 Connections
connection.create
| Member | Type | Required | Meaning |
|---|---|---|---|
connection |
object | yes | The new connection. |
connection.id |
string | yes | Its id. |
connection.kind |
string | yes | Which connector, for example github. |
connection.name |
string | no | Default <kind> 1. |
connection.baseUrl |
string | no | Address of the tracker. Default empty. |
connection.project |
string | no | Which project in the tracker. Default empty. |
connection.options |
object | no | Connector-specific settings. Default {}. |
connection.createdAt |
timestamp | no | Default: the event’s at. |
connection.lastSyncAt |
timestamp or null | no | Default null. |
,{"seq":33,"at":"2026-03-07T09:00:00.000Z","by":"Ada","op":"connection.create","connection":{"id":"conn-4c6e8a0b2d1f","kind":"github","name":"GitHub","baseUrl":"https://api.github.com","project":"lighthouse-app/lighthouse","options":{}}}
Effects: the connection is added at the end of connections. History: connection_created, “Ada connected
GitHub”, ref the connection id.
Skipped: the connection needs an id and a kind. · the connection id <id> already exists.
connection.update
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the connection. |
set |
object | yes | The fields to change. |
set.name, set.baseUrl, set.project |
string | no | New values. |
set.options |
object | no | Replaces all connector-specific settings. |
set.lastSyncAt |
timestamp or null | no | When the last synchronization finished; null clears it. |
,{"seq":35,"at":"2026-03-07T09:30:05.000Z","by":"Ada","op":"connection.update","id":"conn-4c6e8a0b2d1f","set":{"lastSyncAt":"2026-03-07T09:30:05.000Z"}}
Effects: the given fields are set. id, kind and createdAt cannot be changed. No history entry.
Skipped: the connection does not exist.
connection.delete
| Member | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Id of the connection. |
,{"seq":47,"at":"2026-03-09T09:05:00.000Z","by":"Ada","op":"connection.delete","id":"conn-4c6e8a0b2d1f"}
Effects: the connection is removed, and its entry is removed from the remotes of every issue, comment and
milestone; a remotes object that becomes empty is removed. The issues themselves stay. History:
connection_deleted, “Ada removed the connection GitHub”, without ref.
Skipped: the connection does not exist.
6.13 History
activity.note
| Member | Type | Required | Meaning |
|---|---|---|---|
message |
string | yes | The sentence to show in the history. A reader uses an empty text when it is missing. |
type |
string | no | Machine-readable kind of the entry. Default note. |
ref |
string | no | Id of the entity the note is about. |
,{"seq":37,"at":"2026-03-08T17:05:00.000Z","by":"Claude","op":"activity.note","type":"note","message":"Claude checked that every task of v1.1 traces to a requirement","ref":"iss-5e7a9c1b3d5f"}
Effects: one history entry with the given type, message and ref; nothing else changes. The upgrade from
format 1.0 writes such an event with the type format_upgraded. Never skipped.
6.14 Operations a reader does not know
A later version of the format can add operations. A reader that meets one it does not know skips it like any event it cannot apply:
,{"seq":48,"at":"2026-03-10T08:00:00.000Z","by":"Grace","op":"sprint.start","sprint":{"id":"spr-7c1e5a9d3b2f","title":"Sprint 12"}}
Skipped event 48 (sprint.start): this build does not know the operation.
The board’s seq and project.updatedAt still advance, and the line stays in the list: the extension writes it
back unchanged with every later change, so a newer build can still apply it. It is lost when the list is
consolidated by a build that does not know it (section 9).
7. Tolerance rules
A .pb file is edited by programs, by people in a text editor, by AI agents and by git merges. A reader
therefore repairs what it can and reports it as a warning instead of refusing the file. The rules, in the order
in which a file is read:
The tree (section 4)
- A missing or mistyped field takes its default. A missing
treeis an empty board with the default workflow. - A member the reader does not know is not kept.
- An entry that reuses the
idof an earlier entry of its list is dropped:Dropped a <label|milestone|issue|attribute|baseline|connection|attachment> that reused the id <id>. - References are repaired as described under “After the last event” below.
The list (section 5)
- A
walthat is missing or not an array is an empty list. - An entry that is not an object is dropped:
Dropped a WAL entry that is not an object. - An entry without
opis dropped:Dropped a WAL entry without an operation. - Colliding sequence numbers are renumbered. An event whose
seqis missing, not a number, or not greater than theseqof the event before it (for the first event: thantree.seq) gets the previous number plus one. The event is applied all the same, at its place in the file; no event is ever dropped because of its number. One warning is given for the whole file:<n> WAL event(s) had a missing or repeated sequence number and were renumbered. - A missing
attakes theatof the event before it; a missingbybecomesUnknown. - A line that stands in the list twice is two events. The second copy of a create event is then skipped because its id exists.
Events (section 6)
- An unknown operation is skipped with a warning and kept in the list (6.14).
- An event whose target does not exist, or whose new entity has no id or an id that is taken, is skipped with a
warning that names the
seq, the operation and the reason. - Inside an applied event, a reference to something that does not exist counts as absent: unknown label ids are left out, an unknown milestone or column counts as null (the backlog, no column), an unknown parent is ignored.
- A duplicate issue number is reassigned. An
issue.createwhosenumberis missing, below 1 or taken gets the highest number in use plus one, without a warning.
After the last event
- Dangling references are dropped, silently: ids in
labelIdsthat name no label; amilestoneIdthat names no milestone (the issue goes to the backlog); astatusthat names no column (the column is derived fromstate); aparentIdthat names no issue or the issue itself (the issue moves to the top level); links whose target does not exist or is the issue itself. - An issue whose
numberis below 1 or already used by an earlier issue gets the highest number plus one:Issue "<title>" had the duplicate or invalid number <n> and is now #<m>. - A chain of parents that runs in a circle is cut:
Issue #<n> was part of a parent loop and moved to the top level.
The same repair runs on the tree alone before the first event is applied.
Repairs happen in memory. They reach the file when the extension next changes the board: it then writes the tree in its repaired form and the list with the new sequence numbers.
What is not tolerated. A file that is not valid JSON (for example after a bad manual merge), a file whose top level is not an object, and a file of a newer or unknown format version (section 14) are not opened for editing. The extension shows the reason and leaves the file exactly as it is.
8. Coalescing
Typing a description in the editor produces a change with every pause. Writing each of them as a line would bury
the history in noise, so the editor merges a new event into the last event of the list when both belong to one
continuous edit (PbSession.append in src/pb/session.ts). All of these have to hold:
- the last event has not been saved to disk yet;
- both events have the same
by; - neither sets
state: closing and reopening is history worth keeping; -
and one of the two cases applies:
- Two updates of the same thing. Both are the same
….updateoperation with the sameid(and the sameissueId, for comments and links). The fields of the newsetare written over the oldset, andatbecomes the time of the new event. - A create followed by an update of what it created.
issue.create+issue.update,comment.add+comment.update,label.create+label.update,milestone.create+milestone.update,attachment.add+attachment.update,attribute.create+attribute.update,connection.create+connection.update. The fields ofsetare written into the new entity of the create event;atstays the time of the creation.
- Two updates of the same thing. Both are the same
Only the last event of the list is looked at: an edit of another issue in between ends the run. Deletes,
link.add, workflow.set, baseline.create, baseline.delete and activity.note are never merged.
What follows for a reader:
- A line that is on disk is never changed. Coalescing only shapes lines that were not saved yet, so it never shows up in a diff as a modified line.
- The new entity of a create event can carry any field that the matching update accepts, which is why
issue.createreadslabelIds,milestoneId,status,parentIdandremotes. - An issue that was created and then retitled before the save is one
issue.createwith the final title: itsrevis 1 and the history hasissue_openedonly. - Several fields changed in one sitting are one
issue.update; the history gets one entry for it (6.6).
Events written to the file by hand are on disk, so they are never merged with anything.
9. Consolidation
The list grows by one line per change. Consolidation folds it into the tree (consolidate in
src/pb/document.ts):
- the board is computed as in section 3;
- that board becomes the new
tree; itsseqis theseqof the last event; - the history is cut to its newest entries when it is longer than the limit the caller gives (the editor passes the activity limit of its settings); the limit is 500 when none is given, and zero or less keeps everything;
- the list becomes empty, and its marker gets the new
tree.seqand the time of the consolidation.
Had the list of section 6 been consolidated on 9 March, the file would continue with this marker and the next event would be number 48:
{"seq":47,"at":"2026-03-09T12:00:00.000Z","op":"wal.base"}
The board reads the same before and after, with three exceptions: history entries beyond the limit are gone; events with an operation the consolidating build did not know are gone, because they were never applied; and the individual events can no longer be read from the file, only from its git history.
Nothing consolidates on its own. It happens when a person asks for it in the editor or runs the consolidate
command of the command line tool. Consolidation rewrites the tree and removes every line of the list, so it is a
change that git cannot merge with anything else: do it on the main branch, when no branch with unmerged board
changes is open, and commit it on its own (section 15).
10. Kanban semantics
Columns. A board has one workflow, the list tree.workflow. The backlog and every milestone show their
issues in these same columns. A column has a category that says what it means whatever it is called: todo
(not started), doing (in progress) or done (finished). A reader guarantees at least one done column and at
least one column that is not done (4.4).
Two fields. An issue has a state, open or closed, and a status, the id of the column of its card or
null. state is what counts for progress, for remote trackers and for people who do not use the board. status
only refines it.
Which column a card shows in (columnOf in src/pb/query.ts). state wins over status:
state |
status names |
The card shows in |
|---|---|---|
closed |
a done column |
that column |
closed |
another column, no column, or null | the first done column |
open |
a column that is not done |
that column |
open |
a done column, no column, or null |
the first column that is not done |
So a file in which the two fields disagree still draws a consistent board, and an event that only says
"set":{"state":"closed"} is enough to move a card to Done. In the example file, issue #2 has status null and
shows in todo.
What the editor writes. The editor keeps both fields in step so that the file says what the board shows:
- Closing an issue writes
{"state":"closed","status":"<first done column>"};statusis left out when the card already sits in adonecolumn. Lineseq6 of the example file is such an event. - Reopening writes
{"state":"open"}, and adds"status":"<first column that is not done>"only when the card sat in adonecolumn. - Dropping a card into a column writes
status, the newrank, andstate:closedwhen the column isdone,openotherwise. Dragging a card to Done closes the issue; dragging it back reopens it. Dropping it on the board of another milestone addsmilestoneId. Only fields that change are written.
Order inside a column. Cards are sorted by rank, ascending; equal ranks by number. Ranks are spaced so
that a move rewrites one card: a card dropped between two others gets the middle of their ranks, a card dropped
at an end gets the neighbour’s rank plus or minus 1024, a card dropped into an empty column gets 1024, a new issue
gets the rank of the last card of its column plus 1024. Only when two neighbours are too close to fit a number between them does the editor renumber the
column in steps of 1024, with one issue.update per card.
Headings have no card. Tasks and requirements both do.
Milestone progress counts the issues of the milestone that are not headings: closed over total.
The current milestone is the open milestone with the earliest dueDate among those that still have an open
issue; when no open milestone has an open issue, among all open ones. Milestones without a due date come last,
and equal dates keep the order of the file.
11. Requirements
A board can hold a requirements document next to its tasks. Requirements are issues, so everything above applies to them: they have numbers, comments, labels, a state and a card.
Kinds. kind is task (a unit of work), requirement (a statement the product has to satisfy) or heading
(a section title). Tools show a requirement as REQ-<number>; the numbers come from the one sequence all issues
share.
The outline. parentId and order arrange issues as a document. The issues with the same parentId are
siblings, sorted by order and then by number; null is the top level. Section numbers such as 2.1.3 are
computed from that, never stored. The format lets an issue of any kind sit under an issue of any kind; the usual
shape is requirements under headings. order works like rank: steps of 1024 and the middle between two
neighbours. A reader never lets the outline loop: an update that would put an issue under itself or under one of
its descendants is ignored, and a loop found in a file is cut (section 7). When an issue is deleted, its children
move up to its parent. In a view that shows only some kinds, an issue whose parent is not shown appears at the
top level.
Attributes. attrs of an issue is a flat object of texts, for example {"priority":"must"}. An attribute
definition in tree.attributes describes one key: the name of its column, its type and, for enum, the allowed
choices. Definitions guide an editor; a reader does not check values against them, an issue may carry keys that
have no definition, and deleting a definition leaves the values in place. The editor offers a starting set for
requirements:
| Key | Name | Type | Choices |
|---|---|---|---|
type |
Type | enum |
functional, non-functional, constraint, interface |
priority |
Priority | enum |
must, should, could, wont |
verification |
Verification | enum |
test, analysis, inspection, demonstration |
rationale |
Rationale | text |
An issue.update replaces attrs as a whole: send every key the issue should have afterwards.
Links. A link is stored in the issue it starts at and points to another issue. Its type is read from the holder to the target:
| Type | Reads as |
|---|---|
satisfies |
this work implements that requirement |
derives |
this requirement is derived from that one |
verifies |
this test or check verifies that requirement |
refines |
this requirement adds detail to that one without adding an obligation (new in 2.1) |
depends |
this cannot be done before that |
conflicts |
the two cannot both hold as written and must be resolved (new in 2.1) |
relates |
the two are related, no stronger claim |
conflicts is symmetric: the editor stores it once, on whichever requirement recorded it first, and shows it on
both. Apart from that the format attaches no behaviour to the types. Links are removed when their target is deleted.
Suspect links. Every issue has a revision counter, rev, that starts at 1 and grows by one with each
issue.update event that changes its title, body, kind or attrs. A link remembers in targetRev the
revision of its target when the link was made. When the target’s rev is greater than the link’s targetRev,
the link is suspect: the target was changed after somebody last checked that the claim of the link still holds.
In the story of section 6, the link “#3 satisfies #5” is made at revision 1 (seq 25), requirement #5 is
reworded (seq 27, rev 2) and the link is suspect until Linus reviews it (seq 28, targetRev 2). Reviewing
is a link.update that sets targetRev to the target’s current rev. Changes of labels, assignee, milestone,
column or state do not make links suspect: they do not change rev.
Baselines. A baseline is a named copy of the requirements document at one moment, to compare later versions
against. baseline.create copies, for every requirement and heading (or every issue, with scope all), the
fields id, number, kind, title, body, state, parentId, order, attrs and rev. Comments,
links, labels and milestones are not part of a baseline. A baseline never changes; comparing an item’s rev
with the rev of the live issue tells whether the requirement changed since.
12. Remotes and connections
A board can be synchronized with projects in remote trackers. The file records what a synchronization needs to continue where the last one stopped, and nothing secret.
- A connection (4.13) names a remote project: the connector
kind, thebaseUrl, theprojectand connector-specificoptions. Passwords and tokens are never part of a connection or of any other part of the file; they are kept outside it. - An issue, comment or milestone that has a counterpart in a remote project records it in
remotes(4.14), an object keyed by the connection id: the remoteid, the remotenumberandurlwhere the tracker has them, andbase. baseholds one short hash per tracked field of the content as it was when both sides last agreed. A side has changed when its current content no longer matches the base, which tells a local edit from a remote edit without comparing clocks.remotesis written byissue.create,comment.addandmilestone.create(the entity arrives from the tracker) and by the….updateevents of the three (the board’s entity was sent to the tracker, or a synchronization moved the base). It is always replaced as a whole; null removes it. Changingremotesdoes not change theupdatedAtor therevof the entity and writes no history: it is bookkeeping, not an edit.connection.updatewithlastSyncAtrecords the end of a synchronization.connection.deleteremoves the connection and its key from everyremotes. The issues stay on the board.
In the story, seq 33 connects the board to a GitHub repository, seq 34 records that issue #3 is issue 17
there, seq 35 records the end of the synchronization, and seq 47 removes the connection, which also removes
remotes from issue #3.
Do not write remotes by hand. A wrong reference makes the next synchronization update the wrong remote item.
13. Attachments
Pictures, sketches and other files are stored inside the board file, in tree.attachments, base64 encoded
(4.10). They are added, changed and removed by the attachment.… events, and they belong to the board, not to
one issue.
A Markdown text of the board (the body of an issue, a comment, the description of a milestone) shows or links an
attachment with the address attachment:<id>:

[sample.csv](attachment:att-6d8f0a2c4e1b)
The first form shows a picture, the second is a link to a file. The comment of seq 31 in section 6.7 uses the
first form. To a reader of the file the reference is ordinary text: nothing in the tree connects a body to an
attachment, and the reducer does not look inside bodies. The programs that render Markdown (the editor, the
reports) replace the address by the content of the attachment with that id. Two consequences:
- renaming an attachment does not break references, because they use the id;
- deleting an attachment does not change the texts that refer to it. The references are left pointing at nothing, and it is up to the renderer what to show in their place.
size is the size of the decoded content in bytes. A sketch keeps two things: data, the rendered picture, and
scene, the editable source of the drawing, which the format stores as it is without interpreting it.
14. Versions and upgrades
The version field. pbVersion is a text of numbers separated by dots, compared number by number. This
document describes 2.1. The released versions are 1.0, 2.0 and 2.1 (KNOWN_VERSIONS in
src/pb/versions/index.ts).
How a file is classified (inspect):
| The file | Is treated as | What the extension does |
|---|---|---|
| is empty or only white space | a new board | opens an empty board and writes the file with the first change |
| is not JSON, or not a JSON object | invalid | shows the reason, changes nothing |
declares the current version, 2.1 |
current | opens it |
declares no version and has a tree object |
2.0 |
offers the upgrade |
declares no version and has no tree object |
1.0 |
offers the upgrade |
| declares an older version that the upgrade chain starts from | older | offers the upgrade; the board cannot be edited before it |
declares a version greater than 2.1, for example 2.2 or 3.0 |
newer | refuses to open it: “Update the extension to open it.” |
| declares any other version | unknown | refuses to open it: “… which no version of Project Board has written.” |
A build never guesses at a format it does not know, and it never writes a file it refused to open. Note that every greater version counts as newer, minor versions included: within a version, readers skip unknown operations (6.14) but do not keep unknown fields (section 4), so a change that adds fields has to be a new version with its own step.
The upgrade chain. Upgrades are a chain of steps, one per released version, each turning exactly one version
into the next (Migration in src/pb/versions/migration.ts):
| Member | Meaning |
|---|---|
from, to |
The version the step reads and the version it produces. |
title |
One line that names the step. |
describe(raw) |
What the step will do to this particular file, as notes of the levels info (carried over), change (becomes something else) and warning (information is lost). Used for a dry run before anything is written. |
migrate(raw, context) |
The upgraded file as a plain JSON value. context gives the by and at of the upgrade. |
To upgrade a file, the extension follows the chain from the file’s version to the current one, applying each step to the result of the step before, and reads the final result like any file of the current version. A file of any released version therefore reaches the current format, however old it is.
Adding a version. A new format version, say 3.0, needs exactly this:
- set
CURRENT_PB_VERSIONinsrc/pb/types.tsto3.0and add it at the end ofKNOWN_VERSIONS; - write one step with
from: '2.1'andto: '3.0'and add it toMIGRATIONS; - leave every existing step as it is: the step from 2.0 to 2.1 keeps producing 2.1, and the new step takes it from there.
What the step from 1.0 to 2.0 does (src/pb/versions/v1to2.ts, “Single JSON document to tree plus
write-append list”). Format 1.0 was one JSON object with project, owner, labels, milestones, issues,
boards and activity, rewritten on every change. The step:
- carries project, owner, labels, milestones, issues, comments and history entries over into the tree, with their ids and numbers;
- turns the first board that has columns into the workflow of the project. Column ids and names are kept. The
category is guessed from the name and the position: a name that contains done, closed, complete, finished,
shipped, merged or released is
done; a name that contains todo, to do, backlog, open, new, planned or ready, or the first column, istodo; the last of more than two columns isdone; everything else isdoing. A file without such a board gets the default workflow; - gives every issue that had a card on that board the column as
statusand its position asrank. Other boards are dropped: their issues stay, only the placement of the cards is lost; - turns pull requests into ordinary issues. The branch names are kept as a note at the end of the description,
“Migrated from a pull request (
headintobase, merged on 2026-01-31).”, and a merged pull request becomes a closed issue; - gives every issue the new fields with their empty values:
kindtask, no assignee, no parent, no tags, no links,rev1; - gives milestones
startDatenull, acreatedAttaken from their earliest issue, and, when closed, aclosedAttaken from their last closed issue; - drops the project’s default branch setting, since the board no longer models git;
- writes
tree.seq0 and a list of two entries: thewal.basemarker and, asseq1, anactivity.noteof the typeformat_upgradedwith the message “upgraded the file format from 1.0 to 2.0". The upgrade is itself the first line of the new history.
What the step from 2.0 to 2.1 does (src/pb/versions/v2to2_1.ts, “Planned dates and requirement
relationships”). Format 2.1 adds the planned dates of an issue and two link types. The step:
- gives every issue of the tree
startDateanddueDatenull; events in the list are left as they are, since anissue.createwithout dates creates an issue without dates; - changes nothing else:
refinesandconflictsare new values of an existing field; - appends to the list an
activity.noteof the typeformat_upgraded, with the nextseqand the message “upgraded the file format from 2.0 to 2.1".
A 2.0 build refuses a 2.1 file (“Update the extension to open it”), since it would drop the dates when it consolidates.
15. Using the file with git
Tell git how to merge
Add this line to the .gitattributes file of the repository:
*.pb merge=union
union is a merge driver built into git. Where two branches changed the same place of a file, it keeps the lines
of both sides instead of reporting a conflict.
What a merge looks like
Two branches start from the board as section 6 left it, with seq 47 as the last event. On main, Ada opens an
issue. On a feature branch, Claude opens another one. Each sees 47 as the last number and 5 as the highest issue
number, so both write seq 48 and both propose number 6:
,{"seq":48,"at":"2026-03-10T09:00:00.000Z","by":"Ada","op":"issue.create","issue":{"id":"iss-d4e5f6a7b8c9","number":6,"title":"Dark theme for the preview"}}
,{"seq":48,"at":"2026-03-10T09:30:00.000Z","by":"Claude","op":"issue.create","issue":{"id":"iss-e5f6a7b8c9d0","number":6,"title":"Document the CSV column mapping"}}
Without the attribute, git stops with a conflict at the end of the list:
<<<<<<< HEAD
,{"seq":48,"at":"2026-03-10T09:00:00.000Z","by":"Ada","op":"issue.create","issue":{"id":"iss-d4e5f6a7b8c9","number":6,"title":"Dark theme for the preview"}}
=======
,{"seq":48,"at":"2026-03-10T09:30:00.000Z","by":"Claude","op":"issue.create","issue":{"id":"iss-e5f6a7b8c9d0","number":6,"title":"Document the CSV column mapping"}}
>>>>>>> feature/docs
The right resolution is always the same: keep both lines and delete the three marker lines. That is exactly what
merge=union does on its own. The merged file has both lines one after the other, and it is valid JSON because
each line brings its own comma:
,{"seq":48,"at":"2026-03-10T09:00:00.000Z","by":"Ada","op":"issue.create","issue":{"id":"iss-d4e5f6a7b8c9","number":6,"title":"Dark theme for the preview"}}
,{"seq":48,"at":"2026-03-10T09:30:00.000Z","by":"Claude","op":"issue.create","issue":{"id":"iss-e5f6a7b8c9d0","number":6,"title":"Document the CSV column mapping"}}
A reader then applies the rules of section 7. The second line repeats seq 48, so it becomes event 49, with the
warning 1 WAL event(s) had a missing or repeated sequence number and were renumbered. Its issue number 6 is
taken by the line before it, so “Document the CSV column mapping” becomes #7. Both issues exist, nothing was
lost, and the next time the extension changes the board it writes the list with the corrected numbers.
Why keeping both sides is safe
- Only appends. Both branches added lines at the end of the list and changed no existing line, so the union of the two is the old list plus both sets of new lines, each set in its own order.
- Lines are independent. Every event is complete on its line and carries its own comma. No line has to be edited when another is added before or after it.
- Position decides, numbers are repaired. Readers apply events in file order and renumber
seqvalues that collide, so it does not matter that both branches counted from the same number. - Ids are random. Two branches cannot create two things with the same id, so the events of one branch never refer to an entity of the other by accident. Issue numbers, which are not random, are reassigned.
- Conflicting edits resolve by order. When both branches changed the same field of the same issue, both events are in the list and the later line wins, the same as if the two edits had been made one after the other. Changes to different fields are both kept. An event that refers to something the other branch deleted is skipped with a warning.
What must not be merged this way
The safety rests on the rule that existing lines never change. Three operations break it, because they rewrite the tree or remove lines: consolidation (section 9), the upgrade to a new format version (section 14), and any edit of an existing line by hand. Merging such a change with a branch that appended events can leave a file that is not valid JSON, or one in which events are applied to a tree that already contains them. Do these operations on the main branch while no branch with unmerged board changes is open, commit them alone, and let the branches start from the result. If it happens anyway, take the file of one side as it is and make the other side’s changes again.