# Sync & Collaboration Every board has exactly one **collab mode** at a time, but the mode is not fixed at creation — it can evolve over the board's lifetime: ``` none ──add git──▶ git ──add remote──▶ git + remote ▲ open a board that already has .git (clone, git init) ``` A board may be created plain — **without any git repository** (a pivot from the pathfinder's auto-init) — gain git later, and later still gain a remote; the remote can be added or changed at any point. The second entry arrow is **adoption** (06-history-undo.md): opening a board whose root already contains `.git` lands directly in git mode (or git + remote, if the repo has one) — the normal path for a second machine joining a shared board: clone in a terminal, open in the app. Mode is detected at every open, nearest-`.git`-wins: root → git mode; ancestor only → repo-nested (left strictly alone, 06); neither → none. The arrows are one-way upgrades in spirit (removing git from a board is not a designed flow), but nothing about a board's mode is locked in on day one. The exception will be tracker-backed boards (a board *representing* an external tracker), whose mode is inherent to their creation — but tracker integrations (Gitea, GitHub, GitLab, Jira) are **out of scope for this design**. The schema keeps `remote`/`remote-state` reserved (see 01-storage-format.md) so a future connector isn't foreclosed, but no componentry, board mode, or UI is designed for them here. ## Mode: none (local-only) Plain folders on local disk. **No git repository at all** — and therefore, since git is the undo substrate, no undo/redo (06-history-undo.md). FSEvents live-reload works as on any board. Adding git later initializes the repo and moves the board to git mode. Honest caveat: without git there is no commit-before-overwrite protection, so on a no-git board **real data loss is possible** (e.g. concurrent or external overwrites) — accepted; adding git is the remedy. Repo-nested boards share this caveat: a repo exists, but the app manages no git there (06-history-undo.md), so its protections never run — committing is the user's own workflow. Deletion is the exception: the tombstone trash (03-board-ui.md) makes deletes recoverable even without git — overwrites are the lossy case, and Empty Trash is deliberate. ## Mode: git The board is a git repository (the 06-history-undo.md substrate — undo/redo, auto-commit trail), optionally with a **remote**. With a remote configured: - Push/pull becomes a sharing mechanism between machines/people at file-level granularity; the fractal one-item-one-file design keeps conflicts rare and small (a reorder touches one file). - **Remote tracking lives in the board popover** (03-board-ui.md): ahead/behind indicator plus manual **Pull** and **Push** controls — which are also Board-menu items (no default chord, remappable — 11-command-nexus.md; the configuration carve-out is 04-interactions.md's). - **Which remote is the board's remote** (settled — adopted clones can carry several): resolution is git's own defaulting — the current branch's upstream remote; else `origin`; else the repo's sole remote. The popover names the remote it tracks, its change-remote control edits exactly that one, add-remote on a remote-less repo creates `origin`, and 06-history-undo.md's quiet first `push -u` targets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none named `origin` — is surfaced honestly: remote operations disable and the popover offers a one-time remote picker, whose choice becomes the branch's upstream on the next push. - **Optional push-on-commit**: a per-board setting (stored app-side in the board registry — 02-architecture.md's per-board app state); when enabled, every auto-commit is pushed immediately. - **Push failures never nag.** A push rejected as non-fast-forward (another machine pushed first) triggers an automatic **fetch → rebase → push**, with bounded retries — the same rebase machinery as Pull, so it cannot block and cannot conflict. Manual Push behaves identically. Stated plainly: enabling push-on-commit implicitly accepts that remote commits may land in the live board whenever pushes race — consistent with the board's live-reload nature, but it should be learned from the design, not discovered. All other push failures stay quiet: pushes queue, the ahead/behind indicator in the board popover carries the pending count and the last error, and pushing resumes automatically on the next commit or manual Push. No modals, no per-commit errors. The one refinement: **authentication failures pause rather than retry** — see Remote authentication below. - **There can be no conflicts — and no data loss.** Every edit becomes a commit before anything can overwrite it (auto-commit settles local changes; the tree is clean by the time a pull runs). A pull — manual, or the automatic fetch-rebase after a rejected push — runs only at **interaction rest**: it queues behind an in-flight drag or open inline editor (the same settled-change notion the auto-commit debounce uses), flushes the pending auto-commit (06-history-undo.md's flush-before-overwrite), then runs bracketed (02-architecture.md's live-reload resilience) — never an error dialog, never a board yanked mid-drag. An open card-body **Edit session neither blocks a pull nor is interrupted by one**: the flush commits the session's on-disk saves as-is (06-history-undo.md's mechanical exception to session-granularity commits) and the rebase runs; when the pulled commits touch the very card being edited, the card window **signposts** the remote change (a transient banner, no modal, no merge UI) while the dirty buffer stays put and wins per 05-card-window.md — the losing remote version is a commit, one revert away. A pull fetches the remote's commits and **rebases local commits on top of them**; where a rebase hits a genuinely conflicting hunk, the **local side wins** — always, with no configuration. (This is the one deliberate history rewrite in the app, and it only ever touches unpushed local commits — see 06-history-undo.md's undo-never-rewrites rule.) Crucially, resolution discards nothing: the losing remote version survives intact in the fetched commits below, so an edit that "lost" the rebase is visible in any git client and one revert away. What may *appear* as data loss is always recoverable. No interactive merge UI, no conflict markers written by the app, sync never blocks. A pull that cannot start or fails cleanly follows 06-history-undo.md's app-initiated-operation posture — lock contention shows as a waiting state in the operation's banner row, clean failures as one-shot banner errors, an interrupted rebase is aborted-and-reported via the own-leftovers exemption; push alone keeps the richer queue-and-badge story (below). Conflict markers encountered in files (from git activity *outside* the app) fail fast only where they break parsing — markers in or around the frontmatter make the file the malformed-input case the loader rejects loudly with the offending path. Markers wholly inside a Markdown body are, honestly, valid input: they load fine and render as body text, and the app deliberately doesn't police body content to detect them (stated stance, not an oversight). (The old repo's AI-THINKING-merge-conflicts.md explored this territory; mine it when specifying the rebase mechanics.) ## Remote authentication (settled) Everything above assumes credentials exist; this is where they come from. Constraints first, stated as honest limits: a sandboxed app with bundled libgit2 cannot read `~/.ssh` (no entitlement grants it — silent access to every key is exactly what the sandbox exists to prevent), cannot reach `ssh-agent` (a unix socket outside the container; this also rules out 1Password/Secretive/YubiKey agents), cannot run the user's credential helpers, and gains nothing by shelling out (children inherit the sandbox). The network-client entitlement is assumed. Auth is therefore app-native, and **the Keychain is the only credential store** — credentials never live in board files or repo config. That is the deliberate inversion of files-are-truth: secrets are the one thing that must never be a file in the board. - **Transports: HTTPS and SSH, both Keychain-backed.** - **HTTPS (primary)**: username + token (forge PATs; plain basic auth for generic hosts), stored as a Keychain internet password keyed by **host + username** — git's own scoping model, shared across boards: one GitHub token serves every board, and two accounts on one host coexist as two usernames. **Which username a board uses is the remote URL's business** (git's own answer, and the HTTPS analogue of the SSH per-host table): a username in the URL (`https://alice@host/…`) selects the Keychain item `host + alice`, and the popover's credential capture stamps the entered username into the remote URL in repo config — the URL is the assignment record, no app-side state (the secret itself stays in the Keychain). A URL naming no username resolves to the host's sole stored username; when a host has several, the popover's username field becomes a picker and saving stamps the choice into the URL, while background operations treat the ambiguity as **Authentication needed** (pause and badge, never guess — the same posture as auth failure). - **SSH — Keychain-resident keys, never key files.** Each Mac has a **Lanework key**: an app-generated ed25519 keypair whose private half lives as an ACL-protected Keychain item and is handed to libssh2 from memory — it never exists on disk. The board popover shows the public key with a Copy affordance; the user adds it to their forge like any machine key. An **existing key imports by paste or drag** (a one-time read under user intent): copied into the Keychain — passphrase entered once at import, stored under Keychain protection thereafter — and the original file is never referenced again. Per-machine identity, per-Mac revocable on the forge — the ssh-idiomatic shape. (Secure Enclave-backed keys — non-exportable, custom sign callback, P-256 — are a possible later hardening, not v1.) - **Key scope: app-level objects, per-host assignment.** Keys are never board state — the machine key plus any imports live app-wide (Keychain), and each SSH host maps to one of them: default the machine key; importing a key during a host's setup assigns it to that host. A "host" is `hostname[:port]` parsed from the remote URL — the same endpoint identity the TOFU fingerprint store uses (OpenSSH's own `[host]:port` convention); the URL's username (`git@`) disambiguates nothing and stays out of it. The assignment table holds **only overrides** — no entry means the machine key, so the default costs zero records and removing an override self-heals to it. The popover's key picker is labeled per-host ("key for github.com"), which teaches the one cross-board consequence: switching a host's key switches it for every board on that host — the same rotate-once-follow-everywhere behavior as HTTPS tokens. Housekeeping stays small: an import referenced by no host row can be removed; the machine key only regenerates (confirm-gated — it invalidates the old public half on every forge), and that is the entire rotation story. The board popover is only the surface — it shows the key for *that remote's host*, the way the commit-identity fields front repo-local config. Known limit, accepted: two accounts on the *same* host can't be told apart by key (forges bind key→account globally; git's own answer is ssh-config aliases, which live in files the sandbox can't read) — a per-remote key override joins the wishlist if it ever bites. - **Host verification is trust-on-first-use**: with no `~/.ssh/known_hosts` readable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side in Application Support (02-architecture.md's app-wide state home, host-scoped). A later mismatch **hard-blocks with an explanation** — that mismatch is the attack the check exists for. - **Setup verifies right there.** Adding or changing a remote (board popover — 03-board-ui.md) probes with authentication immediately (ls-remote): missing or rejected credentials surface **inline in the popover** — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the popover with a remote that demonstrably works, or knowingly not. Boards adopted from a terminal clone (whose auth lives outside the sandbox and can't be reused) hit the same inline flow at the first in-app operation that needs credentials. - **Auth failures pause; they never nag and never hammer.** A push or pull rejected for authentication (expired token, revoked key) is not retried — a dead credential cannot succeed, and hammering invites rate limits and lockouts. The push queue pauses and the popover badge switches to a distinct **Authentication needed** state carrying the error; the popover presents the same inline fields, prefilled where possible. Updating the credential (or fixing forge-side and hitting Verify) resumes the queue. Network failures keep the quiet auto-resume above — only auth pauses. - **Background operations never prompt.** Push-on-commit and the automatic fetch-rebase-push stay silent through auth trouble (badge only); credential capture happens exclusively in the popover, where the user already is when it matters (manual Pull/Push live there too). ## iCloud Drive — not supported (decided) Boards should not live in iCloud Drive. The app makes **no iCloud accommodations**: no NSMetadataQuery watching, no eviction handling, no download triggering, no NSFileVersion conflict resolution. When the user opens or creates a board at a path inside iCloud Drive, the app **warns with a thorough explanation and recommends git integration instead** — it does not hard-block (the user is always right), but the warning must genuinely teach why this is a bad idea: - **Git and iCloud corrupt each other.** A board with git enabled (the undo substrate) has a `.git` inside; iCloud syncs `.git` internals — thousands of small object files and constantly-rewritten refs/packs — poorly and non-atomically; partial or reordered sync can corrupt the repository. Two Macs auto-committing the same board produce divergent histories iCloud cannot merge. - **Eviction breaks fail-fast loading.** iCloud may evict any file's contents to free space, leaving a placeholder. An evicted `index.md` is unreadable; with no download-trigger machinery the board simply fails to load with an I/O error until the user manually re-downloads it. - **iCloud conflict handling silently forks files.** iCloud resolves concurrent edits per-file via hidden file versions with no semantic merge; a board edited from two Macs can silently lose ordering or content coherence with no error surfaced anywhere. - **The right tool exists.** Git mode with a remote is the supported multi-Mac/sharing story: atomic commits, real merges, inspectable history, and conflicts that surface loudly instead of silently. Warning UX: shown on open/create of an iCloud-resident board, once per board (no nagging; the shown flag lives app-side in the board registry — 02-architecture.md's per-board app state), with the recommendation to move the board to local disk and use git integration for sync. The same warning is reasonable for other file-provider-synced locations (Dropbox, OneDrive, Google Drive) since the failure modes are identical — treat that as an implementation detail, not a separate design. **Network volumes (SMB/NFS) get the same once-per-board warning with tailored reasons**: FSEvents delivery is unreliable there, so live reload — the app's central mechanism — silently degrades (external and agent edits may not appear until reopen); and two machines mounting the same volume share one `.git`, a real corruption risk that git-with-remote (each machine its own repo) doesn't have. Same stance as iCloud: **no accommodations** — no polling fallback, no reduced mode; warn honestly (flag in the board registry, 02-architecture.md), recommend local disk + git remote, and respect the user's choice. ## Known issues (flagged, parked) - **Overridden remote edits are not in-app recoverable in-session.** A remote edit that loses the local-wins rebase doesn't enter the running app's undo stack, so recovering it mid-session requires an external git client. The gap is session-scoped only: the rebase leaves a linear history, so the fetched remote commits sit in HEAD's first-parent ancestry and reappear as ordinary undoable steps after the next relaunch (06-history-undo.md's reseed rule). Acceptable for now; revisit when there's an in-app history surface. ## Open questions None currently — sync surface and semantics are settled: manual Pull/Push in the board popover, optional push-on-commit, pull-rebase where the local side always wins conflicting hunks, automatic fetch-rebase-push on rejected pushes, quiet-badge surfacing of push failures, and Keychain-native authentication (HTTPS tokens + the machine SSH key, verify-on-add, pause-on-auth-failure).