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:

  1. Integrity. Nothing anyone records is lost, changed or silently overwritten, and every member sees the same record.
  2. Clarity. The app never leaves anyone unsure what happened: what's saved, what isn't yet, what's current, and what needs them.
  3. Speed. It feels instant, like the apps they already use.
  4. 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.

SituationWhat happensWhy
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 deviceA 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 outIt 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 onceThe 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). Refused becomes 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 Date gives 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:

  1. Decode: open the sealed row with the space key for its address; parse the signed entry, keeping its bytes.
  2. Verify: the signature over those bytes (cached forever by hash: a signature that verified once always will).
  3. 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.
  4. Authorize: against key histories and epoch cutoffs, as of the replay.
  5. 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 at base (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): v in 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.js and soda.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 drafts store 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:

pub struct Status {
    pub connection: Connection,      // Live | Connecting | Offline { since }
    pub space: SpaceSync,            // CaughtUp { as_of } | CatchingUp { done, of } | Stale { as_of }
    pub writes: Writes,              // Saved | Saving { n } | OnThisDevice { n, oldest } | Parked { n, why }
    pub editing: Editing,            // Open | Paused { why: OfflineTooLong | Behind | Locked | Removed | NotCaughtUp }
    pub version: Version,            // Current | UpdateReady | Behind { seen_v }
    pub device: DeviceHealth,        // clock skew, storage kept or not, only-copy age
    pub needs_you: Vec<Attention>,   // not-applied edits of yours, parked changes, removal, etc.
}

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#

impl App for Care {
    type Event = CareEvent;          // derives soda_local::Shape (events.lock)
    type Command = CareCommand;
    const RULES: u32 = 3;            // bump when evolve reads existing events differently
    const OFFLINE_EDITS: Duration = Duration::from_secs(24 * 3600);

    fn kind(command: &CareCommand) -> Kind { /* Record, Edit or Admin */ }
    fn critical(command: &CareCommand) -> bool { /* giving a dose, moving money */ }
    fn target(space: &Space<Self>, author: &str, event: &CareEvent) -> Option<String> { /* what an edit changes */ }
    fn decide(..) -> Result<Vec<CareEvent>, String>;
    fn evolve(..);
    fn describe(..) -> String;
}

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#

TodayPlanWhere
Saved state: one sealed blob per space, plus read positionsFact store, per-fact records; positions derived (I1)engine/persist.rs, engine/load.rs
READING_RULES forces a re-read from the serverRefold from local facts on a rules changeretire after the fact store
Outbox drops a change the server refusesParked, surfaced, never dropped (I8)engine/outbox.rs
Too-big entries fail at sendRefused at signing with a plain messageChanges
Edits last-writer-wins, guarded per form in CareEdits carry base; conflicts are part of interpretation (I7)Changes, Interpretation, apps
Undo via step/travelUndo as a guarded editspace.rs, board/undo.rs
Lost-change recovery in recoverSame rule, as "an edit with a base", in Interpretationengine/recover.rs
s.error / s.notice English stringsTyped Status; apps word it (I11)engine → status.rs, apps
No rules version on entriesv on every entry (I5)entry.rs
Version switch: ad hoc stream-ending and reload rulesLifecycle with safe points, drafts, "Update ready"sw.js, soda.js
Offline: unlimited, unlabeledRecords always; edits 24 h; admin online; labels everywhere (§2)Changes, Status
One 5,240-line engine.rsModules per layer (done: engine/, see phase 6)SDK
No clock checkServer Date vs device clock, surfacedFacts, 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.

  1. 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_RULES is gone: a rules change (RULES, App::RULES) drops caches and folds again from facts. Still to come with Interpretation: the cache of verified signatures.
  2. 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 by Display until apps word them), every operation and every app route answers with a Refused (the app's own words as Refused::App), and the state's notice and error are a Notice and a Refused. "As of" is done: State::as_of gives 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.
  3. Versions. v on entries, holding newer kinds, Lifecycle with safe points and drafts.
  4. Edits with a base. Targets, revisions and conflicts in Interpretation; undo and lost-change recovery rebuilt on them; Care and the board adopt kind, target and critical. Mostly done. App::target names 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 use step/travel still, and sign fresh edits), kind, critical, and Care adopting targets.
  5. Offline rules. The 24-hour edit window and critical actions checking in first.
  6. Layers. Split engine.rs along §3 (much of it falls out of phases 1 to 4). Done. engine/ is a module per concern, grouped by layer in engine/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 on State; Engine keeps the public entry points and what awaits (the store, the disk). Every simulated life replays identically to before the split.
  7. 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.

Esc