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
wss://obsidian.st:59901connections 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
- clone and compile the allroads client:
cargo +nightly build/bundle --release - open the compiled application and switch to the org chart tab
- click create an organization, then enumerate your hierarchy and members
- disable the
offline modecheckbox in the 'Network' menu - you will be connected to
wss://obsidian.st:59901by default - to invite a user, right click a user on the org chart and press 'Generate User Token'
- users in your org then input this token in the 'Edit -> Organizations -> Join' menu
- 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
sync_logchangeset in a sealed envelopechangeset to other peers in the same orgauthentication
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 thehellomessage. 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 sessionnonce— monotonically increasing counter (replay protection)sig— HMAC-SHA256 signature oversession_id:nonce:cbor(body)body— aSealed { 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.
| type | direction | description |
|---|---|---|
hello | c→s | auth handshake. sends node_id, schema_version, last_log_id, token, optional owner_token, client_kind |
auth_ok | s→c | successful auth response. returns session_id, session_key, after_log_id, server_node_id, is_owner, user_id, user_role, is_ai |
node_id_req | c→s | request the server's node identity. one-shot, connection closes after response |
node_id_ok | s→c | returns the server's node_id |
snapshot_req | c→s | request a full encrypted snapshot. includes a reason field |
snapshot_data | s→c | snapshot response. data_b64 (encrypted db), hash (sha256), nonce_b64 |
generate_share_token | c→s | request a share token for a roadmap (owner only) |
share_token | s→c | returns the generated share token and roadmap_id |
share_snapshot_req | any | anonymous request for a shared roadmap snapshot using a token |
share_snapshot_data | s→c | returns the shared snapshot data_b64 and hash |
sealed | both | encrypted wrapper. contains nonce_b64 and data_b64 (chacha20-poly1305 ciphertext) |
changeset | both | contains a vec of Change structs and last_log_id |
rotate_token | c→s | rotate the org or user token |
ack | c→s | acknowledge received changes up to last_log_id |
resume | c→s | resume sync from a specific after_log_id |
ping | s→c | server heartbeat |
pong | c→s | client heartbeat response |
error | s→c | error message with a human-readable string |
migration messages
| type | description |
|---|---|
migration_start_req | initiates org migration. requires owner_token, old/new server urls, org_id, target_server_identity |
migration_start_ok | confirms migration started. returns org_id |
migration_snapshot_req | requests the migration snapshot from the old server |
migration_snapshot_data | returns data_b64 and source_logical_hash |
migration_finalize_req | finalizes migration with target_logical_hash for verification |
migration_finalize_ok | confirms 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 versionsync_context— controls trigger suppression during importssync_peer_state— tracks the last acknowledged log id per peersync_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:
- read the existing row's
updated_atvalue from the database - compare it against the incoming change's
updated_at - if
incoming >= existing, apply the change - 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_atwins, 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_chartuses 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
organization data
auth & session data
sharing & migration
audit
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
| role | permissions |
|---|---|
owner | full access. can generate share tokens, manage migration, rotate tokens, manage users, delete the org. authenticated via the owner_token in the hello message |
admin | can manage org users, org chart, and org settings. cannot delete the org or initiate migration |
leader | can manage org chart relationships. read/write access to roadmap data |
member | can 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 ifentity_id == org_idorg_settings,org_user,org_owner,org_chart,org_roadmap,org_roadmap_editor— filtered bypayload.org_idroadmap— visible if linked to the org viaorg_roadmapquarter— visible if itsroadmap_idis linked to the orgfeature— visible if its quarter's roadmap is linked to the orgsubtask,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:
- write incoming bytes to a temp file
- open the temp db with the encryption key
- if the snapshot has an org scope, clear the corresponding data in the target db
- for each registered table, merge rows using update-then-insert strategy
- triggers are suppressed during import
- 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
- start — the client sends
migration_start_reqto the new server with the owner token, old/new server urls, org id, and the new server's identity - snapshot request — the new server connects to the old server, requests a migration snapshot using
migration_snapshot_req - 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 - finalize on old — the new server sends
migration_finalize_reqto the old server with the target hash for verification - retention — the old server marks the org with a
delete_after_unixtimestamp (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 changesetchange_id— the unique change identifierbefore_snapshot_json— the row state before the changeafter_snapshot_json— the row state after the changerollback_op— the operation needed to reverse the changerollback_payload_json— the data needed to perform the rollback
rollback plans
| operation | rollback op | rollback data |
|---|---|---|
insert | delete | none (entity_id is sufficient) |
update | update | before_snapshot (restores original values) |
delete | insert | before_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
| parameter | value | description |
|---|---|---|
AUTH_WINDOW_SECONDS | 60 | sliding window for counting attempts |
AUTH_MAX_ATTEMPTS_PER_IP | 30 | max auth attempts per ip within the window |
AUTH_HANDSHAKE_TIMEOUT_SECONDS | 10 | connection dropped if not authenticated within this time |
MAX_CONNECTIONS | 256 | max 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, deploymentmobile— design, architecture, auth, features, notifications, submission, launchapi— 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
| variable | default | description |
|---|---|---|
SYNC_DB_PATH | sync.db | path to the sqlcipher database file |
SYNC_KEY | auto-generated | database encryption key. if empty, a random 32-byte base64 key is generated and logged |
SYNC_LISTEN | 0.0.0.0:59901 | listen address and port |
SYNC_TLS_CERT_PATH | none | path to the tls certificate pem file. both cert and key must be set to enable tls |
SYNC_TLS_KEY_PATH | none | path to the tls private key pem file |
server constants
| constant | value | description |
|---|---|---|
MAX_MESSAGE_BYTES | 1,048,576 | max websocket message size (1 mb) |
MAX_CHANGESET | 500 | max changes per changeset |
MAX_ORGS | 128 | max organizations per server |
MAX_CONNECTIONS | 256 | max concurrent connections |
MIGRATION_RETENTION_SECONDS | 604,800 | 7 days — time before migrated org is deleted from old server |
CLEANUP_INTERVAL_SECONDS | 60 | how often expired migrations are cleaned up |
OUTGOING_CHECK_MILLIS | 500 | how often the server checks for outgoing changes |
HEARTBEAT_SECONDS | 25 | heartbeat interval when no changes are pending |
AUTH_WINDOW_SECONDS | 60 | rate limit window for auth attempts |
AUTH_MAX_ATTEMPTS_PER_IP | 30 | max auth attempts per ip per window |
AUTH_HANDSHAKE_TIMEOUT_SECONDS | 10 | connection timeout for unauthenticated peers |
deployment
requirements
- rust toolchain (for building from source)
- linux server with tls support
- port
59901open 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
- the client sends a
rotate_tokenmessage inside a signed envelope - the server hashes the new token with argon2id and updates the database
- existing sessions remain active — the session key is independent of the org token
- 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
| request | response | auth required | purpose |
|---|---|---|---|
node_id_req | node_id_ok | no | discover the server's identity for migration target verification |
share_snapshot_req | share_snapshot_data | no (token-based) | download a shared roadmap snapshot using a share token |
migration_snapshot_req | migration_snapshot_data | owner_token | server-to-server migration snapshot export |
migration_finalize_req | migration_finalize_ok | owner_token | server-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
- client reconnects and sends
hellowith its node_id and last knownlast_log_id - server loads stored
last_acked_idfor that peer - server responds with
auth_okcontainingafter_log_id(the resume point) - client can also send an explicit
resumemessage to set a different starting position - 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 application —
apply_incoming()setssuppress_triggers = 1at the start of the transaction and resets to0before commit. incoming changes are logged explicitly viainsert_incoming_log() - snapshot import —
import_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
| os | backend |
|---|---|
| macos | keychain access |
| linux | secret service (gnome-keyring / kwallet) |
| windows | credential 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_ownertable 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 outgoingorg_settingschange withmode: "hierarchy"is automatically renamed to"hierarchical"
task assignment link resolution
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
| state | description | timestamp field |
|---|---|---|
Planned | initial state. feature is defined but work hasn't started | — |
In Progress | work has started. client sets started_at | started_at |
Paused | work was paused. client sets paused_at | paused_at |
Completed | work is done. client sets completed_at and completed = 1 | completed_at |
subtask states
| state | description | timestamp field |
|---|---|---|
Planned | initial state | — |
In Progress | work started | started_at |
Completed | work done | completed_at |
duration tracking
features support configurable durations via two fields:
days— estimated duration in daysweeks— 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_KEYenv 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
| layer | algorithm | scope | key source |
|---|---|---|---|
| database at rest | aes-256-cbc (sqlcipher) | entire database file | env var / os keychain |
| message encryption | chacha20-poly1305 | per-message, per-session | server-generated session key |
| message signing | HMAC-SHA256 | per-message, per-session | same session key |
| transport | tls 1.2/1.3 | entire websocket connection | pkix certificate |