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.dartandlib/src/testing/are reached only fromtest/andintegration_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):
readywithout 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 withcursor = head_seqand the epoch, then goesliveat once (no catch-up follows anidentifywithout 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
dispatchframes up to the nextcursorframe orcatchup.complete; it is applied withSyncStateDao.commitPageand the cursor of that frame (never below the stored one) and the epoch. Pages aboveclient.sync.parse_offload_bytesdecode in a background isolate (decodeEnvelopes). A failing page leaves rows and cursor as they were and the listener applies nothing more until the nextreadyorresumed. - After
catchup.completethe phase isliveandonLiveruns; every live durable event is its own transaction withcursor = seq; ephemeral events go to theEphemeralStore(typing capped atsync.typing_ttl, presence seeded fromready), raw toonRunUpdated, or as aNotificationCreatedto thenotificationsstream (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 transactionclearMirror(outbox, drafts and share inbox stay) and the snapshot with its cursor and epoch; the threads in focus load their newest page; phaselive, noticeResynced. 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.changedstorespermission_version;removeddeletes the targets withremoveTargetsinside the page (rows, files, search entries, drafts; outbox entries stay), then deletes the file bytes throughFileCacheEraserand emitsTargetsRemoved;addedloads the structure throughFetcher.loadStructure;changedreloads the view with the new level and capabilities. The rest of the page applies after it.4012(ConnectionStatesignedOut/revoked, watched withwatchConnection) andsession.revokedof this device's session wipe the database, stop the page and emitSignedOutNotice(deleteLocalData: true);session.revokedof 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.watchContiguoushides it until then. A failed fill stays recorded;onFocusretries 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 withdependency_discardedinstead of going without it.enqueueresolves 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 aftercatchup.complete, 04 13.4),pause()lets running requests finish and starts none. The firststart()resetssendingentries of an earlier run topending; they are sent again with their stored IDs.confirmByEcho(clientMsgId)confirms an entry when its echomessage.createdarrives, 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 inwaitingfor longer thanclient.outbox_stuck_aftersince it was written reportsdelayed("Sending delayed").depthstreams the number of entries that are not final.- Editing and deleting follow 04 13.3: an entry with
attempts == 0is changed or removed in place; an attempted entry stays as it is and gets a follow-upupdateMessageordeleteMessagethat waits for it and receives itsmessage_idandversion; 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.subscribegoes out once when the connection is identified; afterready(a new gateway session) the wanted subscription is sent again, afterresumedonly a change made while disconnected, because the server restores its own (04 6.3).- An
errorwith opstats.subscribeclears the subscription, sendsstats.unsubscribe(the server keeps an earlier subscription when it refuses a replacing one) and emitsStatsLiveEndedwith the code (forbidden,not_found,feature_disabled,rate_limited). A refusedstats.unsubscribeis sent again after the nextresumed, and astats.livethat arrives while nothing is wanted sendsstats.unsubscribeagain. - The connection takes every
dispatchofstats.liveout 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,
manageor scopes; no rate limits unless injected. Administration: an owner may do everything, anybody else gets403 forbiddenwithowner_onlyoutside the own person; lists have no paging; members do not expire by time. Step-up (M4-25):approveDevice,approvePairingandrevokeDeviceanswer401 step_up_requireduntil the session confirmed with itsstep_upkey (createStepUpChallenge,completeStepUpWithDeviceKey, ES256 signature overchallengeandsid) within five minutes; a pairing code without an open pairing ends that confirmation; no failure counting (auth.step_up_fail_limit). A401is never stored as the answer of anIdempotency-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; onlyES256proofs; no refresh token reuse detection beyond rejecting unknown tokens with401 session_revoked. - Gateway: no heartbeat timeout (
4009), noreauth_required, noidentifytimeout, no outbound queue limit (4014); catch-up runs synchronously, so no live event interleaves. - Events:
agent.invocationand the agent consumer side are not modelled;device.pendingis not written (it is not in the API documents of this base);typing.stophas no effect. cancelRunends the run at once withcancelled.- Terminal (M4-36,
rest/terminal_handlers.dart,terminal_stream_fake.dart,world_terminal.dart):listTerminalSessions,getTerminalSession,openTerminalSession(a grantterminal, the assignee, an own session user the host reports, a known profile and forclaudean 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 withinworld.terminal.quickReattachWindow; an attach from another device closes the open stream with4103) andcloseTerminalSession. The stream endpoint/api/v1/terminal/sessions/<id>/streamsendsterminal.hellowith the DPoP nonce, checksidentify(token, proof with the HTTPS form of the stream URL, the device of the session) and relays the binary messages toworld.terminal.hostLink(FakeTerminalHostLink, the app's tests connect theFakeHostofterminal_ui/testing.dart); without a link it closes with4102. A revoked grant appendsaccess.changedwith the host inremovedand ends the person's sessions there with4100andaccess_revoked;addTerminalHost,hostUpdated,setHostConnectedandworld.terminal.closeStreambuild 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):searchmatches every word ofqas 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,listMyMentionsandlistActiveRunsfollow the API with visibility only. The limits (queryMaxChars,maxCandidates,prefixMinChars, page sizes) lie inworld.searchSettings, so a test can lower them. - Agent interface (M4-21,
rest/agent_ui_handlers.dart,world_agents.dart):createInteractionchecks visibility, the leveltrigger, the state and expiry,required_capabilityandrestricted_to(owners pass) and applies the single-use rule, but delivers nothing to the agent;listChannelCommandslists the commands of agents that see the channel (no guest rule, no app commands), the operator's first;autocompleteCommandanswers from the suggestion function a test registers;invokeCommandrecords the invocation;listThreadRuns,getThreadCosts(no tokens),listAgentSessions,resetAgentSessionandbindAgentSessionfollow the API with visibility and the leveltrigger.ScriptedAgentposts cards, waits for an interaction or a gate of the test, and writes the live box anchor withanchors: true;world.resolveMentionsresolves@handletokens like the server. - Snapshot:
unread_threadsand the read state'smention_countare computed (messages of others after the read position, not deleted);sidebarand 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").