obsidian allroads & allroads-sync

allroads sync server

allroads is a real-time collaborative roadmap planning application. the sync server is a rust binary that multiplexes websocket connections from multiple clients, encrypting all traffic with chacha20-poly1305 and authenticating every message with HMAC-SHA256 signed envelopes.

connection info

service: allroads sync server

port: 59901

protocol: wss (websocket over tls)

encryption: chacha20-poly1305 ae

signing: HMAC-SHA256 ae

connect

connections are accepted on port 59901 over tls. unauthenticated connections are dropped after 10 seconds. all post-auth messages are sealed inside signed envelopes.

quick start

  1. clone and compile the allroads client: cargo +nightly build/bundle --release
  2. open the compiled application and switch to the org chart tab
  3. click create an organization, then enumerate your hierarchy and members
  4. disable the offline mode checkbox in the 'Network' menu
  5. you will be connected to wss://obsidian.st:59901 by default
  6. to invite a user, right click a user on the org chart and press 'Generate User Token'
  7. users in your org then input this token in the 'Edit -> Organizations -> Join' menu
  8. now when you save your roadmap/org, all changes will be pushed to the server, and your users

architecture

the system is composed of three layers: the sync engine (library), the sync server (binary), and the desktop client (egui app).

sync engine — server/src/engine.rs

the core library. manages a sqlcipher-encrypted sqlite database, creates trigger-based change logs, exports and imports org-scoped snapshots, and applies incoming changes with conflict resolution (last-write-wins on updated_at).

sync server — server/src/main.rs

a tokio-based async binary that accepts websocket connections (plain or tls), authenticates clients using argon2id-hashed tokens, enforces org-scoped access control, and multiplexes changes between connected peers. each connection runs in its own tokio task with a 256-connection semaphore limit.

transport — server/src/transport.rs + ws.rs

defines the SyncMessage tagged enum (serde json) and the SignedEnvelope struct. the WebSocketTransport wraps tokio-tungstenite with support for client, plain server, and tls server streams.

data flow

client A edits a feature
sqlite trigger writes to sync_log
client sends changeset in a sealed envelope
server verifies HMAC, decrypts, applies to server db
server broadcasts filtered changeset to other peers in the same org

authentication

every connection must authenticate within 10 seconds or be disconnected. authentication uses argon2id-hashed tokens stored in the database.

token types

  • org token — stored in org.token_hash / org.token_salt. hashed with argon2id. grants standard member access.
  • owner token — stored in org.owner_hash / org.owner_salt. optionally provided in the hello message. grants owner-level access.
  • user token — stored in org_user_token. maps to a specific user within the org. if present, the server resolves the user by token instead of by link_id.

handshake flow

client                                    server
  |---- hello {token, owner_token} -------->|
  |                                         |  verify argon2id hash
  |                                         |  create sync_session
  |<--- auth_ok {session_id, session_key} --|

the session_key is a base64-encoded 32-byte key used for chacha20-poly1305 encryption and HMAC-SHA256 signing of all subsequent messages. the session_id is included in every signed envelope.

session management

sessions are stored in the sync_session table and tracked by session_id. each session records the org, user, role, and last-seen timestamp. sessions are scoped to a single org.

protocol

all communication is json over websocket. the protocol has two phases: handshake (plaintext) and sync (signed + sealed envelopes).

phase 1: handshake

the client sends a plaintext hello message. the server responds with auth_ok containing the session key. alternatively, the client can send one-shot requests (node_id_req, share_snapshot_req, migration messages) which are handled and the connection is closed.

phase 2: sync loop

after authentication, every message is wrapped in a SignedEnvelope containing:

  • session_id — ties the message to the authenticated session
  • nonce — monotonically increasing counter (replay protection)
  • sig — HMAC-SHA256 signature over session_id:nonce:cbor(body)
  • body — a Sealed { nonce_b64, data_b64 } message containing the encrypted inner message

heartbeat

the server sends outgoing changes every 500ms. if no changes are pending, it sends a heartbeat ping every 25 seconds. clients should respond with pong.

message size limits

  • maximum message size: 1 MB (MAX_MESSAGE_BYTES)
  • maximum changes per changeset: 500 (MAX_CHANGESET)

message types

the SyncMessage enum uses serde's #[serde(tag = "type")] representation. every message is a json object with a "type" field.

typedirectiondescription
helloc→sauth handshake. sends node_id, schema_version, last_log_id, token, optional owner_token, client_kind
auth_oks→csuccessful auth response. returns session_id, session_key, after_log_id, server_node_id, is_owner, user_id, user_role, is_ai
node_id_reqc→srequest the server's node identity. one-shot, connection closes after response
node_id_oks→creturns the server's node_id
snapshot_reqc→srequest a full encrypted snapshot. includes a reason field
snapshot_datas→csnapshot response. data_b64 (encrypted db), hash (sha256), nonce_b64
generate_share_tokenc→srequest a share token for a roadmap (owner only)
share_tokens→creturns the generated share token and roadmap_id
share_snapshot_reqanyanonymous request for a shared roadmap snapshot using a token
share_snapshot_datas→creturns the shared snapshot data_b64 and hash
sealedbothencrypted wrapper. contains nonce_b64 and data_b64 (chacha20-poly1305 ciphertext)
changesetbothcontains a vec of Change structs and last_log_id
rotate_tokenc→srotate the org or user token
ackc→sacknowledge received changes up to last_log_id
resumec→sresume sync from a specific after_log_id
pings→cserver heartbeat
pongc→sclient heartbeat response
errors→cerror message with a human-readable string

migration messages

typedescription
migration_start_reqinitiates org migration. requires owner_token, old/new server urls, org_id, target_server_identity
migration_start_okconfirms migration started. returns org_id
migration_snapshot_reqrequests the migration snapshot from the old server
migration_snapshot_datareturns data_b64 and source_logical_hash
migration_finalize_reqfinalizes migration with target_logical_hash for verification
migration_finalize_okconfirms migration complete. returns org_id and delete_after_unix (7 day retention)

change struct

{
  "change_id": "a1b2c3d4...",
  "timestamp_ms": 1718000000000,
  "origin_node": "node-abcdef12345678",
  "entity": "feature",
  "entity_id": "fA",
  "op": "update",
  "payload": { "title": "new title" },
  "hlc": "1718000000000-node-abcdef"
}

each change is uniquely identified by change_id (random 16-byte hex). the op field is one of insert, update, or delete. the hlc (hybrid logical clock) combines a timestamp with the origin node id.

signed envelopes

after the initial handshake, all messages are wrapped in a SignedEnvelope and the inner message body is encrypted using chacha20-poly1305.

envelope structure

{
  "session_id": "uuid-session",
  "nonce": 42,
  "sig": "base64-hmac-sha256",
  "body": {
    "type": "sealed",
    "nonce_b64": "base64-12-byte-nonce",
    "data_b64": "base64-ciphertext+tag"
  }
}

signing

the signature is computed as HMAC-SHA256 over session_id:nonce:cbor(body) using the 32-byte session key. the nonce must be strictly increasing — any message with a nonce less than or equal to the last seen nonce is rejected.

encryption

the inner SyncMessage is serialized to cbor, then encrypted with chacha20-poly1305 using a random 12-byte nonce. the result is wrapped in a Sealed { nonce_b64, data_b64 } message which becomes the envelope body.

verification

fn verify_envelope(state, env):
    if env.session_id != state.session_id: reject
    if env.nonce <= state.last_nonce_in: reject  // replay
    expected_sig = hmac_sha256(session_key, session_id:nonce:cbor(body))
    if env.sig != expected_sig: reject
    state.last_nonce_in = env.nonce
    inner = chacha20poly1305_decrypt(session_key, body.nonce, body.data)
    message = cbor_decode(inner)

sync engine

the sync engine (server/src/engine.rs) is the core library that handles database operations, change tracking, snapshot management, and conflict resolution.

initialization

SyncEngine::init_db() creates the sync infrastructure tables:

  • sync_meta — stores the node id and schema version
  • sync_context — controls trigger suppression during imports
  • sync_peer_state — tracks the last acknowledged log id per peer
  • sync_log — append-only change log with change_id, timestamp, entity, op, payload, hlc

trigger-based change tracking

for each registered table, the engine creates AFTER INSERT, AFTER UPDATE, and AFTER DELETE triggers. these triggers insert rows into sync_log when sync_context.suppress_triggers = 0. during snapshot imports and incoming change application, triggers are suppressed to avoid duplicate log entries.

outgoing changes

list_outgoing() returns unsent changes (where origin_node matches the local node and sent = 0). list_since() returns all changes after a given log id, used for streaming to connected peers.

concurrent multi-peer merging

multiple clients can edit the same org simultaneously. the server acts as the central merge point — it applies every incoming changeset to its own database, then broadcasts the merged result to all connected peers. because all writes go through the server's single database, concurrent edits from different clients are naturally serialized into a consistent order.

the key insight: two clients editing different rows never conflict. two clients editing the same row are resolved by timestamp. this means most real-world collaboration is conflict-free — people tend to work on different features, subtasks, or org entries at the same time.

merge flow

client A edits feature "login"
  client B edits feature "payments"     -- different entity, no conflict
  client C edits feature "login"        -- same entity, conflict resolved

  all three send changesets to server
    |
    v
  server applies A's change → writes to db → logs in sync_log
  server applies B's change → writes to db → logged (no conflict with A)
  server applies C's change → same row as A → compares updated_at
    if C.updated_at >= A.updated_at: C wins (write goes through)
    if C.updated_at < A.updated_at: C is stale (silently dropped)

  server broadcasts merged changeset to all peers
    each peer receives only changes visible to their org

conflict resolution — last-write-wins on updated_at

the should_apply_change() function in engine.rs implements the conflict check. for any table that has an updated_at column:

  1. read the existing row's updated_at value from the database
  2. compare it against the incoming change's updated_at
  3. if incoming >= existing, apply the change
  4. if incoming < existing, silently skip — the incoming edit is stale

if the table has no updated_at column (e.g. org_chart), the change is always applied. if the row doesn't exist yet, the change is always applied regardless.

update-to-insert fallback

apply_update() executes an UPDATE ... WHERE id = ?. if this affects zero rows (the row was deleted by another client, or never existed), it automatically falls back to apply_insert(). this handles the edge case where client A creates a feature, client B deletes it, then client C sends an update — the update becomes a re-creation.

upsert on inserts

apply_insert() uses INSERT ... ON CONFLICT(primary_key) DO UPDATE SET .... if two clients independently create a row with the same id, the second insert merges with the first by updating all non-primary-key columns. this prevents duplicate key errors while preserving the most recent data.

duplicate change deduplication

before applying any change, the engine checks sync_log for an existing row with the same change_id. if found, the change is skipped entirely. this prevents the same change from being applied twice — which could happen during reconnect or snapshot re-import.

non-conflicting merges in practice

because the server maintains a single source of truth in its database, the following scenarios merge cleanly without any data loss:

  • different features — client A edits feature X, client B edits feature Y. both changes apply independently
  • different subtasks of the same feature — each subtask is a separate row, no conflict
  • different fields of the same entity — client A updates the title, client B updates the status. the later updated_at wins, but both fields are preserved because the payload contains the full row state, not a partial patch
  • org chart changes — adding/removing relationships in org_chart uses different rows per manager/report pair
  • task assignments — each assignment is a unique row per feature/user pair

note: because updates send the full row payload (not a diff), the "different fields" case technically overwrites. if client A sets title: "new" and client B sets status: "done" at nearly the same time, the later one wins the whole row. in practice, the UI writes the full current state on every edit, so the winning write includes both changes if the user had already pulled the other's update.

transaction safety

all incoming changes are applied inside a single sqlite transaction with PRAGMA defer_foreign_keys = ON. this means foreign key constraints are checked at commit time, not statement time — so changes can reference rows created later in the same batch (e.g. a feature referencing a quarter that hasn't been inserted yet). any foreign key violations are logged to stderr after commit.

peer state

sync_peer_state tracks the last acknowledged log id per peer. this allows the server to resume sending changes from the correct position after a reconnect.

database schema

the server uses a sqlcipher-encrypted sqlite database. the schema is defined in DB_SCHEMA in server/src/main.rs.

project data

roadmap
id, name, created_at, updated_at
quarter
id, roadmap_id (fk), year, quarter, sort_order
feature
id (text), quarter_id (fk), title, description, completed, status, color, sort_order, days, weeks, start_date, started_at, paused_at, completed_at
subtask
id (text), feature_id (fk), title, description, completed, status, color, sort_order, started_at, completed_at

organization data

org
id, name, token_hash, token_salt, owner_hash, owner_salt, created_at, updated_at
org_settings
org_id (fk), mode (flat/hierarchical), updated_at
org_user
id, link_id, org_id (fk), display_name, role, is_ai, created_at, updated_at
org_owner
org_id (fk), owner_user_id (fk), created_at
org_chart
id, org_id (fk), manager_id (fk), report_id (fk), created_at
org_roadmap
org_id (fk), roadmap_id (fk), created_at — unique org_id index (one roadmap per org)
org_roadmap_editor
org_id (fk), user_id (fk), can_edit, updated_at
task_assignment
id, feature_id (fk), user_id (fk), user_link_id, status, assigned_at, updated_at

auth & session data

org_user_token
token_hash (pk), token_salt, org_id (fk), user_id (fk), created_at, updated_at
sync_session
session_id (pk), org_id (fk), user_id (fk), is_owner, session_key, created_at, last_seen

sharing & migration

roadmap_share
token (pk), org_id (fk), roadmap_id (fk), created_at
org_migration_state
org_id (pk), state, source_logical_hash, server_node_id, target_server_identity, target_server_url, target_server_addr, delete_after_unix, updated_at

audit

ai_change_audit
id, batch_id, created_at, org_id, user_id, session_id, peer_id, change_id, entity, entity_id, op, payload_json, before_snapshot_json, after_snapshot_json, rollback_op, rollback_payload_json

roles & access control

access is org-scoped. every authenticated connection is bound to a single org. changes are filtered so that peers only see data belonging to their org.

role hierarchy

rolepermissions
ownerfull access. can generate share tokens, manage migration, rotate tokens, manage users, delete the org. authenticated via the owner_token in the hello message
admincan manage org users, org chart, and org settings. cannot delete the org or initiate migration
leadercan manage org chart relationships. read/write access to roadmap data
membercan complete tasks, read the roadmap; but cannot edit unless authorized by admin/owner

org settings modes

  • flat — all members have equal access to the roadmap
  • hierarchical — access is governed by the org chart. task assignments are visible only to the assigned user and their management chain

change visibility

the server filters outgoing changesets per connection using filter_changes_for_org(). visibility rules:

  • org — only visible if entity_id == org_id
  • org_settings, org_user, org_owner, org_chart, org_roadmap, org_roadmap_editor — filtered by payload.org_id
  • roadmap — visible if linked to the org via org_roadmap
  • quarter — visible if its roadmap_id is linked to the org
  • feature — visible if its quarter's roadmap is linked to the org
  • subtask, task_assignment — visible if their feature's quarter's roadmap is linked to the org

roadmap editors

the org_roadmap_editor table controls per-user edit access. a user must be listed with can_edit = 1 to push changes to the roadmap.

org limit

the server enforces a maximum of 128 organizations per database (MAX_ORGS). inserts that exceed this limit are rejected. this can be configured, along with MAX_CONNECTIONS via environment variable.

snapshots

snapshots are full copies of the database (or an org-scoped subset) used for initial sync, backup, and migration.

full snapshot

SyncEngine::export_snapshot() creates a copy of the entire database using VACUUM INTO and returns the bytes with a sha256 hash.

org-scoped snapshot

export_scoped_snapshot(org_id, roadmap_id) creates a copy, then deletes all data not belonging to the specified org. sync metadata (sync_log, sync_peer_state, sync_session) is stripped. the resulting file contains only the org's data.

snapshot import

import_snapshot() merges an incoming snapshot into the local database:

  1. write incoming bytes to a temp file
  2. open the temp db with the encryption key
  3. if the snapshot has an org scope, clear the corresponding data in the target db
  4. for each registered table, merge rows using update-then-insert strategy
  5. triggers are suppressed during import
  6. foreign key violations are logged to stderr

encrypted snapshot transfer

when a client requests a snapshot via snapshot_req, the server encrypts the snapshot bytes with chacha20-poly1305 using the session key and returns snapshot_data { data_b64, hash, nonce_b64 }.

share tokens

owners can generate a roadmap_share token via generate_share_token. anyone with this token can request a read-only snapshot via share_snapshot_req without authentication. the token maps to a specific org and roadmap.

migration

org migration allows moving an organization from one server to another with cryptographic verification. the protocol ensures data integrity via logical hashes.

migration flow

new server                    client                     old server
    |                            |                            |
    |<-- migration_start_req ----|                            |
    |                            |--- migration_snapshot_req ->|
    |                            |<-- migration_snapshot_data -|
    |<-- (snapshot imported) ----|                            |
    |                            |--- migration_finalize_req ->|
    |    (hash verification)     |<-- migration_finalize_ok --|
    |    migration_start_ok --->|                            |

steps

  1. start — the client sends migration_start_req to the new server with the owner token, old/new server urls, org id, and the new server's identity
  2. snapshot request — the new server connects to the old server, requests a migration snapshot using migration_snapshot_req
  3. import & verify — the new server imports the snapshot and computes a logical hash (compute_org_scope_logical_hash). the hash must match the source logical hash from the old server
  4. finalize on old — the new server sends migration_finalize_req to the old server with the target hash for verification
  5. retention — the old server marks the org with a delete_after_unix timestamp (7 days). a background cleanup task removes expired migrated orgs every 60 seconds

logical hash

the logical hash is a sha256 digest computed over the ordered contents of all org-scoped tables. each table's rows are sorted by primary key, and each column value is encoded with a type prefix (t: text, i: integer, r: real, b: blob, null). this produces a deterministic hash independent of sqlite page layout.

ai change audit

when a client connects with client_kind: "ai", the server automatically enables change auditing for that session. every change pushed by an ai client is recorded with before/after snapshots and a rollback plan.

audit record

each audited change is stored in ai_change_audit with:

  • batch_id — uuid grouping all changes from a single changeset
  • change_id — the unique change identifier
  • before_snapshot_json — the row state before the change
  • after_snapshot_json — the row state after the change
  • rollback_op — the operation needed to reverse the change
  • rollback_payload_json — the data needed to perform the rollback

rollback plans

operationrollback oprollback data
insertdeletenone (entity_id is sufficient)
updateupdatebefore_snapshot (restores original values)
deleteinsertbefore_snapshot (recreates the row)

auto-marking ai users

when a client authenticates with client_kind: "ai" and the user's is_ai flag is not set, the server automatically updates org_user.is_ai = 1.

rate limiting

the server implements per-ip rate limiting on authentication attempts to prevent brute-force attacks on org tokens.

parameters

parametervaluedescription
AUTH_WINDOW_SECONDS60sliding window for counting attempts
AUTH_MAX_ATTEMPTS_PER_IP30max auth attempts per ip within the window
AUTH_HANDSHAKE_TIMEOUT_SECONDS10connection dropped if not authenticated within this time
MAX_CONNECTIONS256max concurrent websocket connections (semaphore)

when an ip exceeds the attempt limit, the server responds with error { message: "too many auth attempts; retry later" } and closes the connection.

desktop client

the allroads desktop client is an egui/eframe application written in rust. it connects to the sync server via websocket and provides a full gui for managing roadmaps.

views

  • quarters — kanban-style board organized by year and quarter. features and subtasks can be added, edited, completed, reordered, and moved between quarters
  • timeline — horizontal gantt chart showing features across multiple roadmaps with zoom and scroll. features display as bars with configurable day/week durations and start dates
  • org chart — hierarchical tree view of org users with manager/report relationships. supports drag-and-drop reorganization

sync modes

  • one-shot — connect, push/pull changes, disconnect
  • persistent — maintain a live websocket connection with real-time change streaming in a background thread

proxy support

the client supports connecting through:

  • http connect — standard http proxy
  • socks5 — socks5 proxy
  • tor — connects via a local tor socks proxy (typically port 9050)

project templates

the client includes built-in templates for quick project setup:

  • web — planning, backend, frontend, auth, payments, testing, deployment
  • mobile — design, architecture, auth, features, notifications, submission, launch
  • api — specification, database, auth, endpoints, documentation, testing, monitoring

each template includes pre-built features with suggested subtasks.

local database

the client uses a local sqlite database (sqlcipher-encrypted) with the same schema as the server. changes are tracked via the same trigger-based sync log system. the client can function fully offline and sync when connectivity is available.

encryption

the client implements the same signing (compute_sig) and encryption (seal_sync_message) functions as the server. it also supports snapshot decryption for received snapshots. encryption keys are stored in the os keychain via the keyring crate.

configuration

the server is configured entirely through environment variables.

server environment variables

variabledefaultdescription
SYNC_DB_PATHsync.dbpath to the sqlcipher database file
SYNC_KEYauto-generateddatabase encryption key. if empty, a random 32-byte base64 key is generated and logged
SYNC_LISTEN0.0.0.0:59901listen address and port
SYNC_TLS_CERT_PATHnonepath to the tls certificate pem file. both cert and key must be set to enable tls
SYNC_TLS_KEY_PATHnonepath to the tls private key pem file

server constants

constantvaluedescription
MAX_MESSAGE_BYTES1,048,576max websocket message size (1 mb)
MAX_CHANGESET500max changes per changeset
MAX_ORGS128max organizations per server
MAX_CONNECTIONS256max concurrent connections
MIGRATION_RETENTION_SECONDS604,8007 days — time before migrated org is deleted from old server
CLEANUP_INTERVAL_SECONDS60how often expired migrations are cleaned up
OUTGOING_CHECK_MILLIS500how often the server checks for outgoing changes
HEARTBEAT_SECONDS25heartbeat interval when no changes are pending
AUTH_WINDOW_SECONDS60rate limit window for auth attempts
AUTH_MAX_ATTEMPTS_PER_IP30max auth attempts per ip per window
AUTH_HANDSHAKE_TIMEOUT_SECONDS10connection timeout for unauthenticated peers

deployment

requirements

  • rust toolchain (for building from source)
  • linux server with tls support
  • port 59901 open and accessible
  • valid tls certificate for the domain

running with tls

export SYNC_DB_PATH=/var/lib/allroads/sync.db
export SYNC_KEY="your-base64-32-byte-key"
export SYNC_LISTEN="0.0.0.0:59901"
export SYNC_TLS_CERT_PATH=/etc/ssl/certs/allroads.pem
export SYNC_TLS_KEY_PATH=/etc/ssl/private/allroads.key

./allroads-sync-server

running without tls

export SYNC_DB_PATH=/var/lib/allroads/sync.db
export SYNC_KEY="your-base64-32-byte-key"
export SYNC_LISTEN="0.0.0.0:59901"

./allroads-sync-server

without tls, the server accepts plain ws:// connections.

systemd service

[Unit]
Description=Allroads Sync Server
After=network.target

[Service]
Type=simple
User=allroads
Environment=SYNC_DB_PATH=/var/lib/allroads/sync.db
Environment=SYNC_KEY=your-key-here
Environment=SYNC_LISTEN=0.0.0.0:59901
Environment=SYNC_TLS_CERT_PATH=/etc/ssl/certs/allroads.pem
Environment=SYNC_TLS_KEY_PATH=/etc/ssl/private/allroads.key
ExecStart=/usr/local/bin/allroads-sync-server
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

logging

the server logs all events to stderr in the format:

[10/Jun/2026:14:30:00 +0000] event=server_listen mode=wss addr=0.0.0.0:59901
[10/Jun/2026:14:30:05 +0000] event=connection_open peer=192.168.1.10:52341
[10/Jun/2026:14:30:05 +0000] event=auth_ok peer_id=node-abc123 is_owner=true

redirect to a log file or pipe to your log aggregator.

token rotation

org tokens and user tokens can be rotated live without disconnecting active sessions. this allows credential rotation without service disruption.

rotate_token message

{
  "type": "rotate_token",
  "token": "new-argon2id-hashed-token",
  "user_id": null
}

if user_id is provided, the rotation targets that user's individual token in org_user_token. if null, it rotates the org-level token (org.token_hash / org.token_salt).

process

  1. the client sends a rotate_token message inside a signed envelope
  2. the server hashes the new token with argon2id and updates the database
  3. existing sessions remain active — the session key is independent of the org token
  4. future connections must use the new token to authenticate

security implications

token rotation invalidates all non-active connections. any client that hasn't pulled the new token will be unable to reconnect. this is useful after onboarding a new team member or after a suspected token leak.

one-shot connections

not every operation requires an authenticated session. the server supports one-shot connections where the client sends a single request, receives a response, and the connection is immediately closed.

one-shot operations

requestresponseauth requiredpurpose
node_id_reqnode_id_oknodiscover the server's identity for migration target verification
share_snapshot_reqshare_snapshot_datano (token-based)download a shared roadmap snapshot using a share token
migration_snapshot_reqmigration_snapshot_dataowner_tokenserver-to-server migration snapshot export
migration_finalize_reqmigration_finalize_okowner_tokenserver-to-server migration finalization

flow

client opens websocket
  |
  v
client sends single request (e.g. node_id_req)
  |
  v
server processes request
  |
  v
server sends response (e.g. node_id_ok)
  |
  v
connection closed (no sync loop entered)

these operations bypass the auth handshake entirely. the server checks for one-shot message types first, before expecting a hello. if the message doesn't match any one-shot type and the connection isn't authenticated, the server returns an error and closes.

resume & reconnect

clients can resume syncing from exactly where they left off after a disconnect. the server tracks per-peer state to avoid resending already-acknowledged changes.

peer state tracking

the sync_peer_state table stores the last acknowledged log id for each peer:

sync_peer_state (
  peer_id TEXT PRIMARY KEY,
  last_acked_id INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL
)

every time the server receives an ack message, it updates this table. when a peer reconnects and sends a hello with its last_log_id, the server takes the maximum of the reported id and the stored id to determine the resume point.

resume message

{
  "type": "resume",
  "after_log_id": 47
}

sent inside a signed envelope after authentication. tells the server to start sending changes from id > after_log_id in the sync log. this is useful when a client drops mid-session and reconnects without wanting to re-download the entire change history.

reconnect flow

  1. client reconnects and sends hello with its node_id and last known last_log_id
  2. server loads stored last_acked_id for that peer
  3. server responds with auth_ok containing after_log_id (the resume point)
  4. client can also send an explicit resume message to set a different starting position
  5. server streams changes from the resume point using the normal outgoing loop

auto-backfill

when the engine initializes, it calls ensure_logged_all_inserts() which backfills the sync log with any pre-existing data that wasn't tracked. this ensures that when a new peer connects for the first time, all existing data appears as changes in the log and gets synced.

trigger suppression

every registered table has sqlite triggers that automatically log changes to sync_log. during imports and incoming change application, these triggers must be suppressed to avoid creating duplicate or circular log entries.

mechanism

the sync_context table controls trigger activation:

sync_context (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  suppress_triggers INTEGER NOT NULL DEFAULT 0
)

every trigger includes the guard: WHEN (SELECT suppress_triggers FROM sync_context WHERE id = 1) = 0. when suppress_triggers = 1, all sync triggers are disabled without needing to drop and recreate them.

when triggers are suppressed

  • incoming change applicationapply_incoming() sets suppress_triggers = 1 at the start of the transaction and resets to 0 before commit. incoming changes are logged explicitly via insert_incoming_log()
  • snapshot importimport_snapshot() suppresses triggers while merging rows. the imported data is written directly without generating log entries — the assumption is that the importing node will receive future changes via normal sync

why not drop triggers?

dropping and recreating triggers inside a transaction is possible but adds complexity and requires knowing the trigger definitions at suppression time. the sync_context approach is simpler — the triggers always exist, they just check a flag before executing. the performance cost of the subquery is negligible compared to the write operations.

offline-first architecture

the allroads client is designed to function fully offline. all data is stored locally in a sqlcipher-encrypted sqlite database, and sync happens opportunistically when connectivity is available.

local database

the client maintains a complete local copy of the org's data using the same schema as the server. every edit — creating roadmaps, adding features, completing subtasks, reorganizing the org chart — is written to the local database immediately. the user experiences zero latency regardless of network conditions.

local change tracking

the client uses the same trigger-based sync log as the server. every local edit generates a row in sync_log via sqlite triggers. when connectivity is restored, the client pushes unsent changes (where sent = 0) to the server.

snapshot save

the client periodically saves local database snapshots for crash recovery. these snapshots are full copies of the database state at a point in time, allowing the user to revert to a known-good state if the application crashes or data gets corrupted.

sync modes

  • manual / one-shot — the user explicitly triggers a sync. the client connects, pushes all pending changes, pulls new changes, and disconnects
  • persistent — a background thread maintains a live websocket connection. changes are pushed and pulled in real-time as they happen. if the connection drops, the thread automatically reconnects and resumes from the last acknowledged position

conflict handling

when the client reconnects after being offline, it pushes all accumulated local changes. the server resolves conflicts using last-write-wins on updated_at. if the client's changes are older than what's already on the server, they're silently dropped. the client then pulls the server's current state.

keychain security

the client never stores encryption keys in plaintext on disk. instead, keys are stored in the operating system's native credential store via the keyring crate.

supported backends

osbackend
macoskeychain access
linuxsecret service (gnome-keyring / kwallet)
windowscredential manager

what gets stored

  • database encryption key — the sqlcipher key used to encrypt the local sqlite database
  • session keys — keys received from the server during auth, used for message encryption and signing

security properties

keys stored in the os keychain are:

  • encrypted at rest by the os credential store
  • tied to the user's login session — other users on the same machine cannot access them
  • not included in filesystem backups unless the backup explicitly captures the keychain
  • accessible only to the application that stored them (by service name)

change normalization

before sending outgoing changes to the server, the client normalizes them to handle naming inconsistencies and filter internal-only data that shouldn't be synced.

outgoing filters

normalize_outgoing_changes() performs two transformations:

  • org_owner removal — changes to the org_owner table are stripped from outgoing changesets. org ownership is managed server-side and should not be overwritten by clients
  • mode rename — the client uses "hierarchy" as the mode value, but the server expects "hierarchical". any outgoing org_settings change with mode: "hierarchy" is automatically renamed to "hierarchical"

populate_task_assignment_user_link_ids() ensures that outgoing task_assignment changes include the user_link_id field. if the change only has a user_id, the client looks up the corresponding link_id from the local org_user table and populates it. this ensures the server has both identifiers for access control and display.

why client-side?

normalization happens on the client rather than the server because the client has full context about the local data. the server is intentionally stateless about where changes come from — it just applies them. the client is responsible for ensuring its outgoing changes are well-formed and compatible with the server's expectations.

feature lifecycle

features and subtasks follow a defined lifecycle with timestamped state transitions. the client tracks these states and the server syncs them as part of the normal change stream.

feature states

statedescriptiontimestamp field
Plannedinitial state. feature is defined but work hasn't started
In Progresswork has started. client sets started_atstarted_at
Pausedwork was paused. client sets paused_atpaused_at
Completedwork is done. client sets completed_at and completed = 1completed_at

subtask states

statedescriptiontimestamp field
Plannedinitial state
In Progresswork startedstarted_at
Completedwork donecompleted_at

duration tracking

features support configurable durations via two fields:

  • days — estimated duration in days
  • weeks — estimated duration in weeks

these are used by the timeline view to render gantt bars with proportional widths. a feature with weeks: 2 renders as a bar spanning approximately two weeks on the timeline.

start dates

the start_date field allows scheduling a feature to begin on a specific date. in the timeline view, the gantt bar begins at this date. if no start date is set, the feature is rendered from the beginning of its quarter.

ordering

features and subtasks are ordered by sort_order. the client supports reordering via move up/move down operations. features can also be moved between quarters — moving up from the first position in a quarter moves the feature to the end of the previous quarter, and vice versa.

color coding

each feature and subtask has a color field (hex string, e.g. #FF9800). defaults are orange for features and grey for subtasks. the color is used for visual distinction in both the quarters board and the timeline view.

encryption model

allroads uses defense-in-depth with three independent encryption layers: at rest (database), in transit (messages), and at the transport layer (tls).

layer 1: database at rest

both the server and client databases are encrypted with sqlcipher using aes-256-cbc. the encryption key is applied via PRAGMA key on every connection:

PRAGMA key = 'your-encryption-key';
PRAGMA cipher_memory_security = ON;

cipher_memory_security = ON tells sqlcipher to lock decrypted pages in memory, preventing them from being swapped to disk. the database file on disk is always encrypted — without the key, it's unreadable.

  • server — key from SYNC_KEY env var (auto-generated if not set)
  • client — key stored in the os keychain, never written to disk

layer 2: message encryption

all post-authentication messages are encrypted with chacha20-poly1305 using the session key. the session key is a 32-byte random key generated by the server during the auth handshake and sent to the client in the auth_ok message.

properties:

  • ae — authenticated encryption. the ciphertext includes a 16-byte poly1305 tag that verifies both confidentiality and integrity
  • per-session keys — each connection gets a unique session key. compromise of one session doesn't affect others
  • random nonces — each message uses a fresh 12-byte nonce generated with OsRng
  • cbor encoding — messages are serialized to cbor before encryption, not json. this avoids padding oracle attacks that can exploit json structure

layer 3: message signing

every encrypted message is wrapped in a SignedEnvelope with an HMAC-SHA256 signature. the signature covers session_id : nonce : cbor(body). the monotonically increasing nonce prevents replay attacks — any message with a stale nonce is rejected.

layer 4: transport (tls)

the websocket connection itself can be wrapped in tls (wss). this provides an additional layer of encryption independent of the application-level crypto. even if tls is not configured (plain ws), the application data is still encrypted via layers 1-3.

snapshot encryption

full snapshots requested via snapshot_req are encrypted with chacha20-poly1305 using the session key before transmission. this means a network observer cannot read the snapshot contents even if tls is not in use. share snapshots (via share_snapshot_req) are sent unencrypted since the requester doesn't have a session key — use tls to protect these.

summary

layeralgorithmscopekey source
database at restaes-256-cbc (sqlcipher)entire database fileenv var / os keychain
message encryptionchacha20-poly1305per-message, per-sessionserver-generated session key
message signingHMAC-SHA256per-message, per-sessionsame session key
transporttls 1.2/1.3entire websocket connectionpkix certificate