Skip to content

API surface

A REST API under /api/v1/, JSON in and out, with conventional status codes. The full, always-current contract is the interactive API reference, rendered from the live OpenAPI spec. This page covers the shape and the conventions a developer needs to hold in their head.

Routers

Routes are grouped by resource (backend/app/api/v1/routers/):

Resource Scope Highlights
projects workspace + project CRUD, plus membership management
tasks project-scoped CRUD, filtering, optimistic-concurrency updates
dependencies project-scoped create/delete edges, cycle rejection
users workspace-wide list, me, preference updates
notifications per-user inbox, unread count, mark-read, per-user SSE stream
availability workspace-wide default week + dated overrides; opt-in membership, read-roster, write-own
brief project-scoped the Project Brief board; admin-enabled per project
events project-scoped the SSE stream (the one async endpoint)
undo project-scoped reverse the last audit group

Conventions

  • Plural resource names, nested under their project: /api/v1/projects/{project_id}/tasks/{task_id}.
  • JSON bodies; response models serialise ORM rows directly.
  • snake_case field names, matching the database.
  • Enums serialise by value — "in_progress", not "IN_PROGRESS".
  • Errors are {"detail": "..."} for the simple cases; richer conflict bodies are described below.
  • No pagination in v1. List endpoints return all matching rows ordered by created_at. At this scale (hundreds of tasks) that's fine; the SSE catch-up is the one place a cursor exists.

The endpoint map

# Projects & memberships
GET    /projects                                   list my projects
POST   /projects                                   create                         → 201
GET    /projects/{p}                               read
PATCH  /projects/{p}                               update            (admin)
DELETE /projects/{p}                               delete            (admin)       → 204
GET    /projects/{p}/memberships                   list members
PUT    /projects/{p}/memberships/{user}            add / change role (admin)
DELETE /projects/{p}/memberships/{user}            remove            (admin)       → 204

# Tasks  (filters: assignee_id, status, stage, parent_id)
GET    /projects/{p}/tasks                          list
POST   /projects/{p}/tasks                          create                         → 201
GET    /projects/{p}/tasks/{t}                       read detail
PATCH  /projects/{p}/tasks/{t}                       update  (if_unchanged_since)
DELETE /projects/{p}/tasks/{t}                       delete  (409 on dependents)    → 204

# Dependencies
GET    /projects/{p}/dependencies                   list
POST   /projects/{p}/dependencies                   create  (409 on cycle/dupe)    → 201
DELETE /projects/{p}/dependencies/{d}               delete                         → 204

# Project Brief  (ADR 0073) — admin-enabled per project; off by default.
#   Reads answer 200 with `enabled: false` when switched off; writes 404.
#   Items are any-member (a shared scratchpad, not own-edits); groups are admin.
#   No audit rows, no server-side undo; live via ephemeral SSE (brief.*).
GET    /projects/{p}/brief                          whole board: enabled + groups + items
POST   /projects/{p}/brief/items                    create 1..100 (bodies[], group_id)  → 201
PATCH  /projects/{p}/brief/items/{i}                 body / status / group_id / sort_order
DELETE /projects/{p}/brief/items/{i}                 delete (no status precondition)     → 204
POST   /projects/{p}/brief/groups                   create            (admin)            → 201
PATCH  /projects/{p}/brief/groups/{g}                rename / reorder  (admin)
DELETE /projects/{p}/brief/groups/{g}                delete; ungroups its items (admin)  → 204

# Users
GET    /users                                       list
GET    /users/me                                    current user (auto-provision)
PATCH  /users/me                                    update preferences

# Availability  (ADR 0062 / 0063 / 0064 / 0067 / 0069) — workspace-level, NOT project-scoped.
#   Opt-in: every route below requires availability membership (403 otherwise).
#   Reads span the roster; every write targets the caller's own rows, so someone
#   else's row 404s rather than 403s. No audit, no SSE, no undo.
GET    /availability?start={date}&end={date}        the roster's pattern + windowed overrides
GET    /availability/members                        the effective roster (any member)
PUT    /availability/members/{user}                 add        (availability admin); no body
DELETE /availability/members/{user}                 remove     (availability admin) → 204,
                                                               409 on the admin themselves
PUT    /availability/pattern                        replace my default week (set semantics)
POST   /availability/overrides                      add a segment across 1+ dates    → 201;
                                                    optional `block_id` joins an existing
                                                    block of yours or restores a deleted
                                                    one's identity — 409 if it holds
                                                    somebody else's rows — see below
PATCH  /availability/overrides/{o}                  edit one segment, incl. `on_date`
                                                    (move to another day — one atomic
                                                    request, never delete-then-create)
DELETE /availability/overrides/{o}                  delete one segment               → 204
PATCH  /availability/overrides/{o}/dates            re-span the entry this row belongs
                                                    to — its block, or the row ALONE, so
                                                    a one-day entry becomes a block in
                                                    one atomic call. `dates` required,
                                                    set semantics, with
                                                    `seen_start`/`seen_end` — see below
PATCH  /availability/overrides/block/{b}            resize + edit a whole block (atomic);
                                                    `dates` is set semantics and REQUIRES
                                                    `seen_start`/`seen_end` — see below
DELETE /availability/overrides/block/{b}            delete a whole multi-day block   → 204
POST   /availability/overrides/{o}/split            detach one day from its block    → 201

# Real-time
GET    /events?project_id={p}&since={cursor}        SSE stream

# Undo
POST   /projects/{p}/undo                           reverse last audit group

Status codes

Code When
200 successful read or update
201 created (POST)
204 deleted
400 malformed input, or a cross-project edge
401 the credential is missing, malformed, expired, or untrusted — and nothing else
403 authenticated, but insufficient role — or not an availability member
404 not found — or a non-member of a project (existence is hidden)
409 a conflict (see below)
422 a validation failure (e.g. start_date > due_date, or a dependency-date invariant)
503 a dependency the request needs (the database) is unreachable; the request is not the problem, retry shortly. Carries Retry-After

The 404-for-non-members rule is a deliberate information-hiding choice: a user who isn't on a project can't distinguish "doesn't exist" from "exists but you can't see it."

Availability deliberately answers 403 instead (ADR 0063). The 404 rule earns its cost by closing a UUID-enumeration channel across many opaque project ids; /api/v1/availability is a single fixed path with nothing to enumerate, so copying the mechanism would leave its justification behind and turn "ask to be added" into a not found that reads as a broken deployment.

Availability writes: the three rules a client has to know

A partial update never sends null for a NOT NULL field. On PATCH /availability/overrides/{o} and PATCH /availability/overrides/block/{b}, omitting a field leaves it unchanged; an explicit null for on_date, kind, tentative, note or dates is a 422, because there is no value to clear it to. Only start_time/end_time are nullable, they move as a pair, and both null is how "all day" is expressed. (The same instinct as backlog_rank in tasks.py — a meaningless null must never reach setattr. Availability refuses it rather than dropping it, so a client sending one is told, instead of watching the field silently keep its old value.)

A resize declares the range the caller read. dates on either the block PATCH or PATCH /overrides/{o}/dates is set semantics, so the server deletes whatever the submitted set omits. A client whose read window did not cover the whole entry would therefore submit a truncated set and destroy the days it never saw — silently, and permanently, availability having no audit log and no undo. So dates requires seen_start and seen_end (the date range the client actually read), and the call is refused 422 if any day of the entry lies outside that range. A submitted date outside it is fine (ADR 0069 §4): it is a plain create, checked by the same overlap pass every create runs, and refusing it capped a bookable absence at whatever window the client happened to be reading. The remedy the error names is to re-read the block's full span and retry. Sending seen_start/seen_end without dates is also 422: an edit that changes no dates can drop nothing, and a declaration with no effect is worse than none. A move of a single entry to another day is on_date on the row PATCH — one request, one transaction, and a collision on the destination day leaves the entry exactly where it was.

Two routes re-span an entry, and which you use is about what you have in hand. PATCH /overrides/block/{b} addresses it by block_id, which is what a gutter drag holds; PATCH /overrides/{o}/dates addresses it by any row of it, which is what an editor holds. Both apply the same diff, so atomicity, scalar inheritance from the earliest row, and the one-row-block rule are written once. The row route is the one that can grow a single-day entry into a block: the addressed row keeps its id and gains a generated block_id along with its new siblings, in one transaction with nothing deleted. Before it existed that was expressible only as DELETE-then-POST, the shape ADR 0064 §7 took out of the move path because a 422 on the create destroys an entry that no longer exists anywhere. Both routes cap dates at 400, matching the window read's own ceiling — the editor's From/To fields are the first hand-typed input on this router, and a slipped year is one keystroke.

A block_id on create is yours to supply, and yours only. POST /availability/overrides generates a block_id for a multi-date create and leaves a single date unblocked — unchanged if the field is omitted, which is what every ordinary client does. Supplying one is how a dissolved block is put back together (ADR 0067 §3b): nothing else in the API ever assigns a block_id, so without it the survivor of a two-day block that lost one day could never be re-joined to the leave period it came from. An id that matches an existing block of yours joins it; an id that matches nothing creates the rows under that id, which is how a fully deleted block regains its identity and is therefore allowed rather than rejected. An id any of whose rows belong to somebody else is refused 409 — the whole security surface of the field, since without the check one caller could graft rows into another's leave period through a body field that looks like a formatting detail. 409 rather than the router's usual existence-hiding 404 because here "not there" is the case that succeeds, so a 404 would be reserved for the case that was found; and there is nothing to hide anyway, the windowed read returning every roster member's rows including their block_id. Two invariants still bind: every created row goes through the overlap check (a block_id groups rows, it never licenses double-booking a day), and a single date under an id matching nothing is 422, because a one-row "block" must never exist — block_id is null is precisely what distinguishes "this one day" from "this holiday".

The 503 is declared once on the /api/v1 parent router, because every route under it authenticates and authentication is the first thing in a request that touches the database (ADR 0065). A stopped database used to surface as 401 on every endpoint — a server outage reported as a credential rejection, which sent operators to their passwords and hid the outage from the 5xx-only alert.

The conflict contract (409s)

Several distinct conflicts surface as 409, each with a typed body so the frontend can react precisely. They're declared in backend/app/api/v1/schemas/:

Stale update (optimistic concurrency, ADR 0004) — a PATCH whose if_unchanged_since is behind, and whose fields actually overlap what changed:

{
  "detail": "Task has been modified since the provided timestamp.",
  "current_updated_at": "…",
  "current_updated_by": "…",
  "conflicts": { "title": { "client": "…", "server": "…" } }
}

Delete blocked by dependents (ADR 0008) — a task with children or dependency edges can't be deleted; the body names what's in the way:

{
  "detail": "Task has dependents. Remove or reparent them first.",
  "children":  [ { "id": "…", "title": "…" } ],
  "blocking":  [ … ],
  "blocked_by": [ … ]
}

Reparent cycle (ADR 0029) — moving a task under one of its own descendants is refused, naming the task the loop would close through:

{ "detail": "…", "would_close_via": { "id": "…", "title": "…" } }

Dependency cycle / duplicate — a POST /dependencies that would close a loop, or that already exists, is refused with a detail message.

Last admin — demoting or removing the only admin of a project is refused, so the project can't be orphaned.

Availability admin — removing the availability admin from the roster is refused, so nobody can be left unable to administer the board. The check runs before the row lookup, so an admin aiming the route at their own id gets this rather than a misleading 404 (the admin holds no membership row).

Error and exception handling

The codebase never swallows exceptions silently — a handler either deals with an error meaningfully (returning a typed conflict or a 4xx) or lets it propagate. An unhandled exception rolls the request's transaction back, which (by the design in Real-time sync) also drops any events that write had queued, so subscribers never see a phantom event for a failed write.

OpenAPI

FastAPI generates the spec from the route annotations and Pydantic models. It's served at /api/v1/openapi.json, with Swagger UI at /docs. The published API reference renders that same spec, so it always matches the running backend — the docs build regenerates the spec from the app before publishing.