Skip to content

Mail runtime (L2)

1. Implementation boundary

1.1 Contract

The client implementation is a renderer over runtime state. It may use React, local stores, TanStack Query, or other libraries for rendering mechanics, but mail state authority remains behind the runtime adapter.

This spec covers the renderer. When the runtime is remote, the device also runs a client-layer link node (a replica runtime) that holds the pending set, optimistic state, and cache and converges by confirmation watermark (replication L1, runtime internals L1 §1); that node is a runtime implementation, not part of this spec. The renderer never runs its own optimistic mutation engine — it renders the optimistic state and pending markers its runtime node serves.

2. Runtime adapter boundary

2.1 Adapter module

The client has one mail runtime adapter module. It exposes typed functions and subscriptions for runtime views and named mutations.

Bundled application mode selects the embedded-runtime adapter at bootstrap. A temporary loopback HTTP bridge may live behind this module during migration, but components still call runtime view and mutation functions.

Components must not call mail HTTP endpoints, provider APIs, SQLite, or event streams directly when a runtime adapter operation exists.

2.2 Adapter state

Adapter state is a transport mirror of runtime state. It may buffer the latest view payload for React rendering, but it is not an independent mail cache.

When the runtime node is a client-layer replica, its wasm store runs in a Web Worker and a dumb main-thread reactive store (apps/web/src/live-store, useSyncExternalStore) mirrors the worker-projected view/counts/health for React; the mirror holds no fold or projection logic (L3 §6–§7).

When adapter transport reconnects, it resubscribes to runtime views instead of reconstructing mail state locally.

The adapter owns transport concerns such as Tauri invoke calls, event subscriptions, contained loopback tokens, and capability errors. View components receive typed runtime state, not transport details.

2.3 Adapter facade shape

The TypeScript facade exposes these operation families:

openRuntimeLink(init?)
subscribeRuntimeFrames(linkId, afterSeq?)
openLinkView(linkId, descriptor, options?)
updateLinkView(linkId, viewId, descriptor | windowRequest)
closeLinkView(linkId, viewId)
runMutation(name, args, clientMutationId, context?)
resolveResource(resourceDescriptor)

runMutation(name, args, …) is the JS wire form of a mutation: the catalogued name plus JSON args. On the Rust side the operation vocabulary is the typed MailOperation enum in posthaste-contract-core, parsed once where the request crosses the wire and carried typed inward (crate topology).

The facade returns runtime snapshots, frames, receipts, and errors in the shapes defined by the runtime spec. React hooks may wrap these calls, but components should not import transport-specific modules. Renderer push state uses subscribeRuntimeFrames; /v1/events is reserved for external/API consumers, not component cache invalidation.

2.4 Adapter type ownership

Runtime adapter types are imported from shared generated/mechanical types or from a single client adapter type module. Components may narrow those types for rendering, but they must not define alternate mail DTOs for the same runtime state.

3. View subscriptions

3.1 View hooks

UI hooks or stores are thin wrappers over runtime subscriptions. Examples:

  • mailbox or smart-mailbox view subscription
  • search view subscription
  • message detail subscription
  • conversation subscription
  • compose/draft subscription
  • settings/account subscription

The hook names belong to implementation. Their shared rule is that view contents come from runtime state.

Hooks consume RuntimeFrame variants such as viewSnapshot, viewReplace, viewError, and viewClosed from the link stream. A hook may keep the latest snapshot for React rendering, but it must not repair missed frames by applying mail-specific rules. If resume fails, it resubscribes and accepts full runtime snapshots for active views.

If updateView returns a snapshot with a different ViewId, the hook replaces its subscription with the returned ID before applying later frames. Hooks must not assume query text, sort, or target changes preserve view identity.

Hooks pass through coverage and readWatermark from snapshots. Components may render runtime-supplied coverage/freshness affordances, but they do not compare watermarks or infer query freshness from them.

3.2 List rendering

List components render runtime-provided rows, cursors, lifecycle state, and pending row markers. They may preserve scroll anchors, selection, keyboard focus, and virtualizer measurement state across runtime updates.

They must not compute authoritative row insertion, removal, replacement, or pagination repair.

MessageList renders the list from a single source: the useRuntimeMailListView hook opens a runtime link, opens a mailList view, subscribes to the unified RuntimeFrame stream, and returns the rows + loading/retry/window-extend as a view-model the component renders. viewSnapshot/viewReplace/viewDelta frames are applied with exact setQueryData writes to a TanStack Query key the hook owns internally (its reactive store); the component reads only the hook. The legacy HTTP query + domain-event-patch fork (and its runtimeMailListViewsEnabled flag) was retired.

3.3 Window requests and scroll anchors

The UI owns viewport size, scroll offset, virtualizer measurement, keyboard focus, and selected row state. When it needs more rows or needs to preserve position around an existing row, it sends a runtime window request through the adapter.

Window requests use runtime values from the last snapshot: beforeCursor, afterCursor, rowKey, and optional opaque sortKey. The UI may ask for an initial window, the next window, the previous window, or a window around an anchor row.

When a replacement snapshot reports that the anchor was kept, moved, removed, or unknown, the UI uses that status to preserve or reset scroll. It does not synthesize missing rows or reorder rows locally to keep scroll stable.

3.4 Detail rendering

Message detail and conversation components render runtime-provided body, attachment, and conversation state. If a token changes, the runtime adapter receives replacement view state from the runtime.

4. Command dispatch

4.1 Named mutation wrapper

Command hooks wrap runtime named mutations. They attach stable client mutation IDs, pass user intent, and render pending/settlement state returned by the runtime.

Hooks use catalogued names such as message.setKeywords, message.setReadState, message.setFlaggedState, message.setUserTags, message.moveToMailbox, message.moveToRole, message.replaceMailboxes, message.destroy, message.applyDiff, message.snooze, message.unsnooze, draft.save, draft.discard, message.send, settings.patch, and account.patch. The catalogued names are the JS wire strings of the typed MailOperation vocabulary in posthaste-contract-core: each name maps to one MailOperation variant, parsed once at the wire boundary (crate topology). There are no separate sugar mutations for role moves — archive/trash/restore-to-inbox are message.moveToRole with the role intent; message.destroy (permanent deletion) and message.moveToMailbox (by-id addressing) remain distinct. There are likewise no opaque meta-mutations: no mutation.undo (undo/redo are message.applyDiff with the inverse diff), no mutation.retry (retrying a failed durable outbox operation is the retry_operation op-lifecycle call, not a named mutation), and no mutation.resolveConflict (no conflict settlement exists).

For ordinary role actions such as archive, trash, restore to inbox, and junk, the hook submits the role intent. It does not resolve role mailbox IDs from local cached mailbox lists unless the user explicitly picked a concrete mailbox.

A command hook treats accepted, confirmed, and failed as runtime facts. It may map them to toasts, row markers, or disabled controls, but it does not maintain a separate mutation pending set, optimistic projection, or replay log: optimistic state, the pending set, and settlement belong to the runtime node (runtime mutations L1). When that node is a remote replica, the pending set lives in the replica, still not in renderer state.

4.2 Undo and retry

Undo/redo history is authority-server-logged and client-navigated: the authority server durably owns the RevLog of applied change-diffs; the client owns the undo/redo stack as a projection reconciled against it. An undo or redo is dispatched as an ordinary message.applyDiff carrying the inverse (or re-applied) diff — the runtime tier holds no history stack and there is no opaque undo token. Retrying a failed durable outbox operation is the retry_operation op-lifecycle call. The renderer does not store mail before-images beyond the reconciled history projection.

4.3 Input ownership

Text editors and form controls may hold unsent local text. Once submitted as a draft save, send, settings patch, or account mutation, the runtime owns pending and settled state.

5. Presentation state

5.1 Local UI stores

The client may persist presentation state such as theme, pane sizes, selected layout, sort UI, recently focused surfaces, and window geometry according to the UI/domain specs.

Presentation state must stay separate from mail runtime state. Local UI stores must not contain message bodies, attachment bytes, event history, runtime database copies, mutation idempotency records, provider credentials, refresh tokens, access tokens, bearer tokens, or runtime secret references.

Connection-profile metadata may be stored in renderer-owned files when it contains no secrets. Profile tokens, when supported, use the desktop client secret store.

5.2 Focused surfaces

Focused windows and overlays request runtime views by serializable descriptors. They do not receive live mail state through parent component props that bypass the runtime adapter.

6. Validation

6.1 Adapter boundary tests

Tests cover that components use runtime adapter hooks for mail state and do not call mail HTTP endpoints directly. In bundled mode, tests also cover that any loopback HTTP use is contained inside the adapter module.

6.2 Instant action tests

Tests cover that regular actions update rendered state after the local runtime response without waiting for provider reconciliation.

6.3 Window anchor tests

Tests cover that list components request next, previous, and around-anchor windows through the adapter and preserve or reset scroll based on runtime anchor status.

6.4 Command hook tests

Tests cover that command hooks call named runtime mutations, submit role actions without cached role-mailbox resolution, dispatch undo/redo as message.applyDiff inverse diffs, and do not store mail before-images.

6.5 Multi-window tests

Tests cover that two focused surfaces observing the same message or query view update through shared runtime state.

7. Assertions

ID Sev. Assertion
single-runtime-adapter MUST Client mail state flows through one runtime adapter module.
bundled-adapter-selected MUST Bundled application mode selects the embedded-runtime adapter at bootstrap.
no-direct-mail-http MUST UI components do not call mail HTTP endpoints directly when a runtime adapter operation exists.
adapter-not-cache-authority MUST Adapter-held state mirrors runtime state and is not an independent authoritative mail cache.
transport-contained-in-adapter MUST Tauri, event, and contained loopback transport details stay behind the runtime adapter facade.
adapter-facade-operations MUST The client adapter facade exposes link, runtime subscription, view, mutation, and resource operation families.
adapter-types-single-source MUST Components use shared adapter/runtime state types rather than alternate mail DTOs for the same state.
view-frame-consumption MUST Hooks consume link-scoped RuntimeFrame view frames and fall back to full snapshots rather than repairing mail state locally.
view-id-rekey MUST Hooks replace subscriptions when updateView returns a different ViewId.
coverage-pass-through MUST Hooks pass through runtime coverage/read-watermark metadata without interpreting it as renderer-owned freshness logic.
list-renders-runtime-window MUST List components render runtime-provided rows and do not repair authoritative pagination locally.
ui-owns-scroll-not-rows MUST List components own scroll anchors and virtualizer state but request row windows from the runtime.
anchor-status-drives-scroll MUST List components preserve or reset scroll from runtime anchor status rather than synthesizing missing rows.
command-hooks-named-mutations MUST Command hooks submit named runtime mutations with stable client mutation IDs.
role-actions-runtime-resolved MUST Hooks submit role move intent for archive/trash/inbox/junk instead of resolving role mailboxes from cached lists.
renderer-no-mutation-pending-set MUST Command hooks render runtime settlement state without maintaining a separate mail mutation pending set, optimistic projection, or replay log; that state belongs to the runtime node (embedded authority or remote replica).
undo-is-apply-diff MUST Undo/redo dispatch ordinary message.applyDiff inverse diffs from the client-navigated history (reconciled against the authority server's RevLog); no opaque undo mutation, no renderer-side rollback engine.
presentation-state-separated MUST Client presentation state is stored separately from runtime mail state.
no-secrets-in-ui-storage MUST Client local stores do not persist provider secrets, bearer tokens, message bodies, attachment bytes, event history, runtime database copies, or mutation idempotency records.
profile-tokens-secret-store MUST Connection-profile tokens are stored in the desktop client secret store, not in profile JSON or local UI storage.
focused-surfaces-runtime-backed MUST Focused surfaces request runtime views by serializable descriptors rather than parent-owned mail props.