No description
Find a file
qusavin 8df7233fe8
All checks were successful
deploy / deploy (push) Successful in 4m23s
ci / test (push) Successful in 5m43s
fix(sync): a replica replays its own op in journal order, minus the text it merged
2026-09-08 17:32:30 +03:00
.claude/skills feat: hosted origin — echo, pgx, goose, zap, vertaship delivery 2026-08-19 19:58:47 +03:00
.forgejo/workflows ci: fetch modules through a proxy that serves large zips 2026-08-20 14:40:03 +03:00
cmd fix(tui): the read view draws a note once and the terminal is asked its colour before the program starts (VERTA-3678) 2026-09-07 14:14:47 +03:00
deploy chore(deploy): pass the browser client id through to the origin (VERTA-3574) 2026-08-20 17:05:34 +03:00
docs fix(sync): a replica replays its own op in journal order, minus the text it merged 2026-09-08 17:32:30 +03:00
internal fix(sync): a replica replays its own op in journal order, minus the text it merged 2026-09-08 17:32:30 +03:00
third_party/verta-identity feat: origin authenticates every request against verta identity 2026-08-19 20:49:09 +03:00
.gitignore feat: hosted origin — echo, pgx, goose, zap, vertaship delivery 2026-08-19 19:58:47 +03:00
CONTEXT.md docs: the canon records what the phone became 2026-09-08 16:46:00 +03:00
go.mod feat(pwa): the phone lives on a replica and writes intents (VERTA-3576) 2026-08-21 02:46:21 +03:00
go.sum feat(pwa): the phone lives on a replica and writes intents (VERTA-3576) 2026-08-21 02:46:21 +03:00
Makefile feat: hosted origin — echo, pgx, goose, zap, vertaship delivery 2026-08-19 19:58:47 +03:00
README.md fix(phone): a write made while a sync turn is in the air is not lost 2026-09-08 17:32:26 +03:00
vertaship.yaml chore(deploy): pass the browser client id through to the origin (VERTA-3574) 2026-08-20 17:05:34 +03:00

wave

A task manager built around the one thing TickTick, Todoist and Things cannot express: a project decomposed into ordered waves.

area  →  project  →  wave  →  task

A wave is a phase. It stays blocked until every earlier wave is finished, so a plan carries its own sequencing instead of living in your head. Areas are PARA areas, the Inbox is anything not yet filed, and every object is reachable from an AI agent over MCP.

Why it exists

TickTick has no project object — "projects" are parent tasks, so there is no project status, no project dates, no progress, and no way to sequence work. Linear has waves (milestones) but no PARA areas and no recurring tasks. This is the intersection.

Install

go install github.com/qusavin/wave/cmd/wave@latest

Or from a checkout:

make install

make build installs too, because a stale copy on PATH loses features silently rather than failing. wave version prints the commit and build time — check it first whenever a key seems to do nothing.

The TUI reads and writes the replica of the space you chose — one SQLite file per space at ~/.wave/spaces/<space>/replica.db — so wave login and wave use come first. It never waits for the network: reads are local, and a change is applied and queued in one transaction, then sent in the background. The legacy single-machine file at ~/.wave/wave.db is still what wave serve opens (-db overrides that one), and wave import moves it into a space.

The three faces

Command What it is
wave vim-keyed TUI — the desktop interface
wave serve HTTP API on 127.0.0.1:7717 — for scripting and shortcuts
wave mcp MCP server on stdio — the agent interface

TUI

wave
j / k        down / up          a o i     add task
J / K        move the row       e         rename task, project, wave or area
gg / G       top / bottom       n / N     notes in $EDITOR / one line here
1 2 3 4 5    today · inbox ·    t         due date — on a project or a wave, its target
             projects · areas   m         move to area / project / wave
             · calendar         s         sheet — due, move, priority, span, repeat
tab          next view          x         done / undone
enter / l    open               dd        delete
esc / h      back / widen       u         take back the last act; again goes further back
r            reload             p         new project
?            help               w         new wave (inside a project)
ctrl+d       half a page down   A         new area
ctrl+u       half a page up     E         area icon (empty clears it)
ctrl+s       account panel      q         quit

S            today: by area, by project, the order you put it in
O            sort by due date — written down, so every device gets it
f / F        narrow the list to the project · the area of the task under the cursor
/            narrow by text
c            show / hide completed
# / @        today: hide the row's project · area
z            reveal what is hidden
!            everything in conflict
space / V    select the row under the cursor · everything the scope is showing
u            let the whole selection go — with none standing, step back through the acts

On a machine nobody has signed in on, wave still opens: the chrome is drawn, the body is empty because there is no replica to read, and the account panel is the screen rather than something over it. enter there starts the device flow in the box — a code and an address to approve it at, a wait that shows it is waiting, and then either the operator's data or the list of spaces to choose it from. esc stops waiting; q is the way out.

Quick add parses the line:

buy milk @household              → filed into an area
fix parser #mimic ^tomorrow      → into a project, due tomorrow
ship mvp #wave ^2026-09-01 !3    → due date and priority
standup ^tomorrow ~09:00-09:30   → an hour of the day it is due

Due dates accept today, tomorrow, monsun, +3d, +2w, +1m, 2026-09-01. Spans accept 13-15, 13:00-14:30 and 90m, and hang on the day the line is due — with no day to hang on, the line is refused rather than filed at no particular hour.

Neither has to be written out. Type the sigil and the row under the prompt becomes the field you are in: a week of days under ^, the hours of the day under ~. H and L walk a day or carry the span; J and K step a month or move the span's end. What the walk lands on is written back into the line, so enter takes the same thing whichever hand put it there — and a date the parser cannot read is refused on the line it was typed on, with the rest of the line still standing. The sheet's due and span fields are the same two strips.

m retargets a task with the same syntax — @english, #golang /prep, or inbox to send it back.

J and K reorder, in every view including Today. Tasks carry one global rank, and a move swaps the task with the row actually shown next to it — so a Today row steps past its neighbour even when the two live in different projects. Inside a project the swap stops at the wave boundary, because a wave header breaks the run.

Ordering is manual, not automatic. O re-sorts whatever list you are looking at by due date when you want the dates back in charge — it writes the ranks down, so every device gets that order. S is the other half and the opposite kind of thing: it reads Today grouped by area or by project on this terminal, writes nothing and travels nowhere, and while one is live every key that would write a rank refuses rather than folding a view order into the stored one.

5 is the week. Seven day columns and hour rows, read out of the replica like any other view, with a cursor that walks cells rather than tasks — an empty cell is where a task would go, so it is reachable. z narrows the week to the day under the cursor and back, r halves the rows, t returns to today, and enter opens the same sheet the list opens. Two gestures write: s takes an edge of the block under the cursor (tab swaps ends) and m picks the task up — both stage a change that enter writes and esc throws away. A repeating task is drawn on every day its rule lands on, and only the one row is stored.

f narrows the list to the project of the task under the cursor, F to its area, / to a phrase — one narrowing said three ways, so esc takes all of it off. The header names the scope beside the tabs and the bottom line counts it — scope: 3 tasks · #verta-rail — so the size of what the next key means is on screen before you press it. A task with no project refuses f in words rather than narrowing to an empty list, and an Inbox task refuses F the same way. The scope is this terminal's own: nothing is synced, and it lasts until you widen it or change view.

space selects a task, V selects the whole scope. Space takes the row under the cursor and walks on, so space-space-space picks a run; V takes everything the current narrowing is showing, and pressing it again takes that same scope back out — scoping to the wrong project would otherwise cost a u and every mark made before it. u lets the whole selection go.

u takes back the last act. With no selection standing, u is undo: it takes back the last thing this terminal did, whole. Fifty tasks deleted by one dd come back with one press, because the act is the unit and never a row of it — and the footer names what came back, 12 tasks restored. It appends rather than rewinds: the inverses are ordinary operations minted from the values each op overwrote, so they reach every other device like anything else, the log is never rewritten, and the whole act lands or none of it does. An archive and a deleted area, project or wave have no other side in the vocabulary and refuse out loud; dd no longer asks first, because the reason it asked is what u just removed.

Press it again and it goes further back, not forward. An undo is itself an act, so it records the id of the act it took back, and what the next press takes is the newest act that is neither an undo nor already undone. That is the whole stack: it is the log, so it lives in the replica and the walk is where you left it after a restart, and an undo is never itself undone. Redo is deliberately not a feature — walking forward again needs a branching model, and nothing asks for one. It reaches back through the last hundred acts, undos counted among them, so about fifty presses; the end of it is a quiet line saying which end — everything already back, or the bound. One thing is stepped over rather than refused: an act the log kept without the values to put it back, from before preimages existed or captured against a row already gone. The footer counts those, so a press that went further than it looks says so.

Narrowing and selecting are two different things to have said, so esc widens the list and leaves the selection standing: f on a rail row, V, esc, then space on two strays elsewhere is one batch of five gathered out of two lists. A selection is task ids, not row numbers — it acts on every id in it, including the ones the list you act from is not showing, in the order the screen has them with the rest after. The bottom line carries both facts side by side: scope: 3 tasks · #verta-rail · selected: 5.

Completing a task does not make it vanish: it stays where it is, and struck through, until you leave the view or press r. x again reopens it.

c shows completed tasks in whatever view you are in — they render and struck through, and the header gains +done. In Today that means everything due today plus everything closed today, including tasks that never carried a due date, so the list doubles as a record of the day. Over HTTP: /api/today?done=1, /api/inbox?done=1, or /api/tasks?status=done. Over MCP: include_done on wave_today and wave_inbox.

Tab completes the @area, #project or /wave token under the cursor, in every prompt. The candidate strip sits above the input; shift-tab takes the last match instead of the first. Wave completion is scoped to whichever #project the line names, or to the project you have open.

Descriptions are markdown. n opens the description in $EDITOR as a real .md file — nvim here, with your own config and ftplugin — and saves it back on exit. N is a quick one-line edit that never leaves the TUI. enter on a task opens it full-page with the markdown rendered; j/k scroll it. A task carrying a description shows , and its first line previews under the list.

ctrl+s opens the account panel over whatever you are looking at: your spaces, which one you are in, and how much that one still has to send. enter switches — the list, the replica and the bottom line all follow in the same frame, with no restart — s signs out and lands you back on the way in, and ctrl+s or esc folds it away again. It is a panel and not a page: the list behind it stays readable, which matters most in the case that opens it, when the origin has stopped answering and the work on this machine has not. On a client with no space to work in it is the whole screen, and there it does not close, because there is nothing behind it to go back to.

The bottom line is the connection. It always says which space you are in and how the link to it is doing:

Personal · idle              caught up
Personal · syncing           talking to the origin
Work · offline · 3 pending   no network; three changes waiting to leave
Personal · idle · conflicts: 2   two texts need you to choose

The count is real — those are intents already on disk and not yet accepted. Nothing you type waits for the network: a change lands in the replica and in the queue in one transaction, and the queue drains on its own once there is a network. Kill the process mid-queue and the work is still there on restart. r re-reads the replica and, if the origin refused something, asks it to try again.

Two machines end up in the same place. Work on the laptop with no network and on the desktop with one, and when the laptop comes back neither of you is asked to merge anything: reorders in different lists both stand, reorders in the same list settle into one order that is the same on both screens, a repeating task ticked off on both for the same occurrence moves on once, and a task deleted on one is gone on both. The one case that cannot merge — the same note edited on both while apart — keeps both versions instead of picking one; see below. What makes this true rather than hoped for is internal/syncer/converge_test.go, which runs two whole installations against one origin in a single test.

Phone

Open https://wave.vertaspace.com, tap Sign in, approve in the browser and pick a space — the choice is remembered, and the tab in the header is where you switch space, open a new one or sign out. Add it to the home screen: it installs as a standalone PWA with its own icon.

The phone does what the terminal does, in a thumb's vocabulary. Today, the Inbox, projects and areas; a capture bar that files into whatever screen it is standing on; a search field that narrows the list live. Tapping a task opens one editor with the same five fields the terminal's sheet writes — due date, where it goes, priority, the hours it takes, how it repeats — and the repeat is picked on a strip rather than typed. Hold a row and drag it to reorder Today or the Inbox. Areas, projects and waves are made, renamed, dated, archived and deleted from here too, so a space can be shaped from scratch on a phone and not only added to. An undo control in the footer takes back the last thing you did, whole, and pressing it again goes further back. The footer's conflict count opens a screen where each disputed field is drawn as its two versions and you keep one by tapping it. Today can be grouped by area or by project and containers hidden from it — that choice stays on this device and travels nowhere. And the calendar draws the same week and day the terminal draws: long-press a block to carry it to another day or hour, take an edge to change how long it takes, with every date a repeating rule lands on shown and only the one row stored. A write to a repeating task asks which occurrence it means before it writes, exactly as t, m and dd ask in the terminal.

The phone is a device like the laptop, not a window onto the server. It holds a full copy of the space — one IndexedDB document, wiped when you sign out — and every read comes out of it, so the app works in a tunnel exactly as it works at a desk. A tap does not send anything: it records what you did (complete_task, set_fields, move_task) into that copy and into a queue, in one write, and a loop drains the queue and follows the log whenever there is a network.

The footer says which of those it is:

Personal · idle                      caught up
Personal · syncing                   talking to the origin
Work · offline · 3 pending           no network; three changes waiting to leave
Personal · idle · conflicts: 2       two texts need you to choose
Personal · idle · storage not persistent    the browser has not pinned the copy

Edit the same task on the phone and on the laptop, both offline, and neither edit is lost. They come back to one state with nobody merging anything — and when the one case that cannot merge happens, the same text edited in both places, both versions are kept and the task is marked on every device. Settle it by editing the text and deleting the marker line, on whichever device is in your hand.

The phone runs the same op vocabulary as the desktop — and by now most of the same reads and judgements — written a second time in JavaScript because a browser cannot run the Go one. That copy is not taken on trust. internal/syncer/phone_converge_test.go loads the very file the browser is served and plays the phone through these scenarios beside a real desktop replica, inside go test; phone_core_test.go compares its answers against the Go ones function by function; phone_replica_test.go drives its storage and its sync loop against a stubbed IndexedDB; and internal/httpapi/serviceworker_test.go asks the service worker which paths it is allowed to answer for. No Node, no browser: 143 tests in one make test.

wave serve no longer serves this app. It is the hosted origin's, because a replica needs a journal to reconcile against and the local binary has none; what wave serve offers is /api/* for scripting and shortcuts. Nothing about that is compiled in: the app asks the server it is talking to for its login (GET /v1/auth/config), and the local binary answers 404, which is how one file serves both. Sign-in is authorization code + PKCE straight against identity — the device flow the CLI uses is for devices that cannot take a redirect, and a browser can (ADR-0007).

Captures made offline are held per space, so a draft written in one can never be sent to another, and switching back finds it still waiting. Signing out erases the tokens, the chosen space and every held draft on that device.

For capture from anywhere, point an iOS Shortcut at:

POST http://<host>:7717/api/tasks   {"title": "..."}

Agent

wave mcp

Register it once:

claude mcp add wave -- wave mcp

The agent works as you, in the space you chose: it reads ~/.wave/session.json and calls the hosted origin, so wave login and wave use <space> are what point it at anything. With no session every tool answers with an error naming wave login — never an empty list, which an agent would read as "nothing to do". A wave use in another terminal is picked up by the next tool call; the server does not need restarting.

Tools: wave_today, wave_inbox, wave_add_task, wave_list_tasks, wave_update_task, wave_complete_task, wave_reorder_task, wave_delete_task, wave_restore_task, wave_list_areas, wave_create_area, wave_list_projects, wave_create_project, wave_create_wave.

Areas, projects and waves resolve by name, not just id — {"project": "system design", "wave": "2"} works, so an agent never has to carry UUIDs around.

Behaviour worth knowing

Nothing you wrote is thrown away. Two devices editing the same description while both were offline is the one case that cannot merge, so wave keeps both:

the version I wrote on the train
<<<<<<< wave: two versions, keep one and delete this line
the version you wrote with the budget in it

The task shows in every list, the bottom line counts them, and ! lists every conflicted task wherever it lives. Settle one the way you write anything else — n opens the text in $EDITOR (e for a conflicted title, which is no longer one line), keep what you want, delete the marker line, save. That edit is an ordinary change, so it travels to your other devices by itself; the conflict does not stay settled only where you settled it. Titles conflict by the same rules. A delete racing an edit loses to the edit — the task comes back rather than taking the writing with it. The phone is in this on equal terms: it marks the same task, counts the same conflicts in its footer, and settling one there is the same ordinary edit that settles it in $EDITOR.

Waves gate by availability, not by lock. A blocked wave renders dim and reads as blocked; nothing stops you completing out of order. The gate is a signal, not a jail.

Recurring tasks roll forward. Completing a task with an rrule does not close it — it advances to the next occurrence after the moment you completed it and carries its time span with it. Days you missed are not owed: a daily task completed nine days late lands on tomorrow, not on the day after the one it missed.

The rule is picked, not spelled. The sheet's repeat field opens a strip of cells rather than a line: h/l walk it, space toggles the cell under the cursor, enter fixes the rule, esc leaves the last fixed one standing. Weekly picks weekdays, daily an interval, monthly a day of the month, and toggling the strip off clears the rule without touching the due date. A rule the cells cannot say — COUNT, UNTIL, an import from another calendar — is shown verbatim and left alone until you write over it, rather than rounded to the nearest thing the strip can express. Full RRULE syntax still goes in over the API and the agent: FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR.

An edit asks which occurrence it means. A repeating task is one row standing on one date — the calendar draws a copy on every day the rule lands on, but only the row is stored — so writing to it writes the whole chain. The rule, rather than a tally: every gesture in the terminal that writes a field the chain inherits stops and asks first — renaming with e, writing a note with n/N, moving with m, t, dd, the bulk sheet, the calendar drop and the span resize, whichever door each of them is reached through. h/l pick, enter answers, esc writes nothing.

whole series is the write it always was. this occurrence lifts that one occurrence out as an ordinary one-off task and rolls the series on past it — under dd it is the roll alone, no copy and no tombstone, so "delete today's" on a daily task moves it on instead of ending it. Either way u takes the answer back whole. It reaches the occurrence the row is standing on and no further: over a copy the calendar drew three weeks out, the box offers whole series and names why the other half is not there. The API and the agent have no box: update_task writes the stored series directly, by design — there is no cursor there to have meant one occurrence by.

Time spans are real. start_at + end_at on a task, surfaced as span_minutes in the API and 09:0010:30 in the UI.

Inbox is a query, not a list. Any task with no area and no project is in the Inbox.

Cross-container views carry a breadcrumb. Today, Inbox and an area listing show where each task lives — 🇬🇧 English English speaking system wave 1 Prep — because a row pulled out of its container is meaningless without it. A task with no home reads inbox. Inside a project the breadcrumb collapses to just a foreign area badge.

A project can span several areas. The area lives on the task, not the project. A task filed into a project inherits that project's area, but you can override it — so vertaship can hold AI work and Foreign-job work at once. The project row then shows +area for every area beyond its own, and enter on an area lists everything filed there, project work included.

API

wave serve binds loopback; -lan binds 0.0.0.0 and prints the address, unauthenticated, which is fine at home and not on café wifi. It runs over the legacy local file and writes rows straight, so nothing changed through it reaches the log. The same routes are on the origin behind identity, and there they are journalled: a request mints an op and applies it in one transaction, exactly as a pushed op is. So a change made through them — which is everything wave mcp does — reaches every replica on its next pull, and a change the op vocabulary cannot express is refused with a 4xx that names it rather than written quietly.

GET    /api/today            GET    /api/inbox
GET    /api/areas            POST   /api/areas
GET    /api/projects         POST   /api/projects
GET    /api/projects/{id}    PATCH  /api/projects/{id}
GET    /api/projects/{id}/waves
POST   /api/projects/{id}/waves
GET    /api/tasks            POST   /api/tasks
PATCH  /api/tasks/{id}       DELETE /api/tasks/{id}
POST   /api/tasks/{id}/complete
POST   /api/tasks/{id}/reopen
POST   /api/tasks/{id}/restore   (undoes the DELETE above; 404 if there is no task to give back)
POST   /api/tasks/{id}/reorder   {"delta": -1}   (moves through the global order)

On the origin, behind identity and the X-Wave-Tenant space header:

GET    /v1/tenants          POST   /v1/tenants
GET    /v1/whoami
POST   /sync/push           {"device_id": "...", "ops": [{"op_id", "act_id", "kind", "payload"}]}
GET    /sync/pull           ?device_id=...&since=<seq>&limit=<n>

/sync/push is the journal: it answers with the seq it gave each op, in the order they were sent, and sending the same op_id again returns the same seq without writing anything — a dropped answer is retried, not reconciled.

act_id says which ops were one decision — twelve tasks completed by one keystroke share one, twelve completed one at a time do not (ADR-0019). It is optional: an op recorded before acts existed, or by a client that predates them, carries none and is an ordinary op in every other respect. The origin stores it and hands it back on a pull; what it is for — taking an act back — lives on the device that recorded it.

/sync/pull reads it back. With a since it answers with the ops after it, in order, bounded by limit and saying has_more when there are more. Without one it answers with a snapshot of the space at a known seq — materialised rows plus the ids that are dead — and the device follows the log from there instead of replaying it from zero. The since a client sends is also its acknowledgement: nothing else is treated as one, so a lost answer costs nothing.

Layout

cmd/wave          entrypoint, subcommand routing
internal/domain   the bizmodel — Area, Project, Wave, Task
internal/storage  sqlite, goose migrations, repository
internal/service  gating, recurrence, progress rollup
internal/httpapi  REST handlers, DTO mapping, the embedded PWA — index.html,
                  wave-core.js (the vocabulary in JS), wave-replica.js (IndexedDB + the loop)
internal/mcp      JSON-RPC over stdio, tools, name resolution
internal/tui      bubbletea model, vim keymap, completion, $EDITOR bridge, rendering

cmd/wave-server   the hosted origin — the copy every device will sync against
internal/config   envconfig, WAVE_ prefix
internal/logger   zap
internal/origin   echo server, identity middleware, spaces, /sync/push, /sync/pull
internal/postgres pgx pool, goose migrations, the op applier
internal/ops      the op vocabulary — the seventeen intents, their payloads, and the pull page
internal/storage  the device replica: the same rows, the unsent queue, the cursor, the applier
internal/syncer   the client loop — push, pull, the chosen space, the connection state
internal/recur    next occurrence of a recurrence rule, shared by service and applier

The origin

wave-server is the hosted half: one Postgres-backed server every device syncs against. It holds the rows, the spaces they live in, and the journal that orders every change (VERTA-3564), and it hands that journal back — by cursor, or as a snapshot to a device starting from nothing (VERTA-3566). The TUI runs against a replica of it (VERTA-3568) and so does the phone (VERTA-3576): each records intents, applies them locally, and reconciles in the background. The agent has no replica and calls /api/* over the network, but the origin journals those routes too (VERTA-3615), so what the agent changes reaches every replica. wave serve offers the same routes over the legacy local file, unjournalled, for scripting.

make build-server           # bin/wave-server
make test-integration       # testcontainers: migrations from an empty database

Run it against a local Postgres:

export WAVE_POSTGRES_DSN='postgres://wave:wave@localhost:5432/wave?sslmode=disable'
./bin/wave-server

GET /healthz    200, names the commit; never touches the database
GET /readyz     200, or 503 when Postgres stopped answering

Every setting comes from the environment, WAVE_ prefixed — WAVE_HTTP_ADDR (:7717), WAVE_POSTGRES_DSN (required), WAVE_LOG_LEVEL, WAVE_LOG_FORMAT (json/console). Nothing is read from a flag: a secret on a command line ends up in shell history.

Who is asking

Every route except the two probes needs a verta identity token. identity issues opaque tokens, not JWTs, so the origin cannot check a signature: it asks identity about the token (RFC 7662 introspection) and caches the yeses for seconds. A revoked session therefore stops working almost at once instead of living out its expiry, and any failure — identity down, hung, erroring — is a refusal, never an accidental yes.

A refusal names whose fault it is. 401 means the token was the reason: absent, malformed, inactive, minted by another issuer or for another audience. When the origin could not reach a verdict at all it answers 503 with Retry-After, because "we could not ask identity" is not "your session is bad", and sending the operator to sign in again is advice that cannot work (ADR-0015). Which check failed stays in the log; the caller learns only which side of the wire to look at.

WAVE_IDENTITY_BASE_URL           where the discovery document lives
WAVE_IDENTITY_AUDIENCE           wave's application_id (vt_client_ACYJ1VDBGJA963H5);
                                 a token minted for another app is refused
WAVE_IDENTITY_INTROSPECT_TOKEN   wave's own service token, scope session:introspect
WAVE_IDENTITY_CLIENT_ID          the application the phone logs in as; defaults to
                                 the audience, which is the same string today

The issuer and the introspection endpoint are read from the discovery document at startup, never hardcoded. Both required variables have no default: an origin that starts with authentication quietly off is not a state worth being able to reach.

GET /v1/whoami   200 with the account behind the token and the space it resolved
                 to, 401 without a token
GET /v1/tenants  the account's spaces; sits ahead of the space resolver, because
                 listing them is what you do when you cannot yet name one
POST /v1/tenants opens another space, `{"name": "Work"}`

The contract itself lives in third_party/verta-identity/pkg/infraauthv1 — vendored, not forked. Re-copy it from verta-identity rather than editing it.

The origin also serves the phone app itself — /, its two scripts, its service worker, its manifest, its icons and /auth/callback — all without a token, because a signed-out operator has to be able to load the page they are going to sign in on. The list is explicit rather than a /* prefix: authentication is global with an allowlist precisely so that a route added later is closed until somebody opens it, and an asset that is not on the list answers 401 to the operator who has not signed in yet. GET /v1/auth/config publishes where that sign-in happens, read from the discovery document rather than written down. Preflight requests are exempt by method, not by path: an OPTIONS carries no Authorization by definition, so demanding one answers every cross-origin caller with a 401 it cannot act on.

Registering the browser redirect is an Admin action on the Wave application: add https://wave.vertaspace.com/auth/callback and leave allowed_origins empty — identity derives the origin from the redirect and admits it to the CORS lane the login needs (ADR-0007).

Spaces

One account can keep several spaces — work and personal, invisible from inside each other. The terminal picks one and remembers it.

In the TUI that is ctrl+s: the account panel lists the spaces, marks the one you are in, switches to another with enter and opens a new one with n — a name typed inline, and the space appears in the list with the cursor on it. Making one is not switching to it, so the replica under the interface stays where it was until enter says otherwise. A name the account already uses is refused before the request goes out: the origin would take it, and the pair would then be two spaces wave use cannot tell apart. Signing in happens in the same box — an account owning exactly one space lands straight in it, because the origin resolves that case itself — and so does signing out: s drops the tokens and the choice together and leaves you on the screen you sign in from, not in the shell. Unsent work is named as a number and asked about first; it stays in that space's file and goes up at the next sign-in either way. The subcommands are the same thing without a screen, which is what wave mcp, wave serve and scripts have:

wave login                  # device flow, opens a code to approve elsewhere
wave tenants                # the account's spaces, `*` marks the current one
wave tenants --create Work  # opens another
wave use Work               # by name or by id; the choice survives a restart
wave whoami                 # who you are, and which space you are in
wave logout                 # wipes the tokens and the choice together

The choice is stored in ~/.wave/session.json (0600) beside the tokens and travels on every remote call as X-Wave-Tenant. Own more than one space and choose none, and the origin refuses rather than guessing — the CLI turns that into wave use. Per-space client state — the replica, the unsent queue, the cursor and this device's id — is one SQLite file under ~/.wave/spaces/<space>/replica.db, so switching cannot send one space's queue to another: the other file is simply not open (ADR-0006). Either way in — the panel or wave use in another terminal — the previous space's unsent work stays in the previous space's file; from the panel the switch is immediate, and from another terminal a running TUI picks it up within a few seconds.

In production it ships to wave.vertaspace.com through Vertaship — deploy/ holds the compose and the runtime image, vertaship.yaml declares the shape, and the deploy workflow builds, pushes and rolls it forward. Secret values live in Infisical and reach the node as deploy/.env; .claude/skills/{run-local,deploy,secrets} are the local runbooks.

Importing the local database

The single-machine file predates the origin, so it moves there once — as ops on /sync/push, never as a snapshot written past the journal:

wave import --dry-run          # what would travel, and where; sends nothing
wave import                    # asks which space, takes a backup, then pushes
wave import -space Work ~/old.db

The source is opened mode=ro and never migrated: a file still on the pre-00004 schema is read as it is, and the manual order is minted from position, so an un-migrated file and a migrated one produce byte-identical ops. Every op_id is a hash of what the op says, so a second run is a replay the origin already knows rather than a second set of rows. A copy of the database — with its -wal and -shm, which is where a WAL tail lives — is taken into ~/.wave/backups/ before the first op goes out, and the path is printed.

Not done yet

The agent works only online — its writes reach every replica (VERTA-3615) but it holds no local copy of its own, so with no network it does nothing. A recurring task ticked off on the phone rolls forward on the spot for the rules wave's own picker writes, and only those: a rule carrying COUNT or UNTIL, or one imported from another tool, still waits for the origin to say which date it lands on next, because a client that guessed would put the task on a day no other device has (ADR-0027). Subtasks under a task, notifications, an archive view.