The rule F1's sweep applied across Notes, the player, document detail and the readers: a buffer carries its record's id, a load into a selection is held rather than detached, and work that can't be canceled checks a generation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 KiB
This file provides guidance to Claude Code (claude.ai/code), Cursor, and any other Agentic coding tools when working with code in this repository.
Project Overview
An early-stage personal server runtime/overlay OS: a decentralized personal server instantiated across many nodes (phone, desktop/laptop, browser extension, headless cloud server — arbitrarily many per user) that sync personal documents and data directly with each other. That includes files (PDFs, music/video) and metadata like podcast/video playback position or which social feeds (Twitter/X/BlueSky/Mastodon) have been read or archived.
Inspired by Urbit's planet/moon model, but with no central planet — every node is effectively a moon. Each node has its own keypair to sign/encrypt messages, plus access to a global keypair that represents ownership of the node-set as a whole. The goal is encrypted, authenticated communication between arbitrary sets of nodes.
Project Status
Experimental — no backwards-compatibility constraints. Breaking changes to serialization formats, database schemas, wire protocols, key derivation salts, and CLI interfaces are all acceptable. That includes not writing migrations: when a schema changes, an existing store is manually deleted and recreated, not converted in place, so no code rewrites old rows on open or keeps a reader for a retired column, table, or artifact kind.
Design Principles
These hold across every agent and every modality (gpui desktop, Android, TUI, CLI). They are not a backlog — a change that violates one of them is not finished, whatever else it does.
- A sync must never disrupt what the user is doing. Data arriving from
another node is the normal case in this system, not an event, and it can land
at any moment — mid-sentence, mid-scroll, mid-playback. It must never move
what is under the user's cursor, replace text they are editing, reorder a
list they are pointing at, or reset a position they chose. When an update
arrives for something the user is actively in, alert, don't apply: say so
quietly and in place (a marker on the thing, not a modal), keep the incoming
version aside, and change what's on screen only when the user asks for it —
or when they leave and come back, which is itself an explicit act.
- The alert has to name what the user gives up, because with whole-buffer writes both answers lose something: taking the incoming version drops edits made since it arrived, and keeping the local one is written as a diff against the merged text and so removes the other node's edit from the document everywhere. Neither may happen by default.
- While a change is pending, stop writing over it. Autosave, flush-on- close, and any other background write must pause until the user has chosen; a save that silently wins the conflict is the same bug wearing a different hat.
- Worked examples, one per modality:
rust/gui-app/src/agents/notes/mod.rs(check_open_note/render_incoming_body_banner),rust/tui-app/src/notes_screen.rs(on_documents_changed, and the editor's:e), and on Androidui/IncomingBody.kt, whose doc comment is where the rule for telling their write from ours is written down. - The two shared pieces to reach for rather than rebuild: the desktop's
components::sync_guarded::SyncGuarded<T>(ADR 0031) and Android'sui/IncomingBodyplusui/IncomingBodyStrip, whose words come fromcopy::incoming_change::Subject— a new subject is a variant there, not a sentence on a screen.agents/clippingsis the shortest end-to-end reading of the whole shape on both.
- Absence is not an error. A document whose
content lives on another node is a state to render, not a failure and not a
blank. See
docs/data-model.mdand ADR 0030 §4: offering an empty buffer for text that demonstrably exists elsewhere invites the user to retype it, and saving that forks the document. - Editing is a mode, and the mode is the whole screen. A screen that shows
a record either reads it or edits it. Entering edit mode makes every field
on it editable at once; nothing is written until an explicit Save, and Cancel
discards the lot and returns to reading. There is no per-field save, no
section that commits on its own, and no control that persists the moment it
is touched.
- This holds for lists and collections on the form as much as for its plain
fields, however the store happens to keep them. "Each row is its own
artifact, so there's nothing to batch behind Save" is a fact about storage,
not a license to write one — it makes Save mean two different things on one
form, and it leaves the user unable to back out of a removal. Stage the
edit and diff it against what is persisted when Save runs; the Humans
screen's
diff_staged/diffStagedis the shape. - Discarding a staged edit and re-reading the record are the same act, so
they should be one code path. Watch for state that lives outside the
screen's own struct — the desktop's text fields are
TextInputentities, so a Cancel that only dropped the staged collections would leave a canceled rename on screen. - Not everything on a screen is part of its form. A thing that is its own document — Humans' interactions, shared with their other attendees — is written when the user says to write it, and lives outside the mode. The test is whether it is a field of this record or a record of its own.
- A preferences screen is not a record. Settings is a set of independent choices — a theme, a zoom level, a timezone, a sync policy — each meaningful on its own, and some only judgeable once seen applied. So a control there applies the moment it is touched, the way every platform's settings do, and a free-text field (a node name, a credential) commits on its own Save or Enter rather than per keystroke. The test is whether the values describe one thing the user would want to back out of together, or are separate choices that only share a screen.
- Worked examples:
rust/gui-app/src/agents/human.rs(DetailMode,reload_detail,save_current) and Android'sHumanDetailScreen.kt.
- This holds for lists and collections on the form as much as for its plain
fields, however the store happens to keep them. "Each row is its own
artifact, so there's nothing to batch behind Save" is a fact about storage,
not a license to write one — it makes Save mean two different things on one
form, and it leaves the user unable to back out of a removal. Stage the
edit and diff it against what is persisted when Save runs; the Humans
screen's
- The instant-feedback invariant: anything that might not complete promptly
returns control to the UI immediately and becomes a tracked Job. Stated in
full in
docs/command-palette-and-search.md§4.1. - Waiting on the network never occupies a UI worker thread. gpui's
background executor is a fixed pool shared by every screen; a thread parked
in
Handle::block_onwaiting on a peer is a thread the rest of the UI can't have, and enough of them is a dead window. On the desktop app, network work goes to the tokio runtime viagui-app/src/network_task.rsand is awaited from the gpui task — local store reads and decoding stay on the background executor. Any lib function that reaches the network should beasyncrather than taking atokio::runtime::Handleto block on. This is not per-agent: it holds for every agent that opens something living on another node. - A result knows what it was for. Anything a screen loads, saves or plays
in the background can finish after the user has moved on — another note
selected, the document closed, a different track started — and it must not
land on whatever is selected now.
- A buffer carries the id of the record it belongs to. A save takes its
destination and its text from the same value, never the destination from a
separate "selected" field. A stale apply can then at worst show the wrong
text for a moment; it can never write one record's text into another, which
then syncs everywhere. That is the bug Notes'
OpenNoteclosed: a slow load of note A installed A's body under B's title, and the next keystroke autosaved it into B. - A load applied into a selection is held, not detached. Keep its
Taskin a field (_load_task): assigning the next one drops the old, and gpui cancels a droppedTask, so the latest selection wins by construction rather than by a "still selected?" check each async step has to remember. Compose gets the same fromLaunchedEffect(key), which cancels the previous coroutine when the key changes. - Where the work can't be canceled because it has already handed something
off, check a generation at the point it applies —
player_controls'play_seq, compared under the audio lock. A result applied by id looks its target up by that id (detail_view'sdetail_for) rather than writing into whatever is open.
- A buffer carries the id of the record it belongs to. A save takes its
destination and its text from the same value, never the destination from a
separate "selected" field. A stale apply can then at worst show the wrong
text for a moment; it can never write one record's text into another, which
then syncs everywhere. That is the bug Notes'
Build Commands
This project runs on machines with different capabilities — GUI session,
Android Studio/NDK, Nix — some have all of them, some (CI VMs, sandboxed
agent environments) have none. Run just doctor at the start of a
session rather than assuming from a previous session or another machine;
it reports what's available and which command each finding implies.
The justfile is the canonical source of project commands — run just --list
to see everything available, grouped and documented, and prefer just <recipe> over re-deriving the raw cargo/gradlew incantation. A component
with recipes of its own carries its own justfile, declared as a mod in the
root one and listed as <name> ... rather than expanded; just --list <name>
shows what's inside (currently just extension, for the browser extension).
Recipes set up environment variables (NDK toolchain paths, JAVA_HOME,
RUSTFLAGS) that a bare cargo/./gradlew invocation won't have and will
otherwise fail or silently produce a different artifact from what CI/other
developers build.
- Android/Kotlin: see
android/CLAUDE.md— the three toolchain cases fromjust doctor,just android-build/just gradlew <task>/just build-android-nix, running tests (just unit-tests-kotlin), and the two detekt recipes (lint-kotlinvslint-kotlin-fast). - Rust:
just build-synchronicity-lib-androidbuilds the synchronicity_lib crate for Android with the correct NDK linker/target env vars set — don't substitute a barecd rust && cargo build --target aarch64-linux-android --release, it lacksTARGET_CC/TARGET_AR/RUSTFLAGSand will fail to link. For a native (non-Android) build usejust build-native/just build-native-release. - GUI (rust/gui-app, the gpui desktop app): see
rust/gui-app/CLAUDE.md— iterate withjust check-gui-nix, usejust build-gui-nixfor final verification, and whyrust-analyzer.jsonsetscheckOnSave: false. - Run tests: see the justfile "test" group recipes; for example
just unit-tests-rust. - Lint:
just lint-rust(clippy, whole workspace) andjust lint-kotlin(detekt — no baseline, any finding fails the build; see "Two Kotlin lint recipes" inandroid/CLAUDE.mdfor the two variants).lint-rustcovers the whole workspace includinggui-app, so on a machine without the GUI's system libraries it dies in a native build script before linting anything — runjust lint-rust-nixthere instead.just lint-rust-fixapplies clippy's mechanical suggestions (review the diff; it is mechanical, not automatically right). Three separate, compile-free checks cover classes clippy cannot see:just lint-gui-layout(a gpui sizing bug — see the Code Style entry onflex_1plusmax_w),just lint-p2p-admission(a protocol served without the delegation-cert handshake), andjust lint-sql-params(a SQL query carrying a value as text instead of as a?— see the Code Style entry below). Each script's module docstring carries its full reasoning and its escape hatch.
Code Style
- Use American spelling in user-facing copy and documentation (color, not
colour; behavior, not behaviour; canceled, not cancelled; catalog, not
catalogue) — consistently, throughout the codebase. Leave alone any spelling
that comes from outside the project: a fixed external API/library name (e.g.
Kotlin's
kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3'slabelLarge), a URL, or a third-party wordlist (rust/lib/src/ensemble/eff_large_wordlist.txt) — those aren't ours to respell. - Errors: Use typed errors with meaningful messages. In rust, prefer
thiserror. Avoid using .unwrap() or .expect() in non-test code, and fix uses of these or other panicking functions when you encounter them. - the justfile codifies recipes that run standard Rust and Kotlin formatters (just fmt-kotlin, just fmt-rust). Run these tools to keep code formatted on every commit.
- don't generate code that has fully-qualified type names (e.g.
documents::Artifacts::new()); instead prefer touse documents::Artifacts,Artifacts::new() - GUI frameworks (GPUI or Jetpack Compose): Prefer to create components built out of functional subcomponents with meaningful names, instead of long components with no internal structure. Prefer to define named constants instead of using the same color, spacing, etc. values in-situ. Don't define default values for properties unless necessary. Prefer to create text elements that have copy-able text;
- When displaying hex-encoded hashes or UUIDs, use standard UI components (depending on modality) which display a truncated hash but make the full hash available if the user requests it.
- A truncation of human-readable text cuts from the middle, never the end.
What tells two long strings apart is as often at the end as at the start —
Quarterly report - draftagainst- final, an episode number, a file extension — and an end ellipsis drops exactly that.- A cut made by character count is
copy::elide_middle. The TUI'snotes_screen::elideis the same rule in terminal columns, and the browser extension'ssrc/elide.jsis it restated in JS, since the extension cannot call the lib. - Ids and hashes are the exception: a shortened id keeps its prefix
(
3fa9c1d2…), the way git shortens a commit hash. An id is random, so its end is no more distinguishing than its start, and the prefix is the conventional handle — the part people read aloud, compare and type. The full value stays one action away through the hash-chip component (see the bullet above). The helpers arecopy::short_id/copy::truncate_id. - A cut made by layout uses the toolkit's middle ellipsis:
components::truncate::TruncateMiddle::truncate_middle()on gpui rather than.truncate(),TextOverflow.MiddleEllipsisin Compose, and the extension'sfitMiddlewhere CSS would have saidtext-overflow: ellipsis. - The one exception is a multi-line clamp (
maxLines = 2and up): Compose only supports a middle ellipsis on a single line and falls back to a hard clip, so those keepTextOverflow.Ellipsis.
- A cut made by character count is
- gpui sizing: no raw
px()in render code. Font sizes, icon/control sizes, panel widths, and gaps come fromgui-app'ssizingmodule (font::,icon::,control::,layout::), orsizing::scaled(..)for a genuine one-off. These areRems, resolved against the window's rem size at layout time, so they track the UI zoom control — a hard-codedpx()doesn't and silently breaks zoom for whatever it sizes. gpui's rem-based Tailwind helpers (.p_4(),.gap_2(),.text_xl()) are fine for padding/margin/gap; reach for a named token only when an existing one doesn't already fit — the module's job is few sizes, not one per value used.px()is still correct for things genuinely in device pixels: hairline borders (px(1.)), themin_h(px(0.))flexbox idiom, measured on-screen bounds, scroll offsets, and rasterizer inputs (math/PDF/typst renderers). Convert explicitly with.to_pixels(window.rem_size())rather than assuming a 1:1 scale — the mismatch is invisible at 100% zoom and wrong everywhere else.
- User-visible copy lives in
synchronicity-lib'scopy.rs, not in a screen. Any string a user reads — a heading, an explainer, a button word, a confirmation, an outcome sentence — is written once there and imported by every modality that shows it: gpui desktop and TUI and CLI directly, Android through a#[uniffi::export]record inffi/free_fns.rs(placeholder_copyandaction_labelsare the shape). A frontend that retypes a sentence is a frontend that will disagree with the others about it, and the disagreement is invisible until someone opens two of them side by side.- This is not only about literal duplication. Copy that is paraphrased across modalities is the same defect and harder to spot: "a node retired with nobody listening shows a record" versus "…prints a record" is two answers to one question. If two screens are explaining the same thing, they get the same words from the same constant.
- The derivation belongs there too, not just the strings. Where the words
depend on state — which of three sentences a fetch shows, whether a count
reads "all" or "3 of 40" — the function that picks lives in
copy.rsand the screen renders what it returns.copy::content_fetchis the worked example: desktop and Android had each grown their own state machine and disagreed about what "connecting" meant. - Keep it grouped by subject (
copy::revocation,copy::content_fetch,copy::action) rather than by screen, since one subject reaches several screens on several modalities.
- Typefaces come from the
typefacemodule, never a family-name string.rust/gui-app/src/typeface.rsand Android'sui/theme/Typeface.ktare the only two places a font family is named; screens ask for a role (UI,MONO,prose). ADR 0024 has the full reasoning — short version: a CSS generic like.font_family("monospace")silently resolves to nothing in gpui, and a variable font loaded directly collapses to one weight, so vendored faces are always static per-weight instances.scripts/vendor- fonts.shvendors them intoassets/fonts/, shared by both modalities; the set exceeds jj's 1 MiBsnapshot.max-new-file-size, so a fresh clone needsjj config set --repo snapshot.max-new-file-size 64MiBor the fonts stay untracked. To reconsider a face, usedesign/typeface-specimen/rather than eyeballing it — it renders candidates on real content and measures them. - Never write an ALPN's version number outside the constant that holds it.
Every P2P protocol's ALPN carries a version suffix, and it moves whenever
the wire shape does — this project has no backwards-compatibility
constraint, so bumps are routine and cheap. A number copied into prose is
therefore wrong from the next bump onward, and nothing fails when it goes
stale: it is a comment. Write
/syn/doc-sync/…and name the constant (DOC_SYNC_ALPN) for anyone who needs the live value. This holds for docs, ADRs, and doc comments alike — including the module doc of the file that defines the constant, which is not close enough to stay correct on its own.p2p/revocation_delivery.rshad drifted so far its header named a protocol,/syn/revocation, that does not exist.- Say that an ALPN was bumped where the bump is the point — a change to what a message means, rather than a new field, is exactly what a bump is for and worth recording. Just don't say which number it landed on.
- A value goes in a
?, never into the SQL string. Column names, table aliases, and a run of?, ?, ?for a variable-lengthINlist are this codebase's own text and are fine to interpolate; anything that is data — a timestamp, an id, a search term, an enum'sas_str()— is bound.stores::document::SyncScopeis the worked example: it hands back a predicate and the values for it together, so there is no way to use it that puts a caller's string into SQL text.placeholders(n)is the one blessed way to build a list of?s.just lint-sql-paramsenforces this (scripts/lint-sql-params.py). It flags an interpolation wrapped in SQL quotes ('{x}') or sitting where only a value can go (after a comparison, afterLIMIT), in any file that mentions rusqlite. A considered exception takes a// sql-literal-ok: <reason>anywhere in the same statement; the reason is mandatory, and the bar for one is that a parameter genuinely cannot go there (SQLite will not bind an identifier).- The reason it wants a linter rather than a rule of thumb is that the bug
is invisible at the call site: whether
format!(" AND x >= '{s}'")is a security hole depends entirely on wherescame from. One shipped —SyncRequest.sinceis chosen by whichever ensemble member is asking to sync, and reached theWHEREclause verbatim.
design/is for explorations you run to make a visual decision — neitherdocs/(material you read) norscripts/(build, CI, vendoring). Nothing in it is on a build path and the app must never depend on it; the outcome of an exploration belongs in an ADR. Seedesign/README.mdbefore adding one.- Scroll containers need
min_h(px(0.)). Aflex_1child of a flex column defaults tomin-height: auto(its content height), so it grows past the window instead of shrinking to fit — and.overflow_y_scroll()on it never gets a bounded height to scroll within, leaving content stranded off the bottom edge. Apply it at every level between the bounded ancestor and the scrolling element, not just the scroller itself. - A list a user can grow without bound is virtualized — gpui's
uniform_list, Compose'sLazyColumn/LazyVerticalGrid, andtui-app/src/virtual_list.rsfor ratatui. ADR 0037 has the per-modality rules; the ones that bite are thatuniform_listgives every row row 0's height (so nogap, no row that grows, and a focused element inside the list has to be anchored so it can't be scrolled out of existence) and that the TUI helper assumes one terminal row per item. A collection with a structural ceiling — an ensemble's nodes, the TUI's agent rails — stays a plain loop. - Cap a reading column's width; don't let a detail screen fill a 4K monitor.
A gpui screen fills its window, so a label/value list on a 2560px display puts
every value at the left edge and every button half a metre away at the right,
and a row's two ends stop reading as one row. The cap is
sizing::layout::CONTENT_MAX_WIDTH, applied as a plain.max_w(CONTENT_MAX_WIDTH)on the scrolling body itself — not on a wrapper inside it, and not with aw(). On aflex_colbody theflex_1that fills the window vertically is grand-fathered past the rule below, because there it grows height and the cap is on the untouched cross axis.agents/documents/detail_view.rsandagents/human.rsare the call shape;components::detail_formis the label/value grid that goes inside, and deliberately does not offer a second way to do the capping.- Lists that are genuinely about scanning many columns — the document table, the presence grid, the media library — stay uncapped, because there the width is the information. The test is whether a row's rightmost element means anything without its leftmost.
- Sidebars and other already-narrow containers need nothing; neither does a component, which should let the screen that hosts it decide.
- Don't size a container by
flex_1plusmax_w. When the free space exceeds the maximum, the element's final width comes from growing and then being clamped — but its children are laid out against its max-content width, and never replaced. The frame draws at its grown width with its contents placed for the smaller one, so a nominally 630px search field gave its input 2px and clipped every glyph while the caret (2px wide) survived. Worse, it depends on how much free space the row happens to have, so the same component works on one screen and not the next. Give such a container a definitew(..)andflex_shrink_1()instead — a definite width is never re-sized after its children are placed.components/screen_header.rs'sheader_searchcarries the worked example.just lint-gui-layoutenforces this (scripts/lint-gui-layout.py) — it fails on.flex_1()and.max_w()in the same element chain, matching parens rather than lines so an exploded chain and a.child(div()...)argument are each seen correctly. A chain that also declares.flex_col()is exempt: thereflex_1grows height and the cap is on the untouched cross axis. Run it after touching gpui render code; clippy cannot see this class, since both calls are ordinary on their own.
- Cross-platform UI iconicity: when a screen needs to switch between sub-views
within itself (not top-level app destinations), use the GUI's
components::tabs::{tab_bar, tab_pill}on the gpui desktop app and a top-of-screen Jetpack ComposeTabRow/Tabon Android — notNavigationBar/NavigationBarItem, which Material 3 reserves for 3-5 persistent, equal-importance, app-level destinations (see https://developer.android.com/develop/ui/compose/components/navigation-bar vs https://developer.android.com/develop/ui/compose/components/tabs). Examples: the document detail screen's Overview/Sync split, and the Settings screen's System/Sync/Ensemble split. Keep the Android tab row at the top of the screen to match the GUI's tab bar placement. - A label/value list is two-column on the desktop and stacked on Android,
and that is on purpose. The desktop reads a record as a grid — label in a
fixed left column, value beside it (
components::detail_form) — because areading_columnis 1040px and leaves the value ~870. A phone's content width is ~330-380dp, so the same 160dp label column leaves 156-208dp: long values wrap to three lines and every row ends up taller than the stacked version it replaced. Android therefore stacks label over value.- This is the split the desktop and mobile versions of the same apps make for the same reason — macOS and GNOME Contacts are two-column, iOS and Google Contacts stack. It is a width decision, not a platform-taste one, so don't "fix" the inconsistency by making them match; the thing that would change the answer is width, not tidiness.
- Which means a tablet or foldable is the open question, not a phone. The
app has no adaptive-layout machinery at all today (no
WindowSizeClass, noBoxWithConstraints), so Android's choice is currently unconditional. Introducing that machinery is what would let the grid appear on a wide Android window, and it is worth doing for its own sake before it is worth doing for this. - Worked examples:
human.rs'sread_only_row/read_only_valuesagainstHumanDetailScreen.kt'sReadOnlyField. Android's one stacked row isui/LabeledValue; reach for it rather than laying out another.
VCS Workflow
- This project uses Jujutsu (jj) with a git backend. Prefer
jjcommands overgitwhen the checkout is a Jujutsu repo (check for.jj/directory). If only git is available (e.g. a plain git clone), git commands are fine. - Prefer small, self-contained commits, each representing a single logical change — use as many individual commits as a feature needs; it makes review easier and enables cleaner history/bisection.
- jj's working copy (
@) is always a commit, but nothing gives it a message automatically. When you finish a unit of work, runjj describe -m "<message>"thenjj newto seal it and start the next change clean — don't leave@undescribed, and don't keep piling unrelated work onto one anonymous change. Do this early and often, not just at a session's end: the human developer may step away and not return until the context cache has expired, so a change is only recoverable with a good message if it was written while that context was still warm. - Make jj commits freely, but leave changing the commit that jj bookmarks point to, or decisions about when and what commits to push to a remote repo, up to the human developer. location of bookmarks or
- gstack skills that key off branch identity (
/ship,/autoplan,/plan-*-review's file-naming and task scoping) shell out to plain git (git branch --show-current) to find "the current branch." jj's default bookmark-less@is git-detached-HEAD, which that returns""or"HEAD"for — not a real branch identity — so gstack's per-branch scoping buckets all bookmark-less work together. Before invoking one of these skills, runjj bookmark set <name> -r @(a throwaway name is fine) sogit branch --show-currentresolves to something real.
Testing
- when developing GUI functionality, always ask the developer to manually run the GUI and visually inspect the output; do not try to automatically test GUI functionality using screenshots or other similar tools
- when creating tests for new functionality, try to make them as concise as possible. This project uses the rstest crate to define test fixtures and parameterized tests