Skip to content

Authority server /v1 adapter implementation patterns

The /v1 Axum adapter (posthaste-server) implements the API contract over the RuntimeHandle. Handlers do transport work — authorize, parse, map errors — and delegate every mail behavior to a runtime method. The wrapper migration that introduced this seam is complete and its dead residue removed (RFC-L2-architecture-cleanup D20); the surviving from_api_bridge_* entry points are deliberate test-harness seams (testkit), not prod surface. This spec records the realized adapter shape.

1. AppState and startup

AppState carries the runtime handle plus HTTP-adapter-owned state only — no service/store/supervisor/secret_store/event_sender route fields:

pub struct AppState {
    pub runtime: RuntimeHandle,
    pub account_logo_root: PathBuf,
    pub oauth_flows: Arc<OAuthFlowStore>,
    // auth material, origin/host allowlists
}

start_server calls build_authority_runtime(RuntimeBuildConfig { config_root, state_root, cache_root }) before constructing AppState and the router, and keeps HTTP process concerns: daemon settings (read from TOML first), token/CORS/origin/host material, tracing, static renderer serving, API docs, and listener binding. The server cache root is state_root/cache.

2. Read handlers

A read with a runtime method calls state.runtime with an adapter-neutral caller and never reads the same state through a removed service field:

state.runtime.get_app_settings(RuntimeCaller::api()).await
    .map(Json).map_err(ApiError::from_runtime_error)
  • GET /v1/settings is the first runtime-backed read.
  • Account list/get and /v1/read Account/list use runtime account methods.
  • /v1/read mailbox/smart-mailbox/tag use the typed read catalog.
  • Mailbox and smart-mailbox list/get routes use runtime read methods.

Account-scoped reads pass AccountScopeRequest (EnabledAccounts or Explicit { account_ids }) into the read method; adapters must not derive default scope by calling account-list and filtering the response payload.

3. Provider-backed reads and sync

Provider-backed reads (sender-address, identity, reply-context) call runtime methods, which reach the live gateway through LiveAccountRuntimeProviderAccountSupervisor; handlers never call live_gateway once a runtime method exists. Manual account sync (POST /v1/sources/{source_id}/commands/sync) calls a runtime method, not the supervisor directly.

The concrete AccountSupervisor and gateway ownership live in posthaste-authority-server; there is no posthaste-server facade re-export — consumers import the owning crate directly (RFC D19). This keeps Axum handlers out of gateway lookup and makes the account-runtime graph transport-free for embedded desktop reuse.

4. Mail-list, search, and conversations

List/search/smart-mailbox-content/conversation-grouped routes call the generalized query_mail_page runtime read (posthaste-runtime-api) rather than route-shaped methods:

state.runtime.query_mail_page(RuntimeCaller::api(), MailQueryRequest {
    query: "in:Inbox from:alex".into(),
    presentation: MailPresentationRequest::CollapsedByConversation { /* page */ },
    visibility: None,
}).await.map_err(ApiError::from_runtime_error)

HTTP route aliases translate into query text plus a presentation request: in:Inbox selects a saved smart mailbox, in:Account/ a concrete source, in:Account/Archive a concrete mailbox, conversation:<id> a derived conversation. The runtime owns parsing/evaluation, saved-query expansion, and search/cache visibility bookkeeping; conversations remain derived groupings (CollapsedByConversation), not mutable objects.

5. Mutation handlers

Mutation handlers convert HTTP request structs into runtime-contract payloads and call state.runtime; the runtime owns service/secret/store/event/supervisor side effects.

  • Account CRUD/lifecycle (POST/PATCH /v1/accounts, verify/enable/disable, DELETE, POST /v1/config:reload, GET /v1/oauth/callback after exchange) use RuntimeHandle mutation methods; the adapter keeps HTTP request/response schemas and stable error mapping.
  • Account assets: logo metadata writes and account-delete resource linkage go through the runtime (patch_account/delete_account); byte upload/download, content-type mapping, and stale-file cleanup stay in the server as HTTP-managed asset concerns.
  • Settings and smart-mailboxes (PATCH /v1/settings, automation-rules:preview, smart-mailbox create/patch/delete/reset) use runtime methods; semantic validation comes from posthaste-domain-service, the same source the TOML repository uses for hand-edited config.
  • OAuth callback materialization calls create_oauth_account_from_exchange (provider-first) or persist_oauth_token_set (existing account); the adapter keeps PKCE/browser/HTML flow state and must not write token secrets or start the supervisor directly.
  • Message mutations (set-keywords, mailbox membership, replace-mailboxes, destroy, send, detail, attachment) and link-scoped run_mutation call runtime methods; none touch state.service/store/supervisor/publish_events.

Runtime link routes (POST/DELETE /v1/runtime/sessions[/…], the stream, link views, and mutations) serialize the runtime link/view/mutation contracts and delegate registry behavior to the link/view contract (posthaste-client-link). Account-scoped tokens supply a matching sourceId; the handler passes that authorized source into RuntimeCaller, and link mutation handlers require read authority for the same scope before accepting a command. Link ids are opaque; the stream uses SSE id = RuntimeLinkSeq, and stale reconnects receive collapsed current viewSnapshot frames plus latest settlement frames. The sessionless POST /v1/views + GET /v1/views/{view_id}/stream pair (a non-link migration bridge) is deleted (D51/M10): the D23 "kept" verdict verified SDK wiring, not call sites, and no callers survived M9b2's move to link-scoped views. Every view now flows through the link routes above.

GET /v1/events calls the runtime subscribe_events method (posthaste-runtime-api), chains the returned replay and live DomainEvents, and maps each to the SSE frame shape; the adapter no longer queries list_events or the broadcast channel directly.

7. Error mapping and the route guard

Runtime errors crossing /v1 map through ApiError::from_runtime_error, which preserves the runtime message/details and maps error codes to existing API status/code pairs. crates/posthaste-server/tests/runtime_wrapper.rs carries a route-module guard rejecting new MailService::new, DatabaseStore::open, or AccountSupervisor::new construction under src/api outside tests — a fitness function for the seam, not a general dependency analyzer.

8. Runtime flows

Each enabled account has an account runtime owning provider-connection lifecycle and background work: startup sync, scheduled polling, manual sync, push/IDLE hints, cache maintenance, automation/backfill, status updates, and shutdown.

Sync is coordinated by the account runtime and applied by the service/store: a trigger ensures a provider gateway; the service loads cursors, calls gateway.sync(account, cursors, mode), applies the returned batch through the store, records follow-up cache/automation work, and creates domain events; the runtime publishes events and updates status. Full/forced modes change which cursors are supplied; provider-specific planning stays inside the gateway.

Reads need no provider access unless the operation explicitly fetches lazy content: handler → service read method → config snapshot + store projections → domain read model → projection mapping. Local reads stay available when a provider is down, as long as the data is present locally.

Mutation — the authority server is the far (authoritative) end of the runtime↔authority-server link (replication authority-server-link L1). It accepts a forwarded mutation — a MutationRequest carrying the typed MailOperation, with the originating clientMutationId preserved — persists the idempotency record when present, runs or queues the provider command, applies the durable outcome through the store, and records/publishes a before/after assertion plus the confirmation watermark (mutation id, any assigned entity id) by which the near node retires its overlay. The authority server asserts authoritative state rather than reconciling near-node optimism; a provider rejection settles the mutation failed and asserts authoritative state, on which the near node reverts.

Lazy body/blob — metadata syncs before content: a detail/attachment request checks local cache, fetches through the gateway on a miss, persists cacheable content, and returns a sanitized body or authenticated blob. Sanitization happens before HTML crosses the client boundary.

9. Event flow

Domain events are created by service/store operations that change observable state (and by runtime status changes). Message events are built from the same API projection constructors used by HTTP reads, carrying before/after MessageSummaryState with ConversationRef, bodyToken, and attachmentToken when a summary is present. Other client-visible state changes produce before/after assertions for their named state; coarse lifecycle topics produce explicit payloads. The authority server does not emit query-scope invalidation or separate conversation-projection events.

Sync writes and command writes use one event-construction chokepoint per object type: capture previous summary state, apply the change and recompute affected projections, build current summary state, wrap as a message/state assertion or coarse event, append to history, then publish to live subscribers. Snapshot mode reads the current projection for one account scope and emits assertion frames through the same constructors — it must not hand-build a second event shape. If a live broadcast is missed, clients recover by collapsed catch-up or snapshot; query pages and conversation envelopes recover through their HTTP read endpoints. Provider push, IMAP IDLE, and polling are runtime inputs that become client-facing only when they produce a domain or status change.

Generated OpenAPI/AsyncAPI artifacts are checked against committed files so client type generation detects drift; an authority-server change to external request/response/event shape must update the contract and regenerate.

10. Extension points

Add a provider driver: add config/policy fields; implement the gateway contract; define sync cursor/state needs in the store; wire gateway construction into the account runtime; add parity tests (sync, lazy body, mutations, send); update API/config docs only when client-visible setup changes.

Add an authority-server read model: decide config-derived vs store-derived vs composed; add store projections if needed; name the API projection; add/extend a service read method; expose over HTTP only after the API contract is specified; define event behavior only when the model needs state assertions.

Add a mutation: add the operation to the typed MailOperation vocabulary in posthaste-contract-core; define API command shape only when exposed through /v1; add gateway capability or explicit unsupported behavior; define local effect, durable idempotency, queued provider work, and settlement; apply local results through the service/store boundary; define conflict/reconciliation; emit assertions after observable changes; test happy-path, idempotent retry, and provider rejection.

Add an event type: classify as message assertion / other state assertion / coarse lifecycle; for state assertions define the named projection used for before/after; ensure the change is observable through HTTP or snapshot reads; append after the change is durable; add to the event contract and generated artifacts; add convergence coverage when clients apply it as replicated state.

11. Assertions

ID Sev. Assertion
appstate-runtime-http-only MUST AppState declares the runtime handle and HTTP-adapter-owned fields only; legacy service/store/supervisor/secret/event-sender fields stay out of route state.
startup-builder-before-router MUST start_server calls build_authority_runtime before constructing AppState and the router, and keeps HTTP process concerns (daemon settings, auth, CORS, tracing, static serving, docs, listener).
runtime-read-handler-pattern MUST Routes with a runtime read method call state.runtime with RuntimeCaller::api() rather than a removed service field.
first-api-read-runtime-backed MUST GET /v1/settings reads through RuntimeHandle.
account-list-runtime-backed MUST Account list and /v1/read Account/list use runtime account read methods.
account-get-runtime-backed MUST GET /v1/accounts/{account_id} uses a runtime account read method.
typed-read-catalog-runtime-backed MUST /v1/read mailbox, smart-mailbox, and tag list operations use runtime read methods.
collection-list-routes-runtime-backed MUST Mailbox and smart-mailbox list/get routes use runtime read methods where those methods exist.
account-scope-request-pattern MUST Account-scoped runtime reads use AccountScopeRequest, not API-side account-list filtering.
provider-backed-compose-reads-runtime-backed MUST Sender-address, identity, and reply-context read routes call runtime methods and let runtime-owned providers resolve live gateways.
sync-command-runtime-backed MUST Manual account sync commands call runtime methods and provider seams rather than the supervisor directly.
supervisor-ownership-migration MUST Concrete account supervisor/gateway ownership lives in posthaste-authority-server; the server exposes only adapter state and compatibility re-exports.
account-mutations-runtime-backed MUST Account CRUD/lifecycle/verify/reload-config routes call runtime mutation methods, with the runtime owning service writes, secret writes, events, and supervisor lifecycle calls.
account-assets-runtime-backed MUST Account-delete resource linkage calls runtime mutation methods while server routes keep only logo byte upload/download and static-file cleanup.
account-logo-metadata-runtime-backed MUST Account logo metadata (appearance) writes go through RuntimeHandle.patch_account, not a route-local store/service path.
settings-smart-mutations-runtime-backed MUST Settings patch, automation preview, and smart-mailbox create/patch/delete/reset routes call runtime methods, with the runtime owning service/store/event side effects and domain validation shared with TOML load.
oauth-materialization-runtime-backed MUST OAuth callback materialization delegates token-secret persistence and provider-first/existing-account create/patch to the runtime, keeping only browser/HTTP concerns in the adapter.
message-mutations-runtime-backed MUST Message send/keyword/mailbox/destroy/detail/attachment routes and the link-scoped run_mutation call RuntimeHandle methods; list/search routes use query_mail_page rather than route-shaped methods.
events-route-runtime-backed MUST GET /v1/events calls subscribe_events; the adapter only maps returned DomainEvents to SSE frames.
runtime-error-to-api MUST Runtime errors crossing HTTP are mapped through ApiError::from_runtime_error into the stable /v1 error envelope.
migrated-read-compat-test MUST Migrated read routes have compatibility tests comparing HTTP output with the same projection read through the runtime.
route-constructor-guard MUST A route-module guard rejects new direct MailService/DatabaseStore/AccountSupervisor construction under src/api outside tests.
runtime-per-enabled-account MUST Each enabled account has a runtime owner for provider-connection lifecycle and background work.
reads-use-local-projections MUST Ordinary authority-server reads use local config/store projections and do not require live provider access.
sync-through-service-store MUST Sync triggers flow through runtime, provider gateway, domain service, and store write contracts.
mutations-use-gateway MUST Remote mail mutations use the provider gateway or durable provider-command queue per the mutation contract.
mutation-idempotency-before-effect MUST Mutations with client idempotency keys persist the idempotency record before local effects or provider submission.
mutation-emits-watermark MUST A settled mutation emits a before/after assertion and a confirmation watermark (mutation id, any assigned entity id); the authority server asserts authoritative state rather than reconciling near-node optimism.
lazy-content-through-gateway MUST Missing body/blob content is fetched through the provider gateway and crosses the client boundary only through authority-server contracts.
events-resume-from-history MUST Client event resume state refers to authority-server event history rather than provider signal state.
event-shape-chokepoint MUST Sync writes, command writes, live events, and snapshots use the same message assertion constructors.
message-event-conversation-ref MUST Message event constructors include the same ConversationRef produced by message HTTP reads.
message-event-content-tokens MUST Message event constructors include the same body and attachment tokens produced by message HTTP reads.
message-event-before-after MUST Message event constructors capture previous and current summary state for before/after assertions.
no-query-invalidation-events MUST Authority-server event constructors emit state assertions rather than query-scope invalidation commands.
no-conversation-event-duplication MUST Message changes do not emit separate conversation projection events.
snapshot-live-shape-parity MUST Snapshot assertions use the same message payload shapes as live events.
handlers-do-not-connect-providers MUST API handlers do not create long-lived provider connections directly.
extension-updates-contracts SHOULD Authority-server extensions update API, config, event, or provider contracts when behavior becomes externally visible.