sync_engine

Gateway connection, catch-up, outbox, reconciliation, run documents and focus, without Flutter.

Status: the gateway connection with focus and typing (M4-13), catch-up and reconciliation (M4-14), the outbox (M4-15), the run documents of the live box (M4-16), the composition root SyncEngine and the fake server of the integration tests (M4-17) are built. Kind: pure Dart package, a member of the Dart workspace at the repository root (resolution: workspace, publish_to: none).

Dependencies

Allowed members (docs/spec/15-client-architecture.md, 3.1; tools/arch-check/rules.yaml): local_store, auth, api_client, domain (arrows of the diagram). Every other member is out of reach; make arch fails on a dependency or an import of it.

Further rules checked by make arch:

  • no import of package:flutter/, dart:ui (rule 1).
  • no native source (rule 4).
  • package:sync_engine/testing.dart and lib/src/testing/ are reached only from test/ and integration_test/ folders, also inside this package (rule 10).

Gateway connection (M4-13)

GatewayConnection holds the one WebSocket of the device (04 6.1): it never opens a second socket while one is open or opening. It answers hello through GatewaySession with resume (stored gateway session and cursor, last disconnect within resume_window_ms, and this engine reached ready before: a new engine identifies, because only the snapshot of ready seeds its counters, D-M4-30-11) or identify (cursor and epoch only when stored), both with a gateway DPoP proof of auth (DpopProofFactory.forGateway: htm GET, htu the HTTPS form of the gateway URL, the hello nonce, ath). ready stores gateway_session_id, permission_version and head_seq through SyncStateDao; the sync_epoch stays in memory, because M4-14 stores it with the cursor. reauth_required renews the token (RefreshCoordinator.refreshNow) and sends reauth with a proof over the nonce of reauth_required. Heartbeats go every heartbeat_interval_ms, the first after a random fraction; a missing heartbeat_ack within one interval closes with 1000 and reconnects. Waits are full jitter in [0, min(cap, base * 2^n)] (sync.reconnect_backoff_base, sync.reconnect_backoff_cap), reset after ready or resumed; a network change or the foreground starts an attempt at once without raising n. An attempt that has not reached ready or resumed within client.sync.connect_timeout (a stalled connect, no hello, no answer to identify or resume) is dropped and waits the backoff like a failed connect. A server frame that does not decode is counted (decodeErrors) and skipped; unknown ops and fields are ignored.

States

connection.states publishes ConnectionState(phase, reason). Phases (15 7.2): offline, connecting, identifying, catchingUp, live, resyncing, backoff, suspended, signedOut, updateRequired. Reasons: none, networkUnavailable, devicePending, deviceReplaced, forbidden, rateLimited, revoked, protocolUnsupported, closedByServer(code). On mobile the connection closes with 1000 in the background (suspended) and resumes in the foreground; with keepConnectedInBackground (desktop) it stays connected; no reconnect path (backoff timer, token renewal, approved device) opens a socket in the background on mobile.

Close codes

One table, closeCodeTable, resolved by reactTo(code, reason, foreground:) (docs/DECISIONS.md D-M4-13-2). "Next frame" is the answer to the next hello.

Code Phase, reason Next frame Wait
1000 foreground: backoff; background: offline, closedByServer(1000) resume backoff; in the background until the foreground
1001, 4000 to 4003, 4005, 4009, 4014 backoff, closedByServer(code) resume backoff
4004 backoff after one refreshNow; failed refresh: signedOut resume none (a second renewal before ready or resumed: backoff)
4006 backoff identify backoff
4007 updateRequired, protocolUnsupported none never
4008 backoff, rateLimited resume retry_after=<seconds> of the reason, else backoff
4011 backoff after one refreshNow; failed refresh: signedOut resume none (a second renewal before ready or resumed: backoff)
4012 signedOut, revoked; AuthSession.sessionRevoked (SignedOut.deleteLocalData) none never
4013 backoff, closedByServer(4013) resume uniform in [0, base], counter unchanged
4015 offline, closedByServer(4015), logged as unexpected none connectNow
4016 offline, devicePending resume until pollPendingDevice reports active
4017 offline, deviceReplaced resume foreground or connectNow
4018 offline, forbidden; no token renewal resume connectNow
other 4000 to 4099, other codes, a drop backoff (a drop without network: offline, networkUnavailable) resume backoff

Ports

Port Production implementation Tests
GatewayTransport, GatewayChannel IoGatewayTransport on web_socket_channel (this package) a fake transport that records frames and closes with any code
NetworkStatus connectivity of the platform (M4-18) a switchable fake
AppLifecycle lifecycle of the Flutter app (M4-18) a switchable fake
RandomSource SystemRandomSource (Random.secure) a seeded source
Clock (of auth) SystemClock a clock with elapse

What M4-14 and M4-15 plug in

SessionListener (onReady, onResumed, onFrame, onDisconnected) receives the frames in order (the control frames heartbeat_ack and reauth_required act outside that queue, so a long listener step cannot delay them); M4-14 applies the snapshot, catch-up pages, cursor, catchup.complete and resync_required, and sets catchingUp, resyncing or live through SessionControl, which also sends frames and sets lastProcessedSeq for the heartbeat. M4-15 (outbox) reads connection.states to know when the gateway is live. connection.focus (FocusManager, at most sync.max_focused_threads, the complete set per frame with idle, and from M4-39 the background threads of the control room and the run graph in background_thread_ids within the same limit after the visible ones) and connection.typing (TypingThrottle, sync.typing_min_interval) are called by the interface.

Catch-up and reconciliation (M4-14)

CatchUpListener is the SessionListener of the connection (15 7.3, 04 7):

  • ready without a stored cursor (the first connection, or a cursor dropped by a failed resync) empties the mirror and applies the whole snapshot in one transaction with cursor = head_seq and the epoch, then goes live at once (no catch-up follows an identify without a cursor); with a cursor only what is not an event (counters, agent presence, feature flags, active run summaries) and the catch-up delivers the rest.
  • A gateway page is the run of dispatch frames up to the next cursor frame or catchup.complete; it is applied with SyncStateDao.commitPage and the cursor of that frame (never below the stored one) and the epoch. Pages above client.sync.parse_offload_bytes decode in a background isolate (decodeEnvelopes). A failing page leaves rows and cursor as they were and the listener applies nothing more until the next ready or resumed.
  • After catchup.complete the phase is live and onLive runs; every live durable event is its own transaction with cursor = seq; ephemeral events go to the EphemeralStore (typing capped at sync.typing_ttl, presence seeded from ready), raw to onRunUpdated, or as a NotificationCreated to the notifications stream (13 6.3, never stored). Ephemeral events inside a catch-up page run after the commit of the page.
  • resync_required (any reason) keeps the connection: getSnapshot, then in one transaction clearMirror (outbox, drafts and share inbox stay) and the snapshot with its cursor and epoch; the threads in focus load their newest page; phase live, notice Resynced. Frames that arrive meanwhile wait and pass the duplicate rule afterwards. When the snapshot cannot be loaded, mirror and cursor stay and the snapshot is retried after the reconnect backoff on the same connection. The retry runs outside the frame queue and checks its session after its awaits, so a session that started meanwhile is never set live by it (the follow-up reads of a committed snapshot still run); a drain of held frames of an ended session does not hold the frames of the next one.
  • REST reads after a commit store nothing for a target removed meanwhile (the channel row must exist and no removal of the channel, its space or the thread may have happened).
  • access.changed stores permission_version; removed deletes the targets with removeTargets inside the page (rows, files, search entries, drafts; outbox entries stay), then deletes the file bytes through FileCacheEraser and emits TargetsRemoved; added loads the structure through Fetcher.loadStructure; changed reloads the view with the new level and capabilities. The rest of the page applies after it.
  • 4012 (ConnectionState signedOut/revoked, watched with watchConnection) and session.revoked of this device's session wipe the database, stop the page and emit SignedOutNotice(deleteLocalData: true); session.revoked of another session of the person changes nothing.
  • A new message that does not continue the loaded range of its thread is stored, the gap is recorded and loaded with Fetcher.range; MessagesDao.watchContiguous hides it until then. A failed fill stays recorded; onFocus retries it.

Appliers

Rule "version": resource.version must be greater than the stored version (the conditional upsert of local_store); "seq": no resource.version, applied in seq order with absolute values (04 9 item 3). A truncated durable event loads its message, thread, channel or space through the Fetcher instead.

Event type Local tables Rule
message.created, message.updated messages, principals (author), files, message_attachments, reactions_summary, message_components, my_mentions, search_index; loaded_ranges for created; a tombstone clears content and search row version
message.deleted messages (tombstone), reactions_summary, my_mentions, message_components, message_attachments, search_index version
reaction.added, reaction.removed reactions_summary seq
pin.added, pin.removed messages.pinned seq
thread.created, thread.updated, thread.closed, thread.reopened, thread.moved threads, thread_tags, search_index (title) version
thread.moved_out everything of the thread (removeTargets) when it is still in the source channel seq
space.created, space.updated, space.archived spaces version
channel.created, channel.updated, channel.archived channels, channel_operators version
run.state_changed runs version
user.updated principals seq
agent.updated agents, principals version
read_state.updated read_states (mergeReadState), counters version of the read state
draft.updated drafts (not the echo of this device, not while typed here) version
draft.deleted drafts (not while typed here) seq
settings.updated user_settings (deleted: true removes the key) version of the setting
sidebar.updated user_space_states version of the state
notification_pref.updated notification_preferences version of the preference
quiet_hours.updated quiet_hours_rules (rules missing from the payload are deleted) version per rule
file.updated files seq
command.changed commands (removed deletes) version
instance.updated instance (reloaded with getInstance) seq
host.updated, terminal_session.changed none: announced on hostEvents after the commit (M4-36, the app reloads through REST) version
access.changed sync_state.permission_version, removal, REST structure seq
session.revoked the whole database (wipe) seq

In memory: typing.started and agent.presence_changed (EphemeralStore), run.updated (onRunUpdated, raw, also when truncated), notification.created (notifications of CatchUpListener and SyncEngine), host.presence_changed (hostEvents, M4-36). Skipped, the cursor advances (no local table): access.expiring, agent.config_changed, agent.followup, agent.invocation, agent_session.changed, channel.settings_changed, command.invoked, device.pending, gdpr.request_due, gdpr.request_updated, grant.changed, group.changed, import.progress, interaction.created, run.cancel_requested, status.updated, and every type the catalogue does not know.

Host events (M4-36): SyncEngine.hostEvents (HostEventsHub) carries HostChanged (host.updated: ID, version, state, connection, key_changed), HostPresenceChanged, TerminalSessionChanged (ID, host, person, device, state, version, close_reason) and, from the access.changed entries of kind host, HostRemoved and HostAccessGranted. Nothing of them is stored and none carries content; the terminal stream never runs through the engine (15 8). test/catch_up/catalogue_test.dart holds these sets equal to the catalogue.

Ports and hooks

Port or hook Production (M4-17, M4-18, M4-22) Tests
Fetcher ApiFetcher over api_client (this package) ApiFetcher on a fake REST adapter with recorded responses
LocalIdentity AuthIdentity over the AuthSession FixedIdentity
FileCacheEraser the file cache of M4-22 a recording eraser
DraftEditing the composer of the app a switch
onEcho, onRunUpdated, onLive outbox (M4-15), RunStore.onRunUpdated (M4-16), outbox start recorders

Notices on notices: TargetsRemoved, DraftOverwritten (from applyDraftPutResult, the answer of putThreadDraft), Resynced, SignedOutNotice. Counters: countsOf(threadId) and countChanges, reset from each snapshot and read_state.updated, counted from message.* in between (04 14).

Outbox (M4-15)

Outbox is the one write path of the apps (15 1 principle 3, 04 13.2, 15 7.5). Every write is stored in the table outbox of local_store before anything is sent, with its own UUIDv7: the client_msg_id of a message, the client_interaction_id of an interaction, the Idempotency-Key of every other write (04 5). The ID never changes on retry.

Life cycle of an entry:

State Meaning Next
pending stored, waits for its turn in its lane or for an entry it depends on sending
sending the executor runs confirmed (row deleted), waiting, failed
waiting backing off until next_attempt_at sending
failed refused for good, with the problem code and reason; after 412 also the current revision edited, sent again with a new ID, or discarded by the person
  • One worker per FIFO lane sends the oldest entry and the next only after the response (04 13.2 item 2). The lane is the thread, or the lane the executor names for writes outside a thread (setting:<key>, message:<id>, upload:<id>); lanes run in parallel, bounded only by the HTTP client's connection limit. An entry goes no later than the oldest entry that waits for it, also through other lanes, so a replacement at the end of a lane never waits behind its own dependent.
  • An entry with depends_on (a message after its uploads) waits until each of them is confirmed; a failed dependency keeps it waiting and visible with that reason (blockedByCode). One resolver (dependencies.dart) decides this for every path, over every stored entry: a row that leaves the table re-points every entry that names it (confirmed: dropped; retried or edited: its replacement; a dropped follow-up: what it waited for; discarded: dropped after its chain failed), and a dependency that is gone without a recorded confirmation is never taken as confirmed: the entry fails with dependency_discarded instead of going without it. enqueue resolves the IDs it is given through the replacements and confirmations of the running outbox; an ID gone before the outbox was created is unknown and fails the entry the same way.
  • start() sends (M4-17 calls it after catchup.complete, 04 13.4), pause() lets running requests finish and starts none. The first start() resets sending entries of an earlier run to pending; they are sent again with their stored IDs.
  • confirmByEcho(clientMsgId) confirms an entry when its echo message.created arrives, also before the response (04 16.2); the late response then changes nothing.
  • watchThread(threadId) streams the entries of a thread for the display; an entry in waiting for longer than client.outbox_stuck_after since it was written reports delayed ("Sending delayed"). depth streams the number of entries that are not final.
  • Editing and deleting follow 04 13.3: an entry with attempts == 0 is changed or removed in place; an attempted entry stays as it is and gets a follow-up updateMessage or deleteMessage that waits for it and receives its message_id and version; a failed entry edited or sent again becomes a new entry with a new ID, and the entries that waited for it wait for the new one (never sent follow-up edits behind it are dropped, the edit is newer). An edit holds the changed members and is merged into the stored payload. Discarding an entry fails every entry that waits for it, directly or through others (dependency_discarded). An entry the outbox writes itself (a retry, the replacement of an edit, a follow-up) never waits for an entry that failed with its chain (dependency_discarded); it waits for what that entry waited for. An entry that failed for its own reason (an upload) stays a dependency until the person retries or discards it.

Error classes (classify, docs/DECISIONS.md D-M4-15-1):

Response Class
2xx, 200 with Idempotent-Replay: true, 204 confirmed
network error (also a TLS error), timeout, 5xx except 503 setup_required, 429, 409 request_in_progress retry with full jitter between client.outbox_retry_base and client.outbox_retry_cap; 429 and 503 wait Retry-After
412 version_conflict conflict: failed with the current revision
400, 401, 403, 404, any other 409, 410, 413, 415, 422, 428, 503 setup_required final: failed with code and reason
an error that is no transport error and no response (a bug in an executor) final: executor_error
a kind without an executor final: no_executor

How a feature registers a write: an executor sends one entry through the generated client and returns an OutboxResponse on 2xx; it passes ctx.extra so api_client writes the Idempotency-Key.

executors.register(
  'upload',
  (entry, ctx) async {
    final body = decodePayload(entry.payload);
    // ... call the generated API with extra: ctx.extra ...
    return const OutboxResponse();
  },
  lane: (entry) => 'upload:${entry.id}', // null: the entry's thread
);

Registered by this package: MessageOps.register (createMessage, updateMessage, deleteMessage, addReaction, removeReaction) and PersonalOps.register (putReadState, putThreadDraft, deleteThreadDraft, putMySetting, deleteMySetting). M4-21 adds the interactions and commands, M4-22 the uploads. An app registers its own writes with the registerExecutors argument of SyncEngine.create (M4-19: putMySidebarSpace and createThread of apps/client/lib/sync/).

The sidebar counters (M4-19, 04 14): CatchUpListener.countsOfChannel returns the counts of every thread of a channel the engine counts (the snapshot's unread_threads, read states and message events name the channel), and loadThreadPage stores one page of listChannelThreads and returns the next cursor (the paging of the post list).

Run documents (M4-16)

RunStore keeps the document (run_document.v1, API schema Run) of every focused run in memory; run documents never go into the local database (docs/spec/15 5).

Client rule (docs/spec/04 11.3 item 4) per run.updated frame of a focused run, with local the version of the held document:

Frame Result
version not newer than local ignored
snapshot replaces the document (04 11.3 item 3)
base_version equal to local, with ops applyRunPatch applies the ops
anything else: a gap, a truncated frame (only run_id and version), no document yet, an invalid patch refetch of the whole document; it replaces the local one only when newer

While a refetch of a run is in flight, frames with ops of that run wait in a queue and run through the rule again when it returns; frames not newer than the fetched version drop out. A failed fetch keeps the last good document and retries on the next frame or focus. A fetch that returns after unfocus or onRunEnded is ignored. applyRunPatch supports exactly RFC 6902 add, replace and remove with RFC 6901 pointers, copies only the touched branches, and throws RunPatchError for anything else; the store never applies a patch in part.

watch(runId) publishes at most one state per frame, the newest one (15 7.4). RunDocument reads the live box fields of 09 5.4 (state, metrics, cost, plan, steps in ordinal order with children, open subagents, current tool, follow-ups, stall_warned_at, times) and keeps unknown fields in raw.

Ports (no transport dependency):

Port Production implementation (M4-17) Tests
RunDocumentFetcher.fetchRun(RunId) GET /api/v1/runs/{id} (getRun) through api_client, decoded body a fake that counts calls
FrameTicker.requestFrame(callback) SchedulerBinding.instance.scheduleFrameCallback a manual ticker

Wiring by M4-17: the dispatcher of M4-14 passes the decoded payload of every run.updated to RunStore.onRunUpdated; the focus manager calls focus and unfocus for the runs of focused threads; run.state_changed to an end state calls onRunEnded, after which the collapsed row comes from the table runs. A Notifier per run listens to watch.

Composition root (M4-17)

SyncEngine.create(...) is the one place that builds and wires the parts; the apps never wire them themselves (docs/DECISIONS.md D-M4-17-1).

Part Built from
REST client AuthSession.api (the client of the session with its DPoP, refresh and step-up interceptors)
GatewayConnection the transport, network, lifecycle and clock ports; gateway wss://<host>/api/v1/gateway from baseUrl
CatchUpListener and Dispatcher the local database, ApiFetcher over the REST client
Outbox MessageOps and PersonalOps on the REST client
RunStore a fetcher on getRun and the FrameTicker

Hooks: the echo message.created calls Outbox.confirmByEcho with message_id and version; the catch-up's live signal (catchup.complete, or ready without a cursor) calls Outbox.start, every disconnect Outbox.pause (04 13.4); run.updated goes to RunStore.onRunUpdated, a run.state_changed to completed, failed or cancelled to RunStore.onRunEnded; after a resync the focused runs that the new snapshot no longer lists as active end too. A confirmed createMessage or updateMessage stores the returned message at once (CatchUpListener.storeConfirmedMessage), so the entry is replaced before its echo (04 13.2 item 6). EngineFocus joins the visible threads and the threads of focused runs into the gateway focus, so focusRun(run, thread) both fetches the document and lets run.updated arrive. focusRun(run, thread, background: true) (a run graph) and setBackgroundRuns(cards) (the control room cards on screen) put the thread into background_thread_ids instead; setBackgroundRuns returns the cards that got a place, and unfocusRun drops only the kind of focus it names, so the live box and a run graph of one run can hand over in either order (M4-39). baseUrl must be https; only allowPlainHttpForTests: true (the headless tests and the app's test entry point) accepts http with a ws gateway (D-M4-17-6).

Live statistics stream (M6-16)

StatsSubscription (GatewayConnection.stats, SyncEngine.stats) carries the stats.live subscription of the visualizations (docs/spec/25-visualization.md 5.1; docs/DECISIONS.md D-M6-16-1 to D-M6-16-3):

engine.stats.updates.listen((u) => switch (u) {
  StatsLiveReceived(:final frame) => show(frame),
  StatsLiveEnded(:final code) => showProblem(code),
});
engine.stats.subscribe(spaceId: space); // subscribe() for the instance
engine.stats.unsubscribe();
  • stats.subscribe goes out once when the connection is identified; after ready (a new gateway session) the wanted subscription is sent again, after resumed only a change made while disconnected, because the server restores its own (04 6.3).
  • An error with op stats.subscribe clears the subscription, sends stats.unsubscribe (the server keeps an earlier subscription when it refuses a replacing one) and emits StatsLiveEnded with the code (forbidden, not_found, feature_disabled, rate_limited). A refused stats.unsubscribe is sent again after the next resumed, and a stats.live that arrives while nothing is wanted sends stats.unsubscribe again.
  • The connection takes every dispatch of stats.live out before the session listener, so a frame never reaches the dispatcher or the local database; frames of another scope are dropped.

The app implements LiveFrameSource of bothy_viz over it (apps/client/lib/viz/).

Fake server for integration tests (M4-17)

package:sync_engine/testing.dart is a separate library: the headless tests of this package and the app tests from M4-18 on share one fake of the server (D-W-114). Start one per test:

final server = await FakeServer.start(
  options: const FakeServerOptions(
    timings: GatewayTimings(runPatchCoalesce: Duration(milliseconds: 40)),
  ),
);
final world = WorldSeed.standard(server.world); // Alex, Sam and the agent Robin
// point the client at server.baseUri (http://127.0.0.1:<port>, test builds only)
server.faults.dropResponseAfterCommit('createMessage');
// ... drive the client ...
expect(server.violations, isEmpty);
await server.stop();

It binds 127.0.0.1 with port 0, serves REST under /api/v1/ and the gateway at /api/v1/gateway, and holds no global state.

World

FakeWorld keeps the instance and its sync epoch, people and agents, devices, sessions and pairings, spaces, channels with the level of each principal, threads, messages with thread_seq, the event log with a global seq, read states, drafts, settings and run documents. Every write appends the events of 05 section 6 with a new seq and pushes them to the connections that may see them. Test controls: addPerson, addSpace, addChannel, addThread, createMessage (as any author), setAccess (writes access.changed as 04 8.2), approveDevice, expirePendingDevice, rejectPendingDevice, expirePairing, revokeSession, revokeDevice, dropEventsUpTo (retention), changeEpoch, createRun, setRunState, patchRun, and the values catchupPageSize, cursorAdvanceEvery and catchupMaxEvents. WorldSeed.standard builds the space "Team" with the channels general, support and private (only Sam) and one thread each.

Operations

getDiscovery, loginWithPassword (password and TOTP), beginPairing, completePairing, approvePairing, getDevice, approveDevice, refreshToken, logout, getMe, getInstance, getSnapshot, listEvents, listSpaces, getSpace, listSpaceChannels, getChannel, listChannelThreads, getThread, listThreadMessages, getMessage, createMessage (04 5.1 replay and 422 idempotency_key_reuse), updateMessage (If-Match, 412 version_conflict with current, 410 message_deleted), deleteMessage (idempotent 204), addReaction, removeReaction, putReadState, putThreadDraft, deleteThreadDraft, listMyReadStates, listMyDrafts, listMySettings, putMySetting, deleteMySetting, getRun, cancelRun; since M4-19 also getMySidebar, putMySidebarSpace (the fracindex check of the server: a sort_key outside the fractional index is 400 validation_failed at /sort_key), listForumTags, createThread (forum posts only; without first_message 400 validation_failed at /first_message with the reason required_for_forum_post), getBrandingLogo (bytes of world.logo, else 404) and listChannelThreads with state, tag_id, sort, limit and an offset cursor; and from M4-25 in rest/self_handlers.dart listMyDevices, revokeDevice, listMySessions (with current), revokeSession, updateMyProfile, createStepUpChallenge and completeStepUpWithDeviceKey; from M4-26 in rest/admin_handlers.dart the administration of people (listUsers with q and status, createUser, getUser, updateUser, disableUser, enableUser, createEnrollmentCode), their credentials (getUserCredentials, unlinkIdentity, revokePasskey), devices and sessions (listUserDevices, listUserSessions; revokeSession also for an owner), owners (listOwners, addOwner, revokeOwner with 409 last_owner) and groups (listGroups with member_id, createGroup, getGroup, updateGroup, deleteGroup, listGroupMembers, addGroupMember with expires_at, removeGroupMember). These ask for the step-up through ActionCatalog: the step_up value of the operation's x-authz-action in docs/spec/actions.yaml (server.stepUpOf(operationId), server.actionOf(operationId)); createUser and createEnrollmentCode answer a replay with 409 conflict and reason: one_time_secret (06 7). The world has disablePerson, addPasskey, addIdentity, groups, groupMembers and i1OnEnable (people whose enableUser answers 409 i1_violation). From M4-29 rest/audit_handlers.dart serves listAuditEvents (the filters of the operation, newest first, an offset cursor), exportAuditEvents (NDJSON of the contiguous seq span of the range) and verifyAuditChain over server.audit, a FakeAuditLog whose rows a test adds with add and breaks with tamper(seq); all three are owner actions with the catalogue's step-up. The world has addForum, addForumTag, closeThread and putSidebar; a new message in a closed thread reopens it (thread.reopened, 01 6.1). From M4-22 rest/file_handlers.dart serves the files of 12 over FilesWorld (world_files.dart): createUpload with the part plan of world.fileSettings.partBytes (mode direct or presigned; presigned parts go to PUT /objectstore/<file_id>/<n> without Authorization, recorded in server.objectStorePuts), uploadPart with the length and Content-Digest checks and part_conflict, getUpload with the confirmed parts, completeUpload with the SHA-256 check and the scan, abortUpload, getFile, downloadFile and downloadThumbnail with the headers of 12 9.3 (Repr-Digest, one byte range), createFileTicket (a ticket download needs no DPoP and answers 404 for another file, variant or a revoked session), getTranscript, requestTranscription and deleteFile. world.fileSettings holds the faults: abortAfterParts (the next part answers 503 unavailable once), holdScan (the file stays "being checked" and answers 409 file_not_ready until world.finishScan(id)), scanResult (infected or error: "blocked", 409 file_blocked), wrongDigest (a damaged Repr-Digest) and transcriptionMode. world.addReadyFile seeds a checked file, world.setTranscript reports a transcript, world.limits is what getInstance reports, and createMessage attaches attachment_file_ids by the rules of 12 10 (409 conflict with file_not_attachable). Other writes with Idempotency-Key repeat their stored answer (04 5.2). Later tickets add operations with server.handle(operationId, handler) in their own rest/ file (D-M4-17-2). Gateway: hello, identify, resume, ready with the snapshot, catch-up in pages with cursor frames, catchup.complete, resync_required for epoch_changed, retention, too_far_behind and cursor_ahead, live delivery by visibility at delivery time, heartbeat_ack, reauth, focus (with error validation_failed above sync.max_focused_threads; background_thread_ids get run documents and run.updated but no typing, both lists share the limit), typing.started to other focusing connections, coalesced run.updated with the whole document on focus, one connection per device (4017) and the close codes of 04 6.4 for the session checks.

From M4-28 rest/agent_handlers.dart serves the agent administration of screen A5: listAgents, getAgent (the card of FakeWorld.agentJson with the warnings of 10 8; the fake treats billing.mismatch_alert_after as zero), setAgentBillingMode and setCredentialHolder (If-Match), setAllAgentsBillingMode (all or nothing, BR-08) and the agent tokens listTokens, createToken (secret of ADR 0061, shown once) and revokeToken. Tests build agents with world.addAgent, operators with setSpaceOperator and setChannelOperator, and reported values with agentHeartbeat; an agent added with addPerson alone gets the defaults. FakeI1Guard checks I1 (10 5.4) after every change of these handlers and of createGrant, addGroupMember and addOwner: a violation restores everything and answers 409 i1_violation with conflicts; resolve_i1 switches the listed agents to organization first, and all audit rows of the request share one correlation_id (10 11, BR-07). previewGrant reports a violation as invariant_violations. The trigger set is simplified: no thread operators, and a space's default operator serves all its channels.

From M6-07 rest/hint_handlers.dart serves the read-only lists "Permission hints" (listPermissionHints) and "Expiring soon" (listExpiringAccess) of access.hints.read, a small model of the server's internal/authz/app/hints.go and expiring.go with the same order, scope (instance for owners, a space with manage) and labels; the world's expiryReminderLead is authz.expiry_reminder_lead. Simplified: no project bindings or dev environments (so trigger_blocked names only sr2), no apps (only agent tokens) and an offset as cursor.

From M4-30 the world decides hints as 13 says, in world_notify.dart. Preferences are set directly with setNotificationPref(userId, targetKind, targetId, level, mutedUntil:) and deleteNotificationPref (setup steps without events). Since M4-33 rest/notify_handlers.dart serves listMyNotificationPreferences, putNotificationPreference and deleteNotificationPreference (notification_pref.updated per row, an equal write keeps the version, 409 conflict with reason target_archived), the quiet hours operations (world.quietHoursRules, the complete list in quiet_hours.updated; stored, not evaluated) and exportChannel (NDJSON of the channel's messages in the range, read access, the capability export and the step-up of export.channel); a person's message in a thread with kind = thread follows it at all (13 3.2). effectiveLevel(userId, threadId) follows 13 3.1: the own preference for thread, channel and space, the channel's default_notification_level, all in a DM channel, the setting notify.default_level, then world.userDefaultLevel; a selected preference with muted_until in the future is mute. notify(messageId) picks the recipients of 13 2 (active people who see the channel, never the author, nothing for silent, import or system) with the kinds message, mention and dm, and sends each hint as the ephemeral notification.created with the preview of the recipient's notify.preview_mode (or world.defaultPreviewMode; excerpt cut to world.previewMaxChars) only to the live gateway connections of the recipient's active devices (13 4.6). world.notifications records the hints, world.notificationSkips the refusals (level, no_route); world.quietHours lists the people inside quiet hours (no quiet hour rules), and world.notifyOnMessages makes createMessage decide for every new message (off by default). Simplified: no runs, no bundling, no pushes.

The live statistics (M6-16): stats.subscribe and stats.unsubscribe on the gateway with the denials of 25 5.1 (forbidden, not_found, feature_disabled when world.statsLiveEnabled is off); the subscription lives in the gateway session, so resume restores it and identify starts without one (FakeGatewayConnection.statsSubscription). The fake builds no frames: gateway.sendStatsLive(frame) sends one stats.live (for example a fixture of bothy_viz) with the scope of each subscription and returns how many connections it reached; gateway.endStatsSubscriptions(person) ends them with one error as a lost stats.read does. getStatsLive, getStatsTimeline, getStatsSlo and getStatsBackends answer the body stored in world.stats under their operation ID, else an empty one. getStatsTimeline refuses a range whose ends are not full minutes or that is longer than world.statsTimelineMaxRange with 400 validation_failed, as the server does.

Faults

server.faults is a FaultPlan, a queue of rules matched in order and fully deterministic (D-M4-17-3):

Fault Effect
onRequest(op, status:, code:, retryAfter:, times:) answers 409, 410, 412, 429 (with Retry-After), 5xx or any problem instead of the handler
dropResponseAfterCommit(op) stores the write and appends its events, then closes the connection before the response (04 16.2)
delay(duration, operationId:) holds the response after the handler ran
closeGateway(code, reason:, after:) closes every gateway connection with any code
duplicateNextDispatch() sends the next dispatch twice
resyncRequired(reason) answers the next catch-up with resync_required
revoke(deviceId:) closes the device's connections with 4012 and revokes its sessions
holdDispatches(), releaseDispatches() holds live delivery, as a slow gateway does

server.disconnect(deviceId:) ends connections without a close code (a lost network).

Checks

DPoP (08 7.3): ES256 signature with the header jwk, typ, htm, htu (the fake's origin plus the path; the gateway's http form), ath, iat, jti reuse and the bound key; a missing or stale nonce answers 401 use_dpop_nonce with DPoP-Nonce, any other failure 401 invalid_dpop_proof; the gateway closes with 4004. Contract (D-M4-17-4): every request body is validated against docs/api/openapi.yaml (a violation answers 400 validation_failed with the pointer), every response and every frame the fake sends against openapi.yaml and asyncapi.yaml, every client frame too (a violation closes with 4002); all of them land in server.violations. The validator (ContractSchemas) reads the documents once per isolate, walking up from the working directory. Where no docs/api/ is above it (the app under test in the macOS app sandbox, M4-18), it uses the copy compiled into lib/src/testing/contract_documents.g.dart; make contract-documents writes it and make codegen-check fails when it differs from docs/api/ (D-M4-18-17).

Scenarios

scenarios.dart holds the flows of 04 16 as functions over a ScenarioClient: scenarioSendOnline (16.1), scenarioSendOffline (16.2), scenarioCatchUp and scenarioResync (16.3), scenarioAccessRemoval (16.4), scenarioAgentRun (16.5, with a ScriptedAgent whose steps come from an AgentScript). test/scenarios/ runs them over SyncEngine; M4-18 implements ScenarioClient over the app (D-M4-17-5).

What the fake simplifies

  • Permissions: only visibility and the level per channel; no grants, groups, manage or scopes; no rate limits unless injected. Administration: an owner may do everything, anybody else gets 403 forbidden with owner_only outside the own person; lists have no paging; members do not expire by time. Step-up (M4-25): approveDevice, approvePairing and revokeDevice answer 401 step_up_required until the session confirmed with its step_up key (createStepUpChallenge, completeStepUpWithDeviceKey, ES256 signature over challenge and sid) within five minutes; a pairing code without an open pairing ends that confirmation; no failure counting (auth.step_up_fail_limit). A 401 is never stored as the answer of an Idempotency-Key, so the repeat after a step-up runs the operation.
  • Identity: a fixed TOTP code per person; a password login gives an active device unless the person has newDevicesPending; access tokens are unsigned JWS-shaped strings the fake recognizes by value; nonces are counters (current and previous accepted) instead of HMAC windows; only ES256 proofs; no refresh token reuse detection beyond rejecting unknown tokens with 401 session_revoked.
  • Gateway: no heartbeat timeout (4009), no reauth_required, no identify timeout, no outbound queue limit (4014); catch-up runs synchronously, so no live event interleaves.
  • Events: agent.invocation and the agent consumer side are not modelled; device.pending is not written (it is not in the API documents of this base); typing.stop has no effect.
  • cancelRun ends the run at once with cancelled.
  • Terminal (M4-36, rest/terminal_handlers.dart, terminal_stream_fake.dart, world_terminal.dart): listTerminalSessions, getTerminalSession, openTerminalSession (a grant terminal, the assignee, an own session user the host reports, a known profile and for claude an index into the own working directories, a connected host, a fresh step-up that the opening consumes), attachTerminalSession (a fresh step-up except from the same device and login session within world.terminal.quickReattachWindow; an attach from another device closes the open stream with 4103) and closeTerminalSession. The stream endpoint /api/v1/terminal/sessions/<id>/stream sends terminal.hello with the DPoP nonce, checks identify (token, proof with the HTTPS form of the stream URL, the device of the session) and relays the binary messages to world.terminal.hostLink (FakeTerminalHostLink, the app's tests connect the FakeHost of terminal_ui/testing.dart); without a link it closes with 4102. A revoked grant appends access.changed with the host in removed and ends the person's sessions there with 4100 and access_revoked; addTerminalHost, hostUpdated, setHostConnected and world.terminal.closeStream build the hosts and give the events and close codes on demand. Simplified: no limits per host, no idle or maximum duration, tokens are plain strings that only the in-process host checks.
  • Search (M4-23, rest/search_handlers.dart): search matches every word of q as a word prefix, case-insensitive, in message texts, thread titles, file names and transcripts (a transcript match is a file hit); the score is the number of matched words, ties newest first; the agent filter uses the thread's operator, else the channel's; the cursor is base64url JSON with a hash of person, query, type, sort and filters. searchThreadTitles, listMyMentions and listActiveRuns follow the API with visibility only. The limits (queryMaxChars, maxCandidates, prefixMinChars, page sizes) lie in world.searchSettings, so a test can lower them.
  • Agent interface (M4-21, rest/agent_ui_handlers.dart, world_agents.dart): createInteraction checks visibility, the level trigger, the state and expiry, required_capability and restricted_to (owners pass) and applies the single-use rule, but delivers nothing to the agent; listChannelCommands lists the commands of agents that see the channel (no guest rule, no app commands), the operator's first; autocompleteCommand answers from the suggestion function a test registers; invokeCommand records the invocation; listThreadRuns, getThreadCosts (no tokens), listAgentSessions, resetAgentSession and bindAgentSession follow the API with visibility and the level trigger. ScriptedAgent posts cards, waits for an interaction or a gate of the test, and writes the live box anchor with anchors: true; world.resolveMentions resolves @handle tokens like the server.
  • Snapshot: unread_threads and the read state's mention_count are computed (messages of others after the read position, not deleted); sidebar and the agent presence (agents) are always empty.

Layout

Path Content
lib/sync_engine.dart the public library; every public symbol has a doc comment (make lint, public_member_api_docs)
lib/src/*.dart the gateway connection: ports, dart:io transport, frames, states, close table, backoff, heartbeat, session, connection, focus, typing, listener (M4-13)
lib/src/catch_up.dart, dispatcher.dart, appliers/, snapshot_applier.dart, fetcher.dart, gap_filler.dart, ephemeral_store.dart, sync_events.dart, isolate_decode.dart, wire.dart catch-up, dispatcher, appliers per resource group, REST reads, gaps, ephemeral store, notices and ports, decoding, the wire mappers (M4-14)
lib/src/runs/ RunStore, applyRunPatch, RunDocument and the ports (M4-16)
lib/src/outbox/ Outbox, the sender, classify, the backoff, the executor registry and the operations (M4-15)
lib/src/engine.dart SyncEngine, EngineFocus (M4-17)
lib/src/stats/ StatsSubscription, the stats.live subscription (M6-16)
lib/testing.dart, lib/src/testing/ the fake server: world, gateway, REST with one handler file per group under rest/, DPoP check, faults, scripted agent, contract validator, seed, scenarios (M4-17)
test/connection/ tests of the gateway connection, one per close code, with the fakes of test/support/
test/catch_up/ tests of the catch-up on the connection fakes, an in-memory store and recorded REST responses
test/outbox/ tests of the outbox on an in-memory database with scripted executors, a fake clock and a seeded jitter
test/runs/ tests of the run documents, fixtures of a document at versions 1 to 4 under fixtures/
test/fake_server/ the fake's own tests: contract, DPoP, idempotency, faults
test/stats/ the stats.live subscription on the connection fakes (M6-16)
test/scenarios/ the scenarios of 04 16 and the wiring of SyncEngine, headless against the fake
test/ tests, run by make test with coverage (quality.coverage.dart_min_percent)

Run from the repository root: make lint test coverage arch COMPONENTS=apps. The API pages come from make docs (Dart API reference).

Libraries

sync_engine
Gateway, catch-up, outbox, reconciliation and run documents of the apps, without Flutter.
testing
The fake server of the client integration tests (brief M4-17; 15 20 row "Integration").