Catalog (L1)
1. Mutation pipeline and catalog¶
A UI mutation follows this shape:
UI runMutation(operation, clientMutationId)
-> runtime validates idempotency and authorization/context
-> runtime persists idempotency mapping and mutation record
-> mutation coordinator applies local effect when allowed
-> state assertion and pending mutation state are emitted
-> provider command or sync work is queued/executed when required
-> provider result settles, confirms, or rejects the pending mutation
-> runtime emits settlement state
operation is the typed MailOperation, defined once in
posthaste-contract-core and parsed once per wire crossing; dispatch is an
exhaustive match, not a string lookup. The catalog below is that enum's variant
list — the per-action names (message.setReadState, …) are the variants' wire
serialization, not stringly dispatch keys.
Accepted mutations are durable runtime state. The runtime records the client id, runtime mutation id, the operation (and its args hash for idempotency comparison), submitter link, affected resources, provider work state, and terminal outcome. Settlement progresses through the states defined by the adapter (adapter L1 §5); the pipeline internals and reconciliation are mutations L2.
2. Mutation argument rules¶
Mutation args carry explicit account/source context and resource identifiers. A message mutation may target one or more messages, but every target is account-scoped. Args express user intent, not transport commands: archive and trash may target roles and the runtime resolves the current account mailbox for that role, so the renderer is not required to look up role mailboxes before submitting ordinary actions.
A repeated clientMutationId with the same operation payload returns the
existing runtime mutation record or terminal result. The same id with a different
operation payload is rejected as invalid_mutation.
3. Local effect policy¶
A local effect is the pure predictor for a named mutation: a deterministic function over replicated mail state that produces the optimistic effect. It is applied as a pending mutation folded over the authoritative projection (an overlay), not an in-place write. Local effect does not mean provider success.
The local effect is defined once and shared by every node that runs it
(replication L1 §4.3, single-local-effect):
in-process in the embedded authority server, and the same definition compiled to
a portable form (e.g. WASM) in a client-layer replica node. A named mutation must
not carry two hand-written predictors.
Folding produces pending markers and recomputed views immediately. The optimistic effect is retired only when the confirmation watermark passes the mutation; because state-assertion effects are idempotent, the base-updated-and-still-folded interval is invisible. Provider rejection or timeout settles through the same mutation record and reverts the view to authoritative state.
4. Message keyword mutations¶
| Mutation | Args | Local effect | Provider work | Failure/settlement |
|---|---|---|---|---|
message.setKeywords |
targets, keyword command {add, remove} |
Apply the add/remove keyword sets to local state (set semantics: adding a present or removing an absent keyword is a no-op). The keyword primitive — setReadState/setFlaggedState reduce to it via $seen/$flagged toggles. |
Set provider keyword state. | Retry transient; on permanent rejection refresh affected messages and settle failed. |
message.setReadState |
targets, read: boolean |
Add/remove the provider seen keyword in local state. | Set provider keyword state. | Retry transient; on permanent rejection refresh affected messages and settle failed. |
message.setFlaggedState |
targets, flagged: boolean |
Add/remove the provider flagged keyword. | Set provider keyword state. | Retry transient; on permanent rejection refresh and settle failed. |
message.setUserTags |
targets, normalized tag list or add/remove delta | Update non-system user keywords in local state. | Set provider labels/keywords per provider policy. | Invalid tags reject before local effect; provider mismatch settles failed. |
The runtime owns keyword normalization for system keywords and user tags; renderer-side validation/completion is allowed but the runtime is the authority.
5. Message mailbox mutations¶
| Mutation | Args | Local effect | Provider work | Failure/settlement |
|---|---|---|---|---|
message.moveToMailbox |
targets, mailbox id | Replace/update mailbox membership per the account mailbox model. | Apply provider mailbox/label move. | Missing/inaccessible mailbox rejects before local effect; provider rejection refreshes and settles failed. |
message.moveToRole |
targets, role: inbox/archive/trash/junk |
Resolve role mailbox and update local membership. | Apply provider move. | Missing role mailbox rejects before local effect. |
message.replaceMailboxes |
targets, full mailbox id list | Replace the message's mailbox membership with the given list (full desired-state replace) — the effect that role moves and snooze/unsnooze resolve to. | Apply provider mailbox/label state. | Provider rejection refreshes runtime state and settles failed. |
message.destroy |
targets | Mark pending destruction and remove from active ordinary list windows. | Permanently delete through provider when supported. | Not invertible by default; provider rejection restores/refreshes runtime state and settles failed. |
message.applyDiff |
sourceId, messageId, diff {keywords,mailboxes}{added,removed} |
Apply an invertible keyword/mailbox diff as a pending mutation — the undo/redo vehicle. Undo/redo history is client-owned (synced-history design): the client captures the diff + navigates a local cursor, dispatching applyDiff(inverse(diff)) (undo) or applyDiff(diff) (redo). The runtime applies it through the normal mutation pipeline without navigating or recording any history. |
Apply the equivalent keyword/mailbox changes on the provider. | Same as the underlying keyword/mailbox mutations it represents. |
message.snooze |
targets, until (unix seconds, UTC) |
Resolve the account's snooze-role mailbox and replace local membership with it (no optimistic fold when no mailbox carries the snooze role); record the return time durably. |
Apply the provider move; the timed return is authority-server scheduler work, not provider work. | Rejects when no mailbox has the snooze role (the user must designate one); provider rejection refreshes and settles failed. |
message.unsnooze |
targets | Resolve the account's inbox-role mailbox and replace local membership with it; the snooze row clears when the message leaves the snoozed mailbox. |
Apply the provider move. | Rejects when no mailbox has the inbox role; provider rejection refreshes and settles failed. |
Mailbox mutations are invertible (a membership replace); the client captures the
before/after diff for undo. Permanent destroy/discard are non-invertible and
out of scope, same as before.
Snoozed messages return automatically: the authority server's supervisor runs a periodic snooze-scheduler tick that moves every due message back to the inbox as an ordinary authoritative role move. The auto-return is not user-initiated and records no undo step.
6. Draft, compose, and send mutations¶
| Mutation | Args | Local effect | Provider work | Failure/settlement |
|---|---|---|---|---|
draft.save |
draft id/intent, headers, recipients, body, attachments | Persist draft locally, update body/attachment tokens and draft views. | Save/update provider draft when supported; else queue sync work. | Local validation failures reject before effect; provider failures leave the draft durable with failed settlement and queued reconciliation work. |
draft.discard |
draft id | Mark discarded locally and remove from compose/draft views. | Delete provider draft when one exists. | Provider failure queues reconciliation or settles failed while preserving recoverability. |
message.send |
draft id or complete send request, optional expected draft revision | Persist pending send/outbox state. Does not assert sent state until provider confirms. | Submit through provider send path, then reconcile sent/draft state. | Duplicate prevention relies on durable idempotency; provider rejection keeps/restores editable draft with failed settlement. |
message.send is the strictest idempotency case: the runtime persists the
accepted mutation record before provider submission, so retrying the same
clientMutationId cannot send a second message.
7. Settings, account, and support mutations¶
| Mutation | Args | Local effect | Provider work | Failure/settlement |
|---|---|---|---|---|
settings.patch |
sparse application settings patch | Persist settings and update settings views. | None unless the setting schedules runtime work. | Invalid patch rejects before effect; runtime-work failures become status/settlement. |
account.create |
account config and secret instruction | Persist config/secret, create account status, update views. | Start/verify provider runtime per policy. | Secret/config failures reject before effect; provider failures become account status. |
account.patch |
account id, sparse config/secret patch | Persist changes and converge account runtime. | Restart/reconnect/verify when affected fields require it. | Failed reconnect becomes account status; invalid config rejects before effect. |
account.enable / account.disable |
account id | Toggle enabled state and update status. | Start/stop provider runtime. | Stop/start failures settle through runtime status. |
account.delete |
account id, retention choice | Remove config, stop runtime, update views, apply local-data retention. | Provider work only when the contract requires it. | Local delete leaves recoverable cleanup status if background cleanup fails. |
sync.trigger |
account id, mode | Record sync request/status. | Account runtime performs sync. | Trigger rejection/sync failure is runtime status, not renderer-owned retry. |
Settings/account mutations may be local-only, but they still use named mutation
records when invoked through the renderer adapter so settlement and multi-window
updates are consistent. Retrying a failed durable outbox operation is not a named
mutation: it is retry_operation, an operation-lifecycle link op
(POST …/operations/{id}/retry) specced with the outbox lifecycle.
8. Assertions¶
| ID | Sev. | Assertion |
|---|---|---|
| message-keyword-catalog | MUST | Read, flag, and user-tag actions are named keyword mutations with runtime-owned keyword normalization. |
| message-mailbox-catalog | MUST | Archive/trash/restore intents (as role moves), move, and destroy actions are named mailbox/destruction mutations with runtime-resolved role moves. |
| draft-compose-catalog | MUST | Draft save/discard and send are named mutations with durable local draft/outbox state. |
| send-idempotency-before-provider | MUST | Send mutations persist their accepted mutation record before provider submission. |
| settings-account-catalog | MUST | Renderer-initiated settings, account, and sync actions use named runtime mutation records. |
| undo-runtime-backed | MUST | Undo/redo are ordinary message.applyDiff mutations applied through the same pipeline as any user action. Undo/redo history is client-owned (the client captures invertible diffs + navigates a local cursor; see synced-history design); the runtime applies the diff without owning or navigating a history stack. |
| mutation-local-phase | MUST | Mutations with local effects update runtime state before provider reconciliation completes. |
| local-effect-is-shared-predictor | MUST | A named mutation's local effect is one pure predictor folded over the projection, defined once and shared (in-process or WASM), not an in-place projection write or a per-language duplicate. |
| mutation-argument-account-scoped | MUST | Mutation args carry explicit account/source context; a repeated client mutation id with the same operation payload is idempotent and with a different payload is rejected. |