How it's built
Soda architecture: how apps on it stay correct
Status: plan, adopted 2026-10-13. This is the design every change to the engine, the web shell and the apps should move toward. Where today's code differs, the "What changes" table says how it gets there. It replaces patching with a small set of rules that make whole classes of problems impossible.
1. What we owe the people using these apps#
A Soda app is a self-custody app: each person's own browser runs the app's backend, the server holds only sealed, signed history it can't read, and nobody (not us, not the server's operator) can see, lose or leak their data. The people we're building for first manage a parent's care and money. They need to trust the record more than they need anything else.
So the order of priorities, when they conflict:
- Integrity. Nothing anyone records is lost, changed or silently overwritten, and every member sees the same record.
- Clarity. The app never leaves anyone unsure what happened: what's saved, what isn't yet, what's current, and what needs them.
- Speed. It feels instant, like the apps they already use.
- Everything else.
We give up convenience before integrity (for example, editing pauses after a long time offline), and we never trade clarity for a cleverer mechanism.
The promise, in the words a user would read#
- When the app says Saved, it's on the server and will reach everyone in the circle. Nothing can take it back.
- Before that, it says On this device, and how long it's been. You can always record what happened, online or not.
- When you're looking at something that might be out of date, the app tells you as of when.
- If two people change the same thing at once, neither change is silently lost: the one that came second is shown to its author, with one tap to apply it anyway.
- Updates install themselves, never while you're typing, and never lose what you've typed.
- Every device in the circle keeps a full copy. The server is a relay and a backup, not the only copy.
2. Decisions made for users#
These are defaults in the SDK. An app may tighten them, never loosen the integrity ones.
| Situation | What happens | Why |
|---|---|---|
| Recording something that happened (a dose given, a note, a reading, a payment made) | Always allowed, online or offline. Marked "On this device" until the server has it. | Refusing to record a real event is itself data loss: people forget. Records never conflict, because each one is a separate fact. |
| Editing something shared (a schedule, a task, an appointment, a card) | Allowed while online, and for 24 hours of being offline. After that, editing pauses (reading and recording don't): "You've been offline since Monday 9 AM. To keep everyone's record in step, editing is paused until you're back online." | Long-offline edits are the ones most likely to collide with others' changes and to be decided on stale information. |
| Administration (people, devices, keys, sharing the money log, invitations) | Online only, and only once this device has caught up. | These change who can see what; they must be decided on the current state. |
| An action the app marks as critical (giving a medicine, moving money) | Online: the device checks in with the server first (usually well under a second, shown as "Checking…") and decides on what it finds. Offline: allowed, with "Last heard from the circle at 2:14 PM. Someone may have given this since." in the sheet itself. | The one place stale information can hurt someone. |
| The view might be out of date (not live for more than 2 minutes) | A quiet "as of 2:14 PM" beside the title; "Catching up…" while a device that's been away reads what it missed. | Like a banking app's balance "as of". |
| Changes still only on this device | A small counter ("3 changes on this device"); after 24 hours offline a clearer line; after 72 hours: "Nothing from this device has reached the server since Monday. If this device is lost, those changes go with it." | People should know when their device is the only copy. |
| A newer version is out | It installs in the background and switches at the next quiet moment. Drafts survive the switch. A device that's behind a version others in the circle already use updates within minutes when online; one that can't (offline) keeps reading and recording, and edits under the offline rule above. | Old code must never be the thing that misreads a newer record. |
| Two people changed the same thing at once | The change that's second in the shared order isn't applied. Everyone, its author included, sees the one shared history, with Ann's. | No silent overwrite, ever, and no merge puzzles for users. |
| A change reaches everyone long after it was made (more than an hour: a phone offline on a trip) | It doesn't count where it was made. The device that made it makes it again if it still applies (a new card, a note, a dose record, with its own time); if someone has changed the same thing since, it isn't added, and only its author is told: "A change you made while it was out of date wasn't added, because someone changed the same thing since, or too much time had passed." | A toggle or a move decided on an old picture shouldn't undo what everyone has seen since; a record of something that happened should still land. |
| The device's clock is off by more than 2 minutes | "Your device's clock is 15 minutes off, so times you record would be wrong." Records still go through, dated by the device's own clock: the store's time only raises the warning, since a store could otherwise choose when things happened. | Times on a medication record matter, and the store isn't trusted. |
| The browser might clear this site's data (Safari, no install, no passkey) | Ask the browser to keep the data the first time there's something to keep. If it says no, nudge toward Add to Home Screen and a passkey, and show the "only copy" warnings sooner. | A browser silently wiping a device is the most common way a device's only copy disappears. |
| The server refuses a change for good (the circle reached its size limit) | The change is kept, never dropped. The owner sees what to do (move the circle to a fresh address, which the app does for them). | Today it's dropped: the one data-loss path left. |
| Something in the history can't be applied (from a device that fell behind itself, or never validly signed) | Listed calmly in History with what became of it. Never a badge, never "refused", never "add this browser again". | Honest without being alarming. |
Words users see: Saved, Saving, On this device, Offline, Catching up, as of, Checking. Words they never see: store, row, entry, fork, epoch, refused, signature, sync (in error states), instance, worker.
3. The shape of the system#
┌────────────────────────── one browser (one device) ──────────────────────────┐
│ page(s) ── the app's view, drafts, status pill ──────────────┐ │
│ ▲ typed status, rendered state commands │ │ │
│ │ ▼ │ │
│ service worker: the instance │ │
│ ┌───────────┐ ┌──────────┐ ┌───────────────┐ ┌─────────┐ │ │
│ │ Lifecycle │ │ Status │ │ Changes │ │ App │ │ │
│ │ versions │ │ typed │ │ decide, guard,│ │ decide │ │ │
│ │ safe point│ │ model │ │ freshness, │ │ evolve │ │ │
│ │ lock,panic│ │ │ │ sign │ │ target │ │ │
│ └───────────┘ └──────────┘ └───────┬───────┘ └─────────┘ │ │
│ ┌──────────────────────────────┐ │ │ │
│ │ Interpretation (pure) │◄────┘ new facts │ │
│ │ decode → verify → order → │ │ │
│ │ fold; caches; lost & conflicts│ │ │
│ └──────────────▲───────────────┘ │ │
│ ┌──────────────┴───────────────┐ ┌────────────────────────┐ │ │
│ │ Trust │ │ Facts │◄─┘ peers.js │
│ │ key histories, signatures, │ │ fact store (IndexedDB),│◄── direct ──► │
│ │ membership, space keys │ │ outbox, store client │◄── server ──► │
│ └──────────────────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘Each layer has one job and a narrow interface:
- Facts gets bytes in and out and keeps them: every row received (from the server or straight from another browser), every row this device wrote and hasn't stored yet. It knows nothing about what they mean.
- Trust answers "who signed this, and were they allowed to": key histories, signatures over the bytes as written, membership, space keys and their epochs.
- Interpretation is a pure function from facts (plus trust, plus the rules version, plus an explicit "now") to the state of each space, with everything the history says about itself: what counts, what lost to another version, what conflicted.
- Changes turns a person's action into signed facts: the app decides, the engine adds the guard and the rules version, checks freshness and size, signs, and hands the result to Facts.
- App is the app's own rules, as pure functions (see §5.4).
- Status is a typed model of everything a person might need to know, which the app renders in its own words.
- Lifecycle owns what runs: which version, when it switches, what happens on a crash, whether the device is locked.
The page only renders and collects input. One service worker per site means one instance per device, so a device always has exactly one writer.
4. Invariants#
Every one of these is checked by the simulator (§8), and code that can't keep one doesn't merge.
I1. Facts are kept; everything else is a cache. A device stores every row it has received and every row it has written, as the bytes that were signed. Read positions, checkpoints, verdicts, lost and conflict lists, and the displayed state are all derived and can be thrown away. Throwing away every cache and recomputing gives exactly the same result.
I2. Interpretation is a pure function of (facts, key histories, rules version, now). Two
devices holding the same facts at the same rules version show the same thing. The only rules that
read now are named: an entry dated more than a day ahead waits; a recovery window (see
social-recovery.md) opens at its time.
I3. Signatures cover bytes, not interpretations. Entries and key events are verified over the exact bytes their signer wrote. An older version verifies a newer version's entry, applies what it understands, and holds the rest unapplied (not refused) until it updates.
I4. Signed history only grows. What events hold can gain kinds, variants and fields that may be
missing, never lose or change them (events.lock, enforced in cargo test and the build). The
meaning of an event already signed never changes: new meaning gets a new event kind or a new field.
I5. Every entry says which rules signed it (v). A device seeing a higher v than its own
knows for certain it's behind.
I6. Nothing honest is ever stranded. No single entry, however malformed, misdated or forked, can stop other entries from counting. Misdated entries are placed after their device's previous one. A device's two versions of one entry resolve by the server's order, the same everywhere, and the losing version's content is made again by its account where nothing has changed since.
I7. No silent overwrite. An edit names the revision of the thing it changes. If that thing has changed since, by anyone, the edit doesn't apply, everywhere, and its author is told.
I8. Nothing signed is ever dropped. A change this device signed stays in its outbox until the server has it. Failures are retried, parked and surfaced, never discarded.
I9. "Saved" means stored. The app only says Saved once the server has the row. A change is durable on the device (written to IndexedDB) before the action returns.
I10. One writer per device chain, and it never signs behind itself. A device signs only past everything it has signed; one restored from an old copy reads its own chain back first.
I11. The engine never speaks to users. It reports typed status; apps choose the words. No engine string reaches a page.
I12. Bounded under attack. What any one author can make a device keep, read or compute is capped (rows per address, bytes per row, unknown rows kept per author), and hitting a cap never loses honest facts.
5. Design, layer by layer#
5.1 Facts#
The fact store (IndexedDB, replacing today's one sealed blob per space):
facts: one record per row, keyed by its hash: the bytes as written (already sealed under the space key, so they're ciphertext at rest), its address, its server row id once known, when this device first had it, and how (server or direct). A row that won't open where it was found is nobody's: it isn't kept, and positions move past it.outbox: the hashes of facts this device wrote that the server doesn't have yet (I8).caches: per space, the folded state and the verified-signature set, keyed by rules version and app rules version (§5.3). Dropped whenever either changes.secrets,drafts(§5.5), as today plus drafts.
Writes are per fact, so saving no longer rewrites a whole space's history (today's saves grow with
the history; these don't). Read positions are written in the same transaction as the facts they
stand for, and move only past rows that were kept (or were nobody's), and only when the read started
at a position the facts already stand for. So a position can never get ahead of the data it stands
for, and it survives every cache being thrown away. That removes the class of bug behind today's
stale-cursor fixes and the READING_RULES re-read.
Sync stays as it is in spirit: the server's append, read, latest and signal; realtime
pushes as hints; direct delivery between members' pages as a fast path; the server's order as the
tie-break for equivocation. Changes:
- The outbox never drops (I8).
Refusedbecomes a parked state with a typed reason. The only refusal an honest device can hit is "address full", and the remedy (move the space to a fresh address under a new key, which already exists as a key change) is offered to the owner. - Sizes are checked when signing, so an entry the server would refuse as too big is never made: "That's too long to save" at the moment of typing, not a failure later.
- The device asks the browser to keep its data (
navigator.storage.persist()) the first time there's a fact worth keeping, and records whether it was granted (for §2's warnings). - Each server response's
Dategives the clock check (§2).
5.2 Trust#
Unchanged in substance; it's the part that's already built this way. Key histories (KERI-shaped) with every valid version kept and one canonical choice (recovery > passkey > devices > lower digest; helpers lowest, once social recovery lands); signatures over bytes (I3); membership and authorization decided at replay; owners pinned; space keys committed by address in the key change that introduces them. Trust's interface to Interpretation is "is this fact validly signed, by whom, and was that device allowed at that point", answered from facts alone.
5.3 Interpretation#
A pipeline, each stage pure and cached by hash:
- Decode: open the sealed row with the space key for its address; parse the signed entry, keeping its bytes.
- Verify: the signature over those bytes (cached forever by hash: a signature that verified once always will).
- Order: per-device chains by sequence and previous hash; two versions of one place resolve by the server's order; an entry carrying on from a version that lost, lost too; misdated entries placed after their device's previous one (I6); the shared order is Lamport time then hash.
- Authorize: against key histories and epoch cutoffs, as of the replay.
- Fold: the app's
evolve, with guards checked (§5.4).
Output per space: the state, plus what the history says about itself: lost versions and their fate (made again, changed since, left out), conflicts (whose edit didn't apply and why), entries held because they come from a newer version, and entries no member validly signed. History screens render these; nothing else does.
Caches, not state. A checkpoint is a cache of stages 1 to 5 up to a point. Locally it's
dropped and rebuilt freely (I1). The server copy (the account's latest slot) remains a
convenience so a new device of the same account can start without replaying everything, and is
only ever trusted from that account's own devices. A checkpoint reaches only as far as the owner's
latest history close (SpaceEvent::HistoryClosed, about hourly, an hour behind): before it, what
counts is settled for good, since a change made before it that arrives after it counts nowhere
(its author's device makes it again if it still applies), so no checkpoint is ever undone for a
late change and the store can let old rows go.
Rules version. RULES is a constant in the engine; each app adds App::RULES for its own
evolve. The cache key includes both. When either changes, a device refolds from its facts on
start: no network needed, typically a few hundred milliseconds even for a year of history,
because signature checks are cached by hash.
5.4 Changes#
Every command an app defines is one of three kinds, declared by the app:
- Record: a fact about the world (dose given, note, reading, payment made, file attached). No guard. Always allowed (§2). Two records never conflict.
- Edit: a change to a shared thing. The app says which thing (
App::target(space, author, event), a string like"med:lis/schedule"or"card:7f/title", as fine-grained as the app wants: a card's title and its column are different targets, so renaming and moving never collide). The engine keeps each target's revision (the hash of the last edit that applied to it) and signs the edit with the revision this device saw (base). At fold, an edit applies only if its target is still atbase(I7). Otherwise it's a conflict: it counts as history ("Ben tried to change the schedule; Ann had already") but not as state. Its author sees the same shared history as everyone; to change it again is a fresh edit against the current revision, made on purpose. - Admin: membership, devices, keys, sharing. Online and caught up only.
This one mechanism replaces several: Care's hidden seen fields and per-form conflict checks, the
undo engine's "starting side still holds" test, and the lost-change recovery's "only if nothing
changed since". All three become "an edit with a base".
Freshness. An app can mark a command critical (App::critical(&command)). For those, when
online, the engine reads the space's addresses before deciding (bounded wait, shown as
"Checking…"); when offline it decides on what it has and the status says how old that is. Every
command records whether it was decided live or offline, so the history can say so.
Offline window. Edits are refused with a typed reason once the device hasn't heard from the
server for App::OFFLINE_EDITS (default 24 hours). Records aren't (§2).
5.5 Versions and lifecycle#
- Rules version on entries (I5):
vin every entry from here on (an additive field). - Older code meeting newer entries: verifies them (I3), applies the fields it knows, holds
entries of kinds it doesn't know as "from a newer version", and reports
Version::Behind. It never moves anything it couldn't apply out of its fact store (I1), so when it updates, the newer code just refolds. - Switching versions is one designed transition, owned by Lifecycle in
sw.jsandsoda.js: a new worker installs in the background; the switch happens at a safe point (the page hidden, or no sheet open and nothing typed for a few seconds); at most ten minutes after the new version is ready, the status offers "Update ready" as a one-tap action instead of waiting longer. - Drafts survive reloads: the shell keeps whatever is typed in a form (by field, per space) in
the
draftsstore until it's submitted or discarded, and puts it back after any reload. So a version switch, a crash or a closed tab never loses typing, and the safe point is almost always "now". - Crashes (a panic in the instance): as today, the worker steps aside and pages reload into a fresh one, with the loop guard. With drafts and facts kept, nothing is lost by it.
- Lock (optional, per device: never, after an hour away, or when the browser closes): forgets the device storage key, so facts and secrets stay sealed until the passkey unlocks them. A locked device doesn't sync; it catches up on unlock. Default "never" on a personal device; suggested for a shared one and for whoever keeps the money log. Only for devices kept with a passkey.
5.6 Status#
One typed model, from the engine, per device and per space:
The web shell ships a default status pill (Saved / Saving / Offline · 3 on this device / as of 2:14 PM / Update ready) that looks like the save indicator in a document app, and apps place it and word it. History pages render the history's own account (§5.3). Nothing else surfaces engine state.
5.7 Security and privacy#
Unchanged guarantees: the server sees sealed bytes at secret addresses, sizes and timing, and nothing else; every change is signed by the device that made it; members can't forge each other's history, rewrite it, or show different people different versions without it being resolved the same way everywhere. Added: facts at rest stay ciphertext under space keys, which stay sealed under the device key, which the lock forgets. Caps (I12) bound what a hostile member can make others store or compute.
5.8 Performance budgets#
Integrity first, but these are requirements, not hopes:
- An action shows its result in the page within 50 ms (local fold), whatever the network.
- Online, another member's open page shows it within 1 s through the server, about 50 ms directly.
- A warm start paints the last state within 300 ms, marked "as of" until caught up; a refold after a rules change of a year-long circle finishes within 1 s on a mid-range phone.
- A device that's been away a week catches up within 3 s on a normal connection.
- Downloads stay within today's sizes (Care under 900 KB compressed, the board under 550 KB) or get smaller.
6. What the app author writes#
Undo comes from targets and revisions (an undo is an edit back to the previous revision's value,
guarded like any edit), so step/travel go away. Conflict handling, freshness, offline rules,
lost-change recovery and status come from the engine.
7. What changes from today#
| Today | Plan | Where |
|---|---|---|
| Saved state: one sealed blob per space, plus read positions | Fact store, per-fact records; positions derived (I1) | engine/persist.rs, engine/load.rs |
READING_RULES forces a re-read from the server | Refold from local facts on a rules change | retire after the fact store |
| Outbox drops a change the server refuses | Parked, surfaced, never dropped (I8) | engine/outbox.rs |
| Too-big entries fail at send | Refused at signing with a plain message | Changes |
| Edits last-writer-wins, guarded per form in Care | Edits carry base; conflicts are part of interpretation (I7) | Changes, Interpretation, apps |
Undo via step/travel | Undo as a guarded edit | space.rs, board/undo.rs |
Lost-change recovery in recover | Same rule, as "an edit with a base", in Interpretation | engine/recover.rs |
s.error / s.notice English strings | Typed Status; apps word it (I11) | engine → status.rs, apps |
| No rules version on entries | v on every entry (I5) | entry.rs |
| Version switch: ad hoc stream-ending and reload rules | Lifecycle with safe points, drafts, "Update ready" | sw.js, soda.js |
| Offline: unlimited, unlabeled | Records always; edits 24 h; admin online; labels everywhere (§2) | Changes, Status |
One 5,240-line engine.rs | Modules per layer (done: engine/, see phase 6) | SDK |
| No clock check | Server Date vs device clock, surfaced | Facts, Status |
Kept as is: the crypto and key histories, signatures over bytes, events.lock, the store and its
migrations, direct delivery, files (with I8 applied to chunks), pairing, passkeys, recovery codes,
the simulator.
8. How we know it holds#
The simulator (src/sim) gains a check per invariant, run after every simulated life:
- I1: drop every cache on a random device mid-life and restart it; its state must be unchanged.
- I2/I3: run two engine versions side by side in one life (the old one as a recorded build), with the newer one adding a field and an event kind; the old one must hold, not refuse, and match after it "updates".
- I6: devices rolled back to old copies of themselves (already there), misdated and malformed entries (already there, from Mallory); nothing honest may wait on anything stored.
- I7: concurrent edits of the same target from different devices, offline and online; every device must agree which applied, and each loser's author must be told.
- I8/I9: the server refusing, rate-limiting and losing pushes; no signed change may ever leave an outbox before the server has it; "Saved" may never be reported before then.
- Offline rules: devices offline past the window must not edit; records must go through.
Alongside: both apps' end-to-end suites, and an upgrade test per release (the previous release's build and the new one against the same circle, an old tab left open across the switch), which is how today's version-skew bug was reproduced.
9. Delivery#
Each phase ships on its own, only when every simulated life (1,000, a third with an attacker), the SDK tests, both apps' suites and the upgrade test pass.
- Facts. Fact store, derived positions, outbox that never drops, size checks at signing,
storage persistence, clock check. Migration: the first start reads the existing saved spaces
into the fact store and reads the server once.
Done. Facts and their positions are written together; a space's cache is written at most every
30 seconds and a start folds in only the facts since. A refused row is parked with its reason and
holds back what needs it (later entries of its space; after a key event, everything). The engine
goes by the store's clock when its own is more than two minutes off.
READING_RULESis gone: a rules change (RULES,App::RULES) drops caches and folds again from facts. Still to come with Interpretation: the cache of verified signatures. - Status. The typed model, the default status pill, "Saved / On this device", and moving
every engine string into the apps. Begun: what the engine refuses and what it says are typed
(
Refused,Notice, one variant per reason, worded in English byDisplayuntil apps word them), every operation and every app route answers with aRefused(the app's own words asRefused::App), and the state's notice and error are aNoticeand aRefused. "As of" is done:State::as_ofgives the last full read once it's over two minutes old, and both apps show it beside "Offline". "Catching up" is done: the engine knows when a read from behind is bringing changes in (State::catching_up), and the stream holds the page as it was under the shell's own status until it's done (at most 12 s), then shows the result in one step, so nobody watches a space rearrange itself as history arrives. - Versions.
von entries, holding newer kinds, Lifecycle with safe points and drafts. - Edits with a base. Targets, revisions and conflicts in Interpretation; undo and lost-change
recovery rebuilt on them; Care and the board adopt
kind,targetandcritical. Mostly done.App::targetnames what an edit changes as a path; the engine signs each edit with its target's revision (Entry::base), and the fold applies it only if nothing at, under or above that target changed since (entry::Revs, kept in checkpoints). Otherwise it's a conflict, the same everywhere: its author is told who got there first and can apply theirs anyway (Engine::apply_anyway). Entries without a base fold as before. The board guards renames and deletes, the template every edit. Still to come: undo and recovery rebuilt on revisions (they usestep/travelstill, and sign fresh edits),kind,critical, and Care adopting targets. - Offline rules. The 24-hour edit window and critical actions checking in first.
- Layers. Split
engine.rsalong §3 (much of it falls out of phases 1 to 4). Done.engine/is a module per concern, grouped by layer inengine/mod.rs: state (state); facts (wire,formats,persist,load,outbox,sync,hooks); trust (kels,account,pairing,restore,keyring,key_change,key_share); interpretation (space_rec,interpret,recover,checkpoints,closes); changes (act,spaces,offers); lifecycle and pages (keeping,tick,view,presence,peers,browser).State's own bookkeeping is in parts each module owns (Disk,Reading,OwnChains,KeyChanges,KeyAsks,Beacon), and the decisions are pure functions with their own unit tests: placing entries (SpaceRec::place), signing (SpaceRec::sign), building a key change, what to make again (to_make_again,remake), when to close a history and how far to fold one, what goes to the store next (next_out), which addresses to read (State::routes), and the disk's bookkeeping (Disk), and the changes to an account's key history (rotations). Everything synchronous is a method onState;Enginekeeps the public entry points and what awaits (the store, the disk). Every simulated life replays identically to before the split. - Then: social recovery (docs/social-recovery.md), the device lock, a verifiable export of a circle, and moving a circle to another server.
10. Not in scope#
Character-by-character co-editing of the same text; groups of more than about fifty people; search or notifications that need a server to read across people (each needs its own "share answers, not data" design); apps whose value is aggregation across users.
11. Decided here (revisit only with a reason)#
- Edits pause after 24 hours offline; records never do; admin needs the network.
- "Saved" means on the server.
- The server's order breaks ties between a device's two versions of one entry, and between two equally strong versions of one step in an account's key history, and only those.
- Conflicting edits: the one that's second in the shared order doesn't apply; its author decides.
- Every device keeps every fact; caches are disposable.
- Updates switch themselves at safe points within ten minutes of being ready; drafts make that safe.
This page is docs/architecture.md in the SDK, built into this site as it is.