Documentation

Accounts

Sign-in, devices, subscriptions, and what an account does and doesn't unlock.

This document supersedes PLATFORM.md §3 and SYNC.md §3. Those refused accounts and hosted sync on my reading of the product. That reading was wrong: accounts and a paid sync service are the intended shape of Slate, with notes belonging to an account and a vault. This is the design for that.

It existed so that pulling the trigger would be a build rather than a research project. The trigger has been pulled — see §0 for where that got to.

Status: Building · Designed: 2026-08-26 · Phases 0–5 shipped: 2026-08-27


0. Where this stands — read this first

Last updated 2026-08-27. Phases 0–5 are done. Sync works end to end and has been verified on the live project; conflicts are detected but not resolved.

Phase
0 External change detection ✅ shipped
1 Supabase project, schema, RLS supabase/schema.sql
2 Account — sign up / in / out app/src/account/
3 Crypto — two passwords, envelope, path HMAC, recovery code app/src/sync/crypto.ts
4 Sync engine — cursor pull, CAS push, blobs app/src/sync/engine.ts + schema-002-sync.sql
5 Statusline segment
8 Selective sync (policy layer, built early on purpose) app/src/sync/selection.ts
6 Conflicts — three-way merge, conflict files app/src/sync/merge.ts
7 Version history UI over the versions table ✅ the versions palette mode
Device list, sync activity feed, history + blob prune schema-005-devices.sql, supabase/functions/prune, devices / activity palette modes
9 Billing — Stripe, plan limits (the storage quota prune enforces against) ← next
10 Shared vaults not started

The second-device trap, and why it is fixed

Opening an empty folder used to put the onboarding screen back up. Shell chose between the editor and Welcome on vaultName && activePath, so a vault with no open note looked like a vault that had not opened — and the one moment that matters most is the second device, where you open an empty folder precisely because sync is about to fill it. The folder had opened; the sidebar was right there. It simply read as a click that did nothing.

EmptyVault now names the open vault and offers the two things that fill it. A pull into an empty vault also opens the first note it landed, so the screen that told you to sync is not still up over a vault that is no longer empty.

Sync happens on its own — three triggers

Everything through phase 5 was correct and entirely manual: the engine ran when someone pressed ⌘⌥S. sync/auto.ts is what notices, and it has three triggers because there are three ways to learn something changed:

Trigger Delay Why
A settled local save 2.5 s dirty going false means autosave already ran; long enough that typing is not a sync storm
A realtime poke 0.6 s Someone is watching for their note to arrive
Returning to the tab 0.6 s Catches a poke that arrived while the socket was asleep

The echo filter is the piece worth knowing about. Our own push comes back to us as a change event. Pulling would find nothing, because the cursor moved past it when we pushed — so ignoring events carrying our own device_id costs nothing and saves a round trip on every save.

Verified 2026-08-27: a row touched from outside the browser moved the vault clock 11 → 12, and the subscribed tab showed ⇅ 0↑ 1↓ within a second, with nobody pressing anything.

Known limitation: two tabs on one machine are one device. The sync state lives in IndexedDB, which is per-origin, so two tabs share a deviceId and a cursor. The echo filter will suppress each other's pokes and the shared cursor is a race. Real devices are the case that matters and this is not worth fixing yet, but it means testing with two tabs will look broken when it is not.

What a user can do today

Sign in, set a vault encryption password, and sync a vault between devices. ⌘⌥S syncs; the statusline segment shows ⇅ 9↑ 0↓ and is clickable in every state. ⌘P → "what syncs" chooses categories, and the row at the foot of the sidebar is the account: signed out it offers a sign-in, signed in it names you and opens a menu with sync, selective sync and sign-out. A newcomer arriving signed out is offered the same thing on the welcome screen, because sync is the paid half of this product and the way in should not live in a command palette.

⌘P → "earlier versions" lists what the server kept. That history is not the same list as .slate/history: local history knows only about this machine, while these are the blobs a push replaced, so they span devices. Content addressing is why they were affordable to keep from phase 4 onward — a past version costs a row, not a copy — and phase 7 turned out to be a reader over data that had been accumulating for three phases. Edits that do not overlap merge silently; edits that do leave your note alone and drop their version beside it as note (conflict 2026-08-27 from Mac).md, listed under ! 1 conflict in the statusline.

The one thing not to forget

The vault password is not recoverable. Losing it loses the notes, by design (§3). The recovery code is shown once at vault creation and never again, because storing it anywhere we could show it from would make it a key we hold.


1. What Obsidian Sync actually does

Researched from Obsidian's own help documentation, not from memory, because the whole point is to follow their method.

The vault model

  • A local vault is the folder on each device. A remote vault is centralized storage that local vaults connect to directly through the app.
  • This is explicitly not folder-passthrough. Dropbox and iCloud watch a folder and copy files between machines; a remote vault is a service the app talks to. Obsidian warns users to migrate off third-party sync rather than run both, because two systems writing the same folder is how you get conflicted copies of conflicted copies.
  • One account can hold several remote vaults (1 on Standard, 10 on Plus), and a remote vault can be shared with other accounts for collaboration.

Two passwords, and this is the important part

  • The account is email + password. It authenticates you to the service.
  • The encryption password is chosen per remote vault, at creation, and is a different secret. Obsidian states plainly: "no one — not even the Obsidian team — can access your notes," and "we're not able to recover your password, or any encrypted data for you."
  • E2E encryption is the default. They also offer "standard encryption," where the key is server-managed — the same model as Dropbox or Google Docs. They recommend against it and only justify it for vaults being published publicly.

What else the service provides

Version history Every change is a new version. Retention is by plan — 1 month Standard, 12 months Plus; attachments 2 weeks. Deleted and renamed files are recoverable.
Conflict resolution Markdown is merged with Google's diff-match-patch. Everything else is last-modified-wins. JSON settings merge key-wise, local over remote. Since 1.9.7 the user can choose auto-merge (default) or "create a conflict file" instead.
Selective sync Per-device. Images, audio, video and PDFs are excluded by default; other types opt-in. Folders can be excluded. Dotfiles never sync, except the config folder.
Config sync Settings, appearance, themes, hotkeys, core plugin list and settings sync. Community plugins are opt-in. Sync's own settings never sync — they are per-device by design.
Storage One account-wide quota. Version history and attachments count against it. Over quota, syncing stops and you are asked to prune. Max file size is plan-dependent.
Regions The remote vault lives in a region you choose at creation.
Devices Unlimited, each with a name, shown in an activity log.
Headless A CLI can sync a vault with no desktop app.

What to copy, and the one thing to change

Copy all of it. It is a well-shaped service and the shape is load-bearing: two passwords, remote vaults, per-file versions, per-device selective sync, and sync settings that deliberately do not sync.

Diverge on one default. Obsidian auto-merges markdown conflicts and warns that this "may sometimes create duplicate text or formatting problems. You will need to fix these manually." Silent duplication inside prose is worse than a second file sitting in your sidebar, and our user is technical enough to resolve one. Default to creating a conflict file; offer auto-merge as the option. Same two choices, opposite default, stated as a deliberate call.


2. Accounts

What the login must not be

The app is monospace, keyboard-first, one modal overlay, no chrome. A hosted sign-in widget — Clerk's <SignIn/>, an Auth0 universal-login redirect — is a foreign object dropped into that, with its own typography, its own rounded corners, and its own idea of what a button looks like. It would be the single least Slate-looking screen in the app, and it would be the first screen a new user sees.

So: headless auth, our own UI. Sign-in is not a page. It is the prompt overlay that already exists (components/Prompt.tsx), in the theme's own tokens, reached from the palette.

The recommendation: Supabase Auth

Revisited and confirmed in AUTH.md, which weighs Supabase against Better Auth and Convex in detail and records why the end-to-end encryption commitment in §3 is what actually decides it.

Not because it is the best auth product in isolation, but because for this product it is not only auth. Sync needs three things, and Supabase is all three in one service:

Need Supabase
Authentication GoTrue — email+password, magic link, OAuth, TOTP MFA. Fully headless SDK.
File metadata Postgres. Real transactions, which the push protocol in §4 needs.
Encrypted blobs S3-compatible Storage, addressed by content hash.
Per-user isolation Row Level Security. Access control lives in the database, not in our request handlers.

That fourth row is the security argument. With RLS, "user A reads user B's vault" is prevented by a policy the database enforces on every query, including the ones we write badly. Without it, that isolation is an if statement in application code that has to be correct in every handler, forever.

It is also self-hostable — Postgres and GoTrue, both open source — so choosing it is not a permanent decision.

Runner-up: Better Auth, if owning the entire stack matters more than speed. TypeScript-native, own your database, good passkey and 2FA plugins, but you are then also running the metadata database and the blob store yourself.

Considered and rejected for this design: Convex. It is built for exactly this shape of problem, but its value is a server that can read your data, and §3 guarantees ours cannot. See AUTH.md §3.

Rejected: Clerk and Auth0. Their central value is prebuilt UI, which is exactly the part we cannot use.

What sign-up looks like

┌────────────────────────────────────────────────┐
    │ ›  Create your account                 ACCOUNT │
    ├────────────────────────────────────────────────┤
    │    email     you@example.com                   │
    │    password  ••••••••••••                      │
    │                                                │
    │    ↵ continue          esc cancel              │
    └────────────────────────────────────────────────┘
    

Three surfaces total, all of them the palette we already have:

  1. account palette mode — sign in, sign out, manage vaults, subscription.
  2. The prompt overlay for the fields themselves.
  3. A statusline segment: ⇅ synced, ⇅ 3↑, ⇅ offline, ! conflict.

No settings page. No account dashboard in the app — billing is a link out to a web page, which is the one place a normal-looking web UI is correct.

Security choices worth stating now

  • Argon2id or PBKDF2 for the account password — whichever the provider does; Supabase salts and hashes for us, which is the main reason not to roll our own.
  • TOTP MFA available from day one, not retrofitted. Supabase supports it; the UI is one more prompt.
  • Sessions are refresh-token based, stored in the OS keychain on desktop and in memory + httpOnly refresh on web. Never in the vault.
  • The account is not required to use Slate. Signed out, the app opens, reads and writes local vaults exactly as it does today. Reversed 2026-09-06 — see decision 7. Signed out, Slate shows a sign-in screen and nothing else: no vault, no notes, no folder tree. The files are still plain .md on the user's own disk and still readable without Slate; what the signed-out app will not do is put them on screen for a session it cannot identify. A build with no Supabase project configured is unaffected — there is no account to be signed out of.

3. The two-password model

This is the whole security design, and it is worth stating as one rule:

The account password reaches our server. The encryption password never does, in any form, ever.

account password ──► Supabase Auth ──► JWT ──► authorizes API calls
                                                    (says who you are)

    vault password ──► Argon2id/PBKDF2 ──► KEK ──► unwraps DEK ──► AES-256-GCM
         (never sent)         + salt                    │              │
                                                        │              └─► note bytes
                                                stored server-side
                                                as ciphertext only
    

Why the two-key hierarchy rather than encrypting with the password key directly: changing your encryption password should rewrap one small key, not re-encrypt and re-upload every note in the vault. The DEK is random, generated once at vault creation, and wrapped by the KEK. The server stores the wrapped DEK and cannot unwrap it.

Crypto specifics

Choice Why
KDF Argon2id (WASM, ~30 KB) — PBKDF2-SHA256 at 600k iterations acceptable for v1 PBKDF2 is native in WebCrypto, so v1 ships with zero crypto dependencies
Content AES-256-GCM, fresh random 96-bit IV per write Native, authenticated, fast. Never reuse an IV with a key
Path privacy Paths are encrypted too divorce-lawyer.md in a filename leaks nearly as much as the file
Path lookup HMAC-SHA256(pathKey, normalizedPath) as the server-side row id Deterministic, so a file is addressable, without the server learning the path
Envelope Every blob carries {v, kdf, iters, salt, alg} Non-negotiable. Without a version field, moving from PBKDF2 to Argon2id later means abandoning every vault created before the change

What shipped, and two places it differs from the table above

sync/crypto.ts implements all of it. Two deliberate divergences, both worth stating because the table calls one of them non-negotiable:

The KDF parameters live on the keyring, not on every blob. The table lists {v, kdf, iters, salt, alg} per blob. The version field is the non-negotiable part and it is on both structures. But kdf, iters and salt describe work that only happens when a password is turned into a key, and a blob is opened with the DEK, which was never derived from one. Stamping a salt on a million blobs would describe a step that does not run when you read a note. The blob envelope is SLT1 | version | alg | iv | ciphertext — 34 bytes of overhead, measured, which is 0.2% on a 20 KB note and 17% on a 200-byte one.

The DEK is a master secret, not the content key. Content and path keys are each derived from it by HKDF with a distinct label. Using one set of bytes as both an AES key and an HMAC key is the sort of shortcut that is fine until it is not, and HKDF costs nothing. It also means a future key type is a new label rather than a new wrapped secret.

Measured cost of guessing. PBKDF2-SHA256 at the shipped 600k iterations takes ~36 ms on an Apple Silicon laptop (100k → 9 ms, 1M → 57 ms). That is comfortable for a person and cheap for an attacker with GPUs, which is the honest reading of the OWASP floor rather than a reason to feel safe. It is exactly why kdf is a stored field: moving to Argon2id changes one string and a rewrap, and every existing vault survives it. Raising the count is a one-character change and costs the user milliseconds.

One mode, not two

Obsidian ships "standard encryption" as an alternative, largely so Publish can read a vault. We have no Publish. Ship E2E only. One mode is a simpler promise, a smaller test surface, and it means the sentence "we cannot read your notes" has no asterisk.

The cost is real and must be said in the UI, at vault creation, in plain words rather than a checkbox: lose this password and the notes are gone. Offer a printable recovery code at creation — a high-entropy string that wraps a second copy of the DEK — so "I forgot it" has an answer that is not "your writing is gone" while still keeping the server unable to read anything.


4. The sync protocol

Schema

vaults        (id, owner_id, name, region, wrapped_dek, kdf_params, created_at)
    vault_members (vault_id, user_id, role)              -- collaboration, later
    files         (vault_id, path_hmac, path_cipher, content_hash,
                   size, mtime, version, deleted, device_id)
    versions      (vault_id, path_hmac, content_hash, version, created_at)
    blobs         → Storage, keyed by content_hash
    

Blobs are content-addressed, which buys three things at once: deduplication across versions and vaults, version history that costs only a row, and an integrity check for free.

The loop

Every metadata write bumps a monotonic version per vault. A client keeps a cursor — the highest version it has seen.

Pullwhere vault_id = ? and version > cursor, apply, advance cursor.

Push — upload the blob if its hash is not already stored, then a compare-and-swap on metadata:

update files set content_hash = ?, version = nextval(...), ...
     where vault_id = ? and path_hmac = ? and version = :base_version
    

Zero rows updated means someone else moved it: that is the conflict signal, and it is detected by the database rather than by a race in our code.

Realtime — a Supabase Realtime channel per vault carrying a poke, never data. The client hears "something changed" and pulls. Keeping payloads off the realtime channel means the encryption story has exactly one path through it.

What shipped

sync/engine.ts is the reconciler, and it sits above StorageAdapter rather than pretending to be one — an adapter has to lie about when a write finished. Three constraints shape it, and each is a test:

  • It never decides what is in scope. shouldSync does, asked once per path.
  • It never sees a note it did not decrypt itself. Everything crossing SyncRemote is ciphertext, and there is no method on that interface that could accept a plaintext note by mistake.
  • It never resolves a conflict. It reports one and stops.

SyncRemote is the seam, the same move StorageAdapter makes for the filesystem — which is what lets the entire engine be tested against an in-memory server with no network and no credentials. The fake models the two things that actually matter, a monotonic per-vault clock and a compare-and-swap that refuses a stale base, because a fake that always says yes would only prove the happy path compiles.

Pull before push, deliberately. A local change is then compared against the newest thing the server has, so the CAS fails on a real disagreement rather than on staleness that asking first would have fixed for free.

Blob before row, deliberately. A metadata row pointing at a blob that is not there yet is a note another device cannot open. An orphan blob is wasted bytes a prune collects. The asymmetry decides the order, and a test asserts it.

Two bugs the tests caught before the database ever saw them: push computed what selective sync had skipped and never returned it, and a file edited on two devices was reported as two conflicts — once by pull, once by push — which would have made one disagreement look like two. pull now hands push the set it already flagged.

Verified end to end (2026-08-27)

Two devices, one live project, through the real UI rather than a harness:

device A: sign up -> set vault password -> recovery code shown once
              -> ⇅ 9↑ 0↓          (the demo vault, encrypted and pushed)
              new note "round-trip" -> ⇅ 1↑ 0↓

    server:   vault "demo vault"  counter=10
              keyring kdf=PBKDF2-SHA256 iters=600000 recovery=yes
              9 rows, paths as HMACs, no plaintext anywhere in metadata
              blob header 53 4c 54 31 01 01 = "SLT1" + v1 + A256GCM, unreadable

    device B: (IndexedDB cleared, in-memory vault empty)
              unlock -> ⇅ 0↑ 10↓  -> "round-trip" is back, content intact
    

The blob header is worth keeping in the record: the bytes the server holds begin with our envelope magic and a version, and contain nothing else a person could read. That is §3's promise as a hexdump rather than a claim.

Conflicts

A three-way merge needs a common ancestor, so the client keeps the last-synced content of every file locally. This is what separates a real merge from the two-way diff that produces the duplicated paragraphs Obsidian warns about.

The first sync is a choice, not a merge

A device arriving at a folder that already has notes is the moment sync is most likely to frighten someone, and it used to be handled silently and badly. localChanged is true for any file that merely exists:

const localChanged = known ? localNow !== known.base : localNow !== null
    

So a device with no memory of a vault treated every differing note as a local edit, and the first sync scattered conflict files through the vault. Correct, and unkind.

survey() now runs first when a device has no state for a vault. It writes nothing, and reports the notes that exist on both sides with different content. The dialog offers three answers and deliberately does not offer "merge" — a three-way merge needs a common ancestor, two copies acquire one by having agreed at least once, and these never have. Pretending otherwise would mean guessing about somebody's prose.

Choice What happens
Keep both (default) Yours stays; theirs lands beside it as a conflict file. The old behaviour, now asked for
Keep this device Your copies are pushed over the server's
Keep the server The server's copies overwrite the ones here

The decision is recorded in this device's state rather than executed directly, so the ordinary loop carries it out — keepLocal becomes a push because the state now says "I have seen version N and my copy differs", and keepRemote becomes silence because the state says they agree. The cursor advances past what was decided, so the next pull does not offer the same choice back as a conflict.

What shipped, and why it is not diff-match-patch

§4 named diff-match-patch. sync/merge.ts deliberately does not use it, for the reason §1 gives when it criticises Obsidian: diff-match-patch applies patches fuzzily, and a fuzzy patch on prose is exactly how you get the duplicated paragraph Obsidian warns its users about. This is line-based diff3, with no dependency — the failure mode is somebody's writing, and a merge nobody can read the source of is a merge nobody can check.

Each side's edit is reduced to hunks against the base. Hunks that do not touch the same base lines both apply; hunks that do are a conflict, and the whole merge is abandoned rather than half-applied.

The first version of this was wrong in a way worth recording. It looked for base lines both sides had left alone and treated everything between two of them as one region — so two edits on adjacent lines, with no untouched line between them, were called a conflict. Comparing edits as ranges has no such blind spot. A live probe caught it; there is now a test for the two-line note where one device edits the first line and the other edits the second.

The local copy is never overwritten, by any branch of the resolver. That is the whole promise, and it holds for a clean merge, a conflict file, and a remote delete alike.

Case Behaviour
Markdown, both changed Three-way merge attempted with diff-match-patch. On any conflicting hunk: write note (conflict 2026-08-26 from laptop).md beside it and flag in the statusline
Anything else Last-modified-wins; the loser stays in version history
Deleted here, edited there The edit wins. Deleting is cheap to redo; losing writing is not

Selective sync came first, on purpose

Phase 8 in the table below is where selective sync was scheduled. Its policy layer shipped before phase 3 instead, and the reordering is the point rather than an accident: a filter added to a finished engine is a filter four call sites forget to apply. sync/selection.ts is pure — no network, no crypto, no store — and it answers one question, shouldSync(path). The engine will be written against it from its first line and is never allowed to decide for itself whether a path is in scope.

Nine categories, with Obsidian's defaults (§1): markdown syncs, attachments do not, and anything that really describes this machine is opt-in.

Category Default Why
Notes, Daily notes, Templates, AI prompts, AI conversations on Markdown is the product
Vault settings (slate.md) on Theme, vim, folder names — Obsidian syncs settings too
Attachments off Where a storage quota goes
Enabled plugins off A plugin list is a statement about a machine
Local history off A local time machine, and large. Phase 7 is the server-side answer

Tasks are not a category. They are checkboxes inside notes, so they travel with the note that holds them — a "tasks" toggle would be a lie about where they live.

Two rules the tests pin down. A named folder beats an extension, so a renamed attachments folder still works and attachments/notes.md is an attachment. And an excluded folder beats an enabled category, because exclusion is the more specific statement: someone who turns notes on and then excludes work/ means it.

Where the selection lives, and why not in the vault

Sync settings never sync — §1 found this in Obsidian's design and it is not a detail. A phone that excludes attachments to save its data plan must not push that choice onto the desktop that has them all.

That rules out slate.md, which is otherwise where this app puts settings ("Configuration is a note"), because slate.md is itself one of the things that syncs. So the selection is machine state in localStorage, keyed by vault — PLATFORM.md §3's tier for exactly this — and a work vault and a personal vault get different answers on one machine.

A stored selection is merged over the defaults rather than replacing them, so a category added in a later release arrives with its intended default instead of undefined, which shouldSync would read as "off" — silently dropping a whole category for everyone who had ever opened the menu.

What never syncs

  • Dotfiles, except .slate/ — mirroring Obsidian's one exception.
  • Machine state: pane layout, float rects, frecency, which plugins are on. Syncing this would rearrange your desktop's windows when you open your laptop.
  • Sync's own settings, including selective-sync rules and device name. Per device, deliberately, exactly as Obsidian does it.
  • Any secret. The config-note parser must refuse *_key, *_token, *secret*, password — PLATFORM.md's rule, and it survives this document intact. A plugin's API key belongs in the OS keychain, never in a synced note.

What syncs for free

Settings are frontmatter in notes (slate.md, .slate/plugins/*.md), so settings sync is not a feature we have to build — it is a consequence of a decision made for unrelated reasons. Worth noticing that it paid twice.


5. What this means for the code we have

The prerequisite — ✅ shipped (2026-08-27)

This section used to read "SYNC.md §0 still stands and gets worse, not better," and it no longer does. Slate now compares mtime on window focus and tab visibility, and again inside flush() before any write; a clean buffer reloads, a dirty one stops and asks. See SYNC.md §0.

The reasoning is worth keeping even though the work is done, because it is why this was first rather than tenth: with sync, we become the second writer to the vault, on purpose, constantly. Detecting external change was never a nice-to-have preceding sync — it is sync's first component. Phase 0 of §6 is therefore complete, and the build order starts at phase 1.

Seams that already fit

StorageAdapter Every filesystem touch already goes through it. The sync engine sits above it as a reconciler and calls it — it is not itself an adapter, and modelling it as one would be the mistake
Prompt overlay Sign-in, sign-up, and the encryption-password prompt are three uses of a surface that exists
Palette modes account and vaults are two more modes in a component built for exactly this
Statusline Sync status is a segment, next to the plugin segments already there
Themes The login inherits all twenty themes for free, because nothing in it names a colour

The honest exception

Sync cannot be a plugin, and it is the only thing so far that cannot. It needs unrestricted vault read/write, network access, and raw crypto — precisely the three things the plugin sandbox exists to withhold. It ships as a first-party module that presents through plugin surfaces (a statusline item, a palette mode) without running inside the sandbox.

This is worth writing down rather than glossing: the plugin API's value comes from what it refuses, and the moment we grant an exception to a plugin because it is ours, the sandbox stops meaning anything.


6. Build order

Phase Scope Cost
0 External change detection (SYNC.md §0) — mtime on focus, ask before clobbering a dirty buffer ✅ shipped 2026-08-27 ~140
1 Supabase project, schema, RLS policies, vaults + files + Storage ~1 day
2 account palette mode, sign-up / sign-in / sign-out through the prompt overlay ~200
3 Crypto module — key hierarchy, versioned envelope, path HMAC, recovery codesync/crypto.ts ~250
4 Sync engine — local state table, cursor pull, CAS push, blob uploadsync/engine.ts + schema-002-sync.sql ~450
5 Statusline segment ✅ — activity log and device names remain ~120
6 Conflict handling — three-way merge, conflict files ~200
7 Version history — a palette mode over versions, restore ~180
8 Selective sync, excluded foldersthe policy layer shipped early — storage quota and prune remain ~200
9 Billing — Stripe, a web dashboard, plan limits enforced server-side ~2 days
10 Shared vaults (vault_members), later, only if asked for

Phases 0–4 are a working private sync. Everything after is what makes it a product people pay for.


Decisions this document asks for

# Decision Why now
1 Accounts and hosted sync are in scope; PLATFORM.md §3 and SYNC.md §3 are superseded Those documents currently contradict the product
2 Two passwords: account authenticates, vault password encrypts and is never transmitted It is the difference between "encrypted" and "we cannot read it"
3 E2E only — no server-managed-key tier One mode, one promise, no asterisk
4 Versioned crypto envelope from the first byte written Without it, the first KDF upgrade abandons every existing vault
5 Headless auth (Supabase), our own UI in the prompt overlay A hosted widget would be the least Slate-looking screen in Slate
6 Conflict files by default, auto-merge opt-in — the opposite of Obsidian's default Silent duplication inside prose is worse than a second file
7 The app stays fully functional signed outSigned out shows a sign-in screen and nothing else (reversed 2026-09-06) The original reasoning held for a single-user machine and broke down on a shared one: the vault reopens off disk before any session check can finish, so "signed out" meant a signed-out chrome with someone's notes behind it. One gate (account/gate.ts) now answers "may a vault be on screen", and both the boot restore and the screen under it read it.
8 Sync is first-party, not a plugin, and we say why An exception granted to our own code is the end of the sandbox