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_casefield 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.