rastrillo / native Public

Revise the offline kit spec for the #5 review

Durability watermark and replica-plus-lane reopen (I1a/I1b), first-400 retry
and a 503 for a full Docs update log, unavailable-versus-lost store handling,
never-renamed sealed paths, the existing snapshot op is already
pending-inclusive, offline-on-satisfied-path backs off, FIFO-preserving
compaction, frozen in-flight Sheets entries, mandatory 180-day session
ceiling, a subject guard without a first-seen bypass, Sheets stamps and replay
order, sealing details, a narrower v1 API, and a test strategy. Calendar
device reminders move to their own spec; Docs maxUpdateBytes rises to 8 MiB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0d5c0c47291ffa48f41e9e6d5aa51dcad3dc53b7 parent a845b9a
1 file changed, +392 −148
  • docs/superpowers/specs/2026-09-23-offline-kit.md +392 −148

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
index 9bd9f80..338e54b 100644
--- a/docs/superpowers/specs/2026-09-23-offline-kit.md
+++ b/docs/superpowers/specs/2026-09-23-offline-kit.md
@@ -1,6 +1,9 @@
# 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.**
+Date: 2026-09-23. Status: **design spec, nothing implemented.** Revision 2
+addresses the review on rastrillo/native #5 (findings H1–H5, M1–M7, L1–L4,
+the API-minimalism list and the test strategy). Each finding is marked where
+it changed the text, for example *(H1)*.
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."
@@ -17,7 +20,7 @@ migration code is proposed anywhere in this document.
| 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. |
+| rastrillo/native | `origin/main` **2a5d07b** | The chain merge (33ffe99..2295259, discussion #4) is on main: `Sources/RastrilloApple/*` and `server/` are there. The tree is identical to the merge branch's eacc073, so line numbers are unchanged. *(L4)* |
| 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`. |
@@ -25,17 +28,17 @@ migration code is proposed anywhere in this document.
| 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:
+data apps pin `2295259`, and Calls pins `4c61d62`. With #4 landed, Step 0 of
+PLAN.md §3 reduces to two 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
+1. Re-pin every app to rastrillo/native main: the four data apps off
+ `2295259`, and Calls off `4c61d62`.
+2. Bump each data 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/`.
+- `kit:` means rastrillo/native@2a5d07b `Sources/RastrilloApple/`.
- `DocsKit/` means docs@7543cf5 `native/apple/Sources/DocsKit/`.
- `SheetsKit/` means sheets@b9a92e3 `native/apple/Sources/SheetsKit/`.
@@ -173,8 +176,9 @@ public protocol NativeTransport: Sendable {
- 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`).
+ - v1 needs no per-account transport lookup: only the active account drains
+ (§3.3). Today only the active account's `client` exists
+ (`kit:NativeConnection.swift:8, 55`), and that stays true.
### 3.2 Connectivity monitor: `NativeConnectivity`
@@ -188,6 +192,8 @@ This is `@MainActor @Observable`, with one instance per process.
- 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.
+ - **A change to `true` also fires `changes`**, so a stream that opens kicks
+ the drainers at once *(M2)*.
- **`isOffline`** means `path != .satisfied`, or the path is satisfied and
`serverReachable == false`.
- **`changes: AsyncStream<Void>`** fires on a path change and on app
@@ -196,7 +202,7 @@ This is `@MainActor @Observable`, with one instance per process.
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.
+- **Fail fast.** While `path != .satisfied`, 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`).
@@ -214,6 +220,10 @@ Application Support/<bundle id>/accounts/<account.id>/
- 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.
+- **"Durable on return" means it survives a process kill** *(L2)*: the file is
+ written with `Data.write(options: .atomic)`, a temporary file renamed into
+ place. It does not promise survival of a power loss, which would need
+ `F_FULLFSYNC` on every write. The kill case is what the tests prove.
- 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
@@ -221,16 +231,24 @@ Application Support/<bundle id>/accounts/<account.id>/
- **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.
+ (origin, subject) instead. The subject is a *guard* in §3.5, not the key.
+- **Paths never move** *(H5)*. The sealing binds each file to its path
+ (§3.6), so the kit has no rename or retarget operation. An app that learns
+ a better name for something later, as Docs does when a draft gets its sqid,
+ records the mapping in its own index (§4.5).
#### 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?
+public enum BlobStoreError: Error, Sendable {
+ case unavailable // locked device, file protection, Keychain interaction not allowed: retry later, never delete
+ case keyLost // errSecItemNotFound while the directory holds sealed files
+ case corrupt(String) // one file failed its GCM tag: that path is removed alone
+}
+
+public protocol BlobStore: Sendable { // implemented by NativeSealedStore; tests use in-memory and faulting ones (§3.8)
+ func write(_ data: Data, to path: String) throws // atomic, see "durable on return"
+ func read(_ path: String) throws -> Data? // nil = no such file
func list(_ directory: String) throws -> [String]
func remove(_ path: String) throws
}
@@ -238,9 +256,9 @@ public protocol BlobStore: Sendable { // implemented by NativeSealedS
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 lane: String // FIFO scope: "doc:<key>", "tab:<id>", "notes", "event:<id|temp>"; never changes
public let kind: String // app-defined
- public var payload: Data // opaque app bytes
+ public let payload: Data // opaque app bytes
public let createdAt: Date
public var attempts: Int
public var hold: Hold? // nil = sendable
@@ -249,16 +267,14 @@ public struct OutboxEntry: Codable, Sendable, Identifiable {
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 var blocksLane: Bool = true // v1 (Docs) only ever blocks; `false` arrives with the Sheets slice
}
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 replace(_ ids: [UUID], with payload: Data) throws // compaction; keeps the oldest seq (M3)
+ public func sendable(lane: String, maxBytes: Int?) -> [OutboxEntry] // by bytes, not count: Docs batches 512 KiB
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
@@ -269,23 +285,44 @@ public actor Outbox {
}
```
+**Kept out of v1 on purpose** (the review's API-minimalism list). Docs has to
+exercise every method that ships, which is the "one builds, one adopts" bar:
+- `retarget` is gone *(H5)*. Lanes and paths never move.
+- `update(id, payload:)` (coalescing in place) is deferred to the Sheets
+ slice. Only Sheets needs it, and it must refuse an entry that is in flight
+ (§5, *M4*). Calendar and Memoria adopt it after Sheets.
+- `Hold.blocksLane` defaults to `true`. The non-blocking hold is added and
+ tested with Sheets.
+- Draining accounts that are not the active one is left out (below).
+
**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.
+- **FIFO within a lane; lanes are independent.** A held entry stops its own
+ lane only. Other documents or sheets keep draining.
+- **`replace` keeps FIFO order** *(M3)*. The merged entry takes the seq of the
+ **oldest** entry it replaces:
+ 1. overwrite `<oldest seq>.entry` with the merged entry, in one atomic
+ `write` sealed for that same path. The atomic write is itself a
+ temporary file renamed into place, and it carries the final path as its
+ associated data, so a sealed file is never renamed after sealing (§3.6);
+ 2. delete the other replaced entries.
+
+ A crash after step 1 leaves duplicates only, never a gap and never
+ reordering. Only a lane whose payloads tolerate duplicates may use
+ `replace`. Docs qualifies (Yjs integrates duplicates idempotently: docs
+ `internal/docs/handlers.go:879-880`).
- **No silent caps.** An app sets a per-lane byte ceiling. Exceeding it is a
visible `failed` state (§3.4). It never drops input.
+- **An unavailable store is never an empty store** *(H4)*. `.unavailable`
+ from any store call means: don't read, don't delete, keep the current
+ status, and retry on `.active` or unlock.
#### `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.
+There is one drainer, for the **active** account. A switched-away account
+keeps its outbox untouched and drains when it is selected again. Nothing is
+deleted, which is what principle 4 requires. Draining several accounts at
+once is machinery Docs would never exercise, so it waits until an app needs
+it.
- It takes the account's `Outbox`, a `() -> NativeTransport?` supplier and
`NativeConnectivity`.
@@ -299,21 +336,25 @@ account still drains.
| Typed error | Drainer reaction |
|---|---|
-| `.offline` | Mark offline; stop all lanes; wait for `connectivity.changes`. No timed retries while the path is down. |
+| `.offline`, path not satisfied | Mark offline; stop all lanes; wait for `connectivity.changes`. No timed attempts while the path is down. |
+| `.offline`, path satisfied (for example `timedOut`, `cannotConnectToHost`, or a lost response) | **Back off (§3.4) and retry the same batch** *(M2)*. Waiting for a path change here would strand the drainer until the next foreground. |
| `.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. |
+| `BlobStoreError.unavailable` while acking or holding | `.retry` *(H4)*. The send may already have been accepted; the server-side idempotency of each app (Yjs, base value, `client_id`) makes the repeat harmless. |
+| `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | The app's `send` is expected to catch these and return `.hold` (or `.retry`, see Docs' first-400 rule, §4.4). If it does not, the drainer holds the batch 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.
+- **Triggers:** enqueue, connectivity change (including a stream opening),
+ foreground, re-sign-in, account selection, and an explicit "Try again".
+ At most one batch 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.
+ finish. If the device locks first, the store becomes unavailable, and a
+ failed ack or cache write is a `.retry`, not a loss *(H4)*.
- Nothing is scheduled with `BGTaskScheduler` (D3). On `.active`, the
drainers wake.
- Today there is no `scenePhase` or `beginBackgroundTask` anywhere in Docs
@@ -392,15 +433,34 @@ account still drains.
- `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.
+ `server/auth.go:190-250`) adds an opaque, stable `subject` to its JSON:
+ `HMAC-SHA256(subjectKey, person id)`.
+ - **The key is named and stable** *(M6)*. The server module's `Auth` config
+ gains `SubjectKey []byte`. Each app supplies it from a persistent
+ instance secret that already survives deploys (the secret class its
+ sessions or sealing already depend on), under a distinct label. It is
+ never a per-process random value, which would change the subject at every
+ restart and lock every account out. It is per app, so subjects are 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.
+ - **Accounts without a subject** *(M6)*. "Accept the first subject seen"
+ would disarm the guard for every account that exists today. Instead,
+ the first launch of a build that carries the kit marks every subject-less
+ account session-ended. That happens before any outbox exists, so nothing
+ is at risk, and the one re-sign-in records the subject. Apps hold no real
+ data and Paul is the only user, so one sign-in per app is the whole cost.
+ (A `/native/whoami` endpoint read on the first online request would avoid
+ that sign-in. It is not proposed, because it is another endpoint for a
+ one-time migration.)
+ - **The guard is client-enforced**, and the spec says so plainly. The server
+ cannot tell whose outbox a cookie is draining. The guard stops the app
+ from sending account A's queued changes under person B's session. It is
+ not a server-side authorisation control, and the server still authorises
+ every write as the person who owns the cookie.
- 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
@@ -422,8 +482,8 @@ account still drains.
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
+ - **Switching accounts is not signing out.** It keeps the outbox untouched,
+ and the account drains when it is selected again (§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.
@@ -434,23 +494,52 @@ 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
+- **Key** *(L1)*. One AES-256 `SymmetricKey` per account, stored as a generic
password.
- Service `<bundle id>.store`, account `store:<account.id>|<origin>`.
- - `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, not synchronizable. That
+ - `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, never 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.
+ - It is reached through a `KeyProvider` protocol, so tests can script every
+ Keychain status (§3.8).
+- **Sealing** *(L1, H5)*. Each file is
+ `AES.GCM.seal(plaintext, using: key, authenticating: aad)`, stored as
+ `.combined`.
+ - `.combined` is the 96-bit nonce, then the ciphertext, then the 128-bit tag.
+ - No nonce is passed, so CryptoKit draws a fresh random nonce per call.
+ Random-nonce GCM is safe below 2^32 seals per key (NIST SP 800-38D). The
+ Docs writer seals about once per 2 s of typing, which is nowhere near
+ that. The store still counts seals per key and rotates the key at 2^31:
+ it re-seals under a new key and then deletes the old one.
+ - The associated data is explicit: `accountID | format version | relative path`.
+ A file copied or renamed onto another path, or into another account,
+ fails to open. For example, Document A's replica cannot be swapped in as
+ Document B's. **Because of this, nothing sealed is ever renamed** (§3.3,
+ §4.5).
+- **File protection.** On iOS, files use `.complete` (§3.3). On Mac the
+ sandbox container is the only file protection. That is accepted, because
+ the content is sealed anyway.
- **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.
+- **Unavailable is not lost** *(H4)*. With `WhenUnlockedThisDeviceOnly`,
+ `SecItemCopyMatching` answers `errSecInteractionNotAllowed` while the device
+ is locked: for example, an app resumed in the background, or launched
+ before first unlock. `.complete` files refuse I/O after lock. Today
+ `NativeCredentials.read` folds every Keychain status into `nil`
+ (`kit:NativeCredentials.swift:15-16`), and the store must not copy that.
+ - **Key lost** means `errSecItemNotFound` **and** a populated
+ `accounts/<id>/` directory. Only then is the store treated as empty and
+ removed, with one notice if outbox entries were among the loss: "Unsent
+ changes on this device could not be read and were removed."
+ - **Every other Keychain status, and any file I/O error**, means
+ *unavailable*. Don't read, don't delete, keep the current status, and
+ retry on `.active` or unlock.
+ - **One file failing its GCM tag** is removed alone and reported once. The
+ directory is never removed for it.
+ - Never crash, and never show a partial state.
+ - `NativeCredentials.read` gets the same split, so a locked device reads as
+ "unavailable", not "this account needs to sign in again".
- **Why uniform.**
- Memoria's rule is that content is sealed at rest
(memoria `internal/memoria/seal/seal.go`). The Memoria spec also says the
@@ -468,8 +557,6 @@ the Calendar window, and Memoria's pending notes.
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.
@@ -482,20 +569,76 @@ 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.**
+**Proposed: sliding expiry for native sessions only, with a mandatory
+ceiling** *(M5)*.
- 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 expiry never passes `auth_time` + 180 days.** Without a ceiling, a
+ stolen native cookie that keeps being used would never expire except by
+ explicit revocation, and nobody would see it, because the client stores no
+ expiry. After 180 days the person signs in again (§3.5 keeps the outbox).
- 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.
+- **Where it goes.** This is shared rastrillo work, not kit work:
+ - `sessions` has no native flag and no renewal path today. `lookup` is
+ read-only (rastrillo v0.23.0 `sessions/sessions.go:162`), and the table
+ has no column for either (`sessions/migrations`: `token_hash`,
+ `subject`, `method`, `auth_time`, `created_at`, `expires_at`).
+ - So it is a `carlosframework/rastrillo` change: add `kind` and
+ `renewed_at` columns, and a best-effort renewal in the resolve path that
+ never fails a request.
+ - Then a module bump in the four data apps (docs, sheets, calendar and
+ memoria all require v0.23.0), plus the native server module, which marks
+ the sessions it mints. Calls uses `amadan.net/rastrillo/rastrillo`
+ instead and needs this only once it adopts `NativeConnection` (§8).
+
+### 3.8 Test strategy
+
+The fault model has to reach the *store* and the *timing of the writer*, not
+only the transport. Every durability bug in the review (H1, H2, H4) lives
+there. `RastrilloTestSupport` (§3.1) ships four primitives:
+
+- **`ScriptedTransport`** answers each call from a script: a status and body,
+ `.offline`, a hang until cancelled, or "commit, then lose the response".
+- **`FaultingBlobStore`** wraps an in-memory `BlobStore`. It follows a crash
+ schedule: "throw `SimulatedCrash` at store operation N, and freeze the
+ committed view". The test then rebuilds `Outbox` and the app model from
+ the frozen view, which models kill -9 at every possible point. It can also
+ answer `.unavailable` on demand (a locked device).
+- **`KeyProvider`** is a protocol with a scriptable fake. It returns a key,
+ `errSecItemNotFound`, `errSecInteractionNotAllowed` or any other status. The
+ H4 paths (unavailable versus lost) are then plain unit tests.
+- **An integrating fake server.** "Commit, then lose the response" is only
+ meaningful if the fake actually applies the write. For Docs, the fake
+ integrates each update into its own yrs document and serves `?since=`
+ and the stream. For Sheets, it keeps raws and versions with the `stamps`
+ rule. Convergence can then be asserted in-process.
+
+**The Docs property test (K3).**
+- **Event alphabet:** edit; writer tick, *including between `command` and
+ `enqueue`*; ack; lose-response; crash at any store operation; reopen and
+ apply the lane; reconnect.
+- **Assertions, after every event and at the end:**
+ - I1 (§4.3);
+ - after a full drain, server text equals local text;
+ - no entry is acknowledged that the server lacks;
+ - no text appears twice after a reopen (H2).
+- A generator that cannot place a writer tick between `command` and `enqueue`
+ cannot fail on H1, so that placement is required, not incidental.
+
+**Kept for the real thing.** `native/scripts/test-sync-latency.py` and
+`DocumentSyncIntegrationTests` stay for real kill -9 runs against a real
+server. Network Link Conditioner and airplane mode stay manual checks.
+
+**Sheets.** Extend `internal/sheets/version_contract_test.go` with partial
+batches (§5), and add M4's sequence: a batch commits, the response is lost,
+the same cell is edited offline, reconnect, and the result must contain no
+conflict.
## 4. Docs: first adopter, in detail
@@ -537,27 +680,34 @@ with queued edits, always comes back to `needsSignIn`.
| 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/index` | sqid → `<key>`, for documents born on this device as drafts (§4.5) |
+| `cache/docs/<key>/replica` | Header `{format: 1, sequence: Int64, guid, savedAt}` + Yjs v1 state from the existing `snapshot` op (pending-inclusive, §4.3) |
| `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`) |
+| `cache/docs/images/<digest>` | Image bytes already shown. Slice K7 (§4.10); `DocsImageStore` is memory-only today (`DocsAttachments.swift:25`) |
-- `<key>` is the sqid, or `draft-<creation token>` for a draft (§4.5).
+- `<key>` is the sqid for a document first opened from the server. It is
+ `draft-<creation token>` **for life** for a document created on this device
+ *(H5)*. It never changes, because sealed paths never move (§3.6).
- `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()`.
+**The existing `snapshot` op is already pending-inclusive** *(M1)*.
+- Revision 1 of this spec, following the Docs audit, claimed the `snapshot`
+ op drops yrs's pending structs. That is wrong.
+ - The op is `encode_state_as_update_v1(&StateVector::default())`
+ (`native/crdt/src/lib.rs:43-44`). In the vendored yrs that native/crdt
+ builds against (`native/crdt/Cargo.toml:17`), that function *ends* with
+ `merge_pending_v1` (`native/vendor/yrs-0.23.5/src/transaction.rs:138-143`).
+ - It is `encode_diff_v1` that omits the pending queue. The server core's
+ `core/src/snapshot.rs` header says the same.
+- So no new op is needed. `CRDTReplica.persistentState()` is `snapshot`.
+- **Pin the property.** Port
+ `a_snapshot_with_a_parked_update_heals_once_its_dependency_arrives`
+ (`core/tests/snapshot.rs:121`) into `native/crdt/tests`. It then runs
+ against the vendored crate and its backports, not only the server's copy.
**When the replica is written**
- The writer is a `CoalescedRunner` (`RastrilloNative/CoalescedRunner.swift`).
@@ -565,27 +715,52 @@ with queued edits, always comes back to `needsSignIn`.
`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
+**Invariant I1 (load-bearing), in two halves.**
+
+*I1a. 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, so a replica holding an edit the outbox
+ has lost can 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
+- **It does not hold by construction** *(H1)*. `CRDTReplica` is an actor
+ (`CRDTReplica.swift:5`). `command` updates the actor's `lastGood` before
+ returning (`:149`). `DocsModel.drainEdits` gets control back only after that,
+ and only then calls the durable `enqueue`. Meanwhile the writer and
+ `refresh → replica.apply` (`:121-127`) both read state on the replica actor.
+ Either can capture a `lastGood` that contains a command whose entry is not
+ yet on disk. A crash at that point strands the edit.
+- **Fix: a durability watermark.**
+ - `CRDTReplica` counts `commandsApplied`. `persistentState()` returns
+ `(state, sequence, commandsApplied)` captured together on the actor.
+ - `DocsModel` counts `commandsDurable`, advanced only after `enqueue`
+ returns.
+ - The writer persists a capture only if its `commandsApplied <= commandsDurable`
+ at the moment of writing. Otherwise it drops the capture and re-arms.
+ Durability only grows, so a capture that passes the check is safe.
+ - Remote state applied by `refresh` needs no watermark. It is already on
+ the server.
+
+*I1b. A reopened document is the persisted replica **plus** its lane*
+*(H2).*
+- The writer runs 2 s after the last publish. After a kill the replica is
+ therefore legitimately older than the lane.
+- Reopening from the replica alone would show the last edits missing while
+ offline. The person would retype them, and after the drain the document
+ would hold that text twice. That is the one user-visible duplication path
+ in the design.
+- **So reopening is:** `CRDTReplica.open(update: replica)`, then apply every
+ entry of lane `doc:<key>` in seq order, then publish. Yjs integration is
+ idempotent, so entries already in the state are no-ops. Drafts reopen the
+ same way.
+- `fresh` 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.
+**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 from the replica without
+ever being sent.
+
### 4.4 The unsent-update queue
- **`pending` becomes a view of lane `doc:<key>`.**
@@ -597,8 +772,9 @@ replica is either acknowledged by the server or present in the outbox.*
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).
+ into ≤512 KiB entries with `outbox.replace`. The merged entry keeps the
+ oldest seq, so lane order is unchanged *(M3)*. 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.
@@ -611,16 +787,33 @@ replica is either acknowledged by the server or present in the outbox.*
- **Title.** `pendingTitle` is persisted in `meta` and sent before updates,
as `flush` does today (`:267-275`). Last write wins, as on the server.
+**Why a first 400 is not terminal** *(H3)*. `updateIsClientFault` counts
+`core.ErrUpdateLogFull` as a client fault (`internal/docs/handlers.go:928-933`).
+It is therefore answered 400 with the same `docs.error.badupdate` key as a
+malformed update. But for a document whose handle has reached
+`core.MaxRetainedUpdates` (4096, `internal/core/core.go:141-146`),
+`Store.ApplyUpdate` writes the snapshot first and *expects* that POST to fail
+and the client's retry to succeed. The comment above `h.Apply` in
+`store.go` (about lines 801-816) says so. A long offline replay into a busy
+document will meet exactly this case.
+
+**Server change (small):** give `ErrUpdateLogFull` its own answer, 503 with
+`Retry-After: 1`, so the kit simply backs off. Test: a log at the cap, then a
+POST, then a retry, which must return 204. The client-side first-400 rule
+above stays as a defence while older servers are deployed.
+
**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 |
+| offline | `native.pending`, retry every 2 s | kit: `.offline`. Wait while the path is down; back off while it is up (§3.3, *M2*) |
| 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)` |
+| 400 (`docs.error.badupdate`), first time for these bytes | retried forever | **`.retry` after a `?since=` catch-up** *(H3)*, not a hold |
+| 400 again for the same bytes after a successful catch-up | retried forever | `.hold(.rejected)` |
+| 503 + `Retry-After` for a full update log (after the server change below) | (400 today) | kit backoff |
+| 413 (a single update over the cap, 8 MiB after d3) | retried forever | `.hold(.tooLarge)`. It is practically unreachable, because the whole document GET is capped at the same 8 MiB (`handlers.go:638`) |
| 5xx, 503 `errTeamUnavailable` (`store.go`) | retried | kit backoff |
### 4.5 The list cache and launching offline
@@ -667,9 +860,13 @@ outbox lane is keyed by document, not by window.
- 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
+- **On attach, nothing moves** *(H5)*. The key stays `draft-<token>`, and so
+ do the lane `doc:draft-<token>` and the directory. `meta.sqid` records the
+ server identity, and `cache/docs/index` maps sqid → key, so opening that
+ sqid from the list finds the local copy. Renaming the directory would break
+ every sealed file in it, because the path is part of the associated data
+ (§3.6).
+- A crash before `meta.sqid` is written 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
@@ -707,14 +904,18 @@ outbox lane is keyed by document, not by window.
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`).
+ local replica state as one update, and move to the new document. After d3
+ (authenticated `maxUpdateBytes` raised to 8 MiB) this always fits: any
+ document the client could GET, capped at `maxLogBytes` (8 MiB,
+ `handlers.go:638`), can be POSTed.
- **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).
+**Server change for d3:** raise `maxUpdateBytes` (`handlers.go:31`, 1 MiB)
+to `maxLogBytes` (8 MiB) for authenticated writers. It is one constant, and it
+needs no client-side splitting code. It also removes the 413 hold for a big
+paste.
### 4.8 Close, quit, and the iPhone
@@ -739,13 +940,13 @@ the cap for native writers is small decision d3 (§10).
| # | Slice | Size | Test |
|---|---|---|---|
-| K0 | Land #4; re-pin the apps to main; bump the server module | S | existing `make ci` in each repo |
+| K0 | Re-pin the apps to rastrillo/native main (#4 has landed); 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 |
+| K3 | `NativeSealedStore` + `KeyProvider` + `Outbox` + `OutboxDrainer` + `SyncStatus` and views; Docs lane, durability watermark, replica persistence, reopen as replica + lane (§4.3–4.4); port the parked-update test to native/crdt | M | the §3.8 property test (I1a, I1b, convergence) with `FaultingBlobStore` and the integrating fake; `KeyProvider` unavailable-versus-lost cases; tampered, renamed or cross-account files fail to open; then a real kill -9 mid-typing |
| 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 |
+| K6 | Holds and Save as new; first-400 retry rule; relaxed close guard; iOS background task. Server: `ErrUpdateLogFull` → 503, `maxUpdateBytes` → 8 MiB (d3) | M (+ S server) | force 400 twice, a log at the cap, and a revoked grant with `ScriptedTransport`, then against a real server. Go test: log at cap → POST → retry → 204 |
| 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:
@@ -774,10 +975,22 @@ way CRDT deltas do.
- 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`).
+- Lane `tab:<id>`, with **one *sendable* entry per cell**: `{r, c, raw, base, fromR?, fromC?}`.
+ - This slice adds `Outbox.update(id, payload:)` to the kit (§3.3).
+ - A later edit to a cell whose entry is still sendable updates that entry:
+ it keeps the *first* base and takes the *last* raw.
+ - **An entry in flight is frozen** *(M4)*. Otherwise: a batch commits, its
+ response is lost (`.offline`), and the person edits B7 again offline. The
+ coalesced entry would be `{base: old, raw: newer}`, while the server holds
+ the committed middle value, which equals neither. The person's own write
+ would come back as a conflict.
+ - So a later edit to a cell with an in-flight entry makes a **new** entry
+ whose `base` is the in-flight entry's `raw`.
+ - `update` refuses an in-flight id.
+ - `send` packs at most one entry per position into a batch. That is
+ required, not an optimisation: `SetBatch` rejects a batch that names a
+ position twice (`store/handle_write_batch.go:23-29`). The second entry
+ goes in the next batch, after the first is acknowledged.
- 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.
@@ -786,11 +999,15 @@ way CRDT deltas do.
**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:
+- Compare inside the transaction, against the **staged** handle
+ (`staged.rawText`, `store/handle_write_batch.go:39-74`), not `h.rawText`
+ before it *(M7)*. The staged copy is what the batch is actually writing
+ over. That value is the one `SetReport` reads 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.
+ - `current == raw`: already applied, so succeed without stamping and
+ **without calling `SetReport`**. This makes replay idempotent with no
+ key.
- Otherwise: conflict.
- Compare the *translated* raw (`translateCopied`,
`handlers_cell_batch.go:48`) so paste provenance replays correctly.
@@ -805,12 +1022,24 @@ way CRDT deltas do.
`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.
+- `stamps` counts cells actually stamped. Today it is `len(writes)`
+ (`store/handle_write_batch.go:95`), so it must become a count of
+ `SetReport` calls, or no-ops and conflicts would over-count *(M7)*. With
+ that change the b9a92e3 contract (`version > held + max(stamps, 1)` means a
+ missed edit) holds unchanged for partial batches.
+- A batch in which nothing was stamped is answered like a format response:
+ the version is the next stamp, and `stamps` is absent, so the slack is 1.
+- The applied subset is published on SSE as today. `handleCellBatchPost`
+ publishes the same `gridResponse` it returns (`handlers_cell_batch.go:61-64`),
+ so the rule holds for the SSE copy too.
+
+**Replay order** *(M7)*. After an offline period, other devices have written.
+Every drained batch's `version` would then exceed `held + stamps`, and each
+response would trigger the 25×50 chunked refetch. So on reconnect:
+1. refetch once first (the SSE `onOpen` snapshot already does this,
+ `SheetLive.swift:13-16, 31-36`);
+2. keep the local raws painted through the pending overlay;
+3. then drain.
- 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
@@ -861,9 +1090,10 @@ Memoria captures offline and reads online.
`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.
+- **Editing a pending note** updates the same entry, using the `update` that
+ Sheets adds to the kit, so Memoria adopts after Sheets. 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"
@@ -894,7 +1124,6 @@ Size S.
- 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
@@ -917,10 +1146,12 @@ Size S.
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.
+- **Reminders stay server-side email** (`models_event.go:165-167`), which an
+ offline device does not affect. Device notifications are cut from this spec
+ into their own (D6). They are not offline work, they are L-sized, and they
+ are the only new product surface in this document.
-Size M, or L with device notifications.
+Size M.
## 8. Calls: graceful offline states
@@ -947,13 +1178,22 @@ quickly.
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.
+ so every launch needs a browser sign-in. *(L3)*
+ - The Calls server already has its own `/native/request` and
+ `/native/exchange` (`internal/calls/app.go:437-438`,
+ `internal/calls/native_auth.go`). The exchange reply is
+ `{name, value, target}` (`native_auth.go:173`), which
+ `NativeSession` decodes, because `JSONDecoder` ignores `target`.
+ - **The request does not match, though.** `/native/request` requires a
+ `target` that passes `nativeTarget`: "/", `/m/{sqid}` or `/j/{token}`
+ (`native_auth.go:67-80, 110`). `NativeConnection.signIn` sends
+ `{challenge, callback}` and no target
+ (`kit:NativeConnection.swift:135-136`), so today it would get a 400.
+ - Two ways to adopt: `NativeConnection` gains an optional extra request
+ field (`target: "/"`), or Calls moves to the shared server module.
+ Either way, "Calls" goes into the module's callback allowlist, which
+ still names "Meet" (`server/auth.go:254` on main).
+ - Re-pin Calls from `4c61d62` at the same time.
Size S.
@@ -965,30 +1205,34 @@ Calendar → Calls → K7.
- The session-lifetime server change (§3.7) runs in parallel once D2 is
ruled.
+- Calendar device notifications are not in this order. They get their own
+ spec (D6).
- 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.
+These are all Paul's calls. Each one has a default, and **the spec above is
+written to that default**. Revision 2 adopts the reviewer's position on D2, D6
+and d3. The rest are as first proposed, and the reviewer agreed with them.
-| # | Decision | Proposed default |
+| # | Decision | Default the spec is written to |
|---|---|---|
-| 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). |
+| D1 | **On-device encryption of cached content** | **Encrypt**, with a per-account AES-256-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. A locked device reads as *unavailable*, never as lost (§3.6). |
+| D2 | **Session lifetime** | **Sliding on native use, with a mandatory ceiling.** Sessions minted by `/native/exchange` renew to now + 30 days on use, with the row written at most daily, and never beyond `auth_time` + 180 days. Browser sessions stay fixed at 30 days. This is rastrillo work (§3.7). *(Revised: the ceiling was optional.)* |
| 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). |
+| D4 | **Unsent items at sign-out** | **Warn, then wipe.** Try to send for 10 s, skipped when offline or when the session has ended. 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). |
+| D6 | **Calendar reminders model** | **Email only in this spec.** Server email reminders continue unchanged. Device notifications (the 64-slot planning, re-planning on every change, the preview policy) move to a spec of their own (§7). *(Revised: they were in scope.)* |
| 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:
+Smaller calls, also written to their defaults:
- **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.
+- **d3. Docs large updates.** Raise `maxUpdateBytes` for authenticated writers
+ from 1 MiB to `maxLogBytes` (8 MiB). A document the client may GET can then
+ be POSTed, so Save as new document always fits, and a big paste never holds
+ a lane (§4.7). *(Revised: the 1 MiB cap was kept.)*