Repo-specific instructions. Personal conventions live in ~/AGENTS.md. Design rationale
lives in docs/DECISIONS.md (§0–§12) — cite it by section, never restate it here.
ssync syncs AI-agent session files (pi, omp; claude-code and codex newest-wins) between a user's machines: leaderless p2p via iroh, age-encrypted at rest, no server to run. Rust workspace (edition 2024), single binary + daemon.
Impure shell around pure decision cores:
ssync(bin) is the composition root: config → age identity → irohNode→ adapters →Engine::run.Engine::run(ssync-core) is onetokio::select!loop. Event sources: notify fs watcher,Node::signalswake pings (iroh-docs LiveEvents are drained behind the net seam on a dedicated task — bounded subscriber channels deadlock the iroh-docs actor — and peers learned on the live stream are recorded by the node itself), 15s rescan, 60s resync. FS and index events funnel into a 400ms-debounced tick; the rescan ticks directly, the resync only re-initiates peer sync.- Each tick:
local_snapshot+index_snapshot→ purereconcile()→Vec<Action>{Import, WriteFile, DeleteLocal, Tombstone, Merge} → execute → settle intoSyncState(atomic temp+rename todata_dir/state.toml). SessionFilesystem(ssync-coresession_filesystem.rs) owns adapters, bounded discovery, wire-key ↔ local-path translation, path-map resolution, freeze verdicts, and contained read/write/delete. Engine never touches adapters or filesystem policy directly; tests cover the interface without iroh.- Import path: file → age-encrypt (subprocess) →
Node::publish(blob + index entry keyed{agent}/{relative_path}; the ONLY write path — temp-tag prevents the GC race). - Export path: index entry →
Node::blob(local miss → bounded fetch from peers behind the net seam; iroh-docs never retries a missed download, upstream iroh-docs#88) → decrypt → atomic temp+rename write-back. - Conflicts: winner = newest entry, selected once behind the net seam (
IndexRecord); append-only formats (pi/omp) get the deterministic lossless line-union merge, others newest-wins. The merge verdict is all-or-skip: a partial version set never merges. - Per-key errors are logged (
eprintln!("ssync: …")) and retried next tick; one bad key never kills the daemon. - Pairing is two mechanisms: the cluster artifact (
ssync cluster init/join/add/rm/show; one secret file = namespace secret + recipients + node-ids,cluster_pathin config — the recommended mode; clan assembles the same file at service start viassync cluster render) and ad-hoc ticket exchange (ssync ticket/join; the ticket embeds direct addresses).recipientsset (ticket mode only) = per-machine age identities with multi-recipient encryption; empty = one shared identity. Recipient-set or namespace changes re-publish/evict (issue #22).
- Store session files as opaque bytes (§2). Reading a single header field is fine; parsing entry lines is not.
- Watch-and-import, never sync-in-place (§10). Read from the agent's dir; write back atomically (temp + rename). Never symlink/bind it into the engine.
- Encryption (age) is not optional and not deferrable (§7).
- Leaderless (§4): no code path may assume a special/authority node. User runs no server (§6): iroh public defaults plus mDNS local discovery (LAN peers dialable by node-id alone; tickets additionally embed direct addresses).
- iroh, iroh-docs, iroh-blobs, and age move fast: when unsure of a current API, read the crate's actual docs/source — never code from memory.
crates/ssync/— binary: clap CLI (init/daemon/cluster/ticket/join/status/conflicts/search/cleanup/service/cleanup-timer/keygen-node/keygen-namespace) + daemon wiring;src/main.rsdispatches.systemd.rsowns the secure unit lifecycle shared by the explicit renderers inservice.rsandcleanup_timer.rs; cluster artifact commands live incluster.rs.crates/ssync-core/—Config,Engine,StatusReport; pure decision cores live in their own submodules.crates/ssync-net/— iroh endpoint/router/docs/blobs/gossip setup,Node,IndexRecord(the only place winner selection happens), blob GC.crates/ssync-crypto/—AgeIdentity: PQ-hybrid keys via theage/age-keygenCLI; Rustage-crate backend disabled behind featurerust-ageuntil it gains ML-KEM.crates/ssync-adapters/—Adaptertrait +adapter_forfactory. New agent = oneimpl Adapter+ oneadapter_forarm ("pi" | "omp"sharePiAdapter; claude-code and codex have their own impls, newest-wins permanently by policy — DECISIONS §8, #25).nix/— package, devshell, treefmt, checks (incl. three NixOS VM tests), NixOS/HM/clan modules, nixbot effects.docs/— DECISIONS.md, identity.md, pairing.md, setup.md, threat-model.md, pi-format-notes.md, claude-code-format-notes.md, codex-format-notes.md, m3-cross-network.md, vs-syncthing.md, logos/.- Deferred work lives in the GitHub issue tracker, not a repo file; DECISIONS.md carries the rationale per item.
.tmp/— gitignored scratch handed over by the user; never commit or push it. (Tests do not use it; they create dirs under the system temp dir.)
direnv (.envrc = use flake) auto-loads the devshell: locked toolchain + age CLI
(needed by ssync-crypto tests) on PATH. Without direnv, prefix nix develop -c.
cargo clippy --workspace --all-targets -- -D warnings # lint
cargo test --workspace # tests
nix fmt # format (treefmt); verify: nix fmt -- --ci
nix build .#default # package (→ ./result/bin/ssync; checkPhase runs tests)
nix flake check # full CI gate (package, formatting, vm-sync, vm-mdns, vm-module)The nix build serializes tests (--test-threads=1; parallel in-process iroh nodes starve
each other under CI load) — mimic with cargo test -- --test-threads=1 when two-node
tests flake locally. Never nix shell nixpkgs#cargo … — registry nixpkgs drifts from
flake.lock. CI is nixbot building the flake checks (plus the nixbot effects in
nix/effects.nix); there are no GitHub Actions.
- Errors:
anyhoweverywhere (Contextwith file paths,bail!,ensure!); nothiserror— nothing matches on error variants. Daemon paths skip/reset malformed inputs instead of failing; per-key errors are logged and retried next tick. - Async: tokio (full); one
select!event loop;futures_lite::StreamExt. The unbounded event channels are a documented iroh-docs workaround, not a general preference. Blockingstd::fsis fine in CLI, pure-core, and state code. - DI: constructor injection only (
SessionFilesystem::new(SessionFsConfig)→Engine::with_filesystem(SessionFilesystem, AgeIdentity, Node)); no globals or singletons. Test seams are named production methods (set_resync_interval,spawn_with_gc,disable_auto_download), not mocks. - Modules: split by concept — a pure decision core gets its own submodule with its own
unit tests; no
util/helpersgrab-bags. - Naming:
cmd_*CLI handlers,settle_*state transitions,*_snapshotreconcile inputs,*_ofderived-view lookups. - Comments: why not what; rustdoc summary first line. Rationale is a pointer, not
prose: cite
DECISIONS §Nor the upstream issue for non-obvious invariants. If a diff adds more comment lines than code lines, cut comments; delete redundant comments when touching a file. - Secrets: age identity and CLI-generated keys are written 0600 (
write_secret), and loading refuses group/world-readable key files;SecretFileputs temp key material in$XDG_RUNTIME_DIR(tmpfs) and removes it on drop. Never commit keys (*.key,age.key,node.keygitignored). - State files under
data_dir:state.toml(SyncState),status.toml(mtime = daemon liveness),node.key,namespace/ticket/remote-ticket,blobs/,docs/.
- Single source of truth:
[workspace.package] versionin rootCargo.toml;nix/package.nixreads it vialib.importTOML— never hardcode elsewhere. All crates and dep versions inherit from the workspace tables (workspace = true); the iroh stack is deliberately pinned (0.x iroh-* crates minor-pinned;irohitself is 1.x, major-pinned). - Pre-1.0: feature/breaking → MINOR bump; bugfix/internal → PATCH; MAJOR stays 0 until
the user declares 1.0. Bump once per user-visible-behavior task; pure refactor/docs/CI
need no bump. After bumping, run
cargo checksoCargo.lockupdates; commit the lockfile. - Version bumps are manual; releases are not: the
releasepush effect innix/effects.nixruns after nixbot's build goes green on a main push and tagsv0.x.y+ creates the GitHub release if the workspace version has no tag yet. Never tag by hand. Release notes are--generate-notes: only merged PRs between tags appear, direct pushes to main are invisible — route user-visible work through PRs.
- Colocated jj + git; use jj. Remote
origin=ssh://git@github.com/fosskar/ssync.git. - Do not commit or push unless the user explicitly says so. Stage in the working copy and wait for the go-ahead.
- Commit messages: linux-kernel style, area prefixes (
core:,net:,nix:,cargo:), no trailers, repo terminology.
crates/ssync/src/main.rs— CLI + daemon wiring, entry point.crates/ssync-core/src/reconcile.rs— pure sync decision core; most invariants live here.crates/ssync-core/src/lib.rs—Engine, the event loop.crates/ssync-core/src/config.rs—Config($XDG_CONFIG_HOME/ssync/config.toml,~/expanded per-machine).crates/ssync-net/src/lib.rs—Node,publish,blob,signals,IndexRecord.crates/ssync-adapters/src/lib.rs—Adaptertrait +adapter_for(extensibility point).Cargo.toml(root) — workspace version + all dep versions.flake.nix+nix/— the whole build/CI surface.
- Toolchain from nixpkgs-unstable via the devshell (plain nixpkgs rust, no
rust-toolchainfile). Edition 2024. - Format with
nix fmt(treefmt: rustfmt, nixfmt, taplo, deadnix, statix) — not ad-hoccargo fmt/nixfmt. A missing formatter/linter belongs innix/treefmt.nix, not ad hoc. age/age-keygenwith PQ support (-pq, age ≥1.3) required on PATH at runtime and in tests; the devshell and the nix package wrapper both provide it.- Nix conventions: flake tracks nixpkgs-unstable; package/devshell live in
nix/viacallPackage, not repo root; don't scaffold speculative modules.
TDD is mandatory for new behavior:
- Write a failing test first; confirm it fails for the expected reason.
- Minimum code to green; refactor with clippy
-D warningsand fmt clean. - Never delete or weaken a test to make it pass.
- Prefer deterministic in-process tests; reproduce any bug with a failing test first. For sync, use in-process iroh nodes wired by direct addresses in one test process — no real networking, no relay.
Layout and patterns:
- Plain
cargo testharness (#[test]/#[tokio::test]); zero third-party test deps. Scratch dirs are hand-rolled understd::env::temp_dir(), scoped by pid + tag. - Unit tests live inline (
#[cfg(test)] mod tests) next to the code; atests/dir only where cross-crate or network integration is needed. crates/ssync-core/tests/multi_node_sync.rs— the main integration suite: two/three in-process nodes through the productionEnginepath. Await convergence by polling (eventually(), 500ms steps), never fixed sleeps; drive all mutation throughtick_once()/run().crates/ssync-core/tests/event_flood.rs— 1500-session deadlock regression.crates/ssync-core/tests/recipient_rotation.rs— issue #22 regressions: recipient-set change re-publishes every session; namespace rotation drops the stale replica.- Failure injection via production hooks:
disable_auto_download()(simulates missed downloads), bogus peer addrs,set_resync_interval. - Pure-core changes: extend the inline unit tests (
reconcile, divergence/merge, cleanup selection) first — they cover the invariants cheaply; two-node tests are for the wiring. nix flake checkadditionally runs three NixOS VM tests (vm-synce2e over virtual LAN,vm-mdnsnode-id-only pairing via mDNS,vm-moduleservice/hardening test).
pi stores sessions at ~/.pi/agent/sessions/<encoded-cwd>/<ts>_<sessionId>.jsonl, one
append-only JSONL file per session, header record (version:3, id uuidv7, cwd) on
line 1 (omp: line 2, after a type:title record). pi keys on the absolute cwd, so by
default a synced project must live at the same absolute path on every machine; the
opt-in [[path_map]] bridges differing paths by rewriting exactly the header cwd and
the encoded dir name (DECISIONS §3 amendment, map #42). Derive PiAdapter identity from
the path alone; treat <encoded-cwd> as opaque (pi and omp encode differently — the
path-map encode/decode in pi.rs is the sanctioned exception). omp uses the
same layout at ~/.omp/agent/sessions and adds a per-session artifact dir
(<encoded-cwd>/<ts>_<sessionId>/: subagent transcripts, __advisor.jsonl) whose files
are part of the session and sync with it (identity from the artifact dir name — DECISIONS
§9). pi appends in place (no temp+rename), so
imports must stay debounced. Full reference: docs/pi-format-notes.md (re-verify against
the installed pi version before relying on it).