engine-api is the stable, host-facing entry point to the engine (north-star.md:
"Host-facing APIs live behind engine-api."). It is the one composition
point: instead of wiring engine-store, engine-sync, the providers, a search
layer, and a clock together, every host — mobile (UniFFI), desktop/daemon (the C
ABI), the CLI, and server adapters — drives the engine through this crate.
This doc is authoritative for the facade's shape and the order its slices land.
Read it before touching engine-api or adding a binding/reference-host seam.
- An
Engineowns one durableSqliteStoredriven by a host wall clock (SystemClock), and exposes high-level operations over it. - Hosts call
Engine::open/open_in_memory, thensync_mail/sync_calendar; build a mailbox list withmail_window(the projected rows a list renders, across any set of accounts in one ordered answer), complete its conversations withmail_on_threadsand resolve a named message withmail_by_keys; readmailboxes/messages/calendars/eventsandsearch_mail/search_calendar(which now also matches fetched body text); open a message withmessage_body(fetch-on-demand; caches the raw bytes on disk and the extracted text in SQLite, so reopen is a fast SQLite read and the body becomes searchable), plan a bulk body-warming pass withmail_missing_body(the newest synced messages whose body text is not yet cached — a host feeds each throughmessage_bodyto make its window readable offline), resolve inline CID resources withmessage_inline_parts, list ordinary downloadable attachments withmessage_attachments, fetch a selected attachment withmessage_attachment, recognize a meeting invitation withmessage_scheduling(the inbound iMIP read — cache-first on the same raw source, so it costs no extra fetch; it reports what arrived and deliberately makes no decision, because whether to offer an RSVP is a product rule over theMETHODplus anATTENDEEmatching one of the account's own addresses — seecalendar-semantics.md); and write withsubmit_mail(send) /edit_mail(mark-read/flag, move, delete) /create_calendar_event/patch_calendar_event/delete_calendar_event(+put_calendar_document, the iMIP RSVP escape hatch) /pending_op_state. Contact hosts usesync_address_books, source-boundsync_contact_cards, or combinedsync_contacts; browse via generation-boundpeople_pageandperson; list one account's books withaddress_booksand one person's live source cards withperson_sources(the two reads a host needs before a write: where a new card may go, and which stored card a person's values live in, since a person is several cards and a merged person's values must not be written back into one account's book); write one explicit destination withcreate_contact/patch_contact/delete_contact; fetch authenticated media withcontact_photo; and compose recipients withrecipient_suggestionsplus the history-forget/clear methods. Unsupported destination fields are rejected before enqueue and successful writes refetch the canonical card.contact_destinations(adapters)enumerates the explicitly writable books and takes no account: each adapter is already bound to one, so an account parameter could only re-state what the caller chose when it assembled the list. A read-only source belongs in an address-book listing, never in a "save to…" picker. Group membership is aPeopleQueryfilter, andcontact_photois fingerprint-cache-first. A JMAP adapter must be rebound to each discovered opaque address-book id withwith_contact_address_bookbefore it is supplied as a host-facing destination — unbound, it offers no destination at all.Person::display_nameis anOption: a person with no name and no address has none, and the host — not the engine — chooses what to call them. - A calendar grid reads
occurrences_in, notevents.eventsreturns the projected envelope — a recurring series is one object, at its series start — so a host that lays that out shows a weekly meeting in exactly one week.occurrences_in(account, window)returns the materialized instances overlapping a half-open UTC window, each pointing back at its master for the title/participants. Pair it withto_local/day_bounds_utc(the only UTC→local direction the engine offers, so a host never bundles a second tzdb) to build the window and place a row in a day column. - Widen the horizon with
expand_horizon; a re-sync will not. Sync expands only what its delta changed, so reading a window no sync ever materialized returns empty — permanently, no matter how often the host re-syncs.expand_horizonre-derives the stored events over a new window with no network, and is also the path for a display-zone or tzdata change. Both it andsync_calendarreport the events they could not expand (unexpandable): those materialize zero occurrences and so render nowhere, and the host is expected to surface that rather than lose them silently. - A provider key resolves to one message, not to one row. The store's key is
(scope, key), and two of an account's mail scopes can hold the same key: a Microsoft Graph move keeps the message's immutable id and Graph mail sync is per folder, so between the destination folder's delta and the source folder's, both scopes hold it.mail_by_keystherefore returns two rows in that window — a host reading rows sees the store's truth — whilemessages/messages_by_keysreturn oneMessage, composed from the row with the laterlast_modified. The payload is the message's immutable half and is the same bytes in either scope, so only the row's filing differs, and the later modification time is the move. Do not "simplify"composeback into aHashMapcollect: that resolves the duplicate by whichever scope the read visited last, which is a coin flip between the old folder and the new one (tests/sync/folder_scopes.rsasserts both sort orders for exactly this reason). The read surface enumerates the account's scopes and filters bySyncScope::object_kind, so the facade never hard-codes which scopes a provider uses. The return values (e.g.MailSyncReport,Vec<Message>,Vec<Event>,SearchResults,SubmitOutcome) are the host's feedback. - Providers are host-constructed, not owned by
Engine: the host builds each provider — passing one sharedengine_tls::TlsClientConfigfor the account (tls.md) — and hands it tosync_*. Exposing theTlsPolicyover the bindings is a later slice.
- It is not a second home for domain logic. Normalization, projection,
recurrence expansion, the store contract, and sync orchestration stay in their
crates;
engine-apionly composes them. - It is not provider-aware. It never switches on protocol or names a concrete provider — see the provider-agnostic invariant below.
- Concrete store, not
dyn Store. SQLite is the engine's first store, and the search and other conveniences live onSqliteStore(inherent methods), not on theengine_store::Storetrait. The facade therefore holds a concreteSqliteStore<SystemClock>. Other stores are host adapters; if a second store ever ships, that is the point to introduce a store-selection seam, not before. - The wall clock lives here.
engine-storeships onlyManualClockfor deterministic tests and never reads wall-clock time itself; the engine's time source stays one injected seam.engine-apisupplies the real one (SystemClock, built fromtime::OffsetDateTime::now_utc(), whole-second resolution — enough for lease liveness; it is a wall clock, so cross-step ordering rests on the TTL +StaleLeasereclaim, not on the clock). It is crate-internal (pub(crate)) for now — nothing public accepts a clock — and becomes public when a clock-injection constructor lands (see deferred seams below). Keep new real-world I/O seams (clock, later: network policy, blob roots) on this side of the boundary. - Generic over
Provider.sync_*take&impl Provider, so the facade is provider-agnostic and a host passes aprovider-jmap/provider-imap/provider-caldavadapter. (Theengine-syncfree functions are generic overP: Provider. A host that picks a concrete adapter at runtime can hold aBox<dyn Provider>and still call them:engine-providerprovides a blanketimpl<P: Provider + ?Sized> Provider for Box<P>that delegates every method to the box's contents — kept there, not special-cased inengine-api.ContactsProviderhas the same blanket impl, and needs it for the same reason.) - Host-config is hardcoded in this slice, by design (deferred seams). An
Enginestamps a fixedWorkerId("engine-api"), uses a fixedLEASE_TTL(5 min — a generous safety bound, not a deadline; the sync loop re-claims and recomputes onStaleLease), and constructs its ownSystemClock. The durable docs describe all three as host-controlled seams — host-assigned worker identity, a "TTL (host-tunable via the injected clock)" (store-and-sync.md), and an "injectable clock/time source" (north-star.md) — and the engine layers below honor them; the facade just does not expose them yet. Host-supplied worker id (for multi-device lease attribution), host-tunable TTL, and clock injection (for deterministic facade tests) are deferred to a later slice; threading them throughopen()/sync_*then is an additive change. Until then, fencing tokens (not the worker id) still serialize writers correctly. - Concurrent same-scope syncs resolve to
Busy, not corruption.EngineisSend + Sync; share one asArc<Engine>. Two syncs of different scopes run in parallel, but two of the same(account, scope)cannot both hold its lease: the store returns the retryableScopeHeld, the sync loop surfaces it (it recovers onlyStaleLease), and the facade maps it toApiError::Busy— a distinct, retryable signal separate fromApiError::Sync. The facade does not itself queue or auto-retry; a host serializes per account or retries onBusy. If a future slice wants transparent serialization, add a per-account async lock in the facade — do not widenrun_scopeto swallowScopeHeld. - Abrupt process recovery is explicit. A host that knows prior workers for the
store are gone after process death can call
Engine::abandon_sync_leasesonce at startup. It clears held scope leases and bumps their fencing tokens while preserving cursors, so a cold backfill resumes from its last committed checkpoint immediately instead of waiting for the fixedLEASE_TTLor clearing state. This is not a normalBusyrecovery path for live in-process contention. - Re-export signature types. Types that appear in the facade's own signatures
(
AccountId,TimeZoneId,Horizon, the sync reports,Provider, and the streaming vocabulary —StreamTuning,SyncObserver,SyncCommit,IgnoreCommits,AccountProgress,ProgressSnapshot,SyncScope,SyncWindow,CalendarDate) are re-exported so a host depends onengine-apialone. The concrete provider still comes from the adapter crate. - Display-side timezone resolution.
resolve_instant/resolve_instant_in/is_supported_zone(withExpandError) are re-exported fromengine-recurrenceso a host can resolve a stored event's start to its absolute UTC instant for local-zone display (resolve_instant), get a total-order sort key for a mixed-kind agenda in a chosen display zone (resolve_instant_in), and validate a picked/device zone before adopting it (is_supported_zone) — without depending onengine-recurrenceor bundling tzdata itself (calendar-semantics.md).
Step 6 lands in small, tested slices. Order and status:
-
Lifecycle + provider-driven sync — done.
open/open_in_memory,sync_mail,sync_calendar,SystemClock, andApiError. -
Per-account search — done.
StoreRead::account_scopes(account)enumerates an account's scopes (aSELECT … WHERE account = ?oversync_scope, each JSONscope_keydecoded back to aSyncScope; contract-tested inengine-store, so both the in-memory store andstore-sqlitesatisfy it).Engine::search_mail/search_calendarparse the DSL, filter the account's scopes to the queried domain viaSyncScope::search_domain(so the facade never hard-codes a provider's scopes nor branches on protocol), and run them through the store's executor — returningSearchResultswith coverage. A malformed query string isApiError::Query. -
Writes / outbox — done.
Engine::submit_maildrivesengine-sync's outboxsubmit_mail(durable op → claim → provider send → record), returning aSubmitOutcome(sent key,Message-ID, op id); a failed send is recordedFailed/NeedsConfirmationbefore surfacing asApiError::Sync, so the outbox never blind-retries.Engine::pending_op_stateexposesStoreRead::pending_op_statefor polling an op's lifecycle (e.g. confirming an ambiguous send).Engine::edit_mailrides the same outbox for mail mutations — it takes a caller-minted idempotency key and aMailEdit(mark-read/flag, move, or permanent delete) and returns aMailEditOutcome(resolved key + op id); a failure (e.g. a stale-targetConflict) is recordedFailedbefore surfacing asApiError::Sync.Engine::create_calendar_event/patch_calendar_event/delete_calendar_eventride the same outbox for calendar mutations — a caller-minted idempotency key plus anEventDraft(the event you want), or the event as you read it plus aPatchTarget+EventPatch(what changed, and on which occurrence), or anEventDeletion— returning aCalendarWrite/CalendarDelete. These carry intent: the host never assembles iCalendar, mints an href, or touches anETag, and the same call drives CalDAV and JMAP (providers.md). The write types are re-exported fromengine-api.- Read
Capabilities::calendar_write_guard()before writing.WriteGuard::Enforced(CalDAV) means a stale edit is refused — a412surfaces as aConflict, to be recovered by re-syncing and re-applying, never a blind retry.WriteGuard::Absent(JMAP) means the transport cannot refuse one: a stale edit silently wins, so a successful write does not imply no concurrent edit was lost, and a host that cares must detect it itself (jmap.md). - Read
Capabilities::calendar_scheduling()before offering an RSVP, andscheduling_submission()before composing an iMIP message (issue #105). The first says whether the server delivers the iTIP the answer implies — discovered on CalDAV, constant elsewhere; the second says whether this transport can send one itself. Together they answer "can this account answer an invitation at all?", which no single flag does: on a plain CalDAV calendar,rsvp_calendar_eventstores the rightPARTSTATand the organizer learns nothing. A host that reads neither ships the exact silent success the RSVP verb was designed to prevent. put_calendar_documentcan create, not only replace.EventWrite::creating(…)asks the server to store the document only if nothing is there (WritePrecondition::IfAbsent), so putting an invitation that arrived as mail onto the calendar is a guarded create: a resource that appeared in the meantime is aConflict, never a silent overwrite. This is the path for an inbound invitation specifically becausecreate_calendar_event/EventDraftcarries neither organizer nor attendees and would store a plain appointment with nothing to answer on.- A calendar write reconciles the store before it returns (issue #65). A write's
response is a receipt, not a document (a CalDAV
PUTanswers with anETagand no body; a JMAP/setwith an id and no object), so the driver alone would leave the row holding the pre-write projection,raw_icaland revision. Each facade write therefore runsengine_sync::reconcile_calendar_events— an event-scope delta, one round trip, the same primitive a sync reads through — the moment the write lands. The store then holds what the server holds, a delete is tombstoned locally, and an edit that moved the event moves its occurrence rows. That is what makes "edit, re-read, edit again" work: the second edit's guard is the revision the server reported, not the superseded one it wrote over. Proven live against Stalwart (CalDAV + JMAP) and SabreDAV.- A write is never told what the UI is showing. The reconcile re-expands over the
window the store holds (
ExpansionWindow), so the write methods take nohorizonorhost_zone, and a write can neither widen nor narrow what the host has expanded.Engine::expand_horizonowns the window; seestore-and-sync.md. - Never store our own bytes instead. The reconcile must re-read from the server:
Stalwart reserializes what it stores, so an optimistic local copy would put a
RawIcalin the store the server does not have — and would mask a server that silently dropped a property (caldav.md). Body and revision also cannot move independently: a row claiming a revision whose bytes it does not hold lets a host patch a stale body under a valid guard and silently revert its own edit. - A write that did not reconcile is still a write. The reconcile is a local step
after a write the server already accepted, so it can never fail the write: it is
reported as
Reconciled::{Applied, Busy, Failed}on the outcome, never as an error.Busymeans a concurrent sync holds the event scope. Recover by re-reading (Engine::reconcile_calendar_events, also the batch path for a host driving the low-levelengine_syncdrivers itself) — never by re-issuing the write.
- A write is never told what the UI is showing. The reconcile re-expands over the
window the store holds (
- Read
-
Mail sync — one entrypoint, and the engine owns the fan-out.
Engine::sync_mail(providers, account, tuning, observer)takes the account's mail providers — one per folder where the protocol binds a connection to a mailbox (IMAP), a single element where one serves the account (JMAP, Gmail, Graph) — and runs the whole pass: the folder-list container once, the account-level store steps once, then the folders concurrently, bounded, Inbox first.Each folder commits chunk by chunk under its own lease, reporting a
SyncCommit { scope, fetched, total, upserted, removed }after each committed chunk — so a UI shows recent mail before the sync finishes and splices its list from the exact rows without re-querying. An additive pass checkpoints the cursor per chunk, so a mid-stream crash resumes where it stopped; a reconcile re-snapshot holds the cursor until its tombstoning final chunk (store-and-sync.md).StreamTuningsets the per-sync depthwindowand decouples the fetch batch (round trips) from the chunk size (commit granularity).The observer also receives the pass's lifecycle —
account_sync_started(folders)once the denominator is known,folder_sync_finishedper folder, andaccount_sync_finished— which is what a host renders "syncing, 5 of 10" from. All three default to nothing, so an observer that only splices rows implementscommittedalone. A closure is aSyncObservervia the blanket impl, andIgnoreCommitsis the no-op sink.Each
FolderSyncalso carries aSyncTiming— how long that folder spent fetching (the network), deriving (projecting rows) and storing (the apply). They do not sum to itselapsed; the remainder is the scope lease and the bookkeeping between chunks. Reported rather than logged, for the reason inAGENTS.md: a log line is the host's product surface, a duration is a fact, and this is the same seamConnectObserveruses.It returns a
MailSyncReport, not aResult, because a partial failure is the ordinary case:account_steps(a store fault, never a network one),mailboxes, and oneFolderSyncper folder with its own result and elapsed time. Collapsing those into one error loses what a caller needs to tell an outage from an expired sign-in from a scope another pass is holding —SyncError::is_busynames the last.Why one and not four. There used to be a whole-account convenience, a streaming variant, and the two halves a host drove itself; the shipping client used only the two halves. So anything account-level had to be written into functions that never call each other, and work put in the convenience ran in the tests and nowhere else — which is exactly how the thread-index repair came to be unreachable in the product while every test passed.
-
Targeted refresh —
Engine::refresh_folders(providers, account, tuning, observer). Syncs exactly the folders given and discovers nothing: no folder-list sync, no repair, no recipient backfill, no coverage record, andmailboxesin the report isNone. For a caller that already knows which folder changed — anIDLEpush, a webhook, a folder the user just opened.It is a different operation, not a second way to run a pass, and that distinction is what keeps it from re-creating the problem above: its contract is that it does no account-level work, so anything that must happen once per account goes in
sync_mailand only there, beside the repair and the recipient steps.It exists because discovery is most of what a targeted refresh would otherwise pay. Measured on a steady-state single-folder pass against a live server, the folder list was 57% of the work with
LIST-STATUSand 86% without — and in round trips, which is what a remote server charges for, a server that cannot answerLIST-STATUSis asked for aSTATUSper folder: one extra trip becomes fourteen on a thirteen-folder account, on the path whose job is making new mail appear at once. -
Bindings.
bindings-uniffi(Kotlin/Swift) andbindings-ffi-c(C ABI) overengine-api. These needunsafe/codegen, so they override the workspaceunsafe_code = "forbid"lint locally (isolated + documented, perAGENTS.md), and they pick concrete provider/clock types —engine-apistays idiomatic Rust.
When a slice migrates the CLI onto the facade, reconcile engine-cli's docs (its
lib already anticipates "When engine-api lands, the CLI will consume that stable
facade").
- Keep it provider-agnostic. No protocol branching, no naming a concrete
provider crate in a dependency or signature. New provider behavior belongs in a
provider crate behind the
Providertrait. - Keep it a thin composition. If a method grows real logic, that logic
probably belongs in
engine-sync/engine-search/engine-corewith a test there; the facade just calls it. - Errors wrap, never restring.
ApiError::Store/Synccarry the underlying engine error unchanged so itssource()chain (provider failure class, store backend detail) stays inspectable. The one deliberate exception isScopeHeld, whichmap_sync_errorclassifies asApiError::Busy(a retryable race, not a failure) — classification, not restringing. Add similar classifications there if another error class deserves a distinct host signal. - Not every facade method needs the store.
sender_identities/set_sender_name(providers.md) are the first pair that touches neither the store nor the outbox: a sender name is a host preference, not synced PIM state, and theFroma send carries is assembled from the caller'sDraft. What the engine owns there is the protocol. Passing a call straight to the provider is legitimate when the answer is not ours to keep; reach for the outbox when a side effect must survive a crash. - Reject host input at the facade, before any request.
set_sender_namerefuses a name carrying a control character or longer than 128 characters, asApiError::InvalidInput. The RFC 5322 assembler refuses the same bytes, but by then the user has a mailbox that cannot send and no idea why — and the error message names the offending codepoint rather than echoing it, since an error travels into logs and dialogs and would carry the injected payload with it. - The clock is a wall clock, not monotonic.
now()is whole-second and can step backward (NTP); do not write code or tests that assume monotonicnow(). Lease safety across a step rests on the TTL +StaleLeasereclaim in the sync loop, not on the clock.
The crate's deterministic tests cover it without the Stalwart harness: an
end-to-end tests/sync.rs opens an Engine and syncs mail+calendar through a
cursor-aware fake Provider (snapshot first, delta after), the same way a host
would. From the returned reports it asserts: a first snapshot upserts; a resync
after reopening a file-backed store is an empty delta (proving the cursor — and
data — persisted, since a lost store would re-snapshot and upsert); a delta that
drops a key tombstones it; a provider failure surfaces as ApiError::Sync and a
bad path as ApiError::Store; and two concurrent syncs of one scope resolve to
ApiError::Busy (a tokio::sync::oneshot gate holds one sync's lease while the
other races, deterministically — no timing). The same file's search tests then
exercise per-account search over the synced data: a DSL query finds the matching
mail/event with complete coverage, a malformed query is ApiError::Query, and an
unsynced account returns an empty answer. A SubmittingProvider then exercises the
outbox facade: a successful submit_mail commits the op Succeeded (read back via
pending_op_state), a failed send surfaces as ApiError::Sync, and an unknown op id
reads back None. A sync_mail with a closure observer then asserts
one SyncCommit lands with fetched == total == 2. Run the standard gate (AGENTS.md):
cargo +nightly fmt --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace --all-features, cargo doc. engine-api's own
lines are 100%-covered by these tests (no live provider needed).
The fake Provider and object builders in tests/sync.rs are a third copy of a
pattern engine-sync and engine-provider also hand-roll as crate-private test
code. Promoting one shared fake + builders behind a test-support feature/module
(so the Provider trait has a single fake to update) is a worthwhile follow-up,
deferred here to avoid refactoring three crates' tests in this slice.