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:
accountpalette mode — sign in, sign out, manage vaults, subscription.- The prompt overlay for the fields themselves.
- 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.mdon 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.
Pull — where 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.
shouldSyncdoes, asked once per path. - It never sees a note it did not decrypt itself. Everything crossing
SyncRemoteis 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 | ~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 | sync/crypto.ts |
~250 |
| 4 | sync/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 | ~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 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 |