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:

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

  1. Purpose and design goals
  2. File layout
  3. Reading a board
  4. The tree
  5. The write-append list
  6. Events
  7. Tolerance rules
  8. Coalescing
  9. Consolidation
  10. Kanban semantics
  11. Requirements
  12. Remotes and connections
  13. Attachments
  14. Versions and upgrades
  15. 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:

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:

Layout on disk

The extension writes the file in one fixed layout (serializeDocument in src/pb/document.ts):

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):

  1. Parse the file as JSON. It has to be an object.
  2. Read tree tolerantly: every missing or mistyped field takes its default and broken references are dropped (sections 4 and 7).
  3. Go through wal from the first line to the last. Skip the wal.base marker. Apply every other entry as an event (section 6). An event that cannot be applied is skipped with a warning; reading goes on.
  4. 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 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

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

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.

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:

  1. seq 15 changes title and body of #3: rev becomes 2; history issue_renamed, “Linus renamed #3 to Import issues from CSV”.
  2. seq 16 assigns #3 and gives it two labels: rev stays 2; history issue_assigned, “Linus assigned #3 Import issues from CSV to Linus”.
  3. seq 17 moves #3 into milestone v1.1 and into the column doing: history issue_milestoned, “Linus moved #3 Import issues from CSV to v1.1”.
  4. seq 18 reopens #1 and puts its card into todo: closedAt becomes null; history issue_reopened, “Ada reopened #1 Crash when the board file is empty”.
  5. seq 27 rewords requirement #5: rev becomes 2, which makes the link from #3 suspect (section 11); history issue_edited.
  6. seq 34 records the GitHub counterpart of #3: no history, updatedAt unchanged.

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![CSV import preview](attachment:att-3b5d7f9a1c2e)"}}

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.

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)

The list (section 5)

Events (section 6)

After the last event

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:

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:

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):

  1. the board is computed as in section 3;
  2. that board becomes the new tree; its seq is the seq of the last event;
  3. 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;
  4. the list becomes empty, and its marker gets the new tree.seq and 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:

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.

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>:

![CSV import preview](attachment:att-3b5d7f9a1c2e)
[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:

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:

  1. set CURRENT_PB_VERSION in src/pb/types.ts to 3.0 and add it at the end of KNOWN_VERSIONS;
  2. write one step with from: '2.1' and to: '3.0' and add it to MIGRATIONS;
  3. 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:

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:

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

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.