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/settingsis the first runtime-backed read.- Account list/get and
/v1/read Account/listuse runtime account methods. /v1/readmailbox/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 LiveAccountRuntimeProvider → AccountSupervisor; 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/callbackafter exchange) useRuntimeHandlemutation 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 fromposthaste-domain-service, the same source the TOML repository uses for hand-edited config. - OAuth callback materialization calls
create_oauth_account_from_exchange(provider-first) orpersist_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_mutationcall runtime methods; none touchstate.service/store/supervisor/publish_events.
6. Links, views, and 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. |