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>
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. |