Skip to content

Latest commit

 

History

History
184 lines (136 loc) · 61.3 KB

architecture.md

File metadata and controls

184 lines (136 loc) · 61.3 KB

Orbyn Planner architecture

The plan lives in the internal Orbyn Platform Plan, and the screens in the internal Orbyn Planner Designs. This file is the contract the UI follows. Change it in the same commit as any rule it states. The code layout, one-way imports and feature front doors are in web/src/README.md.

Rules

  • One UI for web, desktop and phone. Platform code stays in thin shells.
  • The planner is for everyone: no student, engineer or business defaults, and no AI in v1.
  • Orbyn house style: Geist and JetBrains Mono; Light, Dark and Dim; at most three interface colours: ink, amber when the person must act, and red for failures and removals. A person's list, calendar and project colours are content and keep their colour.
  • Wire types come from orbyn-engine's contracts and are generated, never written by hand, from the engine commit pinned in web/src/generated/wasm/manifest.json (the same one the wasm package and the end-to-end job use; generate and generate:check refuse any other checkout). They change with a deliberate pin bump, not when engine main moves.
  • The app works offline. A sync line always says whether this device is synced, syncing, offline with changes waiting, or unable to sync.

Design tokens

web/src/styles/tokens.css holds every visual and motion value, taken from the Planner Designs, Motion Designs and Components sources, and tokens.ts mirrors the few the JavaScript needs (a test keeps them equal). Nothing else in web/src may hard-code a colour, a font, a font size, a spacing or size length, a radius, a shadow, a duration or an easing: use the token (var(--sp-12), var(--fs-12-5), var(--r-9), var(--bw), var(--sh-pop), var(--m-fast)).

  • Colour: the light, dark and dim sets (--bg, --main, --raise, --sel, --line, --line2, --t1–--t4, --btn, --btnt, --amber, --amber-text, --amber-bg, --amber-line, --red, --red-bg, --c1–--c4, plus --green and --blue with their tints). --amber is for fills, dots, borders, icons and large figures; every small amber text uses --amber-text (light #9a5705, 5.62:1 on #fff and 5.20:1 on #f6f6f6; in dark and dim it equals --amber). Red is #d42a2a in light (5.04:1 on #fff, 4.67:1 on #f6f6f6).
  • Fonts and sizes: --sans and --mono, weights 400 and 500 (--fw-regular, --fw-medium), font sizes --fs-*, radii --r-* (--r-pill, --r-round), border widths --bw*, spacing and sizes --sp-* (px), and the time grid's --hour.
  • Shadows: --sh-pop (menus, popovers and dialogs; darker in dark and dim) and the rings --ring-1, --ring-2, --ring-amber, --ring-sel; --scrim is the dim behind a dialog.
  • Motion: the 13 tokens --m-fast, --m-base, --m-slow, --m-page and their -out versions, --m-stag, --m-wait, --m-hover, and the easings --m-out and --m-io, all multiplied by --k (1; 4 under html.slow, the designs' "Slow ×4"). Reduced motion is a media rule: no transforms, only colour and opacity change, at --m-fast. Only transform, opacity and colour animate. --spin is the spinner's turn from the designs' .spin rule.
  • Red: a filled red button (.danger) is for confirming a delete or a remove inside a dialog and nowhere else; red text (.ghost.red, .mi.red, .red-text) is for every other delete or remove, on --red. The button fills with --danger-bg, darker on hover (--danger-bg-hover) and press (--danger-bg-press), with white text: light #d42a2a, #b91c1c, #991b1b (5.04, 6.47 and 8.31 to 1), dark #dc2626, #c81e1e, #b91c1c (4.83, 5.74 and 6.47), dim #c93a33, #b8322b, #a82a24 (5.08, 5.95 and 6.95).
  • Not tokens, by necessity: @media breakpoints (a custom property cannot be used in a media query; the phone breakpoint, 767px, is PHONE_MAX_PX in tokens.ts), percentages, unitless line heights, z-index layers, and the pixel position a time-grid event gets from its start and length (computed from HOUR_PX).
  • No 3D anywhere, and the calendar modes are Month, Week, Day and Agenda only.

Local store

The UI talks to a PlannerStore. LocalStore keeps the records in IndexedDB, one object store per record type, and the Rust engine will later implement the same interface.

  • Patches, not records. A write is a patch: a create carries the new record, an update or delete only the fields that changed. In one readwrite transaction the store reads the current record, writes it with just those fields applied and folds the change into the outbox. A tab never writes back its own whole copy, so a stale tab's edit to one field cannot revert another tab's edit to a different field.
  • One outbox entry per record. An entry is {type, id, op, changedFields, idempotencyKey, createdAt, updatedAt}. Later edits of the same record merge into it, the latest value winning, and every merge takes a new idempotency key, so changed content is never mistaken for a retry of what was sent before. When sync arrives, an entry being sent is frozen and later edits start a new entry; a retry of the frozen one reuses its key. An entry stays a create while it is pending. Purging a record that never synced (its entry is still a create) removes the entry and sends nothing. "N changes waiting" counts records. The key (seq) is allocated by IndexedDB inside the transaction, never counted in memory.
  • Tombstones and purge. Delete sets deletedAt; the record stays and can be put back for 30 days. A purge removes the record, removes its pending create or update entry and leaves one delete entry for the engine. A purge on load is skipped when the device clock is more than a day past the newest timestamp it can trust, so a clock set far ahead cannot empty the Deleted list. A timestamp the device made within the last minute is not trusted, because it came from the very clock being checked; until sync exists the evidence is the device's older stamps, and once it does the newest server time replaces them. After a long idle spell the purge waits for the next session after a change.
  • Tabs. After each committed write a tab posts the changed record ids on BroadcastChannel("orbyn-planner"); the other tabs re-read those records from IndexedDB. A version change closes the connection and reloads the page, and a blocked upgrade says which tabs must close. The Inbox has a fixed id so two tabs starting together make one.
  • Failures are shown. A failed or full write, or a database that will not open, sets the store's persistence state to failed with a reason; the sync line then reads "Couldn’t save on this device" and nothing claims the changes are saved. A failed open does not fall back to memory. Memory alone is used only where IndexedDB does not exist, and then the line reads "Not saved on this device".
  • Checklist order. Steps carry a fractional order key (web/src/core/planner/order.ts, a port of the engine's order.rs), so inserting a step changes no other, and steps sort by key then id.

Projects, repeats and reminders

  • Projects hold sections (a fractional order key each, so moving one changes no other) and tasks carry projectId and sectionId. Deleting a section moves its tasks to "No section". A finished project is archived. Stored, a section is three fields of the project (section.<id>.name, .order, .removed), so two devices editing different sections keep both edits.
  • A repeating task is one record: repeat is the engine's Series (its start is the first due date) and due is the current occurrence. Completing it moves due to the next occurrence at the same time of day and records the completion in completions, which feeds "Done this week"; the last occurrence of a count or until end finishes the task. Completing also unticks the checklist for the next time; when the next date is still in the past the toast offers "Skip to next upcoming". Editing the due or repeat asks This task, This and following or All. "This task" skips the date and makes a one-off copy (overrideOf) on the new one; "All" moves the series and its weekdays (a move that would split an every-other-week cycle is refused); a changed rule offers only the last two; a repeat needs a due date, so the date of a repeating task cannot be cleared without stopping the repeat first.
  • Reminders are minutes before the due time (0 is at the due time); a date with no time reminds at 09:00. Notifications use the browser's Notification API only after the person turns reminders on in Settings · Notifications, which is where permission is asked. They fire while the app is open, from one timer per reminder in the next day, rebuilt hourly and whenever the page becomes visible. A reminder inside quiet hours is skipped. "N days before" counts calendar days. With several tabs open, one holds a Web Lock and is the only one to notify. The phone and desktop apps will remind when the app is closed.

Accounts

Accounts are optional in v1. Signed out, the person sees the landing page, and "Use without an account" keeps everything on this device; Settings · Account offers sign-in later.

  • Crypto is not ours. OPAQUE and the Ed25519 and X25519 device keys come from the engine's WebAssembly module, orbyn-wasm. AuthCrypto (web/src/core/account/authCrypto.ts) mirrors that module's functions name for name, and WasmAuthCrypto.ts wires them up. The module is not part of the build yet, so nothing is registered and sign-in says "Sign-in isn't available in this build yet" before it sends anything. FakeAuthCrypto and the mock engine are for tests only; a test fails if a file outside testing/ imports them or if the production bundle contains them.
  • Device keys and the session live in IndexedDB in their own database, orbyn-planner-keys, never in localStorage or in the planner's database. The token is also held in memory, sent as Authorization: Bearer, and cleared on a 401. Signing out forgets the device: the keys, the engine's saved state and the wrapping key are deleted together, under the same lock the engine uses and with the other tabs told over a BroadcastChannel, so the next sign-in is a new, pending device. A session that merely ends (a 401, an expiry) keeps the keys, so the next sign-in can prove it is a trusted device. The keys and the saved state are tied to the account, the normalised email and the device they were made for. Signing in with another email first asks ("This browser holds the keys for another Orbyn account (s•••@example.com)…", Cancel or Sign in anyway) and sends nothing until the person agrees. The attempt then uses new keys held only in memory, with no proof from the old ones, so one key never signs for two accounts and the server can't link them. The stored keys, snapshot and wrapping key are replaced, and the quarantine cleared, only after the sign-in succeeds; a mistyped email or password leaves them untouched. A new device id wipes the saved trust state, and these changes run under the engine's lock. Replacing keys for another account is one IndexedDB transaction across every store but the session (old keys, snapshot, wrapping key, identity and quarantine cleared; new keys, wrapping key and identity written), so a failure partway leaves the old device as it was and there is never a moment with keys and no identity.
  • Adding a device is a two-way comparison (the engine's architecture doc has the exact order; web/src/state/services/pairing.ts follows it). The trusted device A sends only a commitment to its nonce; the new device D answers with its own; A reveals; then both show the same six digits (SAS) and A also shows D's key fingerprint. Nothing is approved until the person taps "They match" on A, which signs; D then finishes when it has seen A's signature and tapped "They match" (or typed A's digits), and sends the digits once. D polls GET /v1/devices/current from 2 s backing off to 10 s, for up to ten minutes. While D finishes, A polls the device list and the approval; D's finish closes the approval too, so an approval that answers 410 is read against the device list once more, and if D is trusted then A is done, not closed (the two reads are separate requests, and D can finish between them). One attempt per approval: a cancel, a mismatch, an expiry or a failure closes it on both devices and the only way on is "Start again", a new approval. The hashing, nonces and signatures are AuthCrypto.approvals(...) (orbyn-wasm's SyncEngine); the planner holds the in-progress sessions in memory only, so a reload ends an attempt. The same steps approve a device the server lists as trusted that this device no longer trusts (stranded by a removal, or approved before the two-way code): Settings · Account offers "Approve again" for any device in verifyDevices outside this device's own trust view, never from the server's state, and a trusted device that is sent such an approval (GET /v1/devices/current returns it) only gets a notice, "Another device asked to approve this one again"; nothing is joined and no nonce is sent until the person taps "Approve this device again", and dismissing hides it for that approval. A second tab of a waiting device that finds the approval already answered shows that and never cancels it. The engine's state (who this device trusts, and the removal sequence) is one SyncEngine per signed-in session, kept sealed in the key store beside the device keys: the module seals it (engine.snapshot(wrapSecret)) under a secret derived from a non-extractable WebCrypto key, and it is never stored unsealed. A snapshot that can't be read, or that was made for another account or device, is moved to a quarantine and the engine starts again from itself. The quarantine keeps three: the first one, pinned, since it is the original, and the newest after it; an identical blob is kept once. The person is told whenever the quarantine is not empty and the wrapping key that sealed it still exists, so the notice survives a reload and reaches the other tabs; forgetting the device clears the quarantine with the key. A failed save fails the action, so a signed removal or confirmation is never sent without its state saved. Each action (approve, pin after the digits, verify the list, remove) runs under a Web Lock shared by the tabs: it reloads the saved snapshot, restores the engine (a device with none starts pinned to itself), acts, and saves, so two tabs never overwrite each other's pins. Removing a device sends {sequence, signature} from signRemoval, signed with a sequence one above the highest removal this device has counted from the device list.
  • Not built yet: passkeys, recovery, and a name or email taken from the server (the engine has no such route yet, so they are kept from sign-up).

Calendar feeds

  • Time. A feed event keeps its wall time and IANA zone; each occurrence is converted to the viewer's zone, so daylight-saving changes in either place land right. UTC (Z) UNTIL, EXDATE and RECURRENCE-ID values are converted into the series' zone before their date is taken. A start inside a skipped hour moves forward by the gap and keeps its written length. Expansions are cached per event, range and viewer zone.
  • Repeats. Only FREQ, INTERVAL, BYDAY (weekly), COUNT and UNTIL are read. Any other rule shows the first date only, with a note, and is listed under "Can't show these repeats" in Calendars; no dates are guessed.
  • Fetching. With VITE_ENGINE_URL set, the engine fetches feeds (POST /v1/feeds/fetch, {url} answered by {status, body}; the request carries the session's bearer token, since the route needs a signed-in, trusted device; refusals are ApiError codes invalid_url, blocked_address, too_large, timeout, upstream_error, unauthorized, device_not_trusted, too_many_attempts, feeds_unavailable, internal). Signed out, or without an engine, the web build fetches directly, which many servers refuse; the desktop app, whose page may not fetch other sites, says "Sign in to subscribe to calendars" when signed out and "Subscriptions need the Orbyn engine" without one.

Desktop

desktop/ is a Tauri 2 shell with its own Cargo workspace; nothing else builds it. It opens one window on the web build (the dev server in development) and adds the standard menus and remembered window state. It exposes two commands to the page: open_link, which opens an http(s) address in the system browser (through tauri-plugin-opener), and save_export, which saves a file the person asked for (an export, see Export, or their recovery codes as .txt) through the system's save dialog (tauri-plugin-dialog). The plugins' own commands are in no capability, so the page has no more power than in a browser: it can't choose a path or write anywhere without the person picking it.

  • Navigation. The window may show only the app's own pages (tauri://localhost, http(s)://tauri.localhost) and, in a debug build, the dev server on port 5173. Links to anywhere else, the engine included, are refused, and a request for a new window is denied.
  • Content security policy. Built from desktop/config.json (engineUrl) by desktop/scripts/engine.mjs: scripts only from the app, connect-src the app and the engine origin, no frames or objects.
  • Platform. web/src/lib/platform.ts tells the desktop app from a browser by Tauri's injected __TAURI_INTERNALS__; the browser-only install hint in Settings is hidden in the desktop app.
  • Links. A link in notes or anywhere else opens in the system browser through open_link; the window never navigates to it. A primary click and a middle click (auxclick) are both handled; a right click is left to the context menu.
  • Content security policy. scripts/tauri.mjs generates the window's policy from the engine URL at build time: a release build allows its own origin and that one https engine origin, and the script refuses to build if the policy would allow localhost, 127.0.0.1 or ::1. A development build adds localhost and the dev server. The checked-in tauri.conf.json carries the closed policy (no engine), so a build that skips the script reaches no engine.
  • Engine address. Production builds refuse a plain http:// engine URL; development accepts it for localhost and 127.0.0.1 only. Feed subscriptions go through the engine (POST /v1/feeds/fetch), since the page's policy forbids fetching other sites.
  • Not yet. Code signing and notarisation, auto-update and background reminders.

The engine's WebAssembly module

Accounts, device keys and the sync engine's saved state run on orbyn-engine's orbyn-wasm crate, not on code in the planner.

  • Built and committed. web/src/generated/wasm/ holds the package (orbyn_wasm.js, its .d.ts, orbyn_wasm_bg.wasm), built by npm run wasm from ORBYN_ENGINE_DIR as the engine's crates/orbyn-wasm/README.md describes: cargo build -p orbyn-wasm --release --target wasm32-unknown-unknown, then wasm-bindgen --target web. web/wasm.lock.json pins the Rust toolchain and the wasm-bindgen CLI (which must equal the crate version in the engine's Cargo.lock; the script checks); build-machine paths are remapped so the bytes are the same anywhere. manifest.json beside it records the engine commit the package was built from, the Rust and wasm-bindgen versions and the sha256 of each file. CI checks out the engine at exactly that commit (ref: from the manifest), fails if the checkout has any change (git status --porcelain not empty), and npm run wasm:check rebuilds on Linux and requires every file, the .wasm included, to be byte for byte what is committed. The committed .wasm is the Linux build (the same source lays its data out differently on macOS, with identical glue), so the CI comparison is exact and an edited .wasm with an edited manifest does not pass. On a Mac, npm run wasm:check:local (--local, ignored on Linux and in CI) lets a .wasm that differs only in those bytes pass with a warning, if the engine checkout is the pinned commit and clean and the toolchain is the pinned one. Moving the pin is deliberate: check the engine out at the commit you want, run npm run wasm on Linux (CI's strict check passes only for the x86-64 Linux build: an arm64 Linux container, such as Docker on an Apple Silicon Mac without --platform, makes a different .wasm from the same source, so always pass --platform linux/amd64; on a Mac, in Docker: docker run --rm --platform linux/amd64 -v <engine>:/work/engine:ro -v <planner>/web:/work/web -v script.sh:/run.sh:ro rust:1.99.1 bash /run.sh, where the script installs Node and git, adds the wasm32-unknown-unknown target for the pinned toolchain, installs wasm-bindgen-cli at the pinned version and runs ORBYN_ENGINE_DIR=/work/engine ORBYN_WASM_TARGET_DIR=/tmp/target node scripts/wasm.mjs), and commit the package with its manifest. Needs cargo install wasm-bindgen-cli --version 0.2.130 --locked and rustup target add wasm32-unknown-unknown --toolchain 1.99.1.
  • Loaded only when accounts are used. src/data/account/realCrypto.ts imports the module dynamically, and only when the build has an engine address (VITE_ENGINE_URL). Vite puts the glue and the .wasm in chunks of their own; a build without an address drops the import and ships no WebAssembly at all (a test builds both ways). createAuthServices({ ready }) holds the first read of a saved session until the module has loaded. WasmAuthCrypto is the adapter, and the compiler checks the real module against its shapes. FakeAuthCrypto stays for unit tests and is never bundled.
  • Content security policy. Compiling WebAssembly needs 'wasm-unsafe-eval' in script-src. The desktop app's policy (desktop/src-tauri/tauri.conf.json and desktop/scripts/engine.mjs) has it, and no 'unsafe-eval'. The web build sets no policy of its own; a host that adds one must allow the same.
  • What the real module does that a fake should too. Every disagreement in the pairing (the digits, a nonce against its commitment, the signature, an approval older than ten minutes, keys the server swapped, a step out of order) is the one code mismatch; only a second use is already_finished. Any failure of the sign-in protocol, a wrong password or a malformed answer, is auth_failed. A wrapping secret under 32 bytes is invalid_wrap_secret; a wrong secret, other keys or changed bytes are open_failed; bad JSON is invalid_json. The planner checks an approval's age itself to say "ran out of time".

Running the end-to-end test

src/e2e/engine.e2e.test.ts signs up, signs in on a second device, pairs with the digits compared by the test, removes a device, and checks a cancel and a refusal, all against a real engine with the real module. It runs only when ORBYN_E2E_ENGINE is set.

The End to end job in .github/workflows/ci.yml runs it on every pull request, as a check like the others (timeout-minutes: 20). It checks out orbyn-dev/orbyn-engine at the commit in web/src/generated/wasm/manifest.json (refused unless it is 40 hex characters, as in the Wasm package job), so the engine under test is the one the committed module was built from. It then:

  1. builds the engine's Docker image (docker compose build);
  2. removes any volume (docker compose down --volumes), so the database is made fresh for the run, and makes ORBYN_AUTH_SETUP with orbyn auth-setup inside the image (masked in the log);
  3. starts postgres, migrate, server, worker and mailpit (a mailbox the worker sends to, from .github/e2e-mail.compose.yaml, which the job adds to the engine's compose file with COMPOSE_FILE; sign-up mails a six-digit code and the tests read it from Mailpit's API at ORBYN_E2E_MAIL), deletes the rows of auth_attempts (already empty on a fresh database; it keeps a repeated run on a reused one under the sign-up limit), and waits up to two minutes for /health;
  4. runs npx vitest run src/e2e with ORBYN_E2E_ENGINE=http://127.0.0.1:8080 (the files run in parallel against one engine. They once had to run one at a time because pairings occasionally failed, from two causes: the approver closing when D finishes between its two reads, fixed in the planner, and the engine refusing two simultaneous sign-ups from one address as "too many attempts", fixed in orbyn-engine, which the pinned commit includes);
  5. on failure, uploads docker compose logs as the e2e-compose-logs artifact; always, stops the stack and removes its volume.

To run the same thing locally, check the engine out at the pinned commit (git -C ../orbyn-engine worktree add --detach /tmp/engine-pin $(node -p "require('./src/generated/wasm/manifest.json').engine"), from web/), then:

cd /tmp/engine-pin
docker compose down --volumes --remove-orphans      # a fresh database
docker compose build
SETUP=$(docker compose run --rm -T server auth-setup)   # prints the value alone
export COMPOSE_FILE=compose.yaml:<planner>/.github/e2e-mail.compose.yaml   # the mailbox for the sign-up code
ORBYN_AUTH_SETUP="$SETUP" docker compose up -d postgres migrate server worker mailpit
docker compose exec -T postgres psql -U orbyn -d orbyn -c "delete from auth_attempts"
curl -fsS http://127.0.0.1:8080/health
cd <planner>/web
ORBYN_E2E_ENGINE=http://127.0.0.1:8080 ORBYN_E2E_MAIL=http://127.0.0.1:8025 npx vitest run src/e2e
docker compose down --volumes                       # in the engine checkout

The files run in parallel, as in CI.

The compose file publishes Postgres on 5432 and the server on 8080, so another engine stack on the machine is in the way. Put a compose.override.yaml beside the checkout's compose.yaml that moves the ports (ports: !reset [] for postgres, ports: !override ["127.0.0.1:18080:8080"] for server) and give every service of orbyn-engine:dev another image: so the build does not replace your own, set COMPOSE_PROJECT_NAME to another name, and point ORBYN_E2E_ENGINE at the new port. Each run makes a few accounts and the engine allows 20 sign-ups an hour per address; if a run on a reused database answers "Too many attempts", the delete from auth_attempts line above clears the window.

Record sync

A signed-in, trusted device syncs its planner data end to end encrypted through the engine, and stays local-first: every write goes to IndexedDB and the outbox first, and sync only moves what changed. Nothing here runs for a signed-out, local-only or pending device.

  • Zones. One per list (list:<id>), project (project:<id>) and calendar (calendar:<id>), named by kind and id so the name is the same on every device. A task lives in its list's zone and an event in its calendar's; the list, project or calendar record is the zone's own record (its id is the zone's). The type is the engine's own: record() returns {record_type, fields}, authenticated with the record, and it is checked against where the record is: a calendar zone holds only events and the calendar itself, a list zone only tasks and the list, a project zone only the project, a record id equal to the zone owner's is only the owner kind and must look like one (a name, none of a task's or event's fields), and the server's record_type for a page, when there is one, must agree. Anything else is quarantined, not applied (src/core/sync/mapping.ts). A removal has no record to name a type, so the server's type for the page or the local record's stands in. A task moved to another list is deleted from the old zone and made whole in the new one. The account-level settings zone holds the tag definitions (records of type tag) and nothing else yet (preferences are still per browser); it is made, keyed and uploaded to like the others, once the first tag exists, and a zone of that name accepts only tag records with a name.

  • Keys. The device that first writes to a zone no one has made creates the zone (PUT) and its key (createZoneKey) and makes the key locally and records it (made) before PUT-ing the zone, so a crash between the two never strands a zone; "on the server, no key for me" is mine to create only if this device made the zone, otherwise it waits for keys and says so. The key goes to the trusted devices (envelopesFor, POST /v1/keychain/envelopes), and a zone gets no upload until that send is confirmed for the current device set (keysSent in the sync state); an unconfirmed send is retried on every pass. Others take envelopes from verified devices (acceptEnvelopes) at the start of every pass and retry what was waiting. When the set of trusted devices changes, every zone's keys are sent again, so a newly approved device gets them. Records in a zone whose key hasn't arrived wait in the engine's quarantine (Settings · Account says how many) and open when it does.

  • A pass (src/state/services/syncCycle.ts), in this order: stage the outbox; take keys; compare every zone's token (checkZones); fetch what changed and apply it; send what is dirty; send keys owed; clear what was acknowledged. Staging comes first because the engine's clock moves past every stamp it receives: each field is staged with the time it was edited (tickAt(editAt, now), which never goes behind the clock and treats a future time as now), so a conflict on one field goes to the later edit and not to whoever syncs last. Each field is staged with its own edit time (the outbox keeps a time per field), so an older edit of one field never beats a newer edit of another. Fields of a record merge one by one in the engine; the merged record then goes to the store through applyRemote, which uses the patch path with local patches (no outbox entry, other tabs hear it). A field of an edit not yet staged stays as it is on screen.

  • Outbox. An entry is staged once (its idempotency key is remembered in the sync state beside the engine's snapshot) and cleared only after its zone has nothing left to send, by acknowledge: an entry edited since keeps its newer key and stays. A lost answer replays: the engine returns the same batch with the same operation id until it is answered.

  • Deletes. A soft delete (deletedAt) is an ordinary field. A purge (a hard delete after 30 days) is the engine's signed delete; a record another device deleted while this one had edits goes to the engine's trash for 30 days.

  • Change tokens. src/core/sync/tokens.ts is the one place that knows how a zone's token is used and recovered. After a 410 token_expired to a fetch or an upload the engine is told (tokenExpired(zone)), the zone is fetched again from the start and its pages are applied with applyChangesFrom(zone, since, page); records the full listing no longer has go to the 30-day trash, and delete wins over edits, everywhere: a record that was on the server and is now gone keeps its unsent edits in the trash and its outbox entry is dropped, not uploaded, and Settings says "'<title>' was deleted on another device. Your unsent changes are in Recently deleted for 30 days." Only an unsent create, which the server never had, is never trashed: it is re-staged and uploaded. If the server refuses again right after starting over, the sync stops with a plain reason. The engine's own quarantine list (quarantined(zone)) is what Settings counts, besides the records this side rejected; skippedFutureStamps() after a restore is logged when it is not zero.

  • Live. One tab at a time (a Web Lock) runs the passes and holds /v1/sync; the others see the leader's writes as the store's own changes and its status over a BroadcastChannel. The socket's first message is the auth (the token is never in the URL), then ready, zone_changed and resync; a notice is only a hint to run a pass, and ready and resync run a full one. It reconnects with backoff (1 s up to 30 s, jittered) and a pass also runs after a change to the outbox (debounced), when the browser comes back online, and once a minute.

  • States. "Synced · N devices" is the last pass's trusted device count; "Offline · N changes waiting" is the browser being offline or a request failing, and the pass retries by itself with backoff (honouring Retry-After). A refusal that waiting won't fix shows "Couldn't sync · Retry". Settings · Account shows the same, what can't be opened yet, and "Sync now".

  • A refused zone. Each zone is handled on its own and the pass always goes on to the other zones, the acknowledgements and the state save. zone_deleted: the zone's local entries are dropped by the delete-wins rule (they go to the trash) and the person is told. record_too_large (or a record over 200,000 bytes, caught before upload): that record is marked failed, shown, and left out of uploads until it is edited again. Any other 4xx: the zone is skipped for this pass with a backoff. A transient failure still ends the pass after the acknowledgements, and is retried.

  • Stopping. stop() aborts the pass in flight: it is checked between steps and before every save, results that arrive after it are ignored, nothing is scheduled once stopped, and the sync state is never written after a forget (the check is inside the same lock as the save).

  • Accounts on one browser. Each signed-in account has its own planner database (orbyn-planner-account-<id>), outbox included, and the signed-out planner is a separate one ("on this device only"); nothing is uploaded to an account silently, and one account never sees another's records or outbox. Sign-out asks "Remove this account's planner from this browser?": Remove (the default) deletes that database; "Keep on this browser" leaves it, and it opens only when the same account signs in again. After sign-out the app opens the device-only planner. Replacing the keys for another account (replaceDevice) clears the sync state but leaves the previous account's planner as Keep would.

  • Bringing the device-only planner along. The first time an account signs in on this browser, if the device-only planner holds anything beyond the Inbox and Personal, the app asks "Add this device's planner to your account?". Add to account (primary) takes those records into the account's database as new local edits (LocalStore.adopt, which skips ids already there, so it is safe to repeat), waits for them to be written, then deletes the device-only database; they upload through the normal outbox and keys path. Keep separate leaves it as it is, and it opens again after sign-out. The answer is remembered per account per browser (localStorage, with a page-lifetime fallback), so the question comes once; nothing is asked, or remembered, while the device holds nothing.

  • When the engine's snapshot goes. The sync state's staged, stagedZone, keysSent, made and quarantined are only true while the snapshot they describe is. Wherever the snapshot is dropped or replaced (a new device id at sign-in, an identity mismatch, an unreadable snapshot), KeyStore.clearSnapshot / resetEngineSide reset them in the same step, so every outbox entry is staged again and nothing is cleared as sent that never was. The fresh engine trusts nobody and holds no zone keys, so edits wait (visibly) until the device is trusted again and the keys arrive; the fetch never takes a remote delete for an unsent create.

  • A device that lost its saved state. The fresh engine trusts only itself while the server still lists other trusted devices. That is one state, "This device needs to be approved again to open your lists. Approve it from another of your devices", shown in Settings · Account and on the lists page; it replaces any waiting or stranded notice, and the outbox keeps every edit. It is the existing two-way "Approve again" path; when an approval completes, both devices forget which zone keys they sent (resendKeysAfterApproval), and every other trusted device does the same on its next pass when it sees the device's approval_id change in the device list (recorded per device in the sync state), so each sends the re-approved device every zone key it holds (at most once per approval value and once per ten minutes per device, so a server that changes the value on every poll can't make every pass resend every key; an approval completed here resends at once), including zones a third device made. The device then fetches, opens and uploads its outbox.

  • A zone nobody can open. Only when BOTH this device's own trust view AND the server's device list show no other trusted device, and this device has not been reset (a reset flag in the sync state, set whenever the snapshot is dropped and cleared when an approval completes here, so a server that hides the other devices can't make a reset device look alone) (its maker was removed before the key reached anyone, or the account's only device signed out with its planner removed) is a zone it can't open stranded. Settings · Account and the lists page say "This list can't be opened on any of your devices" and offer "Remove it", which asks "Delete '' on every device? This can't be undone." (Cancel, or Delete in red) ("Only do this if no other device of yours is signed in.") and then makes the signed zone delete for the account; the next pass drops this device's unsent changes for it. With other trusted devices nothing can be said for certain (the server doesn't know which device holds a key), so those zones stay "waiting for a key". The sign-out dialog warns when this is the only trusted device.

Export

Settings · Privacy · Your data has "Export your planner" (the design's Privacy screen has an "Export everything from this device" row with a Download button; this follows it, with a small dialog to pick the format). It is made in the browser from the local planner of the account that is open and saved with a download, or on desktop through save_export's save dialog. Nothing is sent to the engine, and nothing but the planner's records goes into either file: no keys, tokens, sign-in details or sync state (the outbox of waiting changes is left out). The dialog says the file is not encrypted.

  • JSON (src/features/export/exportJson.ts): format ("orbyn-planner-export"), formatVersion (1; raised only when a field is removed or changes meaning), exportedAt, then lists, tasks (each with the ids of its tags), projects (with sections), tags, calendars, events and trash (tasks, events and tags that were deleted, kept 30 days). readExport reads a file back and refuses another kind of file or a newer version.
  • .ics (src/features/export/exportIcs.ts): the events of the person's own calendars as VEVENT and tasks with a due date as VTODO (the list and the task's tags are its CATEGORIES). Repeats are RRULE (frequency, interval, weekdays on weekly rules, count or until), skipped dates are EXDATE, and an occurrence changed on its own is a VEVENT with RECURRENCE-ID on the series' UID. Times are floating (the wall time the planner shows), all-day events end the day after their last day, reminders are VALARM, and lines are folded at 75 octets. Deleted items and the events of subscribed calendars (copies of someone else's feed) are left out.

Tags

A tag is a record of type tag (name, colour, deletedAt) in the account's settings zone. A task carries a tag as one field per tag, tag:<tagId> = true (false to remove), never a single list field: the per-field merge then makes a task's tags a set, so two devices that add different tags to a task offline both keep theirs. The app reads those fields as task.tags (sorted ids) and writes them with setTaskTag; storage and the outbox hold only the fields, like a repeat's repeat.skip.<date>.

  • Trust. A tag:<id> field is used only when its id is UUID-form and its value is true or false, and a task keeps at most 100 tags (the first 100 by id). Anything else is ignored when a record is read and when fields arrive from another device (sanitizeFields); the task is never quarantined for it.
  • Names. A tag can't be made or renamed to the name of another live tag, compared without regard to case ("A tag with that name already exists."). Two synced tags can still share a name (two devices made one at once): both show, every one after the first (by id, the same everywhere) is marked "(2)", "(3)", and nothing is merged.
  • Purged tags. A purge is remembered (purgedTags in storage), and a task that still carries a purged tag's field has it cleared the next time that task is edited. Nothing is rewritten otherwise.
  • Deleting a tag sets deletedAt (it is hidden everywhere); the tasks keep their tag:<id> field, and an id with no tag, or a deleted one, is hidden, so restoring brings the tag back on every task that had it. A tag deleted for 30 days is purged with the other deleted records.
  • Where: tag chips beside a task's title and in its panel (type a new tag, or pick one; × removes it), a Tag filter on Tasks, and Settings · Tags (make, rename, recolour, delete, restore). The design has no tags screen; these use its chip, pill and row patterns. #name in quick add is the list in the design (its List column), so it is not a tag.
  • Engine: no change. The planner stages raw fields through stage (not the planner crate's Item), and the engine and server accept the settings zone and tag: fields (the e2e covers both).
  • Export: JSON has tags (and deleted ones in trash.tags), each task lists its tag ids, and the .ics gives a task's tags as CATEGORIES beside its list.

Two-factor

Settings · Account has a Two-factor row; its state is the engine's (totp_enabled on GET /v1/devices/current), read each time the row opens. The design has no two-factor screen; these use its account row and dialog patterns.

  • Turning on (src/features/account/TwoFactor.tsx): the trusted device signs the request (signTotpManage from the wasm module, orbyn/totp-manage/v1 over account, device, action and time; confirm also covers the code), so a bare token gets device_proof_required. The engine answers a secret and an otpauth:// link; the QR code is drawn on the device from that link (uqr, MIT) and the secret is shown as text. A code from the app confirms, and the ten recovery codes are shown once, with Copy and Download .txt (a browser download, or the desktop save dialog); Done waits for "I've saved these codes".
  • Turning off needs only this device's signature, no code.
  • Signing in (src/state/services/flows.ts, SecondFactorNeeded): when the password was right but the engine answers second_factor_required, the sign-in stays open with its keys in memory (nothing stored) and the screen asks for the six-digit code, with "Use a recovery code" to switch. A wrong code is "That code didn’t work." and the sign-in stays open (the engine closes it after five); anything else sends the person back to the password step. A browser that already holds trusted keys is not asked.
  • A 401 second_factor_failed or device_proof_required on a signed-in call is about the request, not the session, so it doesn't sign the person out.

Email verification at sign-up

Sign-up makes no account and no session until the address is proven. After the password steps and the device's first signature the engine answers a signup_id and mails a six-digit code (the same answer for an address that already has an account, which is mailed a notice instead, so the page can't tell). The screen "Check your email" (src/features/account/AuthScreens.tsx) takes the code; signUp (src/state/services/flows.ts) returns a PendingSignUp that holds the device's keys in memory only, and stores them, the identity and the session only once verify succeeds. Closing the page, "Use a different email" or leaving the screen lets the keys go and leaves nothing behind (the engine drops the sign-up 30 minutes after its last code).

  • The code card follows Orbyn Account Designs ("Check your email: 6-digit code"): six boxes over one real field (typing, paste and autofill all land in it), Verify enabled at six digits, a 60-second countdown that then becomes the link "Send a new code", and "Wrong address? Change email". A wrong, expired or made-up code is one answer, shown in red boxes: "That code isn’t right. Use the code in the newest email." When the engine answers a resend with too_many_attempts, or after three resends in an hour (the page also counts them), an amber line says "You’ve asked for several codes…", the resend line becomes "Send a new code · not available right now", and the boxes stay usable.
  • If the code is right but this browser will not store the keys, the account is kept in this tab's memory only (withMemoryFallback in src/data/account/keyStore.ts). The keys, the wrapping key they are sealed under and the identity are written in one transaction (replaceDevice), so a refusal leaves nothing in storage; the tab then holds keys, identity, session and engine state in memory, and signing out (clearDevice) clears only the tab's memory: storage may hold another account's device, and nothing of this account reached it. The gate shows "Your account is ready, but this browser couldn’t save its keys" (KeysNotSaved) and nothing else opens. "Try saving again" carries the whole device (keys, engine snapshot, sync state with made and keysSent, quarantine, session) into storage in one transaction under the engine's lock, or leaves nothing written. "Approve another device from this tab" is a dialog: it waits for a new device (when several wait, "Which device is it?" lists them with the newest selected), shows its name, kind and time, then the 6 digits, the key fingerprint and They match; Escape, a click outside and Cancel all close the approval on the server too, except Cancel at the comparison, which says the digits didn't match. The dialog says why an approval ended: ten minutes passed (the engine answers an expired and a closed approval alike, so this device's clock, started before the request that makes the approval, tells them apart), the other device cancelled, the digits didn't match, or a network or server error ("Couldn't finish approving. Check your connection. Nothing was shared."; a coded refusal such as too many attempts keeps the engine's own words); each offers Start again. When it completes, "Your keys are on now." offers Continue or Sign out of this browser. After Continue the planner runs on a memory-only store (memoryPlannerFor), what the account screens remember goes to memory too (src/lib/browserStorage.ts), and closing the tab is asked about first while a zone made here has no key that really reached another device (MemoryOnlyGuard; the answer is worked out each time the sync state is saved, from keysReached, which is set only when an envelope was put, never from the keysSent marker alone). Reminders shown (shown.ts) and what the account screens remember also stay in memory then. Before a sign-up creates anything it checks that this browser can hold the keys at all; if not, "This browser can’t create an Orbyn account" says nothing was created (it offers "See the Orbyn apps", which links to <VITE_ORBYN_SITE_URL>/download (default https://orbyn.dev) with the line that the apps aren't out yet; VITE_ORBYN_APPS_AVAILABLE=true is the launch switch that makes the button read "Install the Orbyn app" and drops that line). Such a tab also takes no part in what tabs say to each other: it posts nothing on the sync status channel (device names, who waits for keys), does not compete for the global sync lock, and neither posts nor acts on the orbyn-planner-auth sign-out channel, so its sign-out clears only its own memory and another tab's sign-out leaves it alone.
  • An account nobody used stays unused. The sign-up uploads nothing and signs nothing, and the sync cycle sends nothing (no zone, no protection setting) until the account's first real action: a task, an event or a list of its own (state.opened in the sync state; the Inbox and the Personal calendar every device makes do not count). Until then the engine sees no stored data, so the Start over link in the sign-up email can still delete the account. Signing out with Keep on this browser keeps the device keys, so the next sign-in resumes the same device.
  • The protection setting. With that first real upload the first device signs Standard as sequence 1 (signStandardProtection in src/state/services/flows.ts: the engine's signProtection, then POST /v1/keychain/protection, then it verifies its own link). It never blocks the upload: if the call fails the data still goes and Settings says the level can't be confirmed (Not set, with Choose). On a protection_conflict (another device got there first) it fetches the chain and verifies it; it never signs again on its own.
  • Settings · Privacy · Protection (src/features/account/ProtectionSettings.tsx, trusted devices only) shows the level this device verified from the server's signed chain (readProtection: devices, then the chain from protectionFrom() page by page into verifyProtection), never the server's word, in the three states Orbyn Account Designs draw: Verified ("Confirmed by your devices. Last change 3 Nov on ."), Confirming (a provisional setting with nothing wrong) and Can't confirm. stopped is read from what verifying the last page returns, since the engine's later level report never carries it. Can't confirm has seven drawn reasons (rollback, fork, a signer this browser doesn't know, a signer the account doesn't list, bad signature, gap, invalid entry), each ending "This browser keeps the setting it last confirmed…" ("this browser" on the web; the desktop app says Mac or PC), then the laptop line "Open Orbyn on a trusted device to fix this." and Details, except the two signer cases, which lead with Review devices (opens Account). Not set (no setting signed yet) says "Choose your protection level" with Choose, which opens the confirm dialog for Standard. The title keeps the last setting this browser verified and its date, remembered per account (src/state/protectionNotice.ts), or "last confirmed on this browser" when there is no date. Verified adds the quiet line that right now only the trusted devices hold the keys and Orbyn holds no copy; Standard's recovery copy, account recovery and Advanced are Planned (Account Designs v47; the later wording waits for key service slice 8 and account recovery, orbyn-dev/orbyn-engine#66). Details (reason, newest setting and its signer, last confirmed here, Copy details) is for support; which devices confirmed a change is not shown, since nothing records it. Advanced is a dimmed row with a Planned label. A rollback or a fork is refused by the engine with no change, so the screen never keeps showing the earlier setting as verified.
  • Changed by another device. When the chain's head was signed by another device after the setting this device had verified before the read, a banner says "Protection was changed to Standard on · " with Review (Details) and Dismiss, remembered per account in local storage by sequence, and only when this device verified that head. Signing out removes everything the browser remembered here. Notifications (src/features/notifications) is one list with an unread dot, a Mark all as read button and a count on the sidebar. So far it holds two kinds: reminders that have come due ("<title> is due at 12:00" for a timed task due today, "is due today", "is due tomorrow" or "is due Monday 9 November" for a date alone, "was due yesterday" once late; a date alone never shows a time; amber while unread, Open task), and a protection change another device made (shield, "Protection was changed to Standard on ", "Opens Data protection in your account", the time, a blue dot). Selecting the protection row opens Settings › Privacy (src/state/settingsTarget.ts) until account.orbyn.dev exists. What was read is remembered in this browser. Left out because the designs mark them Planned: snooze, shared-list changes, comments, accepted events and grouping.
  • Confirm your protection level is the only thing that calls resignProtection (confirmProtection). It opens once per setting, per device, when the server's setting is one this device has not confirmed and nothing is wrong with it; Not now is remembered for that setting. Confirm signs that setting after the head. If another device signed first (protection_conflict), the screen reads the chain again and the dialog shows the new setting with a blue line naming the device, to be checked and confirmed again; it is never a retry of the old signing. Only when the first signing never happened (no setting on the server) does the earlier "choose a level" dialog show, since the designs have no state for it.
  • Not built: the "Signer's key" row in Details for an unlisted signer, because a protection link carries no public key for its signer; and the card for an account whose keys no browser could hold ("Your account was created, but this browser can't keep its keys"), because signing in from another browser would only wait for an approval that can never come.

Deleting the account

Deleting is asked for, waits seven days, and can be cancelled until then (the engine's docs/architecture.md, "Deleting an account", has the rules). Settings · Privacy has a "Delete account" row under Export your data. The dialog (src/features/account/DeleteAccount.tsx) names the date (seven days from now), what Orbyn Planner holds and that anything saved in other Orbyn apps goes too, that it is gone from the servers and every device, that Orbyn keeps a record of the deletion and its day with no email and no content, and that it can't be undone. There is no typed email and no password: with two-factor on (the engine says; if it can't say, the dialog shows the error and no form) the confirmation is a current 6-digit code or a recovery code (ten letters or digits), and Delete stays disabled until it is complete; with two-factor off, Delete is enabled. Cancel has focus.

  • A recent sign-in is the engine's rule: it schedules a deletion only from a session that finished a sign-in in the last ten minutes (reauthentication_required otherwise, which does not end the session). The dialog does not ask in advance: when the engine answers that, it shows "For your safety, deleting an account needs a sign-in from the last 10 minutes…" with Cancel and "Sign in again", which opens the sign-in screens and, once the person has signed in, opens the dialog again. On a browser that isn't trusted the dialog says deletion can't start here, and "Approve this browser" opens the approval screens.
  • The request (scheduleDeletion in src/state/services/flows.ts): this device signs it with its own key (signAccountDelete, orbyn/account-delete/v1 over account, device and time), each signature strictly later than the last for that device, and a single device_proof_required is answered by signing again once. A wrong code is second_factor_failed: nothing is scheduled, and the session stays. The answer is when it will happen.
  • After scheduling nothing is signed out or wiped: the account works as usual, so the person can still export. The dialog turns into "Deletion scheduled": the date, the email with the cancel link, what is paused, Cancel deletion and Done.
  • Every device shows it. DeletionBanner (src/features/account/DeletionBanner.tsx) reads deletion_scheduled_at from GET /v1/devices/current while a device is signed in, trusted or still waiting for approval, and shows an amber banner above every screen with Cancel deletion (and nothing else; it can't be dismissed). The countdown follows the UTC calendar (deletionCountdown): "on Saturday 14 November, in 7 days" (seen on 7 November), the UTC day before "tomorrow at 12:00 UTC, in 23 hours", and on the day "today at 12:00 UTC, in 3 hours", then "in under an hour". Settings shows the date in place of the Delete row (src/state/deletionNotice.ts is the shared state). Cancelling shows the toast "Deletion cancelled. Your account stays as it is."; a failed cancel has no designed state, so the banner and row just stay.
  • Failures in the dialog and on Cancel (Account Designs v40): a wrong code or recovery code keeps the dialog open with the code in the field (red field, the designs' two messages; the code is cleared only on success); while deleting, the field and Cancel are disabled; if the engine can't say whether two-factor is on, the dialog says so and offers only Cancel and Try again; a failed Cancel deletion keeps the banner amber, adds "Couldn’t cancel deletion. Nothing changed, so it is still scheduled." (also in the Settings row) and the button reads Try again.
  • Cancelling (cancelDeletion): this device's signature under orbyn/account-delete-cancel/v1 (signAccountDeleteCancel). A device that is still pending may too, within ten minutes of its sign-in.
  • The email's link opens /cancel-deletion#<token> (CancelDeletionPage). The page is isolated: main.tsx renders it before the app shell, so no sign-in, no planner stores and no engine module are started, and it imports no account, store or sync modules (a test checks). It takes the token off the address at once (history.replaceState), keeps it in memory, sets <meta name="referrer" content="no-referrer">, and posts it (POST /v1/accounts/delete/cancel-by-mail, never a GET) only when the person presses "Cancel deletion", so a mail scanner that opens the link cancels nothing. Its states are ready, busy, cancelled, one "This link doesn't work any more" for every refused token (the engine answers a used, expired, voided or wrong token alike, 400 invalid_cancel_token) and an error that keeps the deletion scheduled, with Try again. There is no server-header config in this repository, so the Referrer-Policy header is for the host that serves the page; the meta tag covers the browser. It moves to account.orbyn.dev when that site ships. The host must serve index.html for this path like any other; the engine needs ORBYN_ACCOUNT_URL to put the address in the link.
  • Start over: the link in "Delete your unused Orbyn account" opens /delete-unused#<token> (DeleteUnusedPage), isolated in the same way as the cancel page (main.tsx renders it before the app, it imports no account, store or sync modules, and a test follows its whole import graph). The token leaves the address at once and never goes in a Referer. Opening the link sends nothing; Delete this account posts POST /v1/accounts/delete-unused/confirm {token} once. The answer's invite_released picks the done screen ("Your invite code works again…" or "No invite code was released."); 400 is one "doesn't work any more" state for a wrong, used or expired token, 409 "can't be deleted this way", 429 a neutral "Too many tries. Wait a while, then try again." with nothing to press, and a network or server failure "Couldn't delete the account" with Try again. The page never shows the address (the confirm answer has none). Its links open /sign-up and /sign-in, which AuthGate turns into those screens (screenAtAddress). The request side is StartOver on the card shown when a sign-up made the account but this browser could neither store nor hold its keys: POST /v1/accounts/delete-unused always answers 202, so the "Check your email" screen reads the same whether or not anything was sent; a new link waits 60 seconds, and three requests an hour (or the engine's 429) end in an amber state with no wait time. Account Designs v60, Planner Designs v46.
  • After the deletion finished (src/data/account/deletedAccount.ts, DeletedAccountScreen): the engine ended every session, so the next call is a 401. On a 401 that ended a session the device asks POST /v1/accounts/status {account_id} once (no session; 200 {deleted_at}, the UTC day, for a deleted account and 404 for everything else; 429 past 60 an hour). Only a 200 shows the screen; a 404, a refusal or an unreachable engine is an ordinary signed-out state, asked again on the next 401. The screen covers everything ("Nothing else opens until you choose") because this browser's copy is now the only one: Export saves the existing JSON export (orbyn-planner-<date>.json, line "Downloaded · ") and removes nothing; Remove it now (no second confirmation) deletes the account's planner database and the device's keys, and nothing is wiped before that choice. A failed export or removal has its own screen and retries. A browser that held the account in memory only has nothing to export or remove and goes straight to "This browser had no copy to remove." The last screen offers Create a new account and Sign in with another account (the normal sign-up and sign-in screens). The date is the UTC day, like the banner (deletionDay follows the UTC calendar). Safety: Remove clears the device keys only if the identity stored on the device is still this account's (checked under the engine lock; another tab may have signed in to a different account meanwhile), and removes everything kept for the account (its planner database, the protection notice, the device-data answer). Not lost on a reload: a device that kept its keys but has no session asks the status route once when the page starts, so a check that never answered is asked again; the request times out after 10 s and is then read as unreachable.
  • Other changes wait. The engine answers 409 deletion_scheduled to removing a device, turning two-factor off or on, a protection setting, a key rotation and signing everyone out while a deletion is scheduled; the message is "Your account is scheduled for deletion. Cancel the deletion first to do this." The planner shows those controls paused (Add a device, Approve, Remove, two-factor on and off, protection Choose) with the tooltip "Cancel deletion to change this" on the device and two-factor controls and "Cancel deletion to change protection" on protection's Choose, and an "Adding devices is paused" card; a 409 from the engine puts the screens in the same state. "Approve this browser" in the untrusted-device dialog belongs to the account site; the planner never shows it (a browser that is not trusted cannot reach the dialog there), so the planner keeps it only as the built fallback. The engine does not yet refuse making or confirming an approval during the grace period; the controls stay paused as designed.