rastrillo / native Public

Spec the native offline kit and its adoption by Docs, Sheets, Memoria, Calendar and Calls

Design only; nothing implemented. Shared kit in RastrilloNative/RastrilloApple
(typed errors and injectable transport first, connectivity, per-account sealed
outbox, backoff, SyncStatus to savestatus, reauthenticate keeping the account
id), Docs as first adopter in detail, Sheets base-value conflicts in outline,
and a Decisions for Paul section with proposed defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a845b9ac37749f4bbf97b104fcbc0b1866634b33 parent e51949f
1 file changed, +994 −0
  • docs/superpowers/specs/2026-09-23-offline-kit.md +994 −0

CI failed — run details

diff --git a/docs/superpowers/specs/2026-09-23-offline-kit.md b/docs/superpowers/specs/2026-09-23-offline-kit.md
new file mode 100644
index 0000000..9bd9f80
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-23-offline-kit.md
@@ -0,0 +1,994 @@
+# Native offline mode: the shared kit, and how Docs, Sheets, Memoria, Calendar and Calls adopt it
+
+Date: 2026-09-23. Status: **design spec, nothing implemented.**
+Paul, 2026-09-23: "make sure that all the native apps as appropriate have
+offline mode with changes synced back when the connection is restored."
+
+Inputs: `oficina/offline-audit/{shared,docs,sheets,calendar,memoria}.md` and
+`oficina/polish-audit/PLAN.md` §3–4. Everything cited below was re-read on
+2026-09-23 at the refs in §0. It was not copied from the audits. Where the
+audits have gone stale, this spec says so.
+
+Apps hold no real data, and Paul is the only user. Destructive local-format
+changes are therefore acceptable throughout: delete and re-fetch. No
+migration code is proposed anywhere in this document.
+
+## 0. Where things stand (verified)
+
+| Repo | Ref read | What changed since the audits |
+|---|---|---|
+| rastrillo/native | `origin/main` e51949f; `origin/merge/native-apple-chain` eacc073 | Main still holds only `RastrilloNative/CoalescedRunner.swift`. The chain merge `3851559` (33ffe99..2295259) is approved on discussion #4 (Fable: APPROVE-WITH-NITS) and waits on CI infrastructure. Kit citations below are to **eacc073**, which is what main will contain once the merge lands. |
+| oficina/docs | `origin/main` 7543cf5 | The `native/sync-batching` line is on main: merged batching (op 253), `creation_token` drafts (migration 0008), rename. Pin is `2295259` (`native/apple/Package.swift:6`, `native/ios/project.yml:11`). |
+| oficina/sheets | `origin/main` b9a92e3 | `native/document-windows` is on main. **B12 is fixed.** Write responses now carry `stamps`, and `SheetModel.accept` refetches only when `version > held + max(stamps, 1)` (`SheetModel.swift:115-124`, `grid.go` `gridResponse.Stamps`). Pin is `2295259`. |
+| oficina/calendar | `origin/main` f6aa41c | The native API and clients are on main (cd3478c). **B11 is fixed**: a failed load keeps the last good events (`CalendarModel.swift:75-76`). There is still no revision, uid, delete or reminder data in `internal/calendar/native.go` (grep finds none), and no delete route (`routes.go:112-117`). Pin is `2295259`. |
+| oficina/memoria | `origin/main` 02cb530 | The native clients are on main (e449e4b). **B10 is fixed**: `client_id` idempotency (`internal/memoria/native.go:83-122`, `MemoriaModel.swift:78`). A repeated `client_id` *revises* the same note. Pin is `2295259`. |
+| oficina/meet (Calls) | `origin/main` 3846ca4 | Unchanged for this purpose. It pins `4c61d62` for `RastrilloMacUpdates` only (`native/apple/Package.swift:6`). Cookies are still held in memory only (`CallsModel.swift:34`). |
+
+So the four-way pin split in the shared audit has collapsed to one. The four
+data apps pin `2295259`, and Calls pins `4c61d62`. Step 0 of PLAN.md §3
+reduces to three things:
+
+1. Land #4.
+2. Re-pin every app to the merge commit on rastrillo/native main.
+3. Bump each app server's `amadan.net/rastrillo/native/server` module off
+ `33ffe99` (docs `go.mod:9`, and likewise in the others). #4 already notes
+ that this is needed for the browser callback. §3.5 needs it too.
+
+Citation shorthand used below:
+- `kit:` means rastrillo/native@eacc073 `Sources/RastrilloApple/`.
+- `DocsKit/` means docs@7543cf5 `native/apple/Sources/DocsKit/`.
+- `SheetsKit/` means sheets@b9a92e3 `native/apple/Sources/SheetsKit/`.
+
+## 1. Goals and non-goals
+
+**Goals**
+- Anything a person could see a moment ago is still readable with no
+ network, including after a relaunch. What "anything" covers is scoped per
+ app in §4–7.
+- Edits made offline are never lost, and never silently dropped at sign-out,
+ after a crash, or when iOS terminates the app.
+- They reach the server exactly once in effect when the connection returns.
+- The status tells the truth. It never shows "Saved" while something is
+ unsent, and it never shows a generic error for "you are offline".
+- One kit gives every app the same connectivity, outbox, backoff, status
+ vocabulary, re-sign-in, and at-rest protection.
+
+**Non-goals**
+- Offline sharing, invites, grants or publishing. These stay online-only in
+ every app.
+- Offline search in Memoria. Offline formula evaluation in Sheets.
+- Background sync while an iPhone is locked (see decision D3).
+- A general sync engine. **The kit moves opaque app payloads. Each app owns
+ what a conflict means.**
+
+## 2. Principles
+
+1. **The outbox is the unit of durability.** A change counts as "saved on
+ this device" only once its outbox entry is on disk. Everything else,
+ such as replica state or list caches, is a cache that can be rebuilt.
+2. **Status comes from outbox state, never from HTTP activity.** This follows
+ estilo's rule: "Do not infer save state from HTTP activity alone"
+ (estilo `implementation/save-status.md:60`).
+3. **Connectivity is observed, never probed.** `NWPathMonitor` gives the
+ fast signal. Real traffic, such as the SSE streams Docs and Sheets
+ already hold, proves that the server is reachable. Nothing pings.
+4. **The account identity outlives the session.** A session that ends must
+ never orphan or delete what the account has queued.
+5. **Cached content is sealed the same way in every app** (proposed default,
+ D1). Memoria's rule that content is sealed at rest is the strictest in the
+ suite, so the kit meets it once and every app gets it.
+6. **The kit carries no conflict policy.** Docs merges with a CRDT. Sheets
+ checks a base value. Calendar merges field groups. Memoria has nothing to
+ conflict.
+
+## 3. The shared kit (rastrillo/native)
+
+This follows the package rule: "Keep platform-specific dependencies out of the
+shared Swift target" (rastrillo/native `AGENTS.md`).
+
+- **RastrilloNative** (Foundation only) holds the pure logic: `Outbox`,
+ `Backoff`, `SyncStatus` and the drain state machine.
+- **RastrilloApple** holds everything that touches Security, CryptoKit,
+ Network, SwiftUI or `NativeHTTP`: typed errors, `NativeTransport`,
+ `NativeConnectivity`, `NativeSealedStore`, `OutboxDrainer` and the views.
+
+The package rule also says to "add a component only after proving adoption in
+existing applications". This spec builds the kit in rastrillo/native from its
+first cut, marked **in-progress primitive** in the README, and does not mark
+it stable until Sheets has adopted it. That is estilo's savestatus pattern.
+Pins already make every adoption opt-in. (Small decision d1 in §10.)
+
+### 3.1 Prerequisite: typed errors and an injectable transport
+
+This ships first, on its own. Nothing else can be built until an offline
+failure can be told apart from an ended session.
+
+**What is wrong today**
+- `NativeClientError` has one case, `.message(String)` (`kit:NativeHTTP.swift:9-12`).
+- `request<T>` flattens every non-2xx status into an English sentence
+ (`:137-147`). A 401 or 303 becomes "Your session has ended. Sign out and sign
+ in again." (`:141`). That advice is impossible to follow in Docs when edits
+ are unsent (§4.6).
+- A raw `URLError` escapes from `session.bytes` (`:94`, `:116`).
+- A refused stream becomes a message (`:121`).
+
+**Change:**
+
+```swift
+public enum NativeClientError: LocalizedError, Sendable {
+ case message(String) // kept: local validation text
+ case offline(URLError.Code) // see classification below
+ case unauthorized // 401, and 303 (NoRedirects surfaces the redirect to /signin)
+ case forbidden // 403
+ case notFound // 404
+ case conflict(Data) // 409, body kept for the app
+ case tooLarge // 413
+ case rateLimited(retryAfter: TimeInterval?) // 429; also 503 carrying Retry-After
+ case rejected(status: Int, body: Data) // any other 4xx
+ case server(status: Int) // 5xx
+ case invalidResponse
+ public var errorDescription: String? { … } // today's strings, so existing callers compile
+ public var retryClass: RetryClass { … } // .offline / .transient / .auth / .permanent / .conflict
+}
+```
+
+- **`.offline` covers:** `notConnectedToInternet`, `networkConnectionLost`,
+ `cannotFindHost`, `cannotConnectToHost`, `dnsLookupFailed`, `timedOut`,
+ `dataNotAllowed` and `internationalRoamingOff`.
+- **Not offline:** TLS failures (`secureConnectionFailed`,
+ `serverCertificate*`) are `.transient`. They do not mean "you are offline",
+ and showing them as offline would hide a real fault.
+- **What throws:**
+ - `response`/`responseDetails` keep returning the status. Callers that
+ compare codes are unchanged. They now throw `.offline` for transport
+ failures.
+ - A new `NativeResponse.requireSuccess() throws` maps a status to the typed
+ case.
+ - `stream()` throws the typed case for a refused stream, so a 401 on
+ `/stream` reads as `.unauthorized`.
+
+**`NativeTransport`** is a protocol that `NativeHTTP` conforms to:
+
+```swift
+public protocol NativeTransport: Sendable {
+ var origin: URL { get }
+ func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse
+ func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws
+ func download(_ path: String, to destination: URL) async throws
+}
+// response(), request<T>() become protocol extensions over responseDetails.
+```
+
+- **Outcome hook.** `NativeHTTP` gains an optional outcome observer, passed at
+ init. The observer receives "a response arrived" or "an offline-class
+ error". `NativeConnectivity` uses it (§3.2), so no separate probe is needed.
+- **Test support.** A `RastrilloTestSupport` library product ships
+ `ScriptedTransport`. Each call answers from a script: a status and body,
+ `.offline`, a hang until cancelled, or "commit, then lose the response".
+ Apps reuse it instead of the Python fixture servers they spin up today
+ (for example Calendar's `SessionLifecycleTests`).
+- **Consumers take a supplier, not a client.**
+ - Today `NativeHTTP` is immutable, with `let credential` (`:21-34`), and
+ apps capture it: `DocsModel.client` is a `let` (`DocsKit/DocsModel.swift:41`).
+ - Anything that can outlive a session takes
+ `@MainActor () -> NativeTransport?` instead. Re-sign-in (§3.5) swaps the
+ transport under it.
+ - `NativeConnection` gains `transport(for accountID:) -> NativeTransport?`.
+ Today only the active account's `client` exists (`kit:NativeConnection.swift:8, 55`).
+
+### 3.2 Connectivity monitor: `NativeConnectivity`
+
+This is `@MainActor @Observable`, with one instance per process.
+
+- **`path`** is `.satisfied`, `.unsatisfied` or `.requiresConnection`, plus
+ `isExpensive` and `isConstrained`, all from `NWPathMonitor`.
+- **`serverReachable[origin]`** is `true` or `false`, starting unknown.
+ - The first response from that origin sets it true.
+ - A `.offline` error sets it false.
+ - A path change resets it to unknown.
+ - Nothing pings. The Docs and Sheets SSE streams act as the heartbeat. The
+ other apps learn on their next real request.
+- **`isOffline`** means `path != .satisfied`, or the path is satisfied and
+ `serverReachable == false`.
+- **`changes: AsyncStream<Void>`** fires on a path change and on app
+ foreground. Drainers and stream loops wait on "backoff elapsed **or**
+ connectivity changed", so a restored path is acted on at once rather than
+ after the current sleep.
+ - This replaces the fixed 2 s sleeps in Docs `listen` (`DocsKit/DocsModel.swift:323`)
+ and Sheets `watch` (`SheetsKit/SheetLive.swift:28`).
+- **Fail fast.** While `path == .unsatisfied`, drainers do not attempt sends.
+ Apps may skip user-initiated requests and show the offline state at once.
+ Memoria search should, instead of waiting out the 30 s timeout
+ (`kit:NativeHTTP.swift:32`).
+
+### 3.3 Per-account durable outbox: `Outbox` and `NativeSealedStore`
+
+#### Storage
+
+```
+Application Support/<bundle id>/accounts/<account.id>/
+ outbox/<seq, 16 hex digits>.entry sealed, one file per entry
+ cache/… sealed, app-owned (Docs replicas, Sheets snapshots, …)
+```
+
+- Everything is written by `NativeSealedStore` (§3.6). On iOS the file
+ protection is `.complete`, and files are `isExcludedFromBackup`: the key
+ cannot leave the device, so a backed-up copy could never be read anyway.
+- SQLite is unnecessary. The volumes are small, and one atomic file per entry
+ is enough. Sheets proposed SQLite in its audit. With one entry per *cell*
+ (§5), a long offline session is at most thousands of small files, and that
+ is fine.
+- **Keying.** The key is `NativeAccount.id`, the UUID that
+ `credentialKey` already binds to the origin (`kit:NativeAccount.swift:9`).
+ §3.5 makes that id survive a re-sign-in. The audits proposed keying by
+ (origin, subject) instead. The subject is added as a *guard* in §3.5, not
+ as the key, so an account that has not yet been given a subject keeps
+ working.
+
+#### Types (RastrilloNative, Foundation only; the store is behind a protocol)
+
+```swift
+public protocol BlobStore: Sendable { // implemented by NativeSealedStore; tests use an in-memory one
+ func write(_ data: Data, to path: String) throws // atomic
+ func read(_ path: String) throws -> Data?
+ func list(_ directory: String) throws -> [String]
+ func remove(_ path: String) throws
+}
+
+public struct OutboxEntry: Codable, Sendable, Identifiable {
+ public let id: UUID // stable; apps may send it as an idempotency key (Memoria client_id)
+ public let seq: UInt64 // per account, monotonic, recovered as max+1 at load
+ public var lane: String // FIFO scope: "doc:<sqid|draft-token>", "tab:<id>", "notes", "event:<id|temp>"
+ public let kind: String // app-defined
+ public var payload: Data // opaque app bytes
+ public let createdAt: Date
+ public var attempts: Int
+ public var hold: Hold? // nil = sendable
+}
+
+public struct Hold: Codable, Sendable {
+ public enum Reason: Codable, Sendable { case conflict(Data), rejected(String), accessLost, tooLarge }
+ public let reason: Reason
+ public let blocksLane: Bool // Docs: true (CRDT order); Sheets conflict on one cell: false
+}
+
+public actor Outbox {
+ public init(store: BlobStore, account: String)
+ public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry // durable on return
+ public func replace(_ ids: [UUID], with entry: (lane: String, kind: String, payload: Data)) throws // write-new-then-delete-old
+ public func update(_ id: UUID, payload: Data) throws // coalescing (Sheets cell, Calendar event, Memoria edit)
+ public func retarget(lane from: String, to: String) throws // Docs draft -> sqid
+ public func sendable(lane: String, limit: Int) -> [OutboxEntry]
+ public func ack(_ ids: [UUID]) throws
+ public func hold(_ ids: [UUID], _ hold: Hold) throws
+ public func release(_ id: UUID) throws // user resolved: back to sendable
+ public func discard(lane: String) throws
+ public func purge() throws // sign-out wipe
+ public var summary: OutboxSummary { get } // per-lane pending/held counts
+ public var changes: AsyncStream<OutboxSummary> { get }
+}
+```
+
+**Rules**
+- **FIFO within a lane; lanes are independent.** A held entry with
+ `blocksLane` stops its own lane only. Other documents or sheets keep
+ draining.
+- **`replace` is not atomic.** It writes the new entry, then deletes the old
+ ones. A crash between the two leaves both, so it must only be used where a
+ duplicate is harmless. That holds for Docs (Yjs integrates duplicates
+ idempotently: docs `internal/docs/handlers.go:879-880`). For Sheets and
+ Calendar, coalescing uses `update` on one entry instead.
+- **No silent caps.** An app sets a per-lane byte ceiling. Exceeding it is a
+ visible `failed` state (§3.4). It never drops input.
+
+#### `OutboxDrainer` (RastrilloApple)
+
+There is one drainer per account with sendable entries and a credential.
+This includes accounts that are not the active one, so a switched-away
+account still drains.
+
+- It takes the account's `Outbox`, a `() -> NativeTransport?` supplier and
+ `NativeConnectivity`.
+- The app supplies
+ `send(lane: String, entries: [OutboxEntry], transport: NativeTransport) async -> [UUID: Disposition]`,
+ where `Disposition` is `.sent`, `.retry` or `.hold(Hold)`.
+ - The call is batch-shaped on purpose. Docs merges a lane prefix into one
+ POST, and Sheets packs up to 500 cells.
+- **Errors the app throws out of `send` are classified by the kit.** The
+ drainer, not the app, decides what an error means:
+
+| Typed error | Drainer reaction |
+|---|---|
+| `.offline` | Mark offline; stop all lanes; wait for `connectivity.changes`. No timed retries while the path is down. |
+| `.unauthorized` | Mark the account **session ended** (§3.5); stop all lanes; wait for re-sign-in. Never hold or drop entries. |
+| `.rateLimited(after)` | Back off at least `after`. |
+| `.server`, `.transient`, `.invalidResponse` | Back off (§3.4) and retry the same batch. |
+| `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | The app's `send` is expected to catch these and return `.hold`. If it does not, the drainer holds the batch with `blocksLane: true` rather than spinning. |
+
+- **Triggers:** enqueue, connectivity change, foreground, re-sign-in, and an
+ explicit "Try again". At most one batch per account is in flight, which is
+ the simplest correct ordering against one server.
+
+#### iOS lifecycle
+- On `scenePhase == .background`:
+ - apps flush their cache writes (for example the Docs replica, §4.3);
+ - the drainer wraps any in-flight send in `beginBackgroundTask` so it can
+ finish.
+- Nothing is scheduled with `BGTaskScheduler` (D3). On `.active`, the
+ drainers wake.
+- Today there is no `scenePhase` or `beginBackgroundTask` anywhere in Docs
+ `native/` (grep, 7543cf5).
+
+### 3.4 Backoff, `SyncStatus`, and the savestatus mapping
+
+**`Backoff`**
+- The delays reuse callscore's numbers: 0.5 s, doubling, capped at 10 s
+ (meet `callscore/room.go:155, 216`). Callscore resets the delay after a
+ connection that lived more than 5 s (`:210`).
+- Each delay gets equal jitter, a random point in [d/2, d].
+- `Retry-After` is honoured, up to 5 minutes (callscore does the same:
+ `callscore/transport.go:137`).
+- It resets on success, on a path change and on foreground.
+- **It never runs while the path is unsatisfied.** That is the battery rule:
+ an offline phone makes no timed attempts at all.
+- Docs and Sheets stream reconnects use the same `Backoff`.
+
+**`SyncStatus`** is computed from `OutboxSummary`, the drainer phase and
+`NativeConnectivity`, in this priority order:
+
+| # | Condition | `SyncStatus` | estilo savestatus | Default copy (kit catalog, app may override) |
+|---|---|---|---|---|
+| 1 | a held entry | `.failed(pending, reason)` | `failed`, **sticky** until the user resolves or discards | app-owned, with an action ("Review", "Save as new document") |
+| 2 | session ended | `.needsSignIn(pending)` | `failed`, sticky | "Sign in to send N pending" + **Sign in** |
+| 3 | nothing sendable, nothing in flight | `.saved` | `saved`, silent | none |
+| 4 | a send in flight | `.saving` | `saving` | "Saving…" |
+| 5 | `connectivity.isOffline` | `.offline(pending)` | `retrying` (the Slides precedent, estilo `save-status.md:39-41`) | **"Offline · N pending"** |
+| 6 | backing off after a transient error | `.retrying(pending)` | `retrying` | "Trouble saving · Retrying…" |
+
+- **What N counts is supplied by each app.** It is a number a person
+ recognises: documents (Docs), cells (Sheets), events (Calendar), notes
+ (Memoria). By default it counts lanes. Plurals come from a stringsdict.
+- **Offline with nothing pending is `.saved`.** The status stays silent, and
+ the *connectivity* chrome (below) says "Offline". Status and connectivity
+ are separate signals, so "Offline · 0 pending" never appears.
+- **Views**
+ - `NativeSyncStatusView(status:)` is a compact label for a toolbar or title
+ bar. It is silent when `.saved`, and keeps its accessibility element
+ present when silent, as estilo requires.
+ - `NativeOfflineBanner(connectivity:, summary:)` is a thin inset bar: "You're
+ offline. Changes are saved on this device and will be sent when you
+ reconnect."
+ - Accessibility announcements happen only on entering `offline`,
+ `needsSignIn` and `failed`. A `saving` flicker is never announced.
+- **Localisation.** Kit strings live in the package's own `.lproj` catalog,
+ the way `RastrilloMacUpdates/Resources` does. That covers the same 12
+ languages. Apps override through a `NativeSyncStrings` value. English
+ wording is reviewed before translation (package `AGENTS.md`).
+
+### 3.5 Re-authentication that keeps the account id
+
+**Today**
+- `rememberSession` always mints a **new** `NativeAccount` UUID
+ (`kit:NativeConnection.swift:64`).
+- `signOut()` deletes the Keychain item *and* the account record
+ (`:175-191`).
+- So the only advice the kit gives on expiry, "Sign out and sign in again"
+ (`kit:NativeHTTP.swift:141`), would orphan everything keyed by account id.
+
+**Change** (in `NativeConnection`):
+- **`reauthenticate(_ account: NativeAccount)`** runs the same proof-bound
+ browser sign-in against `account.origin`.
+ - To make that possible, the loop in `signIn` (`:120-173`) is factored out
+ as `obtainSession(origin:) async throws -> NativeSession`.
+ - On success it calls `NativeCredentials.save(credential, origin: account.credentialKey)`.
+ That call already updates an existing item in place (`kit:NativeCredentials.swift:21-22`).
+ - The account id stays the same. The connection then rebuilds the transport
+ for that account and publishes it. Drainers and stream loops pick it up
+ through their supplier (§3.1).
+- **`sessionEnded: Set<String>`** (account ids) is published. The drainer and
+ any 401 or 303 seen by an app set it, and a successful `reauthenticate`
+ clears it.
+ - Apps show **Sign in** in place of "Sign out and sign in again".
+ - `selectAccount`'s "This account needs to sign in again." (`:46`) routes
+ to `reauthenticate` as well.
+- **Subject guard** (server + client). `/native/exchange` (rastrillo/native
+ `server/auth.go:190-250`) adds an opaque, stable `subject` to its JSON. This
+ is an HMAC of the person id under the app's key, so the value is not
+ portable across apps.
+ - `NativeAccount` gains `subject: String?`, an optional Codable field that
+ old records decode without.
+ - `reauthenticate` refuses a session whose subject differs from the stored
+ one: "You signed in as someone else. The pending changes belong to
+ ‹account›." It then offers *Add as a new account*.
+ - An account with no stored subject yet accepts the first one it sees.
+ - This needs the app servers to leave `33ffe99` of the server module (§0).
+- **Sign-out with pending items: warn, then wipe (D4).**
+ - `NativeConnection` gains `signOutImpact: (accountID) async -> Int`, fed by
+ the kit's per-account registry: pending plus held entries across the
+ app's lanes.
+ - If the count is nonzero and the account is online and not session-ended,
+ the kit first tries to send for up to 10 s ("Sending…"). If anything is
+ still left, it shows the confirmation:
+
+ > **Delete N unsent changes?**
+ > They are only on this device. Signing out deletes them, and everything
+ > else this app keeps offline for ‹account›.
+ > [Sign In Again] (only when the session has ended) · [Cancel] · **[Sign Out and Delete]** (destructive)
+
+ - Confirming wipes in a fixed order:
+ 1. Delete the account's store key, which crypto-shreds everything (§3.6).
+ 2. Remove `accounts/<id>/`.
+ 3. Remove the credential.
+ 4. Remove the account record.
+ 5. POST `/signout` as today.
+ - "Remove account" in `NativeAccountsView` follows the same path.
+ - **Switching accounts is not signing out.** It keeps the outbox, and the
+ switched-away account keeps draining (§3.3). Docs currently disables
+ Accounts and Sign out while anything is unsaved (`DocsKit/DocsWorkspace.swift:49, 56`).
+ Once the outbox is durable, that gate only needs to cover unpersisted
+ input.
+
+### 3.6 On-device encryption: `NativeSealedStore` (proposed default, D1)
+
+This covers every byte of cached content and every outbox entry, in every
+app. Cached content today includes Docs replicas and lists, Sheets snapshots,
+the Calendar window, and Memoria's pending notes.
+
+- **Key.** One 256-bit `SymmetricKey` per account, stored as a generic
+ password.
+ - Service `<bundle id>.store`, account `store:<account.id>|<origin>`.
+ - `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, not synchronizable. That
+ is the same class as the session (`kit:NativeCredentials.swift:26`).
+ - It is created on first write and never leaves the device.
+- **Sealing.** Each file is `AES.GCM.seal(plaintext, using: key, authenticating: relativePath)`,
+ stored as `.combined`. The relative path is the associated data, so a file
+ copied or renamed onto another path fails to open. For example, Document
+ A's replica cannot be swapped in as Document B's.
+- **Wipe.** At sign-out or "Remove account", delete the key first, then the
+ directory. A file that survives the delete (for example in a crash, or in a
+ snapshot) is unreadable.
+- **Unreadable store.** If the key is missing or a file fails to open, treat
+ the store as empty and remove it. If outbox entries were among the loss,
+ say so once: "Unsent changes on this device could not be read and were
+ removed." Never crash, and never show a partial state.
+- **Why uniform.**
+ - Memoria's rule is that content is sealed at rest
+ (memoria `internal/memoria/seal/seal.go`). The Memoria spec also says the
+ session is the only persisted app state
+ (`docs/superpowers/specs/2026-09-03-memoria-design.md:4074`).
+ - The Docs spec lists offline plaintext as leak 6
+ (docs `docs/superpowers/specs/2026-08-30-docs-design.md:110-113`).
+ - The Sheets server seals cells, and the Calendar server seals titles,
+ locations and notes.
+ - One sealed store costs less than four arguments about which app's content
+ may sit in plaintext. On iOS it also keeps content out of iCloud device
+ backups, which Data Protection alone does not.
+- **Consequences, accepted in this spec**
+ - Nothing is readable while an iPhone is locked, which is consistent with
+ D3.
+ - The Calendar menu bar is Mac-only, and the Mac login keychain is unlocked
+ while the user is logged in, so the menu bar is unaffected.
+ - Calendar local notifications (D6) are scheduled into the system while the
+ app runs, so they fire without reading the store.
+- **"Remove offline copies"** is a per-account setting. It deletes the
+ `cache/` directory and keeps the outbox. This is the "forget this device"
+ control the Docs spec calls for.
+
+### 3.7 Session lifetime (proposed default, D2)
+
+**Today.** Every native session is minted by `/native/exchange`
+(`server/auth.go:234`, `sessions.Mint`) with an **absolute 30-day TTL and no
+renewal** (rastrillo v0.23.0 `sessions/sessions.go:56-57, 90-91`;
+`auth/auth.go:68`). No app overrides it. So a laptop left idle for a month,
+with queued edits, always comes back to `needsSignIn`.
+
+**Proposed: sliding expiry for native sessions only.**
+- Mark sessions minted by `/native/exchange` as native.
+- An authenticated request on a native session extends its expiry to
+ now + 30 days. The row is written at most once per 24 h, so a busy client
+ does not write a session row on every request.
+- The idle limit stays 30 days. Browser sessions are unchanged.
+- The cookie value does not change, so the client needs no refresh protocol.
+ The stored credential simply stays valid while it is used.
+- Revocation still works, because renewal happens server-side on a row that
+ can be deleted.
+- An absolute ceiling (for example 1 year) is optional, and listed under D2.
+- **Where it goes:** the rastrillo `sessions` package, plus the native server
+ module, plus a module bump in each app. This is shared rastrillo work. It is
+ not a kit change.
+
+## 4. Docs: first adopter, in detail
+
+### 4.1 What happens today (7543cf5)
+
+**Nothing is on disk.**
+- `NativeHTTP` has no cache (`kit:NativeHTTP.swift:29-33`).
+- The list, the detail, the replica and the queue are memory-only
+ (`DocsKit/DocsModel.swift:9, 12, 32, 34`).
+- `CRDTReplica.close()` wipes `lastGood` (`DocsKit/CRDTReplica.swift:38`).
+
+**The queue already exists in memory.**
+- `pending: [Data]` receives one Yjs v1 delta per command
+ (`DocsModel.swift:185`).
+- `flush()` merges up to 512 KiB of the head with op 253 (`:281-294`;
+ `native/crdt/src/lib.rs:134-137`) and POSTs it to `/d/{sqid}/updates`,
+ expecting 204 (`:296-302`).
+- On any failure it keeps the queue and sets `native.pending` (`:303`).
+- `listen` retries every 2 s (`:307-325`).
+- The server accepts deltas made against any older base.
+ - `Store.ApplyUpdate` has no precondition (`internal/docs/store.go:776`).
+ - Updates with missing dependencies wait in yrs's pending queue.
+ - Duplicate rows are harmless (`handlers.go:879-880`).
+- **So offline for Docs is persistence, reachability and honest status. The
+ merge needs no new logic, and the server needs no change for correctness.**
+
+**The limits today**
+- `enqueue` refuses input past 2 MiB of unsent updates plus queued commands
+ (`:166-167`).
+- On iPhone, a terminated app loses everything silently: there is no guard
+ and no lifecycle hook.
+- On Mac, the close guard offers Save, Cancel or Discard. Save fails offline
+ (`DocsCloseGuard.swift:39-53, 68-74`).
+- Nothing can be opened at launch, because `open()` needs two GETs
+ (`:145-147`).
+
+### 4.2 Local store (per account, under `cache/docs/`)
+
+| Path | Content |
+|---|---|
+| `cache/docs/list` | Last successful unfiltered `DocumentList` + `fetchedAt` |
+| `cache/docs/<key>/replica` | Header `{format: 1, sequence: Int64, guid, savedAt}` + **pending-inclusive** Yjs v1 state |
+| `cache/docs/<key>/meta` | `sqid?`, `draftToken?`, `title`, `pendingTitle?`, `can_write`, `can_share`, `lastOpened`, `lastSyncedAt` |
+| outbox lane `doc:<key>` | Unacknowledged deltas, FIFO (kit outbox, not a Docs file) |
+| `cache/docs/images/<digest>` | Image bytes already shown. Slice D6 (§4.10); `DocsImageStore` is memory-only today (`DocsAttachments.swift:25`) |
+
+- `<key>` is the sqid, or `draft-<creation token>` for a draft (§4.5).
+- `sequence` lives in the same file as the state it describes. A sequence can
+ then never be newer than its state, so `?since=` can never skip rows.
+
+### 4.3 Persisted Y.Doc state
+
+**Use a pending-inclusive encoding.**
+- The native `snapshot` op is `encode_state_as_update_v1(&StateVector::default())`
+ (`native/crdt/src/lib.rs:43-44`). That drops yrs's *pending* structs, the
+ ones whose dependencies have not arrived.
+- The server core adds them back through `merge_pending_v1` for exactly this
+ reason (`core/src/snapshot.rs:7-21, 59`).
+- Add a `snapshot_pending` op to native/crdt. It calls the core function if
+ the crate graph allows, or otherwise mirrors it, with a golden test that
+ both produce identical bytes for a document holding pending structs.
+- Persist only that encoding. `CRDTReplica` gains `persistentState()`.
+
+**When the replica is written**
+- The writer is a `CoalescedRunner` (`RastrilloNative/CoalescedRunner.swift`).
+- It writes 2 s after the last publish, after every server catch-up in
+ `refresh` (`DocsModel.swift:326-339`), on window close, and on
+ `scenePhase == .background`.
+
+**Invariant I1 (load-bearing).** *Every local change contained in a persisted
+replica is either acknowledged by the server or present in the outbox.*
+- The server exposes no state vector, and a whole-document POST is capped at
+ 1 MiB (`handlers.go:31`). A replica that holds an edit the outbox has lost
+ can therefore never be reconciled. That edit would stay on this device
+ forever.
+- I1 holds by construction:
+ - `drainEdits` awaits a durable `enqueue` before it takes the next command
+ (replacing `pending.append`, `DocsModel.swift:185`);
+ - the replica writer only snapshots on the main actor after that.
+- **Discard must delete the replica too.** `discardAll()`
+ (`DocsModel.swift:242-254`) becomes: discard the lane, delete
+ `<key>/replica`, and refetch. Otherwise discarded text would reappear
+ locally without ever being sent.
+
+**Reopening**
+- `CRDTReplica.open(update:)` from the stored state. `fresh` already reserves
+ every client id found in the state and never reuses one
+ (`CRDTReplica.swift:39-50`), so restarts are safe.
+- Undo history does not survive a relaunch. That is accepted.
+
+### 4.4 The unsent-update queue
+
+- **`pending` becomes a view of lane `doc:<key>`.**
+ - `flush()` asks the outbox for `sendable(lane:)`. It keeps the 512 KiB
+ op-253 batching (`:281-294`), then returns `.sent` for the batch it
+ merged.
+ - `hasUnsavedChanges` (`:44`) splits in two:
+ - `hasUnpersistedInput` covers the command queue, IME composition and
+ rename input. Only this blocks close.
+ - `hasUnsentChanges` means the lane is non-empty. This is status only.
+- **Compaction.** When a lane passes 256 entries or 1 MiB, merge its head
+ into ≤512 KiB entries with `outbox.replace`. A duplicate left by a crash is
+ harmless for Yjs (§3.3).
+- **The cap becomes a disk cap and is visible.** Replace the 2 MiB in-memory
+ refusal (`:166-167`) with 32 MiB of unsent updates per document. The
+ command-queue cap stays.
+ - Exceeding it holds the lane: "Too many unsent changes to keep on this
+ device. Connect to save."
+ - Editing stops only at that point, and says why.
+- **If enqueue fails** (disk full, or the store is unreadable), input pauses
+ and the status becomes `failed`: "Can't save on this device." A change
+ never shows as saved when it is not.
+- **Title.** `pendingTitle` is persisted in `meta` and sent before updates,
+ as `flush` does today (`:267-275`). Last write wins, as on the server.
+
+**How each server response is handled** (`send` for lane `doc:*`):
+
+| Server answer | Today | Proposed |
+|---|---|---|
+| 204 | `pending.removeFirst(count)` | `.sent` for the merged ids |
+| offline | `native.pending`, retry every 2 s | kit: `.offline`, wait for connectivity |
+| 401 / 303 | `refreshAccess` → `inputPaused`, `can_write=false` (`:298-299, 354-358`) | kit: **session ended** (§4.6). Input is *not* paused and `can_write` is untouched |
+| 403, or 404 confirmed by `refreshAccess` | `inputPaused` | `.hold(.accessLost, blocksLane: true)` → §4.7 |
+| 400 (`docs.error.badupdate`) | retried forever | `.hold(.rejected, blocksLane: true)` |
+| 413 (a single update >1 MiB) | retried forever | `.hold(.tooLarge, blocksLane: true)` |
+| 5xx, 503 `errTeamUnavailable` (`store.go`) | retried | kit backoff |
+
+### 4.5 The list cache and launching offline
+
+**List**
+- `load()` (`:45-59`) renders `cache/docs/list` at once, then refreshes it.
+- `ListState` gains `.cached(fetchedAt)`. Offline the list reads "Offline ·
+ list from ‹relative time›" instead of `docs.error.generic`.
+- **Row states**
+ - Rows with a stored replica open normally.
+ - Other rows are dimmed and say "Not available offline".
+ - Rows with a non-empty lane show a pending dot.
+ - Local drafts are merged in at the top as "Not uploaded yet".
+- **Search.** The query is server-side (`/?q=`). Offline, it filters cached
+ titles instead and says "Searching titles on this device".
+
+**Launch offline, step by step**
+1. `NativeConnection.init` restores the account without the network
+ (`kit:NativeConnection.swift:99-118`).
+2. The workspace shows the cached list.
+3. Opening a document with a stored replica: `open()` reads `meta` and
+ `replica`, restores `sequence` and `can_write`, and publishes. No GET is
+ needed. It then starts `listen`, which is gated on connectivity.
+4. When the connection returns:
+ - the stream opens, and the server sends the current seq straight away;
+ - `refresh` fetches `?since=sequence` and applies it;
+ - the replica is persisted;
+ - the drainer sends the lane.
+
+ This is the existing convergence path, which the Docs spec calls out as
+ "Compaction does not strand an offline client"
+ (`2026-08-30-docs-design.md:170`).
+5. Mac windows restored by the system reopen their documents the same way.
+
+**Mac: one replica per document.** Each Mac document window owns a
+`DocsModel`, so two windows on one document would both write
+`<key>/replica`. Opening a document that already has a window focuses that
+window. `DocsWindows` gets that rule if it does not already have it. The
+outbox lane is keyed by document, not by window.
+
+**Drafts**
+- `newDraft` (`:62-80`) persists `draft-<token>/replica` and `meta`, and
+ enqueues on lane `doc:draft-<token>`.
+- After a relaunch, `startCreation` resumes. `POST /documents` with the same
+ `creation_token` returns the same document (`internal/docs/store.go:287`,
+ migration 0008).
+- On attach, `outbox.retarget` moves the lane to `doc:<sqid>`, and the
+ directory is renamed.
+- A crash between attach and rename is safe. The next launch re-POSTs the
+ same token and receives the same sqid.
+
+### 4.6 The dead end: an expired session with unsent edits
+
+**Today** there is no way out that keeps the edits:
+1. A 401 on the update POST, or a refused stream, runs `refreshAccess`. That
+ also gets a 401, so it sets `inputPaused = true` and `can_write = false`
+ (`DocsModel.swift:298-299, 317-321, 354-358`). Editing stops.
+2. The only message the kit offers is "Sign out and sign in again"
+ (`kit:NativeHTTP.swift:141`).
+3. Sign out and Accounts are both disabled while `hasUnsavedChanges`
+ (`DocsWorkspace.swift:49, 56`).
+4. So the edits can be kept only by leaving the app open for ever, which
+ iPhone will not allow, or they can be discarded.
+
+**Proposed**
+1. A 401 or 303 from any Docs request (flush, stream, refresh, list, open)
+ marks the account session-ended through the kit (§3.5). Access is not
+ revalidated, because a 401 means "who are you", not "you lost the
+ document".
+2. Editing continues, because every edit is durable in the outbox. The
+ status shows `needsSignIn(n)`, and a banner reads: "Your session has
+ ended. Sign in to send your changes." with a **Sign in** button.
+3. **Sign in** calls `reauthenticate(account)`. The account id and outbox are
+ unchanged. `DocsModel`, which now holds a transport supplier instead of
+ `let client` (`:41`), sees the new transport. The drainer sends, and the
+ stream reconnects.
+4. If the person signs in as someone else, the subject guard refuses
+ (§3.5). The state stays `needsSignIn`.
+5. **Sign out** stays available and goes through warn-then-wipe (§3.5).
+6. If the document itself is gone after re-sign-in (a 403, or a confirmed
+ 404), §4.7 applies.
+
+### 4.7 Terminal holds: a document that can't take the edits
+
+A held lane shows `failed`, which is sticky. It offers:
+- **Save as new document.** Create with a new `creation_token`, POST the
+ local replica state as one update, and move to the new document. This is
+ offered only when the state fits the 1 MiB update cap (`handlers.go:31`).
+- **Copy text**, from the projection, which is always available.
+- **Discard local changes**, which removes the lane and the replica, then
+ refetches.
+
+Splitting a large state across several updates is left out of v1. Raising
+the cap for native writers is small decision d3 (§10).
+
+### 4.8 Close, quit, and the iPhone
+
+- **Mac.** `DocsCloseGuard` (`DocsCloseGuard.swift:39-53, 68-74`) prompts
+ only for unpersisted input or a held lane. A pending-but-durable lane
+ closes quietly. Quit shows no prompt for it either, because the next launch
+ sends it.
+- **iPhone.** In the background, persist the replica and let the drainer
+ finish any in-flight POST inside `beginBackgroundTask` (§3.3). Durability
+ no longer depends on staying alive.
+
+### 4.9 What works offline in Docs
+
+| Action | Offline |
+|---|---|
+| Browse list | Yes, cached, "as of" |
+| Open | Documents opened on this device (prefetch is D7) |
+| Edit, rename, create | Yes |
+| Delete, share, grants, publish, public link, export PDF/Markdown, cross-document search, insert new image, presence | No. Controls are disabled with "Needs a connection" |
+
+### 4.10 Docs and kit slices (each ships and tests on its own)
+
+| # | Slice | Size | Test |
+|---|---|---|---|
+| K0 | Land #4; re-pin the apps to main; bump the server module | S | existing `make ci` in each repo |
+| K1 | Typed errors + `NativeTransport` + `RastrilloTestSupport` (§3.1). Docs takes a transport supplier | S | `ScriptedTransport` for each case; a real `NativeHTTP` pointed at a closed port → `.offline` |
+| K2 | `NativeConnectivity` + `Backoff`; Docs `listen` and Sheets `watch` wait on them; offline banner | S | injected path monitor unit tests; Network Link Conditioner "100% Loss"; Wi-Fi off; airplane mode on a device |
+| K3 | `NativeSealedStore` + `Outbox` + `OutboxDrainer` + `SyncStatus` and views; Docs lane, replica persistence (§4.3–4.4), open from replica | M | kill -9 mid-typing, relaunch offline, reconnect: server text equals local text. A property test of I1 over random edit/ack/crash sequences. Tampered or renamed files fail to open |
+| K4 | List cache, offline launch, drafts across relaunch (§4.5) | S–M | open, stop the server, relaunch, reopen; create offline → exactly one server document |
+| K5 | `reauthenticate`, subject guard, warn-then-wipe sign-out; §4.6 dead end removed | S (+ server module) | delete the session row server-side, keep editing, see `needsSignIn(n)`, sign in, see the drain, same account id. Sign in as another person → refused |
+| K6 | Holds and Save as new; relaxed close guard; iOS background task | M | force 400, 413 and a revoked grant with `ScriptedTransport`, then against a real server |
+| K7 | Image disk tier; LRU eviction of `cache/` (never evicting a document with a non-empty lane); "Remove offline copies" | S | eviction unit tests |
+
+Optional, not needed for correctness:
+- a `Docs-Update-Id` header to dedupe duplicate rows on retry;
+- a list `updated_at` or etag for cheap revalidation.
+
+## 5. Sheets: second adopter (outline)
+
+Sheets corrects the kit's interface, because its writes do not commute the
+way CRDT deltas do.
+
+**Today (b9a92e3)**
+- **One failed write blocks everything.**
+ - After a failure, `write()` refuses all further writes until the user
+ retries or discards (`SheetsKit/SheetWrites.swift:6-9`).
+ - `select`, format, resize, command and create refuse too, through
+ `hasUnconfirmedChanges` (`SheetModel.swift:22, 147, 193, 208, 244, 263`).
+ - The queue is memory-only (`SheetWrites.swift:38-43`).
+- **Errors are opaque.** A 401 during a write reads "The server did not
+ confirm the edit." (`:26-28`).
+- **Silent last-writer-wins.** There is no base or precondition on
+ `cells/batch` (`internal/sheets/handlers_cell_batch.go:23-61`). A late
+ replay would overwrite a newer value.
+
+**Local store and outbox**
+- Sealed: the list, plus one tab snapshot per tab (the whole grid window
+ ≤500×200, its version, formats and sizes). Reconnect refetches whole, as
+ today.
+- Lane `tab:<id>`, with **one entry per cell**: `{r, c, raw, base, fromR?, fromC?}`.
+ - Coalescing keeps the *first* base and the *last* raw.
+ - This is required, not an optimisation: `SetBatch` rejects a batch that
+ names a position twice (`store/handle_write_batch.go:23-29`).
+- Format and size entries are last-writer-wins and carry no base.
+- A pending overlay paints queued raws over the server cells. Dependents are
+ marked stale, because formulas evaluate only on the server.
+- **The gate goes.** Selecting and editing never wait for the network again.
+
+**Base-value rule (server, about 30 lines in `SetBatch`)**
+- Each cell write gains an optional `base`: the stored raw the client last
+ saw.
+- Under `h.mu`, `current := h.rawText[key]`. That value is already read as
+ `prevRaw` (`store/handle_report.go:116`). Then:
+ - `current == base`: apply.
+ - `current == raw`: already applied, so succeed without stamping. This
+ makes replay idempotent with no key.
+ - Otherwise: conflict.
+ - Compare the *translated* raw (`translateCopied`,
+ `handlers_cell_batch.go:48`) so paste provenance replays correctly.
+- An absent `base` keeps today's last-writer-wins, so the web grid is
+ unaffected.
+- Raw comparison is used rather than a per-cell version, because clearing a
+ cell deletes its row (`store/handle_set.go:46-80`).
+
+**409**
+- Any conflict answers 409 with the usual `gridResponse` for the cells that
+ *were* applied, plus
+ `conflicts: [{r, c, base, current, display, mine}]`.
+- **Non-conflicting cells are applied** (proposed; small decision d2). One
+ conflicted cell must not hold 499 good ones hostage.
+- `stamps` counts applied cells only. The b9a92e3 contract
+ (`version > held + max(stamps, 1)` means a missed edit) then holds
+ unchanged for partial batches.
+- A batch in which every cell conflicts is answered like a stampless
+ response.
+- The applied subset is published on SSE as today.
+- Extend `internal/sheets/version_contract_test.go` with partial batches.
+- **Client handling.** Applied cells return `.sent`. Conflicted cells return
+ `.hold(.conflict(…), blocksLane: false)`, so the rest of the lane keeps
+ draining.
+
+**Conflict UI (surfacing is D5)**
+- The cell carries a conflict badge. The status is `failed`: "1 conflict ·
+ Review".
+- **Review** lists each conflict: "B7 changed on another device. Theirs 1200
+ · Yours 1250", with [Keep theirs] and [Keep mine], plus "Keep all mine" and
+ "Keep all theirs".
+ - Keep mine re-sends with `base = current`.
+ - Keep theirs discards the entry.
+- Unresolved conflicts never block other edits. They count as pending, and
+ sign-out warns about them.
+- History already records `OldRaw` (`store/handle_set.go:79`,
+ `handlers_history.go:129`), so nothing is unrecoverable either way.
+
+**Other responses**
+- 401: the kit's session-ended state.
+- 403 or 404: `.hold(.accessLost)`, with "Copy my changes as TSV" (TSV
+ support already exists), then Discard.
+
+**Out of scope offline:** create, rename, delete, sharing and links,
+comments, history, restore and undo, import and export, the sort/filter view.
+
+**Slices**
+1. Server `base`, 409 and partial apply, with the stamps tests.
+2. Read cache.
+3. Outbox lanes and the overlay, removing the gate.
+4. Conflict UI.
+5. Formats and sizes in the outbox.
+
+Overall size M, plus S on the server.
+
+## 6. Memoria (brief)
+
+Memoria captures offline and reads online.
+
+**Now on main**
+- The Add Note sheet mints one UUID per draft and sends it as `client_id`
+ (`MemoriaModel.swift:78`).
+- The server keys the note as `native-<client_id>`, refuses non-canonical or
+ nil UUIDs, and revises on repeat (`internal/memoria/native.go:83-122`).
+
+**Offline capture**
+- Save always succeeds locally. It enqueues on lane `notes` with entry
+ `id = client_id` and payload `{title, body}`, then closes the sheet: "Saved
+ on this device. Will be sent when you're online."
+- Replay is idempotent by construction.
+- **Editing a pending note** updates the same entry. On the server, a repeated
+ `client_id` revises the note, so even an edit made after a lost response is
+ correct.
+- **Server limits are checked before enqueueing** (title ≤512 B, body ≤2 MiB,
+ non-empty: `native.go:106`), so a 400 never becomes a poison entry.
+- `ErrIndexLag` (`:123`) counts as `.sent` with a "not searchable yet"
+ notice.
+
+**Stays online:** search, sources and on-device answers. Answers already
+re-run server search before generating. With the path unsatisfied, fail fast
+with "Search needs a connection". **There is no read cache.** The spec rules
+out an offline permission cache (`2026-09-03-memoria-design.md:4048`).
+
+**Two Memoria-specific notes**
+- A de-admitted person gets a 404 from `/api/native/*`. For Memoria that
+ means access lost for the account, not "unsupported server".
+- The spec's "the session alone is stored in Keychain" (`:4074`) must be
+ amended to allow the sealed outbox. This adoption does that. D1 makes the
+ outbox sealed.
+
+Size S.
+
+## 7. Calendar (brief)
+
+**Now on main:** the native API, and keep-shown-events on a failed load
+(f6aa41c).
+
+**Server changes still needed** (the audit's list, re-verified as missing)
+- Expose `sequence` on `nativeEvent`.
+- Accept `base_sequence` on update, and answer 409 with the current event.
+- Accept a client `uid` on create, idempotent through `uid_hash`.
+- `DELETE /api/native/events/{id}`, idempotent and 409 on a newer sequence.
+ There is no route today (`routes.go:112-117`).
+- Expose reminder offsets.
+
+**Client**
+- A sealed snapshot of −30 to +90 days of *expanded* occurrences. Recurrence
+ expansion stays on the server. The menu bar reads from this snapshot, and
+ it is refreshed whole.
+- Lane `event:<id|temp>`.
+ - Create plus later edits coalesce into one create.
+ - Create plus delete cancels out.
+ - A temp id is swapped for the server id once the create is acknowledged.
+- **Conflict rule: a three-way merge by field group** (title, location,
+ notes, and schedule as one unit).
+ - Changed only on one side: that side's value is kept.
+ - Changed on both: the server wins, and the local value is kept as a "Use
+ mine" note on the event.
+ - Local delete versus a server edit: the event is **not** deleted, and the
+ user gets "Delete anyway".
+ - Local edit versus a server delete: the edit is discarded, with "Restore as
+ new event".
+ - An event that became recurring or booked: discarded, with "Open in web
+ calendar".
+- **Online only:** quick add (the parser is on the server), series edits,
+ booked events, attendees, sharing and settings.
+- **Reminders** are server-side email only today (`models_event.go:165-167`).
+ The device model is D6.
+
+Size M, or L with device notifications.
+
+## 8. Calls: graceful offline states
+
+Calls has nothing to sync. It should say what is happening and recover
+quickly.
+
+- **Offline state.** Adopt `NativeConnectivity`. "You're offline. Waiting
+ for a connection." is shown as a state distinct from "Reconnecting…".
+ Today one string covers both a dropped network and a server hiccup
+ (`CallsKit/Call.swift:279`).
+- **Recover at once.** When the path is restored, reconnect immediately
+ instead of waiting out callscore's backoff. That backoff is 500 ms doubling
+ to 10 s and retries for ever (`callscore/room.go:155-216`). The fix is a
+ small exported `Nudge()` on the bridge that resets the delay and wakes
+ `pause`.
+- **In the room**
+ - Keep the local preview live.
+ - Freeze remote tiles on their last frame with a "Connection lost" overlay
+ instead of going black.
+ - Leave is always enabled.
+- **Chat.** Keep the draft. Sending stays disabled while not admitted, as
+ today (`ChatView.swift:38-39, 49`). Nothing is queued across the gap.
+- **Lobby.** Join and Start are disabled with "Needs a connection" instead
+ of a bridge error.
+- **Separate from offline, but an offline launch cannot get past the lobby
+ without it.** Calls keeps cookies in memory only (`CallsModel.swift:34`),
+ so every launch needs a browser sign-in. Adopting `NativeConnection` needs
+ two things:
+ - `/native/exchange` on the Calls server;
+ - "Calls" added to the server module's callback allowlist, which names
+ "Meet" today (#4).
+
+ Re-pin Calls from `4c61d62` at the same time.
+
+Size S.
+
+## 9. Order of work
+
+K0 → K1 → K2 → K3 → K4 → K5 → K6 (Docs builds the kit) → Sheets 1–5 (the
+kit's interface is corrected here, then the kit is marked stable) → Memoria →
+Calendar → Calls → K7.
+
+- The session-lifetime server change (§3.7) runs in parallel once D2 is
+ ruled.
+- None of these slices touches another app's branch.
+
+## 10. Decisions for Paul
+
+Each decision has a proposed default. The spec above is written to that
+default.
+
+| # | Decision | Proposed default |
+|---|---|---|
+| D1 | **On-device encryption of cached content** | **Encrypt**, with a per-account AES-GCM key in the Keychain (`WhenUnlockedThisDeviceOnly`, never synced). The key is wiped at sign-out, which crypto-shreds the cache. This applies uniformly to every app's cache and outbox (§3.6). |
+| D2 | **Session lifetime** | **Sliding on native use.** Sessions minted by `/native/exchange` renew to now + 30 days on use, with the row written at most daily. Browser sessions stay fixed at 30 days. Optional absolute ceiling: 1 year (§3.7). |
+| D3 | **Background sync on a locked iPhone** | **No.** No `BGTaskScheduler`, and the key and session stay `WhenUnlocked`. Queued changes send on the next unlock and open. `beginBackgroundTask` only finishes a send already in flight (§3.3). |
+| D4 | **Unsent items at sign-out** | **Warn, then wipe.** Try to send for 10 s. If anything is still unsent, confirm with "Delete N unsent changes?", offering Sign In Again when the session has ended. Then shred the key and delete. Switching accounts keeps everything (§3.5). |
+| D5 | **Sheets conflicts** | **Surface them.** Check the base value, answer 409 per cell, and show "theirs vs yours" with Keep mine or Keep theirs. Conflicts never block other edits (§5). |
+| D6 | **Calendar reminders model** | **Device notifications in addition to email.** Schedule local notifications from the cached window, nearest first within iOS's 64-notification limit, re-planned on each refresh and outbox change. Server email continues unchanged. Lock-screen text follows the system "Show Previews" setting. This needs the server to expose reminder offsets (§7). |
+| D7 | **Prefetch** | **None in v1.** Offline copies are only what this device has opened: Docs documents and Sheets tabs. Calendar's −30/+90 window is its normal fetch, not prefetch. Memoria has no read cache. Revisit with "keep the N most recent available offline" once the cache and eviction (K7) exist. |
+
+Smaller calls, where the spec also proposes a default:
+- **d1. Where the kit lives.** In rastrillo/native from the first cut,
+ marked in-progress until Sheets adopts it. The alternative, a literal
+ reading of "add a component only after proving adoption", is to build it
+ inside DocsKit and extract it at Sheets.
+- **d2. Sheets mixed batches.** Apply the non-conflicting cells and return
+ 409 for the rest, rather than rejecting the whole batch.
+- **d3. Docs large updates.** Keep the 1 MiB update cap, and offer Save as
+ new document only when the state fits. Raising the cap for native writers
+ would remove that limit.