19 KiB
Sync & Collaboration
Tier scope: Lanework Pro (12-editions.md). The free tier ships mode:none only — the state machine below never leaves its first state there, .git encountered on disk is inert (12), and the Mode: none section's old no-undo caveat is superseded in every tier by native undo (13-native-undo.md — the provider follows the board, re-ruled 2026-07-31). Teams adds tracker-backed sync behind the same seam (deferred).
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 no git history; undo/redo binds the native stack in every tier (13-native-undo.md, re-ruled 2026-07-31). 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 materialized trash (03-board-ui.md) makes card and lane 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 settings sheet names the remote it tracks (the popover's tracking badge shows it at a glance — the 2026-07-31 popover/sheet split, 03-board-ui.md), its change-remote control edits exactly that one, add-remote on a remote-less repo createsorigin, and 06-history-undo.md's quiet firstpush -utargets the resolved remote (recording it as the upstream, which pins resolution thereafter). The unresolvable case — several remotes, no upstream, none namedorigin— is surfaced honestly: remote operations disable and the sheet 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 commit the app makes is pushed immediately — auto-commits and undo/redo restore commits alike (06-history-undo.md's restores are commits like any other; a shared board never shows a phantom lag after an undo).
- 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). The clean-tree premise is why abnormal repo states pause the remote half too: a detached HEAD or in-progress merge/rebase holds Pull, Push, and push-on-commit alongside auto-commit (06-history-undo.md ▸ Rules ▸ Abnormal repo states — the whole git surface pauses; the ahead/behind badge keeps counting, a fetch being a read). 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. An open raw-source buffer gets the same treatment (settled): a pull neither blocks on it nor invalidates it — the bracket's write lock merely disables Apply while the pull runs — and the same-card signpost shows in source mode too (the banner strip is window furniture, not part of the swapped content area). A later Apply is last-writer-wins across the whole file, frontmatter included, with the overwritten pulled version a commit one revert away — on the same branch this is exactly the Edit-buffer race. (Branch switch and undo restore must gate raw buffers on save-or-discard instead — 06-history-undo.md ▸ Branch switching — because there a stale Apply would write onto a different tree's card, not merely race a newer version of the same one.) 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 itemhost + alice, and the settings sheet'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 sheet'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 settings sheet 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]:portconvention); 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 sheet'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 settings sheet 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_hostsreadable, the first connection to an SSH host confirms its fingerprint with the user; accepted fingerprints live app-side (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.
- 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 (
- Setup verifies right there. Adding or changing a remote (board settings sheet — 03-board-ui.md, the 2026-07-31 popover/sheet split) probes with authentication immediately (ls-remote): missing or rejected credentials surface inline in the sheet — HTTPS shows username + token fields with a forge-appropriate hint; SSH shows the machine key to copy plus Verify. The user leaves the sheet 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 badge points at the settings sheet); the sheet 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 settings sheet, one click behind the badge that says it's needed (manual Pull/Push stay in the popover — daily operations, the split's other half).
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. (Base Lanework keeps the warning without the recommendation — there is no git to recommend and no Pro pitch in a warning; it recommends a local folder on the eviction/silent-fork grounds alone — 12-editions.md ▸ Edition naming in base.)
- Git and iCloud corrupt each other. A board with git enabled (the undo substrate) has a
.gitinside; iCloud syncs.gitinternals — 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.mdis 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).