# Flux API

A small REST API for driving Flux boards programmatically — built for agents and
scripts. Everything the web app does, an agent can do too.

Base URL: `http://localhost:8000` (or whichever port the server runs on). The
canonical production base URL is `https://fluxtask.org`.
All request/response bodies are JSON.

For machine-readable API discovery, use [openapi.yaml](openapi.yaml).

## Authentication

Two ways to authenticate:

1. **Session cookie** — used by the web app. `POST /api/auth/login` sets an
   HTTP-only cookie.
2. **API token (Bearer)** — used by agents. Create one in the app
   (sidebar → your account → **API tokens**) or via `POST /api/tokens`, then send:

   ```
   Authorization: Bearer flux_xxxxxxxxxxxxxxxxxxxxxxxx
   ```

A token belongs to one user and grants access to every board that user can reach
(their own boards plus any shared with them). The plaintext token is shown
**once** at creation and stored only as a hash.

> Note: cookie-authenticated mutations must send `Content-Type: application/json`
> (a CSRF safeguard). Bearer-token requests are exempt.

## Boards, sharing & roles

A **board** contains **stages** (columns) and **tasks** (cards). Boards are
shared: each board has a set of members, each with a role.

| Role | Can do |
|---|---|
| `viewer` | read the board |
| `editor` | read + modify stages, tasks, checklists |
| `owner` | everything, plus manage members and delete the board |

You only ever see boards you're a member of. Requesting a board you can't access
returns `404` (so board ids aren't leaked). Insufficient role returns `403`.

> `/api/projects/...` is kept as a backward-compatible alias for `/api/boards/...`.

## Response envelopes

Task responses come in **two shapes**, and which one you get depends on whether
you asked for **one task** or **a list of tasks**. Both shapes are permanent —
clients depend on each — so the rule is stated here once and repeated beside
every affected endpoint below.

| You called | You get | Read a field as |
|---|---|---|
| any **single-task** endpoint | `{"task":{…}}` — **wrapped** | `.task.notes`, `.task.v` |
| any **task-list** endpoint | `{"tasks":[{…}]}` — entries **bare** | `.tasks[0].notes`, `.tasks[0].v` |
| `GET /api/boards/:id` | `{"board":{…,"tasks":[{…}]}}` — entries **bare** | `.board.tasks[0].notes` |

**Wrapped `{"task":{…}}` — all of these, on success *and* on conflict.** This
is the complete list, one route per line, aliases included; the test suite
derives the same list from the server — from the responses themselves, not from
this page — and fails if the two disagree, so a route added here that the server
does not wrap (or wrapped there and missing here) is a build failure rather than
a documentation drift. The derivation reads the response's property *name*, so
every spelling of it — `{ task }`, `{ "task": task }`, `` { `task`: task } ``,
`{ ["task"]: task }` — is the same route to it, and reformatting a handler
cannot change the list. A task-shaped response the derivation cannot match to a
route, or whose key is only known at run time, fails the build too, so neither
an unfamiliar routing style nor an unreadable key can quietly shrink the list it
is checked against:

- `POST /api/boards/:id/tasks` — create (`201`, or `200` on an idempotent replay)
- `GET /api/boards/:id/tasks/:tid`
- `PATCH /api/boards/:id/tasks/:tid` — `200` **and** the `409` stale-`ifVersion` body
- `POST /api/boards/:id/tasks/:tid/move`
- `POST /api/boards/:id/tasks/:tid/claim` — `200` **and** the `409` already-claimed body
- `POST /api/boards/:id/tasks/:tid/unlock`
- `PUT /api/boards/:id/tasks/:tid/workflow` — `200` **and** the `409` stale-`ifVersion` body
- `POST /api/boards/:id/tasks/:tid/workflow-error/dismiss` — `200` **and** the `409` stale-version-or-error-id body
- `POST /api/boards/:id/pop`
- `POST /api/boards/:id/stages/:sid/pop`
- `POST /api/boards/:id/stage/:sid/pop` — the singular alias, same shape

**Bare task objects inside an array — and these stay bare.** The task lists are
not being brought into the wrapper; where a reader has the choice they are the
safer read, because at the entry level `.notes` is already the right depth:

- `GET /api/boards/:id/tasks` → `{"tasks":[…]}`
- `GET /api/boards/:id/stages/:sid/tasks` → `{"tasks":[…]}`
- `GET /api/boards/:id/stage/:sid/tasks` → `{"tasks":[…]}` (the singular alias)
- `GET /api/boards/:id` → `{"board":{…,"tasks":[…]}}`
- `GET /api/public/boards/:id` → `{"board":{…,"tasks":[…]}}` (redacted)

### Reading a wrapped response at the top level fails silently

It does not error. It yields `null`/`undefined`, which reads as *the task has no
such value* — an absence indistinguishable from a genuinely empty field:

```bash
# WRONG — prints the literal text `null` no matter what the task holds
curl -fsS -H "$AUTH" "$BASE/api/boards/$BID/tasks/$TID" | jq -r '.notes'

# RIGHT — the task is under `.task`
curl -fsS -H "$AUTH" "$BASE/api/boards/$BID/tasks/$TID" | jq -er '.task.notes'
```

This is not hypothetical. Two separate readers concluded a task carried **zero
notes** while it held thousands of characters, because they checked `.notes` on
the wrapper. The same trap applies to `.v` (a missing version silently disables
`ifVersion` conflict detection — see [Tasks](#tasks)), `.noteCount`/`.hasNotes`,
`.tags` (a claimed task looks unlocked), `.done`, and `.stageId`.

**Fail closed.** Make a missing field abort rather than become an absence you
act on:

```bash
V=$(printf '%s' "$TASK" | jq -er '.task.v')   # non-zero exit if absent
```

```js
const { task } = await response.json();
if (!task) throw new Error("expected a wrapped {task} response");
```

```swift
// FluxKit decodes every single-task response through one named envelope.
let task = try decoder.decode(TaskEnvelope.self, from: data).task
```

Where a reader has the choice, the **list** endpoints are the safer read: their
entries are bare, so `.notes` at the entry level is correct. For notes
specifically, use the [task notes list](#task-notes-list-preview--flux_task_notes1),
which returns `{"notes":[…]}` with no task wrapper at all.

## Quick start (agent)

For a fresh-account, copy-paste production walkthrough of registration, safe
token handling, ID discovery, and a complete `pop` → versioned update/move →
`unlock` queue cycle, follow the **[Agent API quickstart](agent-api-quickstart.html)**.
Agents that leave reports on tasks should also read
[Appending to task notes](#appending-to-task-notes). When `FLUX_TASK_NOTES=1`,
append one independently addressed note through the
[native endpoint](#task-notes-list-preview--flux_task_notes1) and do not rewrite
the task's legacy `notes` field; with the flag off there is no comments endpoint,
so a report is a read-modify-write of `notes` and gets it wrong loudly.

```bash
BASE=https://fluxtask.org
IFS= read -r -s -p 'Flux API token: ' TOKEN
printf '\n'

# list boards you can access
printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" |
  curl --config - --fail-with-body --silent --show-error "$BASE/api/boards"

unset TOKEN
```

Changes made via the API appear in open web clients via server-sent events plus
revision polling.

Clients can report their active board with `POST /api/presence {boardId}`. Board
summaries include recent `viewers` for the title-row avatar display.

## Auth & account

| Method | Path | Body | Notes |
|---|---|---|---|
| POST | `/api/auth/register` | `{email, name?, password}` | `{user, verification}`; password ≥ 8 chars; sets cookie; seeds starter boards; sends a verification email (or auto-verifies when email is unconfigured); never grants admin by user count (see README §Accounts & security). The session is issued even when delivery fails — `verification.status` is `send_failed`, not a success |
| POST | `/api/auth/admin-bootstrap` | `{token}` | auth required; redeems the hosted admin bootstrap capability (`FLUX_ADMIN_BOOTSTRAP_TOKEN_HASH`/`FLUX_ADMIN_BOOTSTRAP_EXPIRES_AT`) exactly once, elevating the caller to admin; `404` if not configured/expired/self-hosted, `403` on a wrong token, `409` once any admin already exists |
| POST | `/api/auth/login` | `{email, password}` | `{user, verification}`; sets cookie |
| POST | `/api/auth/logout` | — | clears cookie |
| POST | `/api/auth/verify` | `{token}` | `{ok, user, verification}`; single-use — a replayed link is indistinguishable from an expired one. `400 {error, code:"EMAIL_VERIFICATION_REQUIRED"}` if invalid/expired/replayed, so the link screen can offer an inline resend |
| POST | `/api/auth/verify/resend` | — | `{ok, user, verification}` (auth required); auto-verifies when email delivery is unconfigured. Rate-limited, plus a `FLUX_VERIFY_RESEND_COOLDOWN_MS` cooldown that answers `verification.status:"cooldown"` with `retryAfterMs` **without** sending. Only a *successful* send arms the cooldown, so a failed one is retryable at once. A provider failure is `502` whose body still carries `verification`; an already-verified account returns `alreadyVerified:true` and `verification.status:"already_verified"` — nothing was sent |
| POST | `/api/auth/forgot` | `{email}` | always `200` — never reveals whether the account exists; emails a reset link when it does |
| POST | `/api/auth/reset` | `{token, password}` | set a new password from a reset link; revokes all sessions; `400` if invalid/expired |
| GET | `/api/me` | — | `{user:{id,email,name,analyticsClass,avatarUrl,prefs,level,admin,emailVerified,usage,org,orgMemberships}, verification}`, or 401 |
| PATCH | `/api/me` | `{name}` | update display name; `email` is rejected with `400` — only an admin can move an address (`PATCH /api/admin/users/:id`), which is why `verification.canChangeEmail` is always `false` |
| POST | `/api/me/password` | `{current,next}` | change password; `next` must be at least 8 chars |
| POST | `/api/me/avatar` | raw image body; headers `X-Filename`, `Content-Type` | `{user}`; PNG/JPEG/GIF/WebP/AVIF and sanitized SVG avatars are supported |
| DELETE | `/api/me/avatar` | — | `{user}` with `avatarUrl:null` |
| PUT | `/api/me/prefs` | `{theme?, activeBoardId?, boardOrder?, sidebarOps?}` | per-user UI prefs; sidebar changes should use merge-safe operations |

`sidebarOps` applies idempotent mutations to the latest stored sidebar instead of
replacing a stale snapshot. Supported operation types are `setGlobalsCompact`,
`setGroupCollapsed`, `addFolder`, `removeFolder`, `setFolderCollapsed`,
`moveFolder`, and `moveProject`. Organization-folder structural operations are
restricted to the organization owner. A legacy whole `sidebar` replacement must
include the matching `ifSidebarVersion` returned by `GET /api/me`; stale or
unversioned replacements return `409`.

`level` is `{id,name,maxStorageBytes,maxBoards,canPublicBoards}`. `usage` is
`{storageBytes, boardsOwned}`. `org` is `{enabled,maxMembers,members}` where
members are `{email,userId|null,addedAt}`.

### The `verification` object

Register, login, `GET /api/me`, verify and verify/resend all return one, so a
client never has to infer the state of an email address from an HTTP code:

```json
{ "required": true, "status": "cooldown", "code": "EMAIL_VERIFICATION_REQUIRED",
  "email": "someone@example.com", "expiresAt": 1760000000000, "retryAfterMs": 42000,
  "canResend": false, "canChangeEmail": false, "error": null }
```

| `status` | Meaning |
|---|---|
| `verified` / `not_required` / `already_verified` | terminal; `required` is `false` and nothing was sent |
| `sent` | a message was accepted by the provider — **the only status that may say so** |
| `cooldown` | withheld by `FLUX_VERIFY_RESEND_COOLDOWN_MS`; `retryAfterMs` says when the button returns |
| `expired` | the outstanding link is past `expiresAt`; a resend is the way out |
| `needs_resend` | unverified with no live link |
| `send_failed` / `unavailable` | the provider refused or timed out, or none is configured; `error` says so |

Read **`required === false`** as the terminal signal, not the status string: a
client that does not recognise a future status still ends the journey correctly.
Never treat a `200` as evidence of a send — `cooldown`, `already_verified` and
`not_required` are all `200` with nothing sent.

`canChangeEmail` is always `false` today: `PATCH /api/me` rejects `email`, so
only an admin can move an address. It flipping to `true` is the signal that a
self-service endpoint has landed.

If the account is organization-enabled, it can manage organization member emails:

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/api/me/org/members` | `{email}` | `{org}`; existing users link immediately, otherwise pending; emails the invitee when email is configured |
| DELETE | `/api/me/org/members/:email` | — | `{org}` |
| DELETE | `/api/me/org-memberships/:orgId` | — | `{user}`; leave an organization you belong to |

Organization members are effective `editor`s on every board owned by the org
account, including future boards. Removing them revokes that computed access.

## Tokens

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/tokens` | — | `{tokens:[{id,name,createdAt,lastUsed}]}` |
| POST | `/api/tokens` | `{name}` | `{id,name,token}` — verified email required; `token` shown once |
| DELETE | `/api/tokens/:id` | — | `{ok:true}` |

## Billing (Stripe)

Subscription billing is optional and enabled only when
`FLUX_STRIPE_SECRET_KEY`, `FLUX_STRIPE_WEBHOOK_SECRET`, and a trusted HTTPS
origin (`FLUX_PUBLIC_URL` or `FLUX_CANONICAL_HOST`) are all configured. Billing
redirects never derive from the request Host. Plans map to account levels via
Stripe price `lookup_key`s: `flux_starter_monthly` →
`starter`, `flux_team_monthly` → `team`, `flux_agent_monthly` → `agent`.

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/billing` | — | `{enabled, level, subscribed, subscriptionStatus, plans}` |
| POST | `/api/billing/checkout` | `{level}` | `{url}` — redirect to Checkout; or `409 {error,subscribed:true,action:"portal"}` if a subscription activated concurrently |
| POST | `/api/billing/portal` | — | `{url}` — redirect to the Stripe billing portal |
| POST | `/api/billing/webhook` | Stripe event | `{received:true}` — authenticated by `Stripe-Signature`, not a session |

Checkout uses a fenced per-account owner and a durable attempt ledger whose
customer/session idempotency keys are written before network I/O. Attempt rows
remain available after Stripe's key-retention horizon. A concurrent request
returns `409`; replay after the session is ready returns the same URL. Flux
reconciles subscriptions before and after session creation, then performs a
final reconciliation while holding the response fence. If activation commits
before the response, Flux expires the new session, records the terminal reason,
and returns `409 {error,subscribed:true,action:"portal"}` without a URL. If
activation commits only after a successful `{url}` response was sent, the
webhook expires every open Checkout Session owned by the account before
committing entitlement, making that previously returned URL unusable. Ready
sessions expire after at most 24 hours.
Before replacement, Flux retrieves the session and subscriptions. Provider
500s, process death, and stale owners are reconciled through exhaustive
metadata/client-reference listing; a new persisted attempt key is used only
after the retention horizon and authoritative absence. Provider requests are
bounded below the owner lease so an in-flight request cannot outlive its owner.
Legacy intents without the new metadata are
reconciled through their durable customer and client-reference ownership.

For every state-changing webhook, Flux retrieves all current subscriptions for
the customer and derives entitlement from that authoritative set; payload
arrival order and event-id text are never treated as chronology. `active`,
`trialing`, and `past_due` subscriptions retain access. If several exist, the
highest known entitlement (`agent`, `team`, then `starter`) wins, with
subscription id used only for a deterministic same-level tie. A deletion cannot
revoke another active subscription. Event id and `created` are required for
audit/idempotency, but not ordering; processed event ids are acknowledged once. Failed,
incomplete, unknown-plan, ambiguous, unknown-customer, or mismatched-customer
reconciliation returns non-2xx without changing entitlement or consuming the
event. Levels assigned manually by an admin are untouched unless the user has a
Stripe subscription. A durable per-customer lease serializes provider
fetch-and-commit reconciliation across server processes.

## Templates

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/templates` | — | `{templates}` — default, personal, and visible organization templates |
| POST | `/api/templates` | `{boardId,name,scope?}` | `{template}` — saves a board's stages/custom fields as personal or org template |
| DELETE | `/api/templates/:id` | — | `{ok:true}` — personal/org templates only |

Pass `templateId` to `POST /api/boards` to create a board from a visible template.

The web importer also accepts CSV files with headers such as `title`, `stage`,
`notes`, `priority`, `tags`, `start`, `end`, `due` (legacy end date), `done`,
`size`, and `color`; board menus can export a board's tasks in the same CSV shape.

## Workflows (agent API)

Workflows are account-owned state graphs. Account owners manage definitions;
eligible editors can attach/use them; viewers can read safe transition labels.
The built-in **Default** workflow is synthetic: an open task exposes `complete`
and a completed task exposes `reopen`, while storing no workflow row or node.

| Method | Path | Body / result |
|---|---|---|
| GET | `/api/accounts/:accountId/workflows` | `{workflows:[summary]}`; active visible workflows |
| POST | `/api/accounts/:accountId/workflows` | definition JSON; owner only; `201 {workflow}` |
| GET | `/api/accounts/:accountId/workflows/:workflowId` | `{workflow}` |
| PUT | `/api/accounts/:accountId/workflows/:workflowId` | full definition plus `ifVersion` |
| DELETE | `/api/accounts/:accountId/workflows/:workflowId` | soft delete; `{ok,affectedTasks}` |
| PUT | `/api/boards/:bid/tasks/:tid/workflow` | `{workflowId|null,ifVersion}`; reset to start/Default. Returns `{task}` **wrapped** — `200` and the `409` stale-`ifVersion` body alike |
| GET | `/api/boards/:bid/tasks/:tid/transitions` | current node and safe outbound labels; no mutation JSON |
| POST | `/api/boards/:bid/tasks/:tid/transitions/:name` | atomic transition request below |
| POST | `/api/boards/:bid/tasks/:tid/workflow-error/dismiss` | `{ifVersion,errorId}`. Returns `{task}` **wrapped** — `200`, and `409` (also wrapped) when the version or the error id is stale |

Both task-scoped rows above are single-task endpoints and follow the same rule
as the rest: the task is under `.task`, never at the top level, on success and
on conflict. The `422` from a failed transition is a different shape — it
carries `task`, `code`, and `error` together — so read that one field by field
rather than assuming the envelope. See [Response envelopes](#response-envelopes).

`POST /api/boards/:bid/tasks` accepts optional `workflowId` and always starts a
custom task at the workflow's current `start`. It never accepts a node or error.
Direct legacy task edits remain legal and do not advance a custom workflow.

Definitions contain 1–200 uniquely named nodes and up to 500 uniquely named
transitions (50 ordered mutations each). Node/transition identities match
`[A-Za-z0-9][A-Za-z0-9._-]*`; cycles, self-loops, disconnected nodes, and
nodes with no outbound transitions are valid. Supported mutations are:

```json
{"type":"move","boardId":"board UUID","stageId":"stage UUID"}
{"type":"set","field":"priority","value":"high"}
{"type":"setCustomField","field":{"name":"Decision","type":"select"},"value":"Approved"}
{"type":"deleteTask"}
```

`set` allows `title`, `notes`, `priority`, `size`, `color`, `tags`, `start`,
`due`, `recurrence`, `url`, `repo`, `done`, `assigneeId`, `blockedBy`, and
`previewImage`. `deleteTask` may occur once and must be last. Custom fields use
NFKC/case-folded/trimmed name plus exact type; cross-board moves preserve one
unambiguous compatible value and drop missing, ambiguous, type-incompatible, or
option-incompatible values with an audit reason. Dependencies block
cross-board moves. All visited boards must have the same owner account and be
editable by the caller.

Trigger custom workflows with:

```json
{
  "ifVersion": 12,
  "ifNode": "review",
  "ifWorkflowVersion": 3,
  "requestId": "client-generated UUID"
}
```

Default requests omit `ifWorkflowVersion` (JSON `null` is equivalent; a
non-null sentinel is invalid). The server CASes task version + node + workflow
version, simulates mutations in order, and commits all domain changes and the
node advance or none. Two callers from one state produce one winner and one
`409 task_version_conflict`. Reusing a request ID with changed inputs returns
`409 idempotency_key_reused`.

Exact retries replay the stored HTTP status and body without duplicate task
changes, recurrence children, revisions, events, audits, or webhooks. Runs are
looked up before tasks, so a successful delete transition can be retried after
the task row is gone. Current source-board edit authority is sufficient even if
destination access was later revoked; callers without current source-board or
account authority still receive non-leaking `404`.

Organization transitions write one combined task-audit row containing ordinary
field changes and `workflowTransition` metadata. A post-rollback error write
rechecks source-board edit authority in its new transaction before changing the
task. If another writer moved the task, the late conflict includes the current
task only when the actor can see its new board; otherwise it returns non-leaking
`404`.

Expected runtime failures persist a read-only, dismissible task error and return
`422` with stable `code`, `workflowError`, current `task`, `runId`, and
`requestId`. Codes are `workflow_deleted`, `missing_node`,
`missing_transition`, `missing_board`, `missing_stage`, `invalid_fixed_value`,
`missing_custom_field`, `ambiguous_custom_field`,
`incompatible_custom_value`, `invalid_assignee`,
`cross_board_dependency`, and `mutation_failed`. A late storage failure is
rolled back before an error-write CAS; losing that CAS returns `409`, never
overwrites the winner, and never reports a stale `500`.

Authenticated task reads add `workflowId`, `workflowNode`, `workflowVersion`,
`error`, and `workflowError`. Public board reads use an allow-listed task
projection and expose only `workflowNodeLabel` plus the generic display error
“This task's workflow needs attention.” They never expose workflow IDs,
versions, node identities, machine errors, mutation indexes, run/request IDs,
or raw technical messages.

The web task editor mirrors this API: a task on a custom workflow shows its
current node's transition buttons in the editor footer instead of the ordinary
Complete action, triggers them with the same versioned CAS, and renders the
task's `workflowError` as a read-only banner (plus a warning badge on the card)
with the dismiss operation above. Default/no-workflow tasks keep Complete.

Workflow deletion is soft and marks all live tasks with `workflow_deleted`.
Definition edits may remove live nodes/transitions: tasks retain old node
identity and receive `missing_node`/`missing_transition` on the next stale
attempt. Set `FLUX_WORKFLOWS=0` to disable workflow management, attach, trigger,
and dismiss mutations with `503`; reads remain available.

Transition-generated webhook order is intentionally stable:
`task.left_stage`, `task.moved`, `task.entered_stage`, `task.updated`,
`task.workflow_transitioned`; delete emits `task.deleted` then
`task.workflow_transitioned`. Ordinary `PATCH task` keeps its historical order.

Workspace JSON format v5 bundles every visible account workflow once for a full
workspace export; single-project exports bundle workflows referenced by that
project. Import remaps board, stage, workflow, and task references and preserves
each task's node and machine error under the fresh workflow ID. Organization board
backup format v2 embeds definitions referenced by that board; restore reuses an
identical same-account definition or clones/remaps it without overwriting a
different definition. Legacy JSON migration/export also preserves definitions,
task node state, and errors. Older payloads with no workflow bundle create
Default tasks; CSV and Trello imports always create Default tasks.

Portable board/workspace/account documents and CSV exports carry native notes as
`notesJson` plus an id-independent `notesChecksum` and `notesLossy`. Import
prevalidates the complete note set, deterministically remaps note ids, writes and
verifies in one repository transaction, and rejects a `notesJson` legacy shadow
that disagrees with the task's legacy `notes` string. The legacy string remains a
one-note projection; `notesLossy:true` is the explicit warning that it cannot
represent the whole set. Portability remains active while `FLUX_TASK_NOTES` is
off so a rollback cannot discard stored notes; ordinary note list reads/writes
remain disabled. CSV columns are `notes_json`, `notes_checksum`, and
`notes_lossy`. Trello card descriptions import as exactly one Trello-origin
legacy-shadow note.

## Boards

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards` | optional `?archived=1` | `{boards:[summary]}` — boards you can access; archived boards are hidden unless requested |
| POST | `/api/boards` | `{id?, name, emoji?, color?, stages?, view?, date?, templateId?}` | `{board, role:"owner"}` (`200` on an idempotent replay — see [Client-supplied ids](#client-supplied-ids)) |
| GET | `/api/boards/:id` | — | `{board, rev, role}` (full, with stages + tasks) — tasks are **bare** inside `board.tasks`; see [Response envelopes](#response-envelopes) |
| GET | `/api/boards/:id/rev` | — | `{rev}` — cheap; used for polling |
| GET | `/api/boards/:id/export` | — | `{board}` — editor+ portable document used by workspace/CSV export; includes checksummed `notesJson` |
| GET | `/api/boards/:id/backup` | — | `{backup}` — owner only, organization-owned boards only |
| POST | `/api/boards/:id/restore` | backup JSON | `{board}` — owner only, organization-owned boards only |
| PATCH | `/api/boards/:id` | `{name?, emoji?, color?, view?, date?, description?, url?, background?, customFields?, archived?, public?}` | `{board}` — editor+; `public` owner only and requires `canPublicBoards` |
| PUT | `/api/boards/:id` | `{name?, emoji?, color?, view?, date?, description?, url?, background?, stages, tasks}` | `{rev}` — editor+; whole-board sync, send `If-Match: <rev>`, `409 {board, rev}` on conflict (see [Whole-board sync](#whole-board-sync)) |
| DELETE | `/api/boards/:id` | — | `{ok:true}` — owner only |
| POST | `/api/boards/:id/pop` | `{holder?, ttlSeconds?, capabilities?, stageIds?}` | `{task}` **wrapped** — selects the highest-priority available task (never one tagged `umbrella`), claims it, and returns it; `stageIds` restricts the search to an explicit set of stages (see [Draining across several stages at once](#draining-across-several-stages-at-once)); `400` if the body contains an unsupported field or a malformed `stageIds`, `404` `STAGE_NOT_FOUND` if a listed stage is not on this board |
| POST | `/api/boards/:id/tasks/:tid/claim` | `{holder?, ttlSeconds?}` | `{task}` **wrapped** — claims one specific task cooperatively; `404` if it does not exist, `409 {task}` (also wrapped) if already claimed |

Every row above that returns `{task}` returns it **wrapped** — the task is under
`.task`, never at the top level, and the same holds for the `409` bodies. Board
reads (`GET /api/boards/:id`) return **bare** tasks inside `board.tasks`. See
[Response envelopes](#response-envelopes).

### Whole-board sync

`PUT /api/boards/:id` **replaces** the board document: a stage or task the
document omits is deleted. Send `If-Match: <rev>` with the `rev` from the `GET`
the document was built from, and the replacement becomes a compare-and-swap.

The comparison is atomic. The expected revision is re-checked against the live
board row inside the same transaction that performs the replacement, so a task,
stage, or metadata write that commits after your `GET` — even one that commits
while this very request is in flight — produces a conflict rather than being
silently overwritten. Nothing is written on a conflict, and the revision does
not move.

- **Conflict** → `409 {board, rev}`, carrying the *current* board and revision
  so a client can merge and retry without a second round trip.
- **Malformed header** → treated as a conflict, not a `400`. An expectation the
  server cannot parse is one it cannot honour, so it fails closed. The header is
  *parsed*, not numerically coerced: the only accepted form is a bare decimal
  integer, exactly as `rev` is returned to you. An empty or whitespace-only
  value, a sign (`+1`, `-0`), a leading zero (`01`), a fraction (`1.0`), another
  radix (`0x2`), exponent notation (`2e0`), an ETag (`W/"3"`, `"3"`), and two
  `If-Match` headers on one request are all malformed and all conflict.
- **No header** → the check is skipped and the document replaces the board
  unconditionally. This is the last-write-wins compatibility path; prefer
  sending `If-Match`.

  Sending `If-Match:` with an empty value is **not** the same as omitting it. An
  empty header is a malformed expectation and conflicts; only an absent header
  takes the compatibility path. (Through Flux 0.x these were the same thing:
  the empty value coerced to `0`, which is a newly created board's revision, so
  a blank expectation satisfied the compare-and-swap and could delete the
  stages and tasks a document omitted. A client that relied on that is relying
  on a bug — omit the header instead.)

Agents should prefer stage-scoped `POST /api/boards/:id/stages/:sid/pop` (or the
singular alias `/api/boards/:id/stage/:sid/pop`) when processing a stage queue
because it avoids fetching and sorting the full task list on every claim. When
the agent finishes or abandons the task, it should call
`POST /api/boards/:id/tasks/:tid/unlock` to release the claim.

### Draining across several stages at once

An agent that drains more than one stage should **not** do it by calling the
stage-scoped `pop` once per stage. Popping Bugs, then Features, then Marketing
ranks by *stage* first and priority second, because each call only ever sees one
stage. A `low` Bugs card is therefore claimed ahead of a `high` Features card
every single time, and the board's priority column stops meaning anything.

`POST /api/boards/:id/pop` takes an optional **`stageIds`** — an array of stage
ids on this board — that restricts the search to those stages while leaving
selection board-wide *within* them:

```bash
curl -s -H "$AUTH" -H "$CT" -X POST "$BASE/api/boards/$BID/pop" \
  -d '{"holder":"worker-vm","stageIds":["<Bugs>","<Features>","<Marketing>"]}'
```

Ranking inside the set is **priority first** (`high`, `medium`, `low`, none),
then the board's canonical stage order, then task order. The stage order in
your request is irrelevant — the tie-break is the board's own column order, so
listing the stages differently cannot reintroduce a fixed stage order.

The returned task **keeps its stage**; `pop` selects across stages but never
moves the card. That matters to agents that decide how to work from the stage
a task sits in.

Everything else about `pop` is unchanged inside an allowed set: done, blocked,
`locked`, `umbrella` and unmet-`needs:*` tasks are excluded exactly as before,
the claim and lease work the same way, and the response is the same wrapped
`{task}`. A drained set is the ordinary `404 NO_AVAILABLE_TASK`.

`stageIds` is rejected, not ignored, when it cannot be honoured exactly:

| Request | Answer |
|---|---|
| Field omitted | The whole board is in scope — today's behaviour, unchanged |
| `null`, a non-array, a non-string or empty-string entry, or more than 100 entries | `400` naming the field |
| `[]` | `400` — an empty list is neither "every stage" nor "no stage", and guessing either silently is wrong in a way the caller cannot see |
| An id belonging to another board, or to no board | `404` `STAGE_NOT_FOUND` |
| Duplicate ids | Accepted; duplicates are ignored |

The 404 is what keeps a stale stage id from becoming a silently narrower queue:
a drainer whose Features id is out of date would otherwise drain Bugs only and
report a healthy, empty board. Validation and selection share one transaction,
so a rejected `pop` claims nothing.

`stageIds` is accepted on the **board-wide route only**. The stage-scoped
routes already carry their stage in the path, so they reject it as an
unsupported field rather than accept and ignore it.

Because that rejection is a `400`, do not discover support by sending one:
a drainer that may talk to an older deployment reads `pop.stageIds` from
[`apiCapabilities`](#deployment-preflight) first and falls back to
per-stage pops when it is absent. Probing with a real `pop` is worse than
useless here — the request that tells you the server is too old is also the
request that claims a task from a stage you did not mean to drain.

**An empty-handed `pop` is a `404`, and so is a wrong stage id. Branch on
`code`, never on the message.**

| Body | Means | Do |
|---|---|---|
| `404 {"error":"no available task","code":"NO_AVAILABLE_TASK"}` | This queue exists and has nothing to hand out | Ordinary end of a drain — go to the next stage, or stop |
| `404 {"error":"stage not found","code":"STAGE_NOT_FOUND"}` | This board has no stage with that id | The stage id is stale — re-read `GET /api/boards/:id/stages` before retrying |

`STAGE_NOT_FOUND` is the same answer on every route that takes a stage id
(`GET .../stages/:sid/tasks`, `pop`, `POST .../tasks/:tid/move`, stage `PATCH`
and `DELETE`, inbound route writes), and it means exactly what the board graph
says: a stage is missing from `GET /api/boards/:id` if and only if these routes
answer `STAGE_NOT_FOUND` for it. A stage being created or dropped concurrently
— which is what a mail board's route refresh does — will change that answer
between two requests, since each read is its own request; the `code` is what
lets a client tell that apart from a fault and react to it. The `error` strings
are unchanged, so existing clients that match on them keep working.

**Whole-board sync deletes what it omits — except a routed mail stage.**

`PUT /api/boards/:id` is a *replace*, not a merge: a stage missing from the
document is deleted, and its tasks go with it. Two omissions are refused instead
of applied, and both are about live mail.

The first is **a stage that an inbound mail route points at**. Dropping such a
stage destroys three things at once and reports none of them: the stage, the
mail delivered into it, and the routing rule itself, which cascades off the
stage. The board does not even look broken afterwards, because the next message
for that domain finds no rule and creates a stage with the *same name* and a
*new* id — so the board graph shows a plausible domain stage while every id
anyone recorded for it answers `STAGE_NOT_FOUND` from then on. That is the other
half of the `pop` story above: a stage that "disappears" this way was destroyed
by a write, not by a read disagreeing with itself.

The second is **a message delivered into a routed stage the document keeps**.
Carrying the stage is not enough: mail that arrived after your `GET` is missing
from your document for the same reason the stage would have been, and omitting a
task deletes it. That deletion does not leave the message redeliverable, which
is what makes it worse than an ordinary lost task — the delivery receipt
outlives the task it pointed at, so the message stays recorded as delivered and
every later delivery of the same provider id is answered from that receipt as a
duplicate referring to nothing.

A document is only as fresh as the `GET` it came from, and mail can arrive
between the two, so any correct client can produce either by syncing a board it
read a moment too early. Both are therefore treated as stale writes:

| Body | Means | Do |
|---|---|---|
| `409 {"board":…,"rev":N}` | Your `If-Match` is behind | Refetch, merge, retry — unchanged |
| `409 {"error":"routed stage missing","code":"ROUTED_STAGE_MISSING","routes":[{"routingKey":…,"stageId":…}],"board":…,"rev":N}` | Your document omits a stage that is receiving mail | Same remedy: refetch, merge, retry. `routes` names what you were about to erase |
| `409 {"error":"routed task missing","code":"ROUTED_TASK_MISSING","tasks":[{"routingKey":…,"stageId":…,"taskId":…}],"board":…,"rev":N}` | Your document keeps the routed stage but omits mail delivered into it | Same remedy. `tasks` names the messages you were about to erase |

All three carry `board` and `rev`, so a client that already handles the
`If-Match` conflict handles these with no change; `code` is additive and is the
only way to tell them apart. The two mail codes are distinct because the merge
differs — one says refetch the stage list, the other says refetch the tasks.

The routed-task refusal covers **delivered mail still sitting in a routed
stage**, and nothing else. A task created by hand in that stage carries no
delivery receipt, and mail a drainer has already moved out of the stage is an
ordinary task again; both stay freely replaceable by a whole-board write. Moving
mail out of the stage is a *carried* task, not an omitted one, so ordinary
filing and draining through a board sync are unaffected.

The refusal does not weaken with use. A whole-board write rebuilds the board —
stages are deleted and the document's tasks are reinserted under their original
ids — and each delivered message's receipt is carried across that rebuild and
rebound to its recreated task. So a board that has been synced any number of
times still refuses the next stale document, and a redelivery of an
already-delivered provider id still resolves to the live task rather than
reporting a duplicate that refers to nothing. A receipt whose task the document
genuinely drops is released, which is what deleting delivered mail has always
meant.

To remove either **deliberately**, use the endpoints that say so: repoint the
rule with `PUT /api/boards/:id/inbound/routes/:routingKey` or
`DELETE /api/boards/:id/stages/:sid` for a stage, and
`DELETE /api/boards/:id/tasks/:tid` for a message.
`POST /api/boards/:id/restore` goes through the same write and answers with the
same bodies, `board` and `rev` included, so a backup taken before a domain's
stage existed — or before its latest mail arrived — cannot silently undo the
mail delivered since.
### Guarded transitions

A move is the mutation two agents are most likely to race. An agent reads a
card, decides, and writes several round trips later; anything can claim the card
in between, and there is no way to hold a transaction open across HTTP. So
`move` and `unlock` take optional **preconditions**, checked inside the same
transaction that performs the write:

| Guard | On | Meaning |
| --- | --- | --- |
| `ifVersion` | move | the task is still at this `v`. `pop`, `claim` and `unlock` bump it; a plain move or unguarded PATCH does not |
| `ifStageId` | move | the task is still in this stage |
| `ifUnblocked` | move | no blocker of this task is still open |
| `ifLockHolder` | move, unlock | the live claim is held by exactly this holder; `""` asserts there is no live claim |

Any guard that does not hold returns `409` with the current task — the same
shape `pop`, `claim` and PATCH `ifVersion` already return, so existing conflict
handling covers it. Supplying no guard is exactly the previous behaviour, which
is why every drain loop that posts an empty unlock body keeps working.

`ifUnblocked` tests the blockers' `done` flags and never the length of
`blockedBy`: a lifted gate keeps its edge behind as history, so a count-based
test would block a card forever.

The pattern these exist for is *claim, then write under the claim*: take the
card's own cooperative claim so no `pop` or `claim` can succeed underneath you,
then carry `ifLockHolder` on every write so each one asserts the claim is still
yours. `scripts/reconcile-handoffs.mjs` is the worked example.

A claim **expires**. `holder` is a free-form id for whoever is taking the task
and `ttlSeconds` is how long the claim should last (default 1 hour, maximum 24
hours); both are optional, so an existing `{}` call keeps working. The claim is
reported on the task as `lock`:

```json
"lock": { "holder": "worker-flux", "acquiredAt": 1785110400000, "expiresAt": 1785114000000, "expired": false }
```

`lock` is `null` when the task is free. `pop` releases claims whose `expiresAt`
has passed and may hand that task to the next caller, so an agent that dies
mid-task does not remove the task from the queue permanently. Call `pop` again
before the deadline, or pass a longer `ttlSeconds`, if the work outlives it.
The `locked` tag still appears for the UI, but it is cosmetic: the claim above
is what `pop` honours. A `locked` tag with no claim behind it (set by hand, or
left by a client older than this) is adopted on the next `pop` and given the
default TTL, so it is respected once and then released.

**Capability routing.** `capabilities` is an optional array of strings naming
what the caller can do (e.g. `["macos"]`). `pop` skips any task carrying a
`needs:<capability>` tag the caller did not declare, so a seat is never handed
work it cannot do — the fix for a loop seat that popped a `needs:macos` task on
a Linux host, released it, and popped the same task forever. A task with no
`needs:*` tag is unaffected, and a caller that declares nothing behaves exactly
as before for ordinary work, so existing `{}` callers are not broken; they
simply stop being served `needs:*` work they cannot perform. Matching is
case-insensitive, and **all** of a task's `needs:*` tags must be satisfied for
it to be handed out. Capabilities apply only to `pop`; an explicit `claim`
targets one task by id and ignores them.

**Umbrella tasks are never popped.** A task tagged `umbrella` tracks other
tasks; it describes no change an agent could make. `pop` skips it — board-wide
and stage-scoped, regardless of priority or declared capabilities — because
serving one costs a full agent session that ends in the same card being served
again. The skip is invisible everywhere else: the task keeps its stage, its
`done` flag, and its tags, still appears in stage and task reads (a board's
footprint depends on it), and `claim` still takes it by id for a human or a
Director who wants it deliberately. Only the exact tag counts, so `umbrella-ui`
or `umbrella:parent` is an ordinary tag on ordinary work.

`pop` **rejects an unrecognised field with `400`** rather than silently
discarding it — the accepted keys are `holder`, `ttlSeconds`, and
`capabilities`, plus `stageIds` on the board-wide route. This fails closed: a
transposed `capabilties` or a singular
`capability` would otherwise be dropped, disabling capability routing while
still returning `200`, and hand a `needs:macos` task to a caller that believed
it declared the capability. A bare `{}` (or no body) has no fields and is
always accepted.

**Claiming a specific task.** `POST /api/boards/:id/tasks/:tid/claim` claims one
named task the same cooperative way `pop` claims the top one — same `holder`,
`ttlSeconds`, TTL bounds, and stale-lock adoption — and is the verb that lets a
caller take a task *other* than the top one (or one `pop` never serves, such as
a task in a Review stage) without hand-editing the `locked` tag. It returns
`404` if the task does not exist and `409 {task}` (carrying the current task,
including its `lock` holder) if the task is already claimed by a live holder, so
two agents claiming the same task collide loudly instead of silently
duplicating work. Release it with the same `unlock` endpoint as a popped task.

A board **summary** is `{id, name, emoji, color, view, date, description, url, background, archived, public,
role, ownerId, ownerName, ownerEmail, orgOwned, orgName, rev, stages (count),
tasks (count), done (count), members, viewers, inbound:{enabled}}`. The inbound
status is present only on authenticated board responses, never public graphs.
`publicGraceUntil` (see [Public boards](#public-boards)) is present only for
readers whose role is `owner` or `editor`, on both summaries and board graphs.
`stages` on create is an
optional array; each entry is either a plain name string or a
`{id?, name}` object (a supplied `id` becomes that stage's id — see
[Client-supplied ids](#client-supplied-ids)). Defaults to
`["Backlog","In Progress","Done"]`.
`view` ∈ `board | list | timeline | calendar | files` controls the default board
lens; default `board`. `date` is an optional project/board date in epoch
milliseconds. Dated projects appear on calendar views as project events.
`description` is rendered below the title row. `url` makes the project title open
that link. `background` is `{light?, dark?, blur?}` for theme-specific project
background image URLs, with `blur` clamped from 0 to 40 pixels.
Board creation is limited by the owner's level (`maxBoards`) and returns `403`
with a friendly quota message when the cap is reached.
Boards may define `customFields:[{id,name,type,options?}]`, where `type` is one of
`text | number | date | select | multi-select | checkbox | url`. Task values are
stored in `task.customFields` by field id.

### Client-supplied ids

Create endpoints accept an optional client-generated `id` so offline clients can
mint ids locally and replay creates safely: `POST /api/boards` (`body.id`, plus
per-stage `stages:[{id?,name}]`), `POST /api/boards/:id/stages` (`body.id`),
`POST /api/boards/:id/tasks` (`body.id`), and
`POST /api/boards/:id/tasks/:tid/checklist` (`body.id`).

- The id must be a UUID (`8-4-4-4-12` hex, case-insensitive); it is stored
  lowercased. A malformed id returns `400 {"error":"id unavailable"}`.
- **Idempotent replay** — if the id already exists in the expected scope (a board
  you own; a stage/task in that board; an item on that task), the endpoint returns
  `200` (not `201`) with the existing entity and **no side effects** (no rev bump,
  events, quota consumption, audit rows, or template re-copy). Request body fields
  are **ignored** on a replay: creation is at-most-once, never create-or-update.
- **Out-of-scope collision** — if the id exists but not in the expected scope
  (another owner's board, a stage/task/item belonging to a different board/task),
  the request returns the same uniform `400 {"error":"id unavailable"}`. When a
  supplied stage id inside `POST /api/boards` collides, the whole board create is
  rolled back.

## Members

| Method | Path | Body | Returns | Who |
|---|---|---|---|---|
| GET | `/api/boards/:id/members` | — | `{members:[{id,name,email,role}]}` | any member |
| POST | `/api/boards/:id/members` | `{email, role?}` | `{members}` | owner |
| PATCH | `/api/boards/:id/members/:userId` | `{role}` | `{members}` | owner |
| DELETE | `/api/boards/:id/members/:userId` | — | `{ok:true}` | owner (anyone) or self (leave) |
| GET | `/api/boards/:id/members/pending` | — | `{invites:[{email,role,addedAt}]}` | owner/admin only |
| DELETE | `/api/boards/:id/members/pending/:email` | — | `{members}` | owner/admin only, idempotent |

`role` ∈ `viewer | editor` when inviting/changing (you can't grant `owner`). The
invited email must already belong to a Flux user (`404` otherwise). If that
user isn't yet `email_verified`, `POST` holds the invite pending (mailbox proof
required before board access) rather than granting access immediately; it
activates atomically the moment the invitee verifies. Pending invites are
listable/cancellable only by the board owner (or a Flux admin) via the
`/pending` routes above — ordinary members never see who's been invited but
hasn't verified. Deleting the invited (still-unverified) account invalidates
its pending invite; a later registration that reuses the same email address
does not inherit it.

## Webhooks

Board editors can configure signed webhooks for board-wide or stage-scoped task
events. Requests are delivered asynchronously with bounded retries. Delivery
requests include `X-Flux-Event`, `X-Flux-Delivery`, `X-Flux-Timestamp`, and
`X-Flux-Signature: sha256=<hmac>` where the HMAC input is
`<timestamp>.<json-body>` and the secret is returned only when the webhook is
created.

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/webhooks` | — | `{webhooks}` |
| POST | `/api/boards/:id/webhooks` | `{url, events, stageId?, active?}` | `{webhook}` with one-time `secret` |
| PATCH | `/api/boards/:id/webhooks/:webhookId` | `{url?, events?, stageId?, active?}` | `{webhook}` |
| DELETE | `/api/boards/:id/webhooks/:webhookId` | — | `{ok:true}` |
| GET | `/api/boards/:id/webhook-deliveries?webhookId=<id>` | — | `{deliveries}` |

Events are `task.created`, `task.updated`, `task.deleted`, `task.moved`,
`task.entered_stage`, `task.left_stage`, `task.note_created`,
`task.note_updated`, and `task.note_deleted` (plus workflow events documented
above). Note event payloads are an explicit metadata allow-list with at most
2,000 Unicode code points of body and a `bodyTruncated` flag; they never contain
the task graph, audit log, or revision history. Webhook URLs use the same SSRF
guard as link previews by default; self-hosted deployments can set
`FLUX_WEBHOOK_ALLOW_PRIVATE=1` to allow private-network destinations.

## Organization inbound email (Resend Receive)

Organization board owners can configure a signed Resend Receive endpoint.
Personal boards, organization editors, and viewers cannot access these routes.
Secrets are write-only and AES-GCM encrypted with `FLUX_SECRET_KEYS`; GET/list
responses never serialize them. `FLUX_PUBLIC_URL` must be a trusted HTTPS
origin. Self-hosted instances additionally require `FLUX_INBOUND_SELF_HOSTED=1`
and otherwise fail closed.

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/inbound` | — | `{inbound}` with masked credential state and queue counts |
| PUT | `/api/boards/:id/inbound` | `{resendApiKey?, signingSecret?, receiveMatch?, filterMode?, filterList?, maxAttachmentBytes?, allowedAttachmentTypes?, retentionDays?, active?}` | `{inbound}` |
| POST | `/api/boards/:id/inbound/rotate` | `{what:"endpoint"}` or `{what:"secret", signingSecret}` | `{inbound}`; use the provider-generated Svix secret |
| POST | `/api/boards/:id/inbound/reseal` | — | `{ok:true, activeKeyId, resealed}` |
| GET | `/api/boards/:id/inbound/jobs` | — | `{jobs}` including redacted dead-letter errors |
| GET | `/api/boards/:id/inbound/routes` | — | `{routes, unroutedDomainStages, stageNameCollisions, stageTaskCap}` — route health plus domain-shaped stages missing their explicit route |
| PUT | `/api/boards/:id/inbound/routes/:routingKey` | `{stageId, moveTasks?, dropSourceStage?}` | `{created, route, movedTaskCount, previousStage}`; `201` when the routing key was new |
| DELETE | `/api/boards/:id/inbound/routes/:routingKey` | — | `{ok:true}`; removes the mapping only |
| DELETE | `/api/boards/:id/inbound` | — | `{ok:true}`; existing tasks/files remain |
| POST | `/api/inbound/:endpointId` | raw Resend JSON + `svix-id`, `svix-timestamp`, `svix-signature` | `{queued:true}`, `{duplicate:true}`, or `{ignored:true}` |

The public endpoint authenticates the **raw request body** with Svix HMAC-SHA256
and a five-minute timestamp tolerance. Replayed Svix ids and repeated Resend
email ids are deduplicated transactionally. Unknown, disabled, or
recipient-mismatched endpoints return 200 and do no work.

Endpoint and signing-secret rotation keep the previous value valid for a
24-hour delivery grace period. Flux never invents a provider secret: rotate the
webhook in Resend, then submit the new `whsec_…` value.

Encryption-key retirement is separate from provider-secret rotation. Add the
new key to `FLUX_SECRET_KEYS`, select it with `FLUX_SECRET_ACTIVE_KEY_ID`, restart,
then call `POST /api/boards/:id/inbound/reseal` once for every configured board
owned by the caller (including inactive integrations). Each call opens and
re-seals all two or three stored credentials in one transaction and reports the
active key id and count. `INBOUND_RESEAL_FAILED` identifies the credential field
that could not be opened; no ciphertext is changed on failure. Remove an old key
from the keyring only after every configured board reports success and restart
again. Calls are bounded to one board and at most three ciphertexts.

Processing is asynchronous and leased. Jobs retry with bounded exponential
backoff, then remain visible as dead letters until the board's 1–365 day
delivery-record retention expires. A new verified delivery for a dead email id
redrives that job. Admission is backpressured with 429 at 1,000 active jobs or
16 MiB of queued event data per board (10,000 jobs or 128 MiB server-wide), and
pending jobs older than their retention window are dropped without provider
fetches. Retention covers webhook/job/dedupe records; created board tasks and
files remain until a board member deletes them.

Inbound bodies and attachments are untrusted data. Flux stores a plain-text body
with Resend email/message provenance, never executes message HTML, forces unsafe
file types to download, and preflights sender filters and full mailboxes before
fetching provider bodies or attachments. Every stage on an inbound-enabled board
is capped at 500 total tasks. Attachments use the configured byte/type allowlist
up to the global 15 MiB and 25-attachment limits.

### Inbound routing table

A routing key is either an **exact recipient address** (`ghostpace@fluxtask.org`)
or a **recipient domain** (`fluxtask.org`). Delivery looks up the normalized
recipient first and falls back to the domain, so mapping one address diverts
exactly that local part and leaves every other one on the domain route. The two
kinds cannot collide — an address key always contains `@` and a domain key never
does — so `kind` is derived from the key itself and routes stored before exact
keys existed keep working unchanged.

Only the **domain** fallback is ever created automatically. An unmapped local part
lands in the domain's stage; Flux never conjures a stage per correspondent. Exact
routes exist only where an operator wrote one.

`GET /api/boards/:id/inbound/routes` answers, without database access, where each
recipient's mail actually lands. Delivery creates a domain route the first time a
domain is seen, adopting the stage already named for that domain or creating one;
the route then decides every later delivery, so a route pointing at the wrong
stage means mail is received and silently lost.

```json
{
  "routes": [{
    "routingKey": "fluxtask.org", "kind": "domain",
    "stageId": "67f2c65a-…", "stageName": "fluxtask.org-6cc1123c",
    "stageTaskCount": 12, "mailboxFull": false,
    "matchesRoutingKey": false, "generatedShadow": true,
    "generatedShadowName": "fluxtask.org-6cc1123c",
    "webhookCount": 0, "stageNameCollision": false,
    "namedStage": { "id": "1febd379-…", "name": "fluxtask.org", "taskCount": 3 },
    "movableTaskCount": 12, "movableFitsNamedStage": true
  }, {
    "routingKey": "ghostpace@fluxtask.org", "kind": "address",
    "stageId": "3317a67e-…", "stageName": "GhostPace",
    "stageTaskCount": 4, "mailboxFull": false,
    "matchesRoutingKey": false, "generatedShadow": false,
    "generatedShadowName": null,
    "webhookCount": 0, "stageNameCollision": false,
    "namedStage": null,
    "movableTaskCount": 4, "movableFitsNamedStage": null
  }],
  "unroutedDomainStages": [
    { "id": "aa3894a5-…", "name": "plainbooks.org", "taskCount": 0 }
  ],
  "stageNameCollisions": [{ "name": "fluxtask.org", "stages": [{ "id": "…", "name": "…", "taskCount": 0 }] }],
  "stageTaskCap": 500
}
```

- `kind` — `address` (matched first) or `domain` (the fallback).
- `matchesRoutingKey` — the routed stage is the one named for the routing key.
  For a domain route, `false` is the misrouting condition.
- `generatedShadow` / `generatedShadowName` — the routed stage's name is *exactly*
  `<domain(0,71)>-<sha256(domain)[0:8]>`, the name Flux generates when the plain
  name is already taken. Reconstructed, never prefix-matched, so an operator's own
  `fluxtask.org-archive` is not mistaken for one. `generatedShadowName` is `null`
  and `generatedShadow` is `false` for an address route: Flux never generated a
  stage for an address, so there is no shadow to diagnose.
- `mailboxFull` — `stageTaskCount >= stageTaskCap`. Deliveries refuse at the cap,
  so this reads "the next message to this route is rejected".
- `namedStage` — a *different* stage named for the routing key, if one exists;
  usually the stage the route should point at. Always `null` for an address route.
- `movableTaskCount` / `movableFitsNamedStage` — how many tasks a repoint with
  `moveTasks` would carry, and whether that merge stays strictly under the cap.
- `unroutedDomainStages` — domain-shaped stages that are not targeted by a route
  for the same normalized domain. Address-shaped and ordinary lane names are
  excluded. Duplicate case-insensitive domain stages are reported separately in
  stable stage order rather than choosing one. A non-empty entry is routing drift
  requiring an explicit `PUT /inbound/routes/:routingKey` decision; it never
  authorizes Flux to infer a route or an operator to send test mail.
- `stageNameCollisions` — every group of stages on the board sharing a
  `lower(name)`. Two stages with one name is how a shadow gets created.

`PUT /api/boards/:id/inbound/routes/:routingKey` points a routing key at a stage,
creating the mapping if the key has never received mail (`201`, `created:true`)
— which is how an operator prevents a shadow rather than repairing one, and the
only way an exact-address route is ever created. The key is normalized exactly as
the delivery path derives it, so `FluxTask.ORG` addresses `fluxtask.org` and
`GhostPace+bugs@FluxTask.ORG` addresses `ghostpace@fluxtask.org`. Options:

- `moveTasks` — carry the backlog. Only tasks that did not *provably* arrive
  through a different route move: for a domain key that excludes both another
  domain's mail and any local part with its own exact route, so a domain repair
  cannot drag away mail an operator deliberately diverted; for an address key it
  is the mail addressed to exactly that recipient. Mail whose delivery receipt
  retention already deleted moves too, so the oldest mail is never stranded. The
  whole call fails `409 stage is full` if the merged total would reach the cap,
  since landing on exactly the cap hands back a mailbox that refuses the next
  message.
- `dropSourceStage` — delete the stage the route left, but only when it is an
  auto-generated shadow that is now empty, unrouted, and referenced by no webhook.
  An address key never qualifies — it has no generated shadow name — so repointing
  one cannot delete a stage. Otherwise the stage is kept and
  `previousStage.keptBecause` says which of
  `not_a_generated_shadow | has_tasks | routed | webhooks` stopped it. Deleting an
  operator's own stage stays with `DELETE /api/boards/:id/stages/:sid`.

`DELETE /api/boards/:id/inbound/routes/:routingKey` removes the mapping only:
stages, tasks and delivery receipts are untouched. Removing an address route hands
that local part back to the domain route; removing a domain route means the next
message for the domain re-adopts the stage named for it.

These routes are owner-only like the rest of `/inbound`, and deny quietly — a
caller with no role on the board gets `404`, not `403`. Routing configuration is
never included in a board graph, and never in a public or shared projection.

## Stages

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/stages` | — | `{stages}` |
| POST | `/api/boards/:id/stages` | `{id?, name, color?, description?, url?, headerImage?, headerCrop?, terminal?, defaultTaskTemplateId?}` | `{stage}` (`200` on an idempotent replay — see [Client-supplied ids](#client-supplied-ids)) |
| PATCH | `/api/boards/:id/stages/:sid` | `{name?, color?, description?, url?, headerImage?, headerCrop?, terminal?, defaultTaskTemplateId?, order?}` | `{stage}` |
| DELETE | `/api/boards/:id/stages/:sid` | — | `{ok:true}` (also deletes its tasks) |
| GET | `/api/boards/:id/stages/:sid/tasks` | — | `{tasks}` (ordered tasks in this stage) — entries are **bare**, so read `.tasks[].notes` |
| POST | `/api/boards/:id/stages/:sid/pop` | `{holder?, ttlSeconds?, capabilities?}` | `{task}` **wrapped** (read `.task.notes`) — selects and locks the highest-priority available task in this stage, skipping any tagged `umbrella`; `400` if the body contains an unsupported field; `404` with `code` `NO_AVAILABLE_TASK` (empty stage) or `STAGE_NOT_FOUND` (no such stage) |

The two stage endpoints above are the asymmetry in miniature: `pop` wraps its
single task, the task list does not wrap its entries. See
[Response envelopes](#response-envelopes).

`/api/boards/:id/stage/:sid/tasks` and `/api/boards/:id/stage/:sid/pop` are
singular aliases for agent-friendly stage-scoped workflows.

Every route above that takes `:sid` answers `404
{"error":"stage not found","code":"STAGE_NOT_FOUND"}` when the board has no such
stage. See [Boards](#boards) for why `pop`'s two `404`s must be told apart by
`code` rather than by their message.

Stages may include `description`, `url`, `headerImage`, and `headerCrop` strings.
The web app renders `url` as a stage-title link and crops `headerImage` as the
column header background. `headerCrop` is a CSS-like percentage position such as
`50% 50%`; legacy values `center | top | bottom | left | right` are also accepted.
`defaultTaskTemplateId` applies a board-scoped task template to tasks created
through that stage's inline new-task area. Whole-board writes preserve an
existing stage default when the property is omitted, allowing older clients to
sync safely; send an explicit `null` to clear it.

`terminal` (boolean, default `false`) marks a stage as *completed work*, and is
what ties the column to the task `done` flag:

- moving a task **into** a terminal stage sets `done: true`; moving it **out**
  into a non-terminal stage sets `done: false`. Moves that don't cross the
  boundary leave `done` alone — including terminal → terminal, so shuffling
  finished work between a `Done` and an `Archive` column does not reopen it. A
  `PATCH` that changes `stageId` and `done` together resolves to the stage's
  answer, because clients routinely echo `done` back from a stale read while a
  stage change is always a deliberate gesture;
- a workflow `move` mutation is a move and obeys the same rule, in mutation
  order, so a later `{"type":"set","field":"done"}` in the same transition still
  wins;
- creating a task in a terminal stage defaults it to `done: true` — an explicit
  `done` in the body still wins, so imports and restores keep their state;
- setting `terminal: true` on a stage completes the tasks already sitting in it.
  Clearing the flag is a configuration change only and does not reopen them;
- whole-board writes (`PUT /api/boards/:id`) and workspace imports record the
  `done` they are given. They are full-state writes, not gestures.

**Where `terminal` is inferred from the name.** Only at first contact, where the
stage is necessarily empty and inference can therefore complete nothing:
`POST /api/boards/:id/stages`, the stages a new board is created with, the seed
board, and hydration of a JSON document that predates the flag. An explicit
`terminal` in the request always wins, which is the opt-out for a team whose
`Done` column means "done coding, pending review".

**Renaming a stage never re-derives `terminal`, in either direction.** Renaming
a populated column *to* `Done` would otherwise fire the "flagging a stage
terminal completes what is parked in it" rule and silently finish live work;
renaming *away* from `Done` would reopen finished cards on a cosmetic edit.
After creation the flag is explicit and is set with `PATCH .../stages/:sid`.

**Consequence worth planning for: dragging a card out of a terminal stage
clears `done`, even if it was set by hand.** If you set `done: true` on a task
that lives in a terminal stage and then move it to an open column, the move
wins and the task reopens. This is intentional and symmetric — see below — but
it means a client that treats `done` as independent of position will see it
change under a move it did not think was a completion gesture. Moves that do
not cross the boundary never do this.

This matters because `pop` treats a blocker as cleared only when the blocking
task has `done: true`, and skips any task that is itself `done: true`. Without a
terminal stage, dragging a card to a "Done" column leaves it `done: false` and
everything it blocks stays unpoppable with no visible reason; leaving `done`
set when the card is dragged back out is the same failure pointing the other
way, which is why the rule is symmetric.

Existing boards with a stage whose name is exactly `Done` (trimmed and
case-folded) were flagged `terminal: true` — and the tasks parked in them
completed — by schema migration 35. That rule is deliberately narrow because it
completes tasks: `Archive`, `Shipped`, `Complete`, `Closed`, `Done ✅` and
`Done (Q3)` are all left alone and are the board owner's call to flag. A board
hydrated from a JSON export that predates the flag infers it the same way; an
export that carries `terminal` round-trips unchanged.

## Task dependencies are same-board

`blockedBy` holds task ids **on the same board**, and only those. Task ids are
unique across the instance, so a cross-board id used to store and read back like
any other dependency — it just was not one. `pop` drains a single board and
treats a blocker as cleared only at `done: true`, so a blocker parked on another
board is either invisible to the seat working this one or clearable only by
someone who never sees the card it gates. The card reads as blocked and the
control is somewhere nobody is looking.

So the server refuses the write rather than storing an edge it cannot enforce,
and refuses it **before** anything changes — no task row, no version bump, no
audit entry, no board revision:

```json
400 {"error":"blocker <id> not found on this board",
     "code":"BLOCKER_NOT_ON_BOARD","field":"blockedBy",
     "boardId":"<board>","taskId":"<task>","blockerId":"<id>"}
```

The response deliberately does **not** say whether the id belongs to another
board or to no task at all. The caller has edit rights on this board and nothing
else, and "that id exists, just not here" is a cross-tenant existence oracle.

Every path that writes `blockedBy` enforces this: task create, task `PATCH`, the
whole-board `PUT`, backup restore, workspace import, and workflow `set`
mutations. Document-shaped writes add a `location` naming the offending entry —
`"location":"tasks[3].blockedBy[0]"` — because a rejected 400-task import is
unactionable without one, and they fail atomically: the document lands whole or
not at all. A blocker may name a task declared later in the same document; ids
are resolved after every row is written.

**What "new" means.** Every write replaces the whole array, so an ordinary edit
resubmits the ids the last read handed out. An id that is **already a
cross-board edge of that same task** therefore passes validation even though it
is not a task on this board — otherwise a database holding a legacy cross-board
edge (see *Edges already stored* below) would reject every subsequent edit to
that task, and renaming it would need an out-of-band repair. The tolerance is
narrow and one-way:

- it applies only to the exact `(task, blocker)` pair already stored, and only
  where the blocker is **not** a task on this board — another task may not adopt
  the id, and a task being created has nothing stored yet, so both refuse it.
  A whole-board `PUT` or backup restore snapshots those edges before it
  rebuilds, so round-tripping a board that holds a legacy edge works, while an
  edge the document invents does not;
- a **same-board** id is never grandfathered, because it does not need to be: it
  revalidates against the board the write produces. So a `PUT` or restore that
  deletes a task while another task still lists its id is rejected
  `400 BLOCKER_NOT_ON_BOARD` with `location`, like any other unresolvable id,
  rather than being admitted on the strength of the edge the same document is
  destroying;
- omit the id from the array and the edge is gone for good; it cannot be added
  back;
- any id on the same write that is *not* already stored is validated normally,
  so a request cannot smuggle a new cross-board edge in beside a legacy one.

**Workflow transitions are a write path too.** A transition simulates the task's
whole state, so it revalidates `blockedBy` even when it declares no dependency
mutation. The same tolerance applies, computed against the board the edges are
stored on: a task carrying a legacy cross-board edge stays transitionable and
keeps the edge, while a `set blockedBy` naming any other cross-board id fails
the transition with `invalid_fixed_value` and changes nothing. A transition that
moves the task to another board still requires that it have no dependencies at
all.

**One deliberate exception: task templates.** A template stores editor values
that outlive the tasks they reference, so a stored `blockedBy` whose target was
deleted afterwards is dropped when the template is applied rather than failing
the create. The task that results still satisfies the rule; nothing unenforceable
is ever stored on a task.

### Cross-board dependencies use a local gate card

When work on board A genuinely depends on something on board B, model it on
board A:

1. Create a **gate card** on board A — a normal task, not done — whose notes
   name the external task (board id, task id, and what has to be true).
2. Point the dependent task's `blockedBy` at the local gate.
3. When the external task is verified done, mark the gate done. The dependency
   is now clear to `pop`, which never had to reach across boards to find out.

The gate is closed by whoever owns the dependency, on the board where the
blocked work lives — which is the property a cross-board id could not provide.

### Edges already stored, and auditing them

`task_blockers.blocker_task_id` references `tasks(id)` globally rather than
per board, so databases predating this validation can contain cross-board edges.
**They stay readable and keep behaving exactly as they did** — reads never drop
one, because silently deleting somebody's dependency graph to make a report look
clean is worse than the edge. They also stay *editable*: a cross-board edge
already stored against a task is retained on that task's writes and workflow
transitions (see *What "new" means* above), so an unrelated edit neither fails
nor quietly erases it. Repointing `blockedBy`
at a local gate through the API replaces the whole set and clears it; no
migration is involved.

The task editor renders such an edge as its own dependency chip — labelled
*Unavailable task `<id prefix>`*, not clickable, with the same remove button
every other chip has — so the thing the audit reports is visible and repairable
where the work is, rather than hidden state riding along on every save.

**Format conversions carry the edge, they do not clean it up.** The legacy
JSON→relational importer (`scripts/migrate-to-relational.mjs`, the
`FLUX_IMPORT_JSON` hydration, and any export/re-import round trip) preserves an
edge whose blocker resolves to a task anywhere in the document, and records it
in the migration report as **R17** — `preserved legacy cross-board blocker <id>
on board <boardId>`. An edge naming no task at all is still dropped as **R10**:
there is no row a foreign key would accept and no card a repair could point at.
This matters because a conversion that quietly dropped the resolvable ones
produced a database that had already lost the findings the audit and the chip
exist to surface — repair has to start from something you can still see.

To find them:

```sh
node scripts/audit-blockers.mjs                 # default database
node scripts/audit-blockers.mjs --source db.sqlite --json
```

The script opens the database **read-only** and repairs nothing. It classifies
every stored edge as `same-board`, `cross-board` (blocker is a task on a
different board) or `missing` (blocker is no task at all — reachable when a dump
or migration ran without enforced foreign keys), and prints ids only: board,
task, blocker, blocker's board. No title, notes, or tag reaches the output, so a
report is safe to paste into a ticket. Exit status is `0` when every edge
resolves on its own board, `1` when any does not, and `2` when the script could
not run.

## Task templates

Task templates belong to one board. They store task-editor values (including
checklist and custom-field values, but not attachments or audit history).

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/task-templates` | — | `{taskTemplates}` |
| POST | `/api/boards/:id/task-templates` | `{id?, name, task}` | `{taskTemplate}` |
| DELETE | `/api/boards/:id/task-templates/:templateId` | — | `{ok:true}` |

Saved board templates include the board's task templates and stage-default
assignments. Workspace JSON export/import and organization board backups also
preserve them. New/imported boards receive fresh task-template and custom-field
ids, with custom-field and dependency references remapped.

## Tasks

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/tasks` | — | `{tasks}` (ordered) — entries **bare**, read `.tasks[].notes` |
| POST | `/api/boards/:id/tasks` | `{id?, stageId, title?, templateId?, notes?, priority?, size?, color?, tags?, start?, due?, recurrence?, url?, repo?, done?, assigneeId?, blockedBy?, customFields?}` | `{task}` **wrapped** (`200` on an idempotent replay — see [Client-supplied ids](#client-supplied-ids)) |
| GET | `/api/boards/:id/tasks/:tid` | — | `{task}` **wrapped** — read `.task.notes`, `.task.v` |
| PATCH | `/api/boards/:id/tasks/:tid` | any of `{title, notes, priority, size, color, tags, start, due, recurrence, url, repo, done, assigneeId, blockedBy, previewImage, customFields, stageId, beforeTaskId}`; optional `ifVersion` | `{task}` **wrapped**, or `409 {task}` (also wrapped) if `ifVersion` is stale |
| DELETE | `/api/boards/:id/tasks/:tid` | — | `{ok:true}` |
| POST | `/api/boards/:id/tasks/:tid/move` | `{toStageId, beforeTaskId?, ifVersion?, ifStageId?, ifUnblocked?, ifLockHolder?}` | `{task}` **wrapped**; `400` if `toStageId` missing, a guard has the wrong type, or the body contains unsupported fields — use PATCH to set fields like `done`; `409 {task}` (also wrapped) if a guard does not hold |
| POST | `/api/boards/:id/tasks/:tid/claim` | `{holder?, ttlSeconds?}` | `{task}` **wrapped** — claims this specific task cooperatively; `404` if it does not exist, `409 {task}` (also wrapped) if already claimed |
| POST | `/api/boards/:id/tasks/:tid/unlock` | `{ifLockHolder?}` | `{task}` **wrapped** — removes only the cooperative `locked` tag; `409 {task}` (also wrapped) if `ifLockHolder` does not match the live claim |

Every single-task row above returns the task under `.task`; only the list row
returns bare tasks, and it returns them inside `tasks`. Reading a field off the
top level of a wrapped body yields `null` rather than an error — see
[Response envelopes](#response-envelopes) for the failure that causes.

- `priority` ∈ `none | low | medium | high`
- `size` ∈ `S | M | L | XL | XXL`, or empty/unset
- `start` and `due` are Unix epochs in **milliseconds**, or `null`; `due` is the end date
- `recurrence` is `null` or `{freq, interval?, count?, until?}` where `freq` ∈
  `daily | weekly | monthly | yearly`; when a dated recurring task is marked
  done, the server creates the next incomplete task with advanced dates. `count`
  is decremented on each generated task and `until` is an epoch millisecond cap.
- `tags` is an array of strings
- `templateId` copies a task template from the same board; explicitly supplied
  task fields override template values
- `url` makes the card's title a link; `previewImage` (a resolved image URL) is
  painted faded behind the card. Use `/api/preview` to resolve one (below).
- `repo` is a repository URL, shown as a small link badge on the card.
- `done` marks a task complete (checkbox in List view; dimmed/struck-through in Board view).
- `blockedBy` is an array of **same-board** task ids this task depends on. The
  server rejects self-dependencies, dependency cycles, and any id that is not a
  task on this board (see *Task dependencies are same-board* below).
- `assigneeId` can be set on organization-owned boards to a user id listed as an organization member.
  The web app exposes a My Tasks view for organization project tasks assigned to the current user.
- `v` is the task's version, bumped on each versioned edit. Pass the value you
  read as `ifVersion` on `PATCH` to detect concurrent edits (409 if someone saved first).
  **A PATCH that omits `ifVersion` (or sends `null`) skips the check entirely *and*
  does not bump `v`** — it overwrites whatever is there and leaves concurrent
  `ifVersion` writers unable to notice. Always send the `v` you read.
- **`notes: null` is not "clear the notes" — it means "leave `notes` alone".** The
  server keeps the stored value when `notes` is `null` or absent, so a botched
  append returns `200` and silently does nothing. Any non-null value replaces the
  whole field, including the four-character string `"null"`.
- `notes` is limited to 20,000 Unicode code points. Task create/PATCH, task
  templates, whole-board sync, restore, and workspace import reject an
  over-limit value before changing tasks, revisions, audit rows, or events:
  `422 {"error":"...","code":"TASK_NOTES_TOO_LONG","field":"notes","limit":20000,"actual":20001,"measurement":"unicode_code_points"}`.
  Clients appending audit text can detect `code` and use a checklist item or
  another durable record instead. Existing legacy rows above the limit remain
  readable and are preserved when a PATCH omits `notes`; shorten them explicitly
  before sending them through a whole-document write.
- `attachments` is a **computed** array of `{id, name, type, size, createdAt}` —
  the files linked to that task (see Files below). The attachment endpoints above
  remain as a compatibility shim over the files store (max 15 MiB, 25 per task).

### Appending to task notes

There is **no comments endpoint** in the general case, so which recipe an agent
needs depends on `FLUX_TASK_NOTES` — which is **off by default**:

- **Flag on** — append through the native task-note endpoint. Prefer this
  wherever it is available: each note is an independently addressed record, so
  an append cannot rewrite or truncate what is already there.
- **Flag off** — the notes endpoints return `503
  {"code":"TASK_NOTES_DISABLED"}` and a report is a read-modify-write of the
  legacy `notes` string, which makes every append a chance to destroy the
  existing body.

**Native append (`FLUX_TASK_NOTES=1`).** The legacy `tasks.notes` string remains
the task's original body and compatibility projection; replacing it is not a
comment operation and can overwrite another client's edit.

```bash
set -euo pipefail
NOTE_ID=$(uuidgen)
REQUEST_ID=$(uuidgen)
BODY=$(jq -nc \
  --arg id "$NOTE_ID" \
  --arg requestId "$REQUEST_ID" \
  --rawfile body report.txt \
  '{id:$id, requestId:$requestId, body:$body}')

curl -fsS -H "$AUTH" -H "$CT" -X POST \
  "$BASE/api/boards/$BID/tasks/$TID/notes" -d "$BODY"
```

`requestId` makes retries idempotent; a stable client-supplied `id` names the
note. Only `requestId` is required — the id the server mints when you omit it is
**not** part of the replay fingerprint, so an omitted-id append is just as
retryable as one that names its note. Replay the exact payload after an uncertain
network result: the server returns the **same note id** with `200` and
`idempotent:true`, without a second append, revision, or board `rev` bump.

Retrying a request first sent with an explicit `id`, but with the `id` omitted,
also replays rather than conflicting — a request row records only the id that was
ultimately used, so that retry is indistinguishable from a client that dropped
its own id. The body is always re-checked either way, so this leniency can never
replay changed content: reusing a `requestId` with **any** changed body returns
`409 {"code":"TASK_NOTE_REQUEST_REUSED"}`, and supplying a *different* explicit
`id` under a used `requestId` is likewise a conflict.

Native appends need no task `ifVersion` and cannot overwrite concurrent appends.
Each note has its own 20,000-code-point / 81,920-byte limit; per-task limits are
2,000 live notes / 10 MiB. Limits reject the new note without truncating or
changing accepted notes.

**Legacy `notes` append (the flag-off fallback).** Two facts make the
read-modify-write easy to get wrong:

1. **Task responses are wrapped.** `GET /api/boards/:id/tasks/:tid`, task
   create, `PATCH`, `move`, `claim`, `unlock`, `pop`, and the `409` conflict
   bodies all return `{"task":{…}}`, never a bare task — the full list is under
   [Response envelopes](#response-envelopes). Reading `.notes` or `.v` off the
   top level yields `null`, and `jq -r` prints that `null` as the literal text
   `null`.
2. **A non-null `notes` replaces the whole field.** Appending to the string
   `"null"` from step 1 and PATCHing it overwrites the real body with
   `null\n\n…`. (`notes: null` itself is a no-op — see the field notes above —
   so the same bug can also present as an append that silently vanishes.)

**Fail closed.** Use `jq -e`/`-er` so a missing `.task.notes` or `.task.v` aborts
the script instead of producing `null`, and never PATCH `notes` you did not
successfully read:

```bash
set -euo pipefail
TASK=$(curl -fsS -H "$AUTH" "$BASE/api/boards/$BID/tasks/$TID")
NOTES=$(printf '%s' "$TASK" | jq -er '.task.notes')   # aborts if absent
V=$(printf '%s' "$TASK" | jq -er '.task.v')           # aborts if absent

BODY=$(jq -nc --arg notes "$NOTES

--- 2026-07-26 worker ---
Branch pushed; see repo link." --argjson v "$V" '{notes:$notes, ifVersion:$v}')

curl -fsS -H "$AUTH" -H "$CT" -X PATCH \
  "$BASE/api/boards/$BID/tasks/$TID" -d "$BODY"
```

Build the payload with `jq -nc --arg`, not string interpolation: notes routinely
contain quotes, backslashes, and newlines that break hand-built JSON.

**Concurrency.** `ifVersion` is the only protection against two agents appending
at once. A stale value returns `409 {"task":{…}}` carrying the *current* task —
re-read `.task.notes` and `.task.v` from that body (or a fresh `GET`), re-apply
your append to the new text, and retry. Do not retry by resending the same
`notes`: that discards whichever comment landed first. Retry a bounded number of
times and report failure rather than dropping the check.

**Size.** The legacy `notes` field caps at 20,000 Unicode code points, and an
append that crosses it is rejected with `422 … "code":"TASK_NOTES_TOO_LONG"`
*before* anything is written — the task is unchanged, so it is safe to catch the
code and put the overflow somewhere durable (a checklist item, a file
attachment, a repo file linked from the task) instead of trimming history to fit.

## Files

Files are first-class: they can exist with **no** association (personal), or be
linked to **multiple boards and tasks** via `links: [{boardId, taskId|null}]`
(`taskId: null` = board-level). You can see a file if you own it or you're a
member of any linked board. Organization members can also see unlinked personal
files owned by the organization account; these are returned with
`organizational:true` and `org:{id,name,email}`. Boards' GET responses include
`files: [...]` (all files linked to that board or its tasks).

The default blob store writes file bytes to `data/attachments`; set
`FLUX_FILE_DIR` to place blobs elsewhere. File metadata remains in the Flux DB.
Metadata storage uses the relational SQLite schema by default
(`data/flux-rel.sqlite`). `FLUX_SQLITE_FILE` can point the relational adapter at
a custom database file. `FLUX_DB_FILE=...` can be used as a one-time JSON import
source for a fresh relational database; JSON is no longer a runtime storage mode.

| Method | Path | Body / notes | Returns |
|---|---|---|---|
| GET | `/api/files` | — | `{files}` — everything you can see |
| POST | `/api/files?boardId=&taskId=` | raw file body; headers `X-Filename`, `Content-Type`, optional client UUID `X-File-Id`; query optional (omit = personal) | `{file,cleanupPending?}` |
| GET | `/api/files/:id` | `?download=1` forces download; safe image/video/pdf/text types render inline. Existing `.pdf` records are treated as PDFs by filename. | the file |
| PATCH | `/api/files/:id` | `{description?}` metadata update; file owner or editor of a linked board | `{file}` |
| PUT | `/api/files/:id` | raw replacement body; headers `Content-Type`, optional `X-Filename` | `{file}` — file owner or editor of a linked board |
| DELETE | `/api/files/:id` | file owner or owner of a linked board | `{ok}` |
| POST | `/api/files/:id/links` | `{boardId, taskId?}` (editor of that board) | `{file}` |
| DELETE | `/api/files/:id/links` | `{boardId, taskId?}` | `{file\|null, deleted}` |

Lifecycle: deleting a file permanently removes it and detaches it everywhere,
including task attachments. Removing a link detaches the file from that board or
task; attachment-origin files are deleted when their last link is explicitly
removed. Deleting a board removes that board's file links but preserves the file
records as owner account files, so they remain visible in Files with no links.
Uploads are limited by the owner's level (`maxStorageBytes`) in addition to the
per-file attachment limit.
An imported upload may return success with `cleanupPending:true` only after its
file metadata and immutable published bytes are durable; the flag means cleanup
of an unreferenced staging/loser key was deferred to the durable retry job.
Text-like file types can be edited in place by replacing bytes through `PUT`; the
file id and links are preserved and linked boards are revised.
Ordinary SVG uploads are download-only. SVG avatars are accepted through
`POST /api/me/avatar` after server-side sanitization and are served inline only as avatars.

## Workspace import runs

Workspace JSON import uses an owner-scoped server claim before creating boards
or children. `importId` is the export's workspace namespace and
`documentHash` is the SHA-256 of the imported JSON text.

Relational schema versions 10–13 belong to billing; workspace import storage
uses versions 14–17. On upgrade, databases created by the pre-integration
workspace branch are identified by migration name and their legacy 10–13
records are atomically remapped to 14–17 before the billing migrations run.
Unknown version/name collisions fail closed before applying schema changes.

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/api/workspace-imports/:importId/claim` | `{documentHash, mappings:[{sourceBoardId,kind,sourceId}]}` | `201 {status:"claimed",claimToken,claimGeneration,mappings}`; `200 {status:"completed",result,mappings}` on a completed replay; or `409` with `retryAfterMs` while another caller owns the run |
| POST | `/api/workspace-imports/:importId/heartbeat` | `{claimToken,claimGeneration}` | `{ok:true,leaseExpiresAt}` while materialization or cleanup still owns the run |
| POST | `/api/workspace-imports/:importId/complete` | `{claimToken,claimGeneration,result:{boardIds}}` | `{result}` after verifying every mapped child exists under its mapped owner board |
| POST | `/api/workspace-imports/:importId/rollback` | `{claimToken,claimGeneration}` | `{ok:true}` when the caller atomically acquires cleanup ownership |
| POST | `/api/workspace-imports/:importId/cleanup-blobs` | `{claimToken,claimGeneration,phase}` | `{ok:true}` after deleting unadopted or unreferenced paths owned by this run through the claimed generation; `503` if durable filesystem cleanup remains pending |
| POST | `/api/workspace-imports/:importId/fail` | `{claimToken,claimGeneration}` | `{ok:true}` after the cleanup owner completes compensating cleanup |

Claims, mappings, and completed results are unique by authenticated owner plus
`importId`. Claim tokens are never returned to concurrent callers. Every board,
child, file, and cleanup mutation made by the importer also carries
`X-Workspace-Import-Id`, `X-Workspace-Import-Token`,
`X-Workspace-Import-Generation`, and `X-Workspace-Import-Phase`; the repository
conditions the write on that immutable claim generation in the same
transaction. Every import upload attempt gets its own generation-owned staging
key. Before publication, its content hash, size, and distinct immutable
published key are recorded durably; one matching attempt is then atomically
adopted as the file's winner. Duplicate matching uploads return that winner,
while live generation/file reference rows account for every adopted published
key. Matching imports may reuse an immutable published key; deleting one file
releases only its reference, and the bytes become reclaimable only after the
last live file reference is removed. That transaction also creates a durable
cleanup intent containing the immutable key and owning import generation.
Filesystem deletion must succeed (or report that the key is already absent)
before the intent and deleted metadata are finalized. Failures return `503` and
remain discoverable with bounded exponential-backoff retries; startup and the
leased maintenance job retry due intents. Cleanup leases are token-fenced, and
replacement uploads always publish a new key or reuse a still-live reference,
so stale cleanup cannot remove successor bytes.

## Admin

Admins are regular users with `user.admin: true`; the oldest existing account is
bootstrapped as admin by migration. Every `/api/admin/*` endpoint requires admin
access.

### Users

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/admin/users` | — | `{users:[{id,email,name,analyticsClass,level,admin,usage,org,online,lastSeen}]}` |
| POST | `/api/admin/users` | `{email,name?,password,level?,admin?,analyticsClass?}` | `{user}` |
| PATCH | `/api/admin/users/:id` | `{email?, name?, level?, admin?, analyticsClass?, org?}` | `{user}` |
| DELETE | `/api/admin/users/:id` | — | `{ok:true}` |

`org` patches accept `{enabled?, maxMembers?, members?}` where `members` is an
array of `{email}` or email strings. `online` is true when the user has reported
presence recently; `lastSeen` is the last presence timestamp in epoch
milliseconds. Admin self-demotion/self-delete is blocked, and the server prevents
removing the last admin.

### Report

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/admin/report` | — | `{report:{onboarding,latencyMs,retention,dailyActive,adoptionV1}}` |

The report is aggregate-only: it never includes emails, names, raw task/board
content, tokens, IPs, or user-agent strings.

### Deployment preflight

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/config` | — | `{defaultSignupLevel,selfHosted,billingEnabled,apiCapabilities}` — and, **for admins only**, `deployment` |

**`apiCapabilities` — which optional request fields this build accepts.** An
array of `<endpoint>.<field>` names, e.g. `["move.ifVersion", "move.ifStageId",
"move.ifUnblocked", "move.ifLockHolder", "unlock.ifLockHolder",
"pop.stageIds"]`.

The endpoint half names the route that accepts the field. `pop.stageIds` is the
allowed-stage list on the **board-wide** `POST /api/boards/:id/pop` only — the
stage-scoped pop routes reject it, and no capability is advertised for them.

Every optional field is additive: omit it and you get the pre-existing
behaviour, so a client that never reads this list keeps working. It exists for
the other direction. Endpoints that take a body reject unknown fields with
`400` rather than discarding them, which is correct — a typo'd precondition
must not silently disable itself — but it means a client sending a field an
older deployment does not know gets a hard failure it can only discover by
trying, possibly halfway through a multi-write sequence. Read this list first
and send only what is in it. The list is anonymous because the negotiation has
to happen before the first authenticated write, and every entry is a field name
already published in this document.

It is append-only across versions: a name that disappears would silently
downgrade clients that had negotiated it.

`GET /api/config` is public and its public payload is otherwise unchanged. When the caller
authenticates as an admin (session cookie or `Authorization: Bearer <api token>`)
the response gains:

```json
{
  "deployment": {
    "commit": "8bc82e2...",          // 40-hex, or null when unknowable
    "commitSource": "env",           // env | git | env-invalid | unknown
    "schemaVersion": 33,
    "requiredConfig": [ { "variable": "RESEND_API_KEY", "control": "…", "required": true, "present": true } ]
  }
}
```

- `requiredConfig` reports **presence only** — never a value, prefix, length or
  hash. `present` is a boolean and there is no field for anything else.
  `required: true` means a hosted server refuses to start without it;
  `required: false` is advisory (reported so an operator sees the absence, never
  fatal). The same list drives `scripts/preflight-config.mjs`.
- `commit` is resolved **once at startup** (`FLUX_COMMIT_SHA` if injected by the
  build/deploy, else the checkout's HEAD), never per request and never by
  shelling out to `git`, so it describes the code the process actually loaded
  rather than whatever the working tree says now. It is `null` — never a guess —
  when neither source is available; `commitSource` says which one answered.
- `schemaVersion` is the relational schema this running process migrated to at
  startup. A database newer than the code aborts startup, so for a server that
  is answering, this is also the database's version.

Intended use is a deploy preflight: check the running commit and that every
required control has configuration **before** the supervisor stops anything.

### Levels

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/admin/levels` | — | `{levels}` |
| POST | `/api/admin/levels` | `{id,name,maxStorageBytes,maxBoards,canPublicBoards}` | `{level}` |
| PATCH | `/api/admin/levels/:id` | any level fields | `{level}` |
| DELETE | `/api/admin/levels/:id` | — | `{ok:true}` |

Deleting a level is blocked while any user is assigned to it. The seeded levels
are `free` (100 MiB of uploads, 10 boards total — the 2 starter boards seeded
at signup count toward that 10 — private boards only, no public links, no
card required, complete access to every API endpoint documented in this file)
and `pro` (10 GiB, 100 boards, public-board capability enabled; this is the
default level for `FLUX_SELF_HOSTED` deployments only — hosted signups never
receive it). The paid catalog (`starter`, `team`, `agent` — see
`lib/stripe.mjs`) exists in the schema for future billing but is not
orderable on the hosted product until billing goes live; every level,
including `free`, has identical API access today — no endpoint in this
document is gated by level.

## Public boards

Owners whose level has `canPublicBoards` may `PATCH /api/boards/:id {public:true}`.
Public boards can be read without authentication. Public viewers can also report
anonymous presence so logged-in viewers see them in the board header:

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/public/boards/:id` | — | `{board, role:"viewer"}` |
| POST | `/api/public/boards/:id/presence` | `{viewerId}` | `{ok:true}` |

The app renders the read-only public view at `/view/:id`.

When an owner's level loses `canPublicBoards`, their existing public links are
retained for a grace window and then made private without deleting anything.
The deadline is projected as `publicGraceUntil` (epoch ms, or `null` when no
countdown is running) — but **only to readers whose role is `owner` or
`editor`**, who are the ones who can act on it. It is omitted entirely, key and
all, from authenticated viewer reads and from `GET /api/public/boards/:id`: the
date an owner's billing downgrade takes effect is not something a shared link
should disclose. Like `inbound` and `agentRunsPublic`, this is a deliberate
projection policy, not an oversight.

## Link preview

| Method | Path | Returns |
|---|---|---|
| GET | `/api/preview?url=<page>` | `{url, image, title, description}` (fields may be `null`) |

Server-side fetch of a page's Open Graph data (so the browser doesn't hit CORS).
SSRF-guarded (private/loopback/link-local hosts are rejected) and cached. Set the
returned `image` as a task's `previewImage`.

## Calendar (aggregation)

| Method | Path | Returns |
|---|---|---|
| GET | `/api/calendar?from=<ms>&to=<ms>` | `{ events }` |

Date-bearing tasks and dated projects across **every board you can access**, for
global calendar / dashboard views without loading each board in full. Task events
are `{taskId, boardId, boardName, boardColor, title, start, due, priority, done}`.
Project events are `{kind:"project", boardId, boardName, boardColor, title, date}`.
`from`/`to` (epoch ms) are optional bounds.

## Task notes list (preview — `FLUX_TASK_NOTES=1`)

A task's notes are moving from the single `tasks.notes` string to independently
addressed note records, so appending a note can no longer rewrite or truncate
what is already there. This is the additive foundation only: **off unless
`FLUX_TASK_NOTES=1`**. When off, the endpoints below return
`503 {"code":"TASK_NOTES_DISABLED"}`. Turning the flag off is the rollback path —
notes already stored are hidden, never deleted, and reappear when it is turned
back on.

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/tasks/:tid/notes?cursor=&limit=&q=` | — | `{notes, nextCursor, noteCount, latestNoteAt, hasNotes, matchCount?}` |
| POST | `/api/boards/:id/tasks/:tid/notes` | `{id?, requestId, body}` | `{note, task, idempotent}` (`201`, or `200` on an exact replay) |
| PATCH | `/api/boards/:id/tasks/:tid/notes/:nid` | `{requestId, ifVersion, body, recoveryId?}` | `{note, task, idempotent}`; stale edit returns `409 {currentNote, recoveredNote, task}` |
| DELETE | `/api/boards/:id/tasks/:tid/notes/:nid` | `{requestId, ifVersion}` | `{note, task, idempotent}` (the returned note is tombstoned) |

- A note is `{id, taskId, body, origin, authorId, authorName, createdAt, updatedAt, v}`.
- Order is stable and chronological over `(createdAt, id)`; `cursor` is opaque
  and `limit` defaults to 50 and is capped at 100. A migrated note carries the
  task's own creation time, so it always sorts first.
- `q` searches live note bodies on the server using case-insensitive substring
  matching. It is capped at 500 Unicode code points and returns `matchCount`
  alongside the task's total `noteCount`. Board access is checked before the
  search runs; a user who cannot read the board receives the same `404` as any
  other private-board read.
- `id` is an **optional** client-supplied UUID naming the note — the server mints
  one when it is omitted; `requestId` is a **required** UUID naming the attempt.
  Replaying the exact same `requestId` and body returns the original note with no
  second append, no revision, and no board `rev` bump, whether or not `id` was
  supplied. The id the server chose is not part of the request, so it never makes
  a genuine retry look different. Supplying a *different* explicit `id` under a
  used `requestId` is a conflict, but **omitting** `id` on a retry of a request
  first made with an explicit one still replays — a request row records only the
  id that was ultimately used, so a genuine cross-upgrade retry and a client that
  dropped its own id are indistinguishable, and replay is the safer reading. The
  body is always re-checked, so this leniency can never replay changed content.
  Reusing a `requestId` with different content returns
  `409 {"code":"TASK_NOTE_REQUEST_REUSED"}`, and reusing a note `id` returns
  `409 {"code":"TASK_NOTE_ID_CONFLICT"}`.
- Viewers may read the list; editors and owners may append. Notes are **excluded
  from public boards entirely** — bodies, authors, and even the counters. Only
  the pre-existing `notes` field remains public.
- A successful append bumps the task's `v` and the board's `rev`, so existing CAS
  and SSE refresh keep working.
- Organization task audit entries record only note action plus
  `{count,latestAt}` before/after summaries. Task graphs never contain native
  note bodies; historical legacy-note audit bodies are projected as
  `{notes:{changed:true}}`.
- PATCH and DELETE use the note's own `v` as `ifVersion`, not the task version.
  Every accepted edit appends the prior body to immutable revision history before
  replacing the live body. DELETE sets `deletedAt`/`deletedBy`; it never removes
  the row or its revisions. Live list/search results exclude tombstones.
- Every mutation requires its own UUID `requestId`. An exact replay returns the
  first response and performs no revision, task-version, board-revision, event, or
  webhook side effect. Reusing the UUID with any changed mutation field returns
  `409 TASK_NOTE_REQUEST_REUSED`.
- A stale edit returns 409 but does not discard its body: the server atomically
  stores it as a new `origin:"offline_conflict"` note linked by
  `conflictedNoteId`. `recoveryId` lets an offline client preassign that copy's
  UUID; when omitted the server derives one deterministically from `requestId`.
  A stale DELETE changes no note and returns the current note for user review.
- Limits are refusals, never truncation or pruning, and are checked before
  anything is written:

  | `code` | Meaning |
  |---|---|
  | `TASK_NOTE_TOO_LONG` | one note over 20,000 Unicode code points |
  | `TASK_NOTE_TOO_LARGE` | one note over 81,920 UTF-8 bytes |
  | `TASK_NOTES_TOO_MANY` | task already holds 2,000 live notes |
  | `TASK_NOTES_TOTAL_TOO_LARGE` | task's live note bodies would exceed 10 MiB |

  Each carries `{code, field, limit, actual, measurement}`. Nothing already
  accepted is changed by a refusal.

**Legacy coexistence.** `tasks.notes` is unchanged and still read and written by
every existing client. Each task's value is mirrored into one distinguished
*shadow* note (`origin: "legacy"`), and a `PATCH` of `notes` edits only that
note — a client that cannot render native notes cannot erase them either.
Clearing `notes` tombstones the shadow rather than destroying its text; writing
it again brings it back. Native notes are never folded back into `tasks.notes`,
because concatenating them would recreate exactly the limit this replaces.

A whole-board write (`PUT /api/boards/:id`, backup restore, workspace import)
rebuilds a board's tasks. Notes for tasks present in the incoming document are
carried across the rebuild with their history and idempotency records intact, and
their shadow note is re-aligned to whatever `notes` the document carries. Notes
for a task the document omits are removed with that task, as they are on
`DELETE /api/boards/:id/tasks/:tid`. Notes are not yet part of the exported
document itself — export/import of the list is still to come.

Rows are backfilled by an operator-run, re-runnable migration that records a
per-task ledger (source code points, UTF-8 bytes, SHA-256, resulting note id) and
verifies every stored body against it before committing. It emits no audit rows,
webhooks, events, or version bumps — migrating a row is nobody's edit.

The web task editor shows the paginated list, a server-backed search field, and
an append-only composer. The legacy shadow is visibly distinguished and is not
editable through the list UI. Offline appends retain separate client-generated
note and request ids until idempotent replay.

Not yet included: editing or deleting notes, mentions, iOS authoring, note
webhooks, and any public opt-in.

## Checklist (subtasks)

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/api/boards/:id/tasks/:tid/checklist` | — | `{checklist:[{id,text,done}]}` |
| POST | `/api/boards/:id/tasks/:tid/checklist` | `{id?, text, done?}` | `{item}` (`200` on an idempotent replay — see [Client-supplied ids](#client-supplied-ids)) |
| PATCH | `/api/boards/:id/tasks/:tid/checklist/:cid` | `{text?, done?}` | `{item}` |
| DELETE | `/api/boards/:id/tasks/:tid/checklist/:cid` | — | `{ok:true}` |

## Task attachment compatibility endpoints

These endpoints are a compatibility shim over the first-class Files API.

| Method | Path | Body / notes | Returns |
|---|---|---|---|
| POST | `/api/boards/:id/tasks/:tid/attachments` | raw file body; headers `X-Filename`, `Content-Type` | `{attachment, rev}` |
| GET | `/api/boards/:id/tasks/:tid/attachments/:aid` | `?download=1` forces download | the file |
| DELETE | `/api/boards/:id/tasks/:tid/attachments/:aid` | — | `{ok, rev}` |

## Errors

JSON `{ "error": "message" }` with standard status codes:

| Code | Meaning |
|---|---|
| 400 | bad input (validation) |
| 401 | not authenticated |
| 403 | authenticated but insufficient role |
| 404 | board/stage/task not found (or not a member) |
| 409 | board revision conflict (PUT) |
| 413 | payload too large |
| 415 | mutation via cookie without JSON content-type |
| 429 | too many auth attempts |
