# The board

Tyler Youk · updated 2026-10-31 · https://tyleryouk.com/writing/the-board

If work is not on the board, it does not exist.

A body of work is a Feature issue with ordered sub-issues. Its status comes from a merged pull request
whose body closes the issue, and nobody ever types a status. If the board is wrong, the fleet works on the
wrong thing. We keep no second list to check it against, on purpose, and this component exists to stop
anyone from creating one.

The vocabulary (plan, Feature, issue) and the lane routing that decides which agent serves a row are
covered on [How work flows](/writing/how-work-flows). This page covers the mechanism underneath. It explains
which fields an agent writes, which ones a job computes, and the four ways the board has ended up showing
something false.

## The fleet owns no file that *is* the board {#the-fleet-owns-no-file-that-is-the-board}

The component's list of owned files is empty. That is accurate, not something we forgot to fill in. The
board is a hosted product. Everything in the repository that touches it belongs to some other component,
and exactly three parts split the work. One script owns the path that writes to the board. One set of
workflows owns the checks that keep it accurate. The scheduler owns the only thing that reads the Todo
status.

Because we name that boundary explicitly, no fourth component can quietly start keeping its own copy of
the board.

## An agent writes two fields. Everything else is derived {#an-agent-writes-two-fields-everything-else-is-derived}

An agent's whole job on the board is to assign itself and give the issue a title. It can also add a
priority label if it wants. A reconciliation workflow on a hosted runner computes every other field, and
writes a field only when its value has changed.

| Field        | Derived from                                                                                                       |
| ------------ | ------------------------------------------------------------------------------------------------------------------ |
| Lane         | the login of the primary assignee. The lane names the box, never the portfolio                                     |
| Priority     | the priority label, or the middle value if there is no label                                                       |
| Sprint       | the current iteration if the field is empty. The reconciler never overwrites an iteration someone chose on purpose |
| Status       | the board's own built-in workflows. The reconciler only acts as the backstop that moves closed issues to done      |
| Archived     | an issue that is closed and done comes off the board. An issue that is open and archived goes **back onto** it     |
| Week / Cycle | no script ever writes this field. See below                                                                        |

This design has two useful results:

* Agents make zero field calls. However busy the agents get, they cannot cause a rate-limit outage
  against the tracker from their side.
* Because the reconciler writes only when a value changed, it is idempotent. Running it twice has the same
  effect as running it once, so it is safe to run on a timer *and* on an event.

## The shape of a filing is a real choice {#the-shape-of-a-filing-is-a-real-choice}

```text
--chain              task k is blocked by task k-1
                     → exactly ONE issue is ever dispatchable

--wave               every task in a group is blocked by the WHOLE of the previous group,
                     and by no sibling
                     → a whole group starts at once
```

Someone once designed a plan as five parallel waves and filed it as a chain fourteen issues deep. Nothing
reported an error. The board just had one issue ready to dispatch instead of five, and the fleet ran at a
fifth of the parallel capacity the plan had, until someone looked at the dependency graph.

If two phases can run at the same time, file them as a wave. The filing tool now refuses a long chain when
its tasks share no file, and it prints the same plan rewritten as waves. A serial order needs a stated
reason. It is not the default.

## The iteration field is read-only to every script, and this one is worth stealing {#the-iteration-field-is-read-only-to-every-script-and-this-one-is-worth-stealing}

Any API write to a board's iteration configuration creates new iteration ids, and every assignment that
pointed at the old ids is left pointing at nothing. On this board, a single call lost 84 field
assignments.

The schema shows why, and the documentation does not, so reproduce it yourself instead of trusting this
paragraph:

```bash
gh api graphql -f query='{ a: __type(name:"ProjectV2IterationFieldIterationInput"){name}
                           e: __type(name:"ProjectV2Iteration"){kind inputFields{name}} }'
```

`a` comes back `null`, which means the input type a caller would need does not exist. And `e.inputFields`
has no `id`. The full input for an iteration is a start date, a duration and a title. So you cannot say
*"update this one"*. You can only say *"here is the new list"*.

**The field id is durable, and it is the only id worth hardcoding. An iteration id changes as soon as a
human edits the field in the interface.** For that reason the filing script looks up the current iteration
live every time it files an issue, and treats the result as advisory.

The rule that applies elsewhere is this. Before you write to a hosted API, check through introspection
whether the API keeps the identity of the thing you are updating. If its input type has no id field, you
are not updating it. You are replacing it.

## Read the board, never recall it {#read-the-board-never-recall-it}

Every count on the board changes by the minute, so this page tells you how to read each number instead of
stating it.

| You want                                                   | Run                                               |
| ---------------------------------------------------------- | ------------------------------------------------- |
| Open issues                                                | `gh api repos/<org>/<repo>/issues --jq 'length'`  |
| Progress for every Feature, in one call                    | `bash _infra/scripts/feature-progress.sh`         |
| One issue with its comments                                | `gh api repos/<org>/<repo>/issues/<n>`            |
| One line for each queued issue, with the reason it is held | `bash _infra/scripts/sweep-dispatch.sh --explain` |

That table has two traps. `gh issue view` currently fails against this board with a deprecation error from
the older projects API, so use the `gh api` form, which works. Also, **`gh api` prints its errors to
standard output**, not standard error. Check the *shape* of what you captured. Do not only check that it
is non-empty.

## Four ways this board has been made to lie {#four-ways-this-board-has-been-made-to-lie}

* **Saying you are *not* closing an issue closes it.** GitHub matches a closing keyword followed
  directly by an issue reference anywhere in a pull request body or commit message, and it ignores the
  words around it. A sentence like *"this does NOT close `#1234`"* counts as a closing reference. This
  happened twice in one day on one issue. The first time, it came from a pull request whose whole purpose
  was to keep the issue open. The second time, it came from the follow-up pull request that documented
  the trap, because that pull request quoted the sentence in its own body.

  Recovery takes two steps, and only the first one is obvious. A close also sets the status to done, and
  reopening the issue does not reset it. The issue ends up open **and** done, and no selector reads that
  state. So reopen the issue, *and* set the status back, then read both values back to confirm. To mention
  an issue without acting on it, use a form with no keyword, and use a placeholder number in any example.
  That is why the example above uses `#1234`.

* **The final state arrives eventually, not instantly.** The close event *tries* to archive the issue, and
  a scheduled sweep *guarantees* it. In one measured window, concurrency cancellation killed 25 of 30
  event runs in a row. Expect a few rows at any moment that are closed but still listed. This fleet has
  already raised a false alarm by filing "done is stuck at zero" after reading the board once.

* **A Feature never closes itself.** A parent issue has no pull request, so a closing keyword can never
  fire on it. Instead, the agent that finishes the last child issue *attests* the Feature, and a separate
  sweep closes it. If a Feature is complete but has no attestation, the sweep **flags it and does not
  close it**. That is the right way to fail, because the alternative is a parent that closes itself while
  its own verifier objects.

* **Nothing gates the default branch.** On this plan tier, branch protection and rulesets both return 403
  in every repository. A red check is information. A merge never waits on a human. The full argument is on
  [How work flows](/writing/how-work-flows).
