Encrypted Local Workspace Mirror
Locked architecture for the offline-support workspace mirror (epic #4808, sources #4233): IndexedDB ciphertext mirror schema, the atomic row+cursor contract, the write-path subscriber, mirror-seeded cold boot, the offline fail-closed arm, eviction, reconnect conflict rules, and the #4357 scoping determination.
Note
Design deliverable for sub-issue #4809 (Wave 1 of epic #4808, Offline Workspace Mirror). Downstream subs #4810–#4815 implement directly against this document — each sub's issue body carries the excerpt that binds it, but this page is the canonical text. Supersedes the sketch in #4233. If an implementation needs to diverge, update this page first.
Goal and non-goals
Goal: a per-workspace, ciphertext-only local copy of the cloud workspace, kept current by subscribing to the workspace model's change feed, so that
a cold boot with no network opens the full workspace read/write ("offline-armed"),
a cold boot with network hydrates instantly from the mirror and pulls only the cursor delta instead of walking a full snapshot, and
files created or deleted offline stay visible/gone across a relaunch (closing the D7 "offline-create invisibility" limitation).
Non-goals (v1):
Assets are not mirrored. They are not workspace files at all — see Assets are structurally excluded.
EFE files are local-only (D5), never in the workspace model, and therefore never in the mirror.
Plaintext caching. The mirror stores wire-format ciphertext only. The persisted key in
workspace-key-store.tsremains the secret. Desktop macOS stores it in the login Keychain; iOS and web retain thelocalStoragetradeoff (#2335).Closing #4357's residual races. The mirror narrows them (see #4357 scoping); it does not close them.
Vocabulary
| Term | Definition |
|---|---|
| mirror | The per-workspaceId IndexedDB record set: file rows + one meta row. |
| mirror row | One {encPath, ciphertext, version?, createdAt?, updatedAt?} record — byte-compatible with WorkspaceCiphertextRecord (workspace-). |
| mirror cursor | The change-log cursor stored in the meta row. Invariant: it may lag the rows, never lead them. |
| seed source | Where a boot's mode: "replace" hydrate came from: "snapshot" (network walk, today's path) or "mirror". |
| offline-armed | A session whose keys were armed without a server verifyPassword round-trip, gated instead by the AEAD probe over mirror rows. |
| first-build | The mirror's population on a not-seeded → seeded transition (snapshot or mirror-seeded boot alike), committed via replaceWorkspaceMirror (full replace) rather than merged through the ordinary upsert batch path. There is no separate build pass. Only a seed the store marks complete replaces: a hydrate that quarantined records, and a delta drain that happens to be the transition, both merge instead. A seed the store does NOT mark complete, and applies no files, produces no first-build at all (no emission to build from) — but a complete seed applying zero files still first-builds, replacing the mirror to zero rows (#4955/#4980; see §4). |
1. Storage schema (IndexedDB) — sub #4810
One IndexedDB database in the renderer origin, first IndexedDB use in the repository:
Database:
zudotext-workspace-mirror, version1.Object store
files—keyPath: ["workspaceId", "encPath"].Object store
meta—keyPath: "workspaceId".
/** One mirrored file. Field-compatible with WorkspaceCiphertextRecord. */
export interface MirrorFileRecord {
/** Wire (encrypted) workspace path — output of encryptWorkspacePathForWire(). */
encPath: string;
/** Serialized encrypt-then-MAC blob — output of encryptFile().encryptedBlob. */
ciphertext: Uint8Array;
/** Server-assigned version; 0 / undefined when the server never acked. */
version?: number;
/** Server timestamps (ISO UTC), when known. */
createdAt?: string;
updatedAt?: string;
}
/** The per-workspace meta row. Exactly one per workspaceId. */
export interface MirrorMetaRecord {
workspaceId: string;
/** Pre-release: any other value means "no mirror" + clearWorkspace. No migration. */
schemaVersion: 1;
/** Change-log cursor the rows correspond to. May LAG the rows, never lead. */
cursor: number;
/**
* Hex-encoded key-derivation salt, copied from the persisted workspace key at
* write time. Non-secret; enables the offline password unlock (section 7).
* Empty string when the writer could not read it.
*/
saltHex: string;
/** ISO timestamp of the last batch commit. Diagnostics only. */
updatedAt: string;
}What is plaintext, and why that is acceptable: encPath is the deterministic path ciphertext (never the plaintext path), ciphertext is the wire blob. version / createdAt / updatedAt / row count are stored in the clear — the same metadata class the sync server's D1 tables already hold, and the same posture as the wire. saltHex is explicitly non-secret (workspace-key-store.ts doc). No plaintext note content or path ever enters the mirror. hydrateWorkspaceFromCiphertext's default decrypt (decryptWireWorkspacePath + decryptWireBlob) consumes these rows with no changes — that is the point of reusing the wire producers.
Why the metadata must be stored at all: store.ts warns twice — a mirror that drops version hands back files that look un-acked after a boot, and a metadata-light re-hydrate would let content-derived timestamps overwrite real server ones, reshuffling "sort by updated" (the exact #4208 instability). Every row therefore carries whatever the model knows via getWorkspaceFileMetadata() at write time.
2. Storage backend seam — sub #4810
Module: tauri- (renderer-owned, like workspace-key-store.ts — the packages' vitest environment is plain node and must stay free of IndexedDB). The seam mirrors the asynchronous KeyStoreBackend / __setKeyStoreBackend; the mirror uses IndexedDB while the desktop key store crosses Tauri IPC to the macOS login Keychain:
export interface MirrorBatch {
upserts: MirrorFileRecord[];
/** encPaths to delete. */
deletes: string[];
/** Cursor to record WITH this batch — see the atomicity contract. */
cursor: number;
/** When present, overwrites meta.saltHex; absent preserves the stored one. */
saltHex?: string;
}
export interface MirrorSnapshot {
records: MirrorFileRecord[];
cursor: number;
/** When present, overwrites meta.saltHex; absent preserves the stored one. */
saltHex?: string;
}
export interface MirrorStoreBackend {
/** Apply rows + cursor ATOMICALLY (one transaction). Rejects on failure. */
applyBatch(workspaceId: string, batch: MirrorBatch): Promise<void>;
/**
* Read everything for workspaceId in ONE transaction (rows + meta together —
* a two-transaction read could observe a torn pair). Returns null when the
* meta row is absent OR schemaVersion !== 1.
*/
readAll(workspaceId: string): Promise<MirrorSnapshot | null>;
/** Atomic full rebuild: delete workspaceId's rows, write records + meta. */
replaceAll(workspaceId: string, snapshot: MirrorSnapshot): Promise<void>;
/** Evict one workspace (rows + meta). */
clearWorkspace(workspaceId: string): Promise<void>;
/** Evict every workspace (sign-out). */
clearAll(): Promise<void>;
}
/** Test seam — no argument restores the IndexedDB default. */
export function __setMirrorStoreBackend(next?: MirrorStoreBackend): void;
// Public module API (thin wrappers over the active backend):
export function applyMirrorBatch(workspaceId: string, batch: MirrorBatch): Promise<void>;
export function readWorkspaceMirror(workspaceId: string): Promise<MirrorSnapshot | null>;
export function replaceWorkspaceMirror(workspaceId: string, snapshot: MirrorSnapshot): Promise<void>;
export function clearWorkspaceMirror(workspaceId: string): Promise<void>;
export function clearAllWorkspaceMirrors(): Promise<void>;Implementation notes (locked):
applyBatchruns onereadwritetransaction over["files", "meta"]: applydeletes, thenupserts, then put the meta row (reading the existing meta inside the same transaction to preservesaltHexwhen the batch omits it). IndexedDB aborts the whole transaction on any request failure — that IS the atomicity mechanism; do not split stores across transactions.replaceAllpreserves an omittedsaltHexthe same way, reading the existing meta row inside its own transaction. The salt is not part of the row set a replace rewrites, so a caller that cannot read the key record this session (loadWorkspaceKeybefore the key is persisted) must not be able to erase one — a blanked salt disables the section-7 offline password unlock.readAlluses onereadonlytransaction over both stores;IDBKeyRange.bound([workspaceId], [workspaceId, []])selects the workspace's rows (arrays sort after every string in IndexedDB key order).clearAllclears both object stores in one transaction. Do not useindexedDB.deleteDatabase(it blocks on open connections).Any open/transaction error surfaces as a rejection. Callers treat a rejected
readWorkspaceMirroras "no mirror" (snapshot fallback) and a rejectedapplyMirrorBatchper the writer's rebuild-or-clear rule (section 3). An environment with no IndexedDB (some private modes) therefore degrades to exactly today's behavior.Unit tests use an in-memory
Map-backed fake injected via__setMirrorStoreBackend. Nofake-indexeddbdependency is added.
Correction: indexedDbMirrorStoreBackend is UNEXECUTED, and nothing in this epic changed that
This section (and §11's sub map) originally said the IndexedDB default was "covered by #4815's integration pass". It is not, and claiming it was is what let the gap survive review. The honest state:
Every test in
workspace-mirror-store.test.tsdrives the fake. The one test that restores the real backend asserts only that it rejects with "IndexedDB is not available" — it reaches three lines ofopenDb()and no further. All fiveMirrorStoreBackendmethods are unexecuted.The reason is structural, not neglect: the renderer's vitest environment is jsdom, which ships no IndexedDB, and
fake-indexeddbis deliberately not a dependency (above). A fake-IDB pass would also not cover the part that actually matters — see the hazard below, which is about real transaction lifetime.The specific hazard:
applyBatch,readAll,replaceAllandclearWorkspaceall issue IndexedDB requests after anawaitinside a live transaction. This is legal only because the spec unsets a transaction's active flag after the microtask checkpoint, so a continuation resumed from an IDB event handler still runs inside it — anawaiton anything else (a timer, afetch, a promise resolved elsewhere) would abort withTransactionInactiveError. Keep everyawaitin these methods on arequestToPromiseof a request issued in the same transaction.Getting real coverage requires a browser, i.e. a WebKit e2e assertion driving a boot that writes and re-reads a mirror. WebKit is the production runtime, so that is the lane that would matter. Not added here; recorded as the honest gap.
3. The atomic row+cursor contract (the cursor seam) — subs #4810/#4811
subscribeWorkspaceFiles delivers changed rows but not the cursor those rows correspond to. Where the cursor is authoritatively known:
The single write point is
setDeviceCursor(cursor)incloud-sync-bridge.ts— every seed path calls it after its apply (hydrateWorkspaceFromCiphertext,seedFromSnapshot, the adapter's cold-model guard), andsyncDrainpersists the head in its step 4.The read
getDeviceCursor()is only trustworthy relative to a change batch in ONE situation: insidesyncDrain's applyFn (the emit fires while the in-memory cursor already equals the head covering exactly that batch) and for local writes (the local edit is newer than any cursor; its own change-row is self-filtered on later pulls precisely because the mirror already holds the content — the mirror restores the "we already hold it locally" assumptioncollapseRawRowswas built on).During seed paths the emit-time sample would be stale if the seed applied before it advanced the cursor, because
applyWorkspaceChangesis what marks the model seeded and emits.hydrateWorkspaceFromCiphertexttherefore callssetDeviceCursor(source.cursor)beforeapplyWorkspaceChanges(applied)— see the correction below, which is load-bearing for the mirror-seeded boot.seedFromSnapshotkeeps the apply-then-cursor order (also below).
Correction: the "a stale sample only lags" argument was wrong for the mirror path
This section originally justified sampling the pre-seed cursor by arguing that a stale sample can only lag. That holds for the snapshot boot, whose rows sit at the server head — at or ahead of any device cursor. It is false for the mirror boot, where the direction inverts: the rows sit at mirror.cursor, which is by construction <= the cursor initCloudSync restored from localStorage (localStorage is written synchronously by persistCursor; the mirror's cursor rides an async IndexedDB commit and routinely lags it at quit — the accepted "seed tear"). Applying first therefore committed the mirror's own rows under a leading cursor on every mirror-seeded boot where the two had torn — the exact silent-permanent-staleness outcome rule 4 exists to forbid.
Locked fix: hydrateWorkspaceFromCiphertext advances the cursor first, so the writer's emit-time sample is exactly the cursor the rows came from. This is safe if the apply then throws: modelSeeded stays false, so the adapter's cold-model guard (!isWorkspaceModelSeeded() && getDeviceCursor() > 0) fires and takes the snapshot path.
seedFromSnapshot deliberately keeps the apply-then-cursor order. Its direction is already the safe one, and reordering it would introduce the bug on the in-session re-seed path: clearWorkspaceModel() drops the writer'ssubscribeWorkspaceFiles handle but NOT its addDeviceCursorListener handle, so asetDeviceCursor(snapshotHead) issued before the apply would enqueue a cursor-only batch at the snapshot head ahead of the row batch that carries it. The mirror hydrate has no such hazard — the cursor it writes is the mirror's own already-committed cursor, so the cursor-only batch it may enqueue is a no-op.
Locked contract:
New seam in
cloud-sync-bridge.ts(sub #4811):/** Fired after the device cursor advances (setDeviceCursor / syncDrain step 4). */ export function addDeviceCursorListener( cb: (cursor: number) => void, ): () => void;setDeviceCursornotifies after assigning + persisting.syncDrainstep 4 is refactored frompersistCursor(storedWorkspaceId, deviceCursor)tosetDeviceCursor(deviceCursor)— behaviorally identical (same value, same persistence, secondary-window guard unchanged insidepersistCursor) plus the notification.Row batches pair with the emit-time cursor sample. The writer's change listener synchronously samples
getDeviceCursor()and enqueues{rows, cursor: sample}.Cursor advances commit as cursor-only batches (
applyBatchwith emptyupserts/deletes) from the cursor listener — strictly behind all earlier row batches in the writer's FIFO chain (section 4).Invariant — two halves, and the second depends on the first.
4a. The mirror must contain every local write.
collapseRawRowsdrops every pulled row whose latest author is this device, unconditionally, and the device id is persisted in localStorage across relaunches. So on a mirror-seeded boot the mirror row is the only copy of a local write that exists — a write the mirror is missing has its change id below the head the next drain reaches AND is self-authored, so no pull will ever re-apply it. The note comes back at its pre-edit body, and an edit on top of that stale body then overwrites the server's newer content: silent permanent staleness plus real data loss. (The pre-mirror snapshot boot was immune becausefetchSnapshotAndDecrypthas no self-device filter.) Consequence for rule 6: whenever the writer drops a batch carrying rows, that workspace's mirror must be DISCARDED, never frozen — on every drop path, not just the failure ones. (A dropped CURSOR-ONLY batch is exempt: it costs the mirror nothing but lag, which 4b permits.) The writer enforces this structurally rather than by repeating the rule at each site: every non-commit exit from its per-batch routine returns aBatchOutcomenaming who owns the mirror afterwards, so a barereturnis a type error and a new drop path cannot silently freeze one.4b. The mirror cursor may lag its rows, never lead them. A cursor that lags (the seed tear: rows written at the stale sample, bumped moments later) is safe given 4a: a boot from that pair delta-pulls rows it already holds,
collapseRawRowsdrops the self-authored ones and re-applying the rest is idempotent — wasteful, never wrong. A cursor that leads its rows claims changes the mirror does not contain; the delta pull can never repair them (their ids are below the cursor) — the same silent permanent staleness. Every seed path must therefore pair its rows with a cursor it can prove is not ahead of them — "the sample is stale, and stale only means lagging" is NOT a proof; it is the reasoning that produced the mirror-boot violation above.
Correction: "a lagging mirror is always safe" was only ever true for a mirror that lags in the CURSOR
Rule 4 above used to be a single invariant about the cursor, and its safety proof turned on the clause "a boot from that pair delta-pulls rows it already holds". That clause is not decoration — it is the whole proof, and it assumes the mirror holds every local write. Nothing enforced that assumption. Two writer paths abandoned a workspace's chain while leaving its mirror in place(processBatch's disarm-race branch and handleChainFailure under a moved arm), and poisonedWorkspaceIds was cleared only on a seed transition — which a steady editing session never produces. One transient IndexedDB rejection therefore froze the mirror for the rest of the session while writes kept flowing to the server, and the next launch hydrated the frozen mirror and self-filtered every one of those writes away.
The irony worth recording: the clearWorkspaceMirror fallback — the outcome rule 6 framed as the worse one — is the safe one. It is the mirror that ispresent but frozen that destroys work.
Locked fix: every path that abandons a batch carrying rows discards the workspace's mirror (discardMirror / abandonAndDiscardMirror inworkspace-mirror-writer.ts), and a successful replaceWorkspaceMirror un-poisons the workspace — a full dump is by construction the whole live model at the current cursor, so continuing to drop that workspace's batches after it is exactly what the rebuild existed to make unnecessary.
And the fix was applied twice, which is the real lesson. The first pass covered the failure paths above but left the two ARM-SCOPE drops (epoch !== armEpoch, and stillArmedFor false after the encrypt) returning bare — they still carried the pre-fix reasoning quoted in §4's arm-epoch bullet. A reviewer caught it; the shape of a partially-applied fix is the recurring failure in this module, not any individual missed line. The drop decision is therefore no longer restated per site: the per-batch routine returns a BatchOutcome("committed" / "dropped" / "mirror-resolved") and one caller applies 4a to every "dropped". Adding a drop path without naming its outcome does not compile.
5. Atomicity is per batch (applyBatch's single transaction) and ordering is the FIFO chain. A mirror row without its cursor cannot be observed because rows and cursor land in one transaction; a cursor without its rows cannot be committed because the chain refuses to reorder. 6. A failed applyBatch poisons the chain for that workspaceId — rebuild, else discard. Dropping one batch and committing the next for the same workspace would create the forbidden cursor-leads-rows state. On failure the writer stops consuming that workspace's remaining batches (batches captured for a different workspaceId — possible across a workspace switch — are unaffected) and attempts ONE replaceWorkspaceMirror(workspaceId, dump) where dump re-encrypts the full in-memory model (workspaceFileEntries() + getWorkspaceFileMetadata()) at the current getDeviceCursor() — in-session, the model is the authority. Three locked details:
The dump is re-checked against the arm before it is written.
buildFullDumpawaits one encrypt per file, so on a large workspace the arm can move under it; a dump built after a switch is the OTHER workspace's file set at the OTHER workspace's cursor, which would be the maximal cursor-leads-rows state reached through the very path meant to prevent it.A successful rebuild un-poisons the workspace. The dump is by construction the whole live model at the current cursor, so the mirror is provably consistent at that instant. Leaving the poison set would drop every later local write for the rest of the session — the 4a loss path.
Every path that does NOT end in a successful rebuild discards the mirror (
clearWorkspaceMirror(workspaceId)) — the rebuild rejecting, the arm having moved, or the encrypt failing outright. No mirror is strictly safer than a frozen one: absence falls back to today's snapshot boot, which has no self-authorship filter and loses nothing (rule 4a).
4. Write path: workspace-mirror-writer.ts — sub #4811
Module: tauri-. Package export additions in packages/: encryptFile, encryptWorkspacePathForWire (both already flagged for #4233 in the cloud-sync-bridge re-export block), getDeviceCursor, and the new addDeviceCursorListener.
Wiring and lifecycle (locked):
The writer registers at module load on
addWorkspaceModelSeededListener(thenote-write-staging.tsprecedent) and is imported for side effect frombootstrap/. On every not-seeded → seeded transition it (re)subscribes viaapp- boot. tsx subscribeWorkspaceFiles(listener)— noprefix(full-workspace) — after dropping any previous handle. Re-subscribing on every transition is required, not defensive:clearWorkspaceModel()(disarm / workspace switch) clearsfileChangeListeners, and the next workspace's seed is the only signal that it is time to listen again.Because
applyWorkspaceChangescallsmarkWorkspaceModelSeeded()beforeemitFileChanges, the subscription created inside the seeded listener receives the seeding batch itself. This is the mirror's first-build mechanism: a snapshot boot emits the whole workspace as oneorigin: "remote"batch, the writer encrypts and commits it, and the cursor listener bumps to the snapshot head whensetDeviceCursorfires. No separate build pass exists. (Re-encrypting the full workspace on a snapshot boot is accepted: workspace content is small markdown — #4233's own sizing argument.)A MIRROR-seeded boot pays the same first-build, and that cost is accepted rather than suppressed.
hydrateWorkspaceFromCiphertextseeds through the sameapplyWorkspaceChangescall, so the writer's fresh subscription receives the whole workspace as one batch and re-encrypts and re-commits rows the mirror already holds, at the cursor it already has. Correctness is unaffected (the emit-time sample issource.cursor, so rows and cursor still pair), but it means O(workspace) AES + HMAC plus one IndexedDB transaction on every launch, including the offline one — not only on the snapshot boot the bullet above sizes. It is kept because (a) the sizing argument is the same one already accepted for the snapshot path, (b) it is the mechanism that re-derives every row under the LIVE keys after each arm, so a mirror the writer discarded mid-session (rule 6) converges again on the next boot, and (c) suppressing it is no longer merely a cost question. The change feed does now carry a per-batch marker, but it is a COMPLETENESS bit, not a mirror-SOURCE bit, so skipping the mirror-seeded rewrite would still need a distinct source signal — and, decisively, that rewrite is now thereplaceWorkspaceMirrorthat prunes rows for files deleted while the app was closed. Skipping it reinstates #4869. Do not read "hydrate instantly from the mirror" as "the mirror boot is cheap on both sides"; it is cheap on the READ side.Secondary windows never write. The seeded listener returns immediately when
getSecondaryWindowScope() !== null. A popout's model is a mount-time subset and its in-memory cursor is deliberately never persisted; letting it write rows or cursors would corrupt the host's mirror.workspaceId is captured at enqueue time (from
getWorkspaceId()), never read live at commit time — an in-flight batch that lands after a workspace switch must write into the workspace it was sampled from (theoutboxBoundClientprecedent).…and so is an ARM EPOCH, because workspaceId alone does not scope a batch. Everything a batch is built from is module-level live state:
encryptFile/encryptWorkspacePathForWirereadcloud-sync-bridge'sderivedKeys, andbuildFullDumpreadsworkspaceFileEntries()/getWorkspaceFileMetadata()/getDeviceCursor(). The writer therefore bumps anarmEpochcounter on every seed transition, stamps it onto each batch at enqueue, and commits a batch only while that epoch is still current — checked before and after the encrypt step, since the encrypt itself awaits. A stale-arm batch carrying rows is dropped AND the workspace's mirror is discarded, not left at its last committed pair (rule 4a). The tempting justification for freezing it — "the re-arm's own first-build repopulates it" — is false in the case that actually matters:armEpochbumps on ANY workspace's seed, so the re-arm is typically to workspace B, and B's first-build repopulates B's mirror, not A's. A's would be frozen missing writes that already reached the server, andcollapseRawRowsself-filters them out of every future pull. A cursor-only stale-arm batch may still be dropped silently: that only makes the mirror lag, which 4b permits. The epoch is the arm-scoped identityworkspaceIdcannot express — it survives an A → B → A round trip.Whether that discard also poisons turns on whether the arm is PROVABLY gone. If
armEpochhas already moved, it is: the counter never goes backwards, so no later batch can pass the checks that dropped this one, and poisoning would only take out the next reseed's own first-build batch (it sits behind the stale one in the chain) and leave the session unmirrored. If the epoch is unchanged — a bare disarm movedgetWorkspaceId()instead — nothing is proven, and the chain must be abandoned too:disposeCloudSync()→initCloudSync()restores the workspace id before the re-arm seeds, and the writer'saddDeviceCursorListenerhandle survivesclearWorkspaceModel(), so a cursor-only batch from the old arm could otherwise commit a cursor onto the just-emptied mirror — rows-less, and maximally cursor-leads-rows.poisonedWorkspaceIdsis cleared per workspace, not globally. A seed transition clears the poison for the newly-seeded workspace only (poisonedWorkspaceIds.delete(getWorkspaceId())); a global.clear()would un-poison a different workspace whose batches are still queued behind the switch.
Correction: first-build committed through the same upsert merge as every batch, and a re-seed onto a mirror holding rows for since-deleted files stranded them permanently
The two first-build bullets at the top of this list — the snapshot boot's seeding batch, and the mirror-seeded boot that pays the same cost — described first-build as the writer's fresh subscription receiving the seeding batch and committing it like any other, through applyMirrorBatch's upsert merge. That merge never deletes. A re-seed (a mirror-seeded relaunch, an in-session re-arm, a workspace switch back) lands its whole-workspace batch as upserts on top of whatever the mirror already held, and a row for a file deleted before that seed — locally or on another device — is never touched by the merge and survives in the mirror forever (#4869). The stray row resurrects the deleted file on the next mirror-seeded boot, and every later first-build re-encrypts and re-commits it along with the live workspace, paying that cost on every subsequent launch too.
Locked fix: the seed-transition emission is distinguished from ordinary batches at the source. The store stamps the ONE emission that takes the model not-seeded → seeded with a completeness bit, and the writer commits a COMPLETE one via replaceWorkspaceMirror (delete-then-write) rather thanapplyMirrorBatch.
Completeness is asserted by the seeding source, never inherited: only a caller holding an authoritative enumeration of what exists opts in — the snapshot boots and a fully-decrypted mirror hydrate. Three shapes therefore keep merging, deliberately:
a partial hydrate (one that skipped records; the §5 partial-rot path), because replacing would delete exactly the damaged rows the queued rebuild exists to repair;
a delta drain that happens to be the transition, because it self-filters this device's own rows and never holds the whole workspace;
a seed batch carrying deletes, which a snapshot cannot express — the store refuses the completeness claim on one, and the writer asserts it again at the branch.
A COMPLETE empty seed now wipes the mirror. A workspace emptied on another device seeds with applied: []; the store carves that ONE case out of the change feed's empty-early-return and delivers it anyway, stamped { seed: { complete: true } }, to unfiltered subscribers only (#4955/#4980). The writer needs no separate branch to consume it — isFirstBuild has no records-length check and replaceWorkspaceMirror no zero-record guard, so the empty batch replaces to zero rows through the same first-build path as any other complete seed. That closes the #4869 stranding at 100% of the mirror that #4913 had scoped out; see workspace-mirror-writer.ts's module doc for the mechanism.
Accepted residual — a DROPPED empty first-build still strands. An empty batch has exactly one way to be abandoned before it commits: the arm-epoch checks, pre-encrypt and post-encrypt. (It encrypts Promise.all([]), so the disarm-race throw a rows-carrying batch can hit is unreachable for it.) On that drop the writer's invariant-1 discard rule does not fire, because it fires only for a batch that carries rows, so the stale mirror is left untouched rather than wiped. Narrower than the pre-#4980 residual it replaces: the next seed that reaches the writer (mirror-seeded or snapshot) self-heals it. Also listed under "Accepted residuals".
mock-adapter diverges by design. Its workspace model is a per-instance stand-in — an own workspaceModelFiles map with its own subscriber set, never the workspace-core store — so nothing there ever performs the store's not-seeded → seeded transition, and its emitWorkspaceFileChanges passes {} as meta unconditionally. It stamps a WorkspaceSeedMarker on nothing, empty seed or otherwise. pnpm dev:mock and Storybook / mock-backed e2e sessions therefore never exercise this section's first-build or empty-seed-wipe paths; only the real workspace-core store (Tauri, REST-armed) does.
Per-batch processing (locked):
// inside the subscribeWorkspaceFiles listener (synchronous part):
const cursor = getDeviceCursor(); // contract rule 2
const workspaceId = getWorkspaceId();
const upserts = [...]; // { path, content, metadata: getWorkspaceFileMetadata(path) }
// — metadata read HERE, synchronously, per upsert
const deletePaths = [...]; // action === "delete"
// `epoch: armEpoch` is NOT optional — see the arm-epoch bullet above. Every
// enqueue site stamps it, and it is checked before AND after the encrypt.
enqueue({ workspaceId, epoch: armEpoch, cursor, upserts, deletePaths });
// inside the FIFO chain (async part), per batch:
// upserts: encPath = await encryptWorkspacePathForWire(path)
// { encryptedBlob } = await encryptFile(path, content)
// // metadata is NOT re-read here — it was already captured in the
// // synchronous part above; a null metadata (path deleted since)
// // just means the record's version/createdAt/updatedAt fields
// // come out undefined
// deletes: encPath = await encryptWorkspacePathForWire(path)
// saltHex = loadWorkspaceKey(workspaceId)?.saltHex ?? undefined // meta preserves last non-empty
// if (batch.epoch !== armEpoch) return; // pre-encrypt drop
// …encrypt…
// if (!stillArmedFor(workspaceId, epoch)) return; // post-encrypt drop
// await applyMirrorBatch(workspaceId, { upserts, deletes, cursor, saltHex });Metadata sourcing is safe because both mutation paths (applyWorkspaceChanges → recordHydratedMetadata, applyLocalUpsert → touchLocalMetadata) record metadata before emitFileChanges fires; the listener captures content from the change and reads getWorkspaceFileMetadata(path) synchronously in the listener (before any await), so it cannot observe a later state. This is a hard requirement, not an optimization — reading it later, inside the FIFO chain's async part, would run after arbitrarily many awaits (this batch's own encrypt calls, and every batch queued ahead of it), by which point the path could have been deleted or rewritten and the metadata would no longer describe the content actually being encrypted.
The cursor listener (addDeviceCursorListener) enqueues {workspaceId, epoch: armEpoch, cursor, upserts: [], deletes: []} through the same chain (contract rule 3).
Encryption timing note: encryptFile / encryptWorkspacePathForWire read the live derivedKeys and throw "Encryption not set up" after a disarm. A batch in flight across a disarm therefore fails its encrypt step. Locked refinement: such a batch is dropped and the chain for that workspaceId is abandoned without attempting a rebuild — a dump would throw on the same missing keys — and the workspace's mirror is discarded. The next arm of that workspace first-builds a fresh one.
The original refinement stopped at "abandoned without rebuild or clear — the mirror simply stays at its last committed pair, which the invariant permits (it lags)". That was the rule-4a violation: the dropped batch is a local write that has already reached the server (or the durable outbox), and a delta pull self-filters this device's own rows forever, so the frozen mirror reverts it on the next boot. Discarding costs workspace A one network-requiring boot; freezing costs the user their work.
Correction — disarm is not the only window; re-arm is the dangerous one. The refinement above only reasoned about disarm → no keys, and that is the benign case precisely because it throws. An in-session workspace switch (epic #4608) is disarm → re-arm to workspace B, and nothing in the switch sequence flushes or fences this chain — prepareForWorkspaceBoundary drains editor buffers, the settings queue and the outbox, but knows nothing about the mirror. In that window the encrypt calls do NOT throw: they quietly succeed under B's keys, so a still-queued workspace-A batch writes B-keyed ciphertext into A's mirror (whose rows the next boot's probe then skips as "partial rot" — a silently partial workspace), and a B-keyed encPath makes a queued delete target a row that does not exist, so the file reappears on relaunch. The rebuildOrClear path is worse still: buildFullDump would dump B's whole file set at B's cursor into A's mirror. The arm epoch above is what closes all three. A failure discovered after the arm moved cannot be repaired at all from B's session, so handleChainFailure discards A's mirror there rather than rebuilding it from foreign state or leaving it frozen (rule 4a); A's next arm first-builds it again.
Assets are structurally excluded
The v1 scope excludes the assets namespace, and the exclusion requires no filter code: assets are not workspace files. They ride the separately encrypted bridge.assets → / surface backed by opaque user_assets D1 rows and R2 objects (D10), and never pass through applyLocalUpsert / applyWorkspaceChanges, so subscribeWorkspaceFiles never emits them. classifyWorkspacePath has no assets namespace. The lock is the invariant statement: the mirror stores exactly what the workspace model stores.
Keeping assets parallel is deliberate even after adding E2EE: the durable outbox has no binary arm; putting blobs up to 25 MiB in IndexedDB would make the mirror needlessly heavy; and making assets workspace files would force every device to pull every attachment whether it displays it or not. Any future move into the workspace namespace must first revise those three constraints and record the new size/namespace filtering decision here. EFE files are likewise structurally absent (D5: local-only, never in the model).
5. Cold boot: mirror-seeded hydrate — sub #4812
workspace-cold-start.ts's seedFromSnapshot() is replaced as the seed step of both arm entry points by:
export type WorkspaceSeedSource = "snapshot" | "mirror";
export interface WorkspaceBootMode {
seed: WorkspaceSeedSource;
/** True when the boot's network leg (snapshot walk or delta drain) succeeded. */
online: boolean;
}
// Valid combinations: {snapshot,true}, {mirror,true}, {mirror,false}.
// {snapshot,false} cannot exist — the snapshot IS a network walk.
export type ColdStartResult =
| { status: "armed"; boot: WorkspaceBootMode }
| { status: "failed"; failure: WorkspaceArmFailure };
export type UnlockResult =
| { status: "armed"; boot: WorkspaceBootMode }
| { status: "wrong-password" }
| { status: "failed"; failure: WorkspaceArmFailure };(Pre-release: the result-shape change is breaking; update the app-boot.tsx consumers in the same change. #4813 consumes boot.)
Seed decision flow (locked):
async function seedWorkspaceModelForBoot(workspaceId: string): Promise<WorkspaceBootMode> {
const mirror = await readUsableMirror(workspaceId); // null on ANY rejection, or 0 rows
if (mirror) {
// `probeMirrorDecrypt` takes the SNAPSHOT and probes its first record
// itself — the caller does not reach into `records[0]`.
if (await probeMirrorDecrypt(mirror)) {
// The three lines below are factored into `hydrateFromMirror(mirror)`,
// which both the online seed and the two offline arms call:
const result = await hydrateWorkspaceFromCiphertext({
records: mirror.records,
cursor: mirror.cursor,
mode: "replace",
});
if (result.skipped > 0) requestMirrorRebuild(); // partial rot; see below
await flushStagedNoteWrites(); // ordering lock, section 6
try {
await syncDrain(async (applied) => applyWorkspaceChanges(applied));
return { seed: "mirror", online: true };
} catch {
return { seed: "mirror", online: false }; // hydrated; delta pending
}
}
// Wholesale AEAD failure on the probe: corrupt (or wrong-key) mirror.
// AWAITED, and skipped entirely in a secondary window — see below.
await discardUnusableMirror(workspaceId);
}
await seedFromSnapshot(); // today's path, verbatim
return { seed: "snapshot", online: true };
}
// Shared by both offline arms and the seed decision above.
async function discardUnusableMirror(workspaceId: string): Promise<void> {
if (getSecondaryWindowScope() !== null) return;
// `.catch` is required, not defensive: a throwing-accessor IndexedDB
// environment (some private-mode shapes) rejects this promise, and an
// unguarded `void` on a rejecting promise is an unhandled rejection
// (#4815 acceptance sweep finding).
await clearWorkspaceMirror(workspaceId).catch(() => {});
}Locked details:
"A mirror exists" =
readWorkspaceMirrorresolves non-null (meta present,schemaVersion === 1) and has at least one row. An empty-records mirror is treated as unusable (nothing to AEAD-probe → nothing to fail closed on) and falls through to the snapshot path.probeMirrorDecrypt= run the default decrypt (decryptWireWorkspacePath+decryptWireBlob) on ONE record; boolean result. Wrong keys fail every record (HMAC is keyed), so one probe discriminates wholesale failure from per-record rot. The probe runs on both online and offline mirror boots, so a corrupt mirror never reaches themode: "replace"hydrate — which matters because an all-skipped hydrate would still mark the model seeded (an empty apply counts as a seed) and fire the staged-write reconcile against an empty model.Partial rot (
skipped > 0after a passing probe): serve the partial model (strictly better than nothing offline), andrequestMirrorRebuild()— a writer API that performs the section-3replaceWorkspaceMirrorfull dump after the next successful cursor advance (i.e. once online state has repaired the model). The delta pull cannot repair skipped rows by itself: their change ids are below the mirror cursor.Delta-pull failure is not a boot failure. A hydrated model with a failed drain returns
{seed: "mirror", online: false}— the session runs offline; the adapter's WS/triggerSync machinery retries later. NEVER a white screen.Every fallback lands on today's snapshot path unchanged — missing mirror, rejected read, schema mismatch, empty mirror, failed probe. The snapshot path keeps its tombstone filter and pending-upsert overlay as-is, and the writer's first-build (section 4) rebuilds the mirror from that very hydrate.
The discard is ORDERED against that fallback, and secondary windows never discard at all. Fire-and-forget was wrong on both counts. (a) The writer's first-build starts the instant
seedFromSnapshotapplies, so a clear that landed after the first build's row batch would wipe those rows while later batches kept appending — leaving a truncated mirror that still has a valid meta row and a decryptablerecords[0], so the next boot'sreadUsableMirrorandprobeMirrorDecryptboth accept it andmode: "replace"-hydrate a workspace missing most of its notes. The "empty mirror is unusable" rule is what saves the adjacent interleaving, so the design already relies on this ordering being benign; it is only benign at the extremes. An extra IndexedDB round trip at boot is free. (b) IndexedDB is per-origin and shared across Tauri windows, and a popout runs the sametryRearmFromStore— so a popout's probe failure would delete the host's mirror, the same cross-window damagepreserveStoredKeyOnFailureexists to prevent for keys. The popout is the window least entitled to adjudicate: its arm can fail for reasons the host's does not share, and the host has no signal that it happened.tauri-adapter.tscold-model guard: no functional change. The guard's predicate (!isWorkspaceModelSeeded() && getDeviceCursor() > 0) remains correct: a mirror hydrate marks the model seeded before the adapter can run a sync, so the guard fires exactly when it fires today (armed model lost without a reseed), and its snapshot fallback doubles as a mirror rebuild via the first-build mechanism. #4812 updates the guard's comment to reference this document; the code stays.The boot drain calls
syncDraindirectly with the sameapplyWorkspaceChangesapplyFn the adapter uses. It runs before<App>mounts, so it cannot race the adapter's single-flightedtriggerSync(whose callers are all mounted UI / WS wake).
6. Cold-boot ordering: staging → hydrate → staged flush → outbox → delta
The locked order and the reason each edge exists:
arm keys
→ mirror hydrate (mode: "replace") [seeds the model]
→ staged quit-write reconcile [flushStagedNoteWrites]
→ delta drain (syncDrain) [online only]
→ outbox flush [its own debounce/backoff, concurrent]Hydrate before staged reconcile. The staging layer (
note-write-staging.ts, #4794 design lock 4) already hooks the not-seeded → seeded transition precisely so that amode: "replace"hydrate can never clobber a reconciled write. The mirror hydrate IS such a hydrate and MUST stay the seed event: the mirror may hold the pre-quit content for a path whose newer content sits in the staged record (the writer's IDB commit is async and loses the quit race by design — the staged record is the synchronous durable copy). Replaying staged content after the replace puts the newer body on top; the resultingapplyLocalUpsertre-emits, so the mirror row is repaired in the same pass.Staged reconcile before the delta drain — the explicit
await flushStagedNoteWrites()inseedWorkspaceModelForBoot(the call is idempotent and joins the seeded-listener flush already in flight). Without it, a foreign delta row for path P could apply first and then the staged replay would overwrite it invisibly. With it, the staged content is in the model and the outbox before the drain starts, so the drain's R1 rule (section 8) resolves the collision deterministically (local wins). This matches today's snapshot-boot semantics, where the staged replay lands on top of the snapshot.Outbox flush needs no new ordering. It arms with the keys (
armOutboxIfReady), retries with backoff offline, and its relationship to the pull is governed by the section-8 rules, not by boot sequencing. The persisted entries themselves are the durability layer for offline edits/deletes — unchanged by this epic.
7. Offline arm: fail-closed without the network — sub #4812
Today both arm paths require the network twice: persistedKeyMatchesWorkspace() (CloudSyncClient.verifyPassword) and the snapshot fetch. The token itself is a third dependency: armCloudClient refuses without one, and the desktop tokenProvider mints over the network.
The fail-closed guarantee (locked): no path returns "armed" without positive cryptographic evidence that the armed keys open this workspace — either (a) the server verifyPassword check (today's mechanism, unchanged when online), or (b) successful AEAD verification of at least one mirror record (probeMirrorDecrypt). (b) is sound because mirror rows are only ever written by an armed session, and every session arms through (a) or (b) — inductively, every row descends from server-verified key material. A wrong key, or a key whose persisted salt has drifted, fails the HMAC on every record and cannot arm. There is no third path, and isCloudWorkspaceReady() (which gates every workspace write) only becomes true through an arm.
tryRearmFromStore (locked flow):
Resolve
workspaceId+ persisted key exactly as today (missing key →needs-unlock, unchanged).Resolve the token, classifying the failure instead of flattening it.
platform.resolveToken()collapses "could not reach the issuer" and "the issuer rejected this session" intostring | null-or-throw, and the original rule (offlineIntent = navigator.onLine === false || token === null) folded both into "offline". That was an auth bypass: a session revoked server-side (signed out elsewhere, token revoked, subscription lapsed) went 401 →null→ offline arm → armed, with the "Working offline" banner up indefinitely, sync silently never resuming, and the user never asked to re-authenticate. Locked replacement:navigator.onLine === false⇒ unreachable (offline arm), whatever the token attempt said. The device says it has no network, that fully explains a null/throwing mint, and a re-auth prompt would be unactionable anyway. A cached token, if there is one, is still handed to the offline arm.Online +
null⇒ rejected: this device holds no usable session. Failauth-invalid.Online + throw ⇒ classify by shape. Only 401/403/404 (the server answered) is rejected; a bare
TypeErrorfrom a dead fetch, a 5xx, anything else is unreachable and keeps the offline arm reachable — the captive-portal / dead-DNS case, wherenavigator.onLinestill reports true.
The same "the server answered, so believe it" rule applies one level up, in
resolveBootRoute: a bound workspace falls through to the arm attempt whenlistWorkspaces()throws, except onWorkspaceAuthError, whichlistAccountWorkspacesraises only for a 401 that survivedSyncApiClient.request()'s own forced refresh-and-retry. An offline device never produces one — an unmintable token throws a plainErrorthere, which classifies asunavailable.Online path (
!offlineIntent): unchanged —armCloudClient,persistedKeyMatchesWorkspace,rearmWorkspaceKeyFromStore,seedWorkspaceModelForBoot. Two divergences only:a transport throw from
persistedKeyMatchesWorkspace(classifiedunavailable— never a 401/403/404, which mean the server answered and keep today's semantics) diverts to the offline path below instead of failing;the seed step is
seedWorkspaceModelForBoot(section 5), so a mirror-armed online boot delta-pulls instead of snapshotting.
Offline path (requires a usable mirror; otherwise
{reason: "unavailable"}— which #4813 renders as the offline gate):initCloudSyncwith the best-available token (token ?? ""— the client must arm forisCloudWorkspaceReady()and the outbox; every network call it makes later re-resolves viatokenProvider/ retries with backoff, so a stale or empty token only delays sync, never the boot).rearmWorkspaceKeyFromStore(workspaceId)— onfalse, tear down (disposeCloudSync, like every other fail-closed exit: leavingcloudClientarmed withderivedKeys === nullis a half-armed sessionisCloudWorkspaceReady()would still wave through), keep the stored key (never destroy key material on an offline signal) and failunavailable.probeMirrorDecryptover the mirror's first record — on failure:disposeCloudSync()(tear the half-armed keys down — fail-closed means unverified keys must not outlive the attempt), re-bindWorkspaceId(see the teardown rule below),discardUnusableMirror— keep the stored key, failunavailable. The next online boot adjudicates the key throughverifyPassword.Hydrate + staged flush as in section 5 (skipping the drain), inside a try/catch —
applyWorkspaceChangesruns the per-surface listeners unguarded and they are documented as allowed to throw; without the catch that throw rejectsresolveBootRouteand lands onAppBoot's generic "Could not start up" instead of a classifiedWorkspaceArmFailure— → return{status: "armed", boot: {seed: "mirror", online: false}}.
Teardown rule: every disposeCloudSync() on a RETRYABLE path must re-bind. disposeCloudSync() calls setWorkspaceId(""), and nothing on the retry path rebinds — unlockWorkspace only calls deps.unlock(password), and the bindWorkspaceId happened once in openWorkspace, before the route ever became unlock. So a single mistyped password on the offline unlock screen was unrecoverable: the retry read getWorkspaceId() as "", skipped the offline branch entirely, and failed auth-invalid (or "No workspace binding" with a stale token) — mistyping was retryable online and fatal offline, the exact inversion of what the offline UX is for. The binding is in-process routing state, not a credential, so restoring it grants nothing: the AEAD probe still has to pass.
armWithPassword offline (locked): three entry conditions, ANY of which diverts to the mirror unlock when a usable mirror exists:
navigator.onLine === false;the token issuer is unreachable — the same
resolveTokenForBootclassification step 2 locks fortryRearmFromStore, applied here too. This one is not optional and was originally missing: the mint is the FIRST network call the online flow makes, so on a captive-portal / dead-DNS device (wherenavigator.onLinestill reports true) it is what fails, and flattening it intoauth-invalidmade the other two conditions unreachable in exactly the case they exist for. Only a 401/403/404 isrejected→auth-invalid; everything else falls through with the best-available token ("");getWorkspace()/verifyPasswordthrows a transport error.
With a usable mirror, all three then run:
saltHex = mirror.saltHex; empty → failunavailable(cannot derive).storeKeys(password, hexDecode(saltHex))— derive from the typed password and the mirror-recorded salt.probeMirrorDecrypt— failure means the password does not produce the keys that wrote this mirror:disposeCloudSync()then re-bindWorkspaceId(the teardown rule above — this is the exact path where losing the binding made a fat-fingered offline password unrecoverable) and return{status: "wrong-password"}. (A fully-rotted mirror is indistinguishable from a wrong password here; accepted — the corrupt-mirror case already cleared itself on the stored-key path, and online unlock always re-adjudicates.)On success:
persistWorkspaceKey(workspaceId, password, salt), hydrate + staged flush →armed {seed: "mirror", online: false}.
The preserveStoredKeyOnFailure popout option keeps its exact semantics: the offline path never drops keys anyway, and popouts never reach the mirror writer (section 4).
8. Conflict outcomes on reconnect — sub #4814
Scenario: a mirror-seeded session (or any session) holds offline edits in the durable SyncOutbox; connectivity returns; the delta pull (syncDrain / pullAndDecrypt → applyRemoteRows) brings foreign rows past the mirror cursor while the outbox flush uploads the queued entries.
Locked rules — implemented in applyRemoteRows (both entry points share it), after decryptPath, using one sample of getQueuedUpserts() + getQueuedDeletePaths() taken at the top of the function (post whenLoaded()):
| # | Queued locally | Remote survivor row | Outcome |
|---|---|---|---|
| R1 | upsert for path | upsert | Skip the row (do not download, apply, or arm markRemoteApply). Local edit stays on screen; the queued upload flushes after and mints a newer change-row, so the server converges to the same content — no divergence. |
| R2 | upsert for path | delete | Skip the row (edit-delete → use-local, matching autoResolveConflicts). The queued PUT re-establishes the file server-side. |
| R3 | delete for path | upsert | Apply the row AND drop the queued tombstone (outbox.remove(path) via a new bridge-internal helper; the removal barrier also kills an in-flight seal; no tracker cleanup is needed — the durable-delete path acked its tracker entry at enqueue). Delete-edit → use-server. |
| R4 | nothing | anything | Apply normally — last-write-wins by server change order, unchanged. |
Rationale locks:
R1/R2 direction (local wins): an un-synced local edit is the user's last action on this device and must never silently vanish from their screen; the remote author's version is not destroyed — the server's note-history version rows (epic #4176) retain it. This is the same latest-wins posture the outbox already applies per path, now made symmetric on the pull side, and it closes the model/server divergence window (remote-on-screen, local-on-server) that the pre-mirror drain had.
R3 direction (server wins) is deliberately OPPOSITE the snapshot tombstone filter's "err toward suppressing": a snapshot row for a queued-delete path is usually the stale pre-delete content — suppressing it is right. A delta row is proof of post-cursor foreign activity: another device touched the path after everything our cursor covers, so the file is alive elsewhere and deleting it would destroy foreign work. Erring toward keeping data wins; the user can delete again. (Which device's action is "later" in intent time is undecidable client-side; recoverability decides the tie-break.)
Sampling staleness is accepted and bounded: a queued upsert that drains between the sample and the row processing still converges (the skip keeps content the server just accepted); an edit enqueued mid-drain is today's unchanged race. Both are strictly narrower than #4357's snapshot-walk windows and are documented there, not re-litigated per call site.
SyncConflictDialog/autoResolveConflicts(cloud-conflict.ts): #4814 verifies the existing machinery makes no live-snapshot assumption about its "local" side. Planning-time reading: its only production feed is push conflicts on tombstones (pushPending), which are queue-shaped, not snapshot-shaped — expected outcome is "no change needed", recorded with evidence in #4814.
9. Eviction and lifecycle — sub #4810
Sign-out clears every mirror.
clearAllWorkspaceMirrors()is wired at exactly the two existing sign-out call sites, immediately after their awaited asynchronousclearAllWorkspaceKeys():tauri-—app/ renderer/ bootstrap/ app- boot. tsx signOutOfBoot()tauri-—app/ renderer/ components/ settings/ sections/ sync- auth- section. tsx handleLogout()
Both call it as
void clearAllWorkspaceMirrors().catch(() => {})— fire-and-forget, after the awaited key clear and beforeawait auth.logout()(web logout may navigate; code after the await may never run). The.catchis not optional and not stylistic: a throwing-accessor IndexedDB environment rejects the promise, and an unguardedvoidon a rejecting promise is an unhandled rejection — the same reason §5'sdiscardUnusableMirrorcarries one. A mirror that survives a killed deletion is ciphertext without keys — not a confidentiality break — which is why key clearing stays awaited and first, and best-effort deletion is acceptable. The next sign-in'sclearAllsweeps any leftovers.A same-account workspace switch clears nothing by itself.
disposeCloudSync()(workspace switch, arm-failure rollback) does NOT touch the mirror — mirror eviction happens ONLY at the two sign-out sites, the corrupt-mirrorclearWorkspaceMirrorin sections 5/7, and the writer's own abandonment discard (section 3 rule 6). Per-workspace mirrors surviving a switch is the feature: each workspace keeps its offline copy and its fast boot.Writer teardown is automatic.
clearWorkspaceModel()(via the disarm handler) drops thesubscribeWorkspaceFilessubscription; the writer re-arms on the next seed transition (section 4). In-flight batches complete against their captured workspaceId; a batch that hits "Encryption not set up" is dropped and that workspace's mirror is discarded under the section-4 refinement, so the switched-away workspace trades its offline copy for the guarantee that it never boots into a reverted note.
10. #4357 scoping determination
#4357 (durable-tombstone residual races) stays open; it is narrowed, not subsumed:
Gaps 1–2 (two-point queue sampling in
fetchSnapshotAndDecrypt's tombstone filter): the steady-state mirror boot never callsfetchSnapshotAndDecrypt— hydrate + cursor delta replaces the snapshot walk entirely, so those windows cannot open on a mirror boot. They persist only on first-build and corrupt-mirror fallback boots, which keep the existing filter verbatim. Their blast radius also shrinks: "bounded by the next relaunch" becomes "bounded by the first successful mirror build".Gap 3 (deletes stranded in un-adopted popout window queues) is orthogonal to the boot's seed source and is NOT addressed by the mirror.
The degraded-storage ack residual is untouched — the mirror changes no outbox ack semantics.
The epoch-log fix remains the eventual closure for gaps 1–2 and stays deferred.
#4814 confirms the steady-state claim with a test (a mirror-seeded boot never enters fetchSnapshotAndDecrypt) and updates #4357's body to this scoping — not a close.
11. Downstream sub map
| Sub | Implements | Sections |
|---|---|---|
| #4810 | workspace-mirror-store.ts: schema, backend seam, atomic applyBatch, eviction wiring at the two sign-out sites, in-memory fake + tests | 1, 2, 3 (store half), 9 |
| #4811 | workspace-mirror-writer.ts: subscription lifecycle, FIFO chain, cursor pairing, rebuild-or-clear, requestMirrorRebuild; bridge exports + addDeviceCursorListener; syncDrain step-4 refactor | 3 (writer half), 4 |
| #4812 | seedWorkspaceModelForBoot, probe, boot-mode result shape, offline arm for both entry points, ordering lock, adapter-guard comment | 5, 6, 7 |
| #4813 | Boot-gate generalization (offline + no usable mirror → blocking screen on ALL platforms; offline + mirror-armed → straight to <App> + "working offline" indicator), consuming WorkspaceBootMode | 5 (shape), 7 (failure surface) |
| #4814 | R1–R4 in applyRemoteRows, tombstone-drop helper, conflict-machinery verification, #4357 body update | 8, 10 |
| #4815 | #4233 acceptance sketch end-to-end, doc sweep (D7 et al.), close-out. (Planned "IndexedDB-backed integration checks" did NOT land — see the §2 correction; the real backend is unexecuted.) | all |
Accepted residuals
Mirror metadata (timestamps, versions, row count, salt) is plaintext-at-rest — same metadata class the sync server already stores.
The seed tear (rows committed at a lagging cursor) costs one wasteful replay-pull on a boot that raced a crash; never staleness.
Offline
armWithPasswordcannot distinguish a wrong password from a fully-rotted mirror; reported aswrong-password, re-adjudicated online.Partial mirror rot serves a partial workspace offline until the next online session rebuilds (
requestMirrorRebuild).A seed that applies NO files but IS marked complete (a workspace emptied on another device) now first-builds to zero rows (#4955/#4980), closing the #4869 stranding at 100% of the mirror that #4913 had scoped out. What remains: if that marked-empty batch is DROPPED before it commits (a stale arm epoch — the only drop path an empty batch has, since it encrypts nothing and so cannot hit the disarm-race throw), the writer's invariant-1 discard never fires for a rows-less batch, so the stale mirror survives untouched until the next seed reaches the writer and self-heals it — see the section 4 caution.
mock-adapternever exercises any of this: it stamps noWorkspaceSeedMarkerat all, by design.Every boot re-encrypts and re-commits the whole workspace through the writer's first-build, mirror-seeded boots included — O(workspace) crypto plus one IndexedDB transaction per launch. The seed-marked fix (section 4) adds a marginal cost on top of that: the IndexedDB write is now a full delete-then-write (
replaceAll) rather than a targeted upsert; the AES/HMAC encrypt cost is unchanged. See section 4 for why the whole-workspace re-encrypt is kept.A workspace whose writer chain is abandoned mid-session loses its mirror (and so its next offline boot) rather than keeping a frozen copy — section 3 rule 6.
Conflict-rule queue sampling has the narrow races noted in section 8 — strictly narrower than the #4357 windows they replace.