Named states, a first run, and fewer ADRs¶
- Status: accepted (2026-11-09, Pierre). Built on 2026-11-09:
app.State,git board state,GET /api/state, the Start page, and the ADR merge. The named states replace the old checks only where this page says so. - Scope: the implicit state machines, the first run in the CLI and the web app, what is missing, and a merge of the ADRs.
The decision: one computed state¶
An implicit state machine is a lifecycle that no type names. The code infers its state from booleans, files, refs and git config keys. The survey found eleven. Each one is inferred in two to five places, and no command or route tells a person or an agent which state the board is in.
The board gets one function, app.State, that computes every machine
once and names each state. Three views read it, and nothing else
derives a state:
git board state, with--jsonand--jqas the other commands have them.git board statusis the inbox, as ingh status.GET /api/state.- The Start page of the web app.
Each machine also lists its next actions. A next action names the CLI command and the API route that does it. A person reads the sentence. An agent reads the command. The web app shows a button, never a command.
The old call sites change to read app.State. The state must not be
a sixth copy of the same tests. This is what makes it a fix, not a
feature.
| Machine | Named states | Inferred today in |
|---|---|---|
| Sync run | idle, running here, running in another process, stopped mid-run, done | surface/http/sync.go:136, sync/lock.go:120, sync/journal.go:216 |
| Forge | found in origin, in a table and off, pull, push, both, first sync, no token, token for another owner | app/forges.go:62, forge/conf.go:147, app/tokens.go:82 |
| Datastore | git, Markdown, PostgreSQL, the forges alone, set by the environment | app/datastore.go:31, app/numbers.go:32 |
| Project file | no repository, no file, current, older schema, newer schema | app/app.go:176, app/init.go:140, app/config.go:226 |
| GitHub Project | none, broken table, configured, fields stated, write on, fields held | tools/ghproject/session.go:124, app/project.go:82, surface/cli/tui.go:58, surface/http/project.go:127, :167 |
| Identity | none, chosen | domain/people/service.go:17 |
| Start over | equal, ours newer, theirs newer, both started over | app/reset.go:102 |
| Old git-bug refs | none, switched, not switched | app/forges.go:291 |
| Serve | none, running, stale | surface/http/serve.go:260 |
| Dev cycle | off, on, broken table | app/config.go:87, app/cycle.go:31 |
Defects that the states expose¶
Each fix keeps the v1 output, which the golden recordings
(golden/compat_test.go) fix byte for byte.
- A run that stopped mid-run is never reported.
sync.Cutandsync.Runninghave no caller outside tests. The status reports the stopped run, and the next sync says that it resumes. - A CLI sync is not visible in
GET /api/sync, because the server reads only its own memory. The status reads the OS lock. - One value has three names:
bothin the table,syncinbsync.Bothand in the API. The API accepts both names and answersboth. The table keeps refusingsync. git board config setacceptsboard.view, but no code reads it.board.experimentalor[experimental]turns the cycle on. The key stays accepted, and the command says that it has no effect.- A broken
[github-project]table looks like no table in the TUI, becausetui.go:58drops the error. The status names it broken. - The datastore's display name is computed twice, and a forge copy is "git" in one place and "the forges alone" in the other. One function names it.
refs/board/switchedis written as a literal in two files, and no code in this repository writes it. One constant holds it, and the error names the v1 command that writes it.- A reset or a drop deletes the sync entity, so the next sync is a first sync again and asks for consent. The status shows this before the run. A test proves it.
The project file on a first run¶
The file stays optional, and the app does not ask for it at start.
A board with no file already works: every ns:value label is a field,
and the forges are the projects that origin names. A question at
start interrupts a person who only wants to read the issues.
The file is written when a person first changes a shared setting. The
Fields page, the Project page and the Sync page already do this
through POST /api/config/init or a write route. The page says, "This
writes .git-board.toml. Commit it to share it with the team."
The global file keeps only personal values. Today it holds one key,
template. It does not hold shared settings, because then two clones
of the same repository read different boards (ADR 0011). A personal
default way for new forge tables is the only candidate key, and it
waits for a real need.
The Start page offers the file as one step that you can skip, with a
choice of template: the default, enac, a URL or a path. Today only
the CLI can choose a template.
The first-run flow¶
The Start page is at #/start. A board is new if no identity is
chosen, or no forge has run and the board holds no issue. A new board
with no issue offers the Start page in its empty state. The page does
not open by itself, because a redirect at start breaks every e2e test
that opens an empty board.
- Who you are. Choose or create your identity. If you skip this step, the board is read-only.
- What you want. Select one goal. You can add the others later.
- Log in to the forge that the goal needs. Today you paste a token, on localhost only. Later the OAuth device flow (#101) replaces this step and keeps the same place in the flow.
- Look at the first run. The app runs a dry run and shows its counts: issues to pull, issues to create, fields to write.
- Run it. The Open the board button then opens the board.
| Goal | What the app writes | Step 4 asks for consent |
|---|---|---|
| See my forge's issues here | way = "pull" on a forge table; a forge found in origin needs a table first, which Add a forge writes |
no, because nothing is created on the forge |
| Work here, and keep the forge in step | way = "both" |
yes, when the board holds issues the forge does not have |
| Keep a backup on another forge | a [forges.backup] table, way = "push" |
yes |
| Keep the issues as Markdown files | board.datastore = markdown:<dir>, then the copy |
no |
| Plan in a GitHub Project | [github-project] with fields, and write if you select it |
no |
Each step writes one decision. The CLI gets the same flow without a prompt: git board state
prints the next actions in order, and git board with no argument
stays as it is.
What is missing¶
These gaps make the web app send you to a terminal, which the rule from ADR 0022 forbids:
- The Fetch, Pull and Push buttons show CLI commands in their tooltips
(
SyncSettings.vue:225). - The token field says to set
GITHUB_TOKEN. That needs a restart from a shell. - The Project form cannot turn on
write = true. You must edit the file by hand. - Markdown export (
git board export --md) has no route. - A file edited by hand needs a restart of the server.
These gaps are in the product itself:
- An empty board says "No issues match this filter." The Start page replaces this on a new board.
- No end-to-end test covers a first run. The flow gets one Playwright
test in
web/e2e. docs/guide/configuration.md:9names "Start from the default in Settings › Fields". The button is "Write the default file".
The ADRs, merged¶
The 22 ADRs contradict each other in six places, and five of them
describe code that is gone. The merge keeps nine. Each group goes
into its lowest number. Each absorbed file becomes a stub of three
lines, "Merged into ADR 00NN", and leaves the navigation. No number
changes, so the 374 references outside docs/adr/ keep their target.
A new page, docs/adr/index.md, reads the nine as one story, in this
order:
| Order | ADR | Title | Absorbs |
|---|---|---|---|
| 1 | 0003 | The board is the team's | 0018 (ownership) |
| 2 | 0002 | Where the board is kept | 0009, 0015, 0016 |
| 3 | 0004 | Issues are operation logs, fields are labels | 0007, 0019 |
| 4 | 0011 | Shared settings in the file, your own in git config | none |
| 5 | 0008 | One truth, many mirrors, and how they sync | 0022, the mappings of 0007 |
| 6 | 0010 | No secret is stored, and the server answers on localhost | 0013 |
| 7 | 0006 | One binary, built in layers | 0005, 0012, 0020, 0021 |
| 8 | 0014 | Tests on real repositories, few licences | 0017 |
| 9 | 0001 | Proof in git notes, and the opt-in cycle | 0018 (the cycle) |
The merge removes the six contradictions:
- 0007 says that the forges get labels and never their own fields. 0022 puts fields in the Project's columns. The merged 0004 and 0008 say that a field is a label in the board, and that a mirror holds it where its mapping says.
- 0005 names
core/, which is gone. - 0006 says that go-git reads objects.
git cat-filereads them (0021). - 0008 says that there is no delete operation. 0019 adds one.
- 0018 keeps the agents in the repository. 0021 moves them out.
- 0002 makes a board kept on the forges alone a copy of the forges. 0022 says that the board is the only truth. The merged 0002 says that this datastore keeps no board of its own.
Content that is not a decision moves out of the ADRs:
- The line and key format of 0001, its evidence and prior art, to
docs/spec/. - The file names and the cache note of 0002, to
docs/spec/datastores.md. - The forge defaults of 0007 and the defaults of 0015, to
docs/guide/configuration.md. - The step logs of 0020 and the open items of 0021, to
docs/rewrite/.
The merged ADRs keep the format of ADR 0022: Status, Date, Context, Decision, Consequences. Each says which ADRs it absorbs.
What was built on 2026-11-09, and what was not¶
Built, each with its test:
app.Stateand its views,git board stateandGET /api/state(TestStateOfANewBoard,TestStateOfAForge,TestState).- A run held by another process shows as
running, and the web app refuses a second run with 409. The server reads the OS lock throughBoard.Syncing. - The API takes
bothfor a two-way run. git board config set board.viewsays that v2 reads it nowhere.- The Start page, with the e2e test
web/e2e/start.spec.ts. - The Fetch, Pull and Push buttons say what they do. The Add a forge
dialog no longer names an environment variable. The Project page
turns
writeon and off (web/e2e/project.spec.ts).
Changed after a closer look at the code:
- A broken
[github-project]table stops the board from opening, so the TUI never sees one. Thebrokenstate stays for completeness. - A pull or a push alone records no run, so the state reads "first" from the sync entity existing, as the consent to create does.
- An older file is no setup step. The only migration comments out two keys that git-board does not read, and every upgrade adds a line.
- A GitLab project that the remotes name by its path is keyed by its
numeric id, which only the forge says. Its state is
onuntil the first sync.
Built later on 2026-11-09, each with its test:
- Every test package is sealed (
hermetic.Run), andTestEveryPackageIsSealedfails one that is not. - A file edited by hand already opened again within a second. A file
that does not open is now told (
config-error), and the web app shows why (TestEventsConfigOnce). - Markdown export from the web app, route 61 (
TestExportMarkdown). - One click makes a forge found in
originread-only: a way change writes the found forges' tables first (TestForgeFoundMadeReadOnly). - The Start page chooses the template. A template's path or URL answers
on loopback only (
TestInitTemplateFromElsewhere). - The login in the browser on github.com, #101: ADR 0010, section 5
(
TestDeviceFlow,TestDeviceLogin,TestDeviceRoutes). git-board's OAuth App id is in the source; a fork sets its own (GITHUB_CLIENT_ID). -
102:
a Markdown folder in another repository's checkout.git board export --html, a read-only site (TestWriteSite), and¶ - The datastore's name in one function,
datastoreKind. - A chip in the header while the board is new.
Not built yet:
- The line that each step writes, shown on the page.