diff --git a/sync-design.md b/sync-design.md new file mode 100644 index 0000000..fad5fee --- /dev/null +++ b/sync-design.md @@ -0,0 +1,778 @@ +# Syncing configuration and projects between the laptop and the box + +Design for two capabilities this repository does not have: keeping the server's +global Claude Code configuration current without a full provisioning run, and +moving a project's work between the two machines in both directions under a +scope the project declares for itself. + +Nothing here is implemented. `flit-spec.md` §4.5 and §5.1 are the sections this +amends; §13.2 and standing rules 8, 12, 17 and 18 are the ones it must not +violate. + +--- + +## 1. Problem, and the invariant question + +### 1.1 What is missing + +**Global Claude Code configuration.** `bootstrap.sh` `phase_2_deliver()` is the +only thing that moves `~/.claude` and `~/misc/chook` to the server. It does so +with `deliver_repo()`, which ships `git archive HEAD` into `~/claude-config` and +`~/chook`, and `check_clean()` refuses to run at all when either tree is dirty. +Two consequences: + +- The box runs the state committed at the last full provisioning run, and drifts + silently behind every edit since. Nothing reports the gap; a stale skill or a + stale hook behaves like a present one. +- An uncommitted tweak cannot be tried on the box at all. The only route is + commit, then a provisioning run, then a revert if it was wrong. + +**Per-project sync.** `laptop/push-project.sh` moves a whole working directory +up, and has no downward half by design. Four shapes are wanted, and a mechanism +that needs a special case per project is not one of them: + +1. Config only — the project's `.claude/`, `CLAUDE.md`, and similar. +2. The whole working directory — what `push-project.sh` does today. +3. One named subdirectory only. +4. Combinations of the above, including a path that moves one way only. + +### 1.2 The motivating case + +A desktop project whose halves have different portability: an Electron +application built against macOS window-server APIs (`CGWindowListCopyWindowInfo`, +`NSWorkspace.frontmostApplication`), with Swift scripts acting as its test +oracles and an Xcode iOS half beside it, and a portable `apps/server` half — +Node, Hono, better-sqlite3 — with its schema and integration-test packages. + +Agents run on the server and work on a feature. Their changes have to come back +**down** to the laptop, because the laptop is not the preferred build machine — +for the desktop half it is the only machine that can build or test at all. This +is an architectural fact rather than a missing package: no amount of provisioning +puts a macOS window server on Ubuntu. + +So the round trip is: work happens on the box, the result lands on the laptop, +the build and the test oracles run there. Today nothing carries the second leg. + +### 1.3 The invariant question + +This repository is built on one-way flow, and the question is which of two +claims that phrase names. They are not the same claim and they do not have the +same strength. + +**Claim A — credentials flow only laptop to server.** §5.1: + +> The laptop provisions the server. The server never fetches its own +> configuration. + +and its reason: + +> Push has no such cycle. `bootstrap.sh` runs on the laptop: provisions the +> instance, rsyncs the repository over SSH, and runs the `server/lib/` scripts +> remotely. No credential is needed on the server to obtain its own +> configuration, and the Hetzner API token never leaves the operator's machine. + +This is a security property, and the argument is entirely about credentials: a +pull model would need a bootstrap token on the box or a public repository, +because cloning `server/lib/` needs the SSH authentication that `45-ssh-config.sh` +is itself responsible for configuring. Nothing in this design touches it. §1 +success criterion 3 — no fetchable secret at rest on the server — rests here. + +**Claim B — the laptop holds the authoritative copy of project content.** §4.5: + +> It is the same push model as everything else here (§5.1): the laptop holds the +> authoritative copy, the server never fetches one, and there is no `--pull`. + +This is weaker, and `push-project.sh` already says why in its own comment: + +> The laptop is authoritative for a project the way it is for configuration, but +> "authoritative" is not "the only place work happens" — this box is a +> development machine, and an edit made on it is real work. + +Claim B is the one being relaxed. Work made on the box is already treated as +real by the guard that stops a push from overwriting it; what is missing is a +route for that work to leave. + +**What is not relaxed.** The relaxation is of authority, not of direction. Every +transfer in this design is **initiated by the laptop**, in both directions. The +laptop holds the key that reaches `$FLIT_USER@$FLIT_SERVER`; the server is given +no key, no token, and no address that reaches back. A downward transfer is the +laptop running `rsync $FLIT_SERVER:… ./` or `git fetch` — the laptop is the SSH +client either way, and the server learns nothing new and holds nothing new. +Claim A's argument survives intact, because it was never about which way bytes +travel; it was about which machine has to hold a credential to make them travel. + +The one-line statement of the amendment: **credentials flow one way; code flows +both ways, and the laptop initiates every transfer.** + +### 1.4 Where a downward script lives + +`CLAUDE.md`'s layout gives `laptop/` as + +> anything the laptop must be the one to run — a laptop-only credential, or a +> push whose direction is the design (§4.5) + +and records that moving a script across the laptop/server line is the project's +most repeated defect. A downward sync belongs in `laptop/`, and the convention's +own test is what puts it there rather than an exception to it: the question the +directory answers is *which machine must be the one to run this*, and for a pull +the answer is still the laptop, for a stronger reason than for a push. Only the +laptop can initiate — the server has no credential reaching the laptop, cannot +address it reliably (a roaming laptop drops the tailnet search domain; standing +rule 15), and must not be given either. + +The convention's *wording* is what needs widening, from "a push whose direction +is the design" to "a transfer the laptop must be the one to initiate". A +`server/lib/` or `server/bin/` counterpart to any of this would be the defect +§13.2 keeps counting: it would need a credential to reach the laptop, which is +exactly what claim A refuses. + +--- + +## 2. Transports considered + +### 2.1 Bidirectional rsync with an arbiter — rejected + +Two rsync passes, one each way, with a rule deciding which side wins per file. + +In its favour: it is the vocabulary already in this repository, it needs no new +software on either side, and it handles tracked and untracked files identically, +which is most of what makes `push-project.sh` work. + +Against it, decisively: the arbiter is the entire problem and rsync gives no +help with it. The available arbiters are mtime (wrong whenever both sides +touched different files in the same window, and dependent on two clocks), size +(not an ordering), or "newest wins" (a rebase or a checkout on either side +rewrites mtimes wholesale and hands the decision to an accident). And the loser +of any arbitration is *gone* — there is no second copy, no record that a choice +was made, and nothing to recover from. That collides directly with the +constraint that an unattended sync must never silently overwrite uncommitted +work, and there is no way to satisfy it except by building, out of band, the +history that the next option has for free. + +### 2.2 Git as transport, plus rsync for the residue — chosen + +Tracked files move as commits over the tailnet: the laptop pushes to the server +and fetches from it, both initiated by the laptop. Untracked and gitignored +files move by rsync, under a narrower policy (§3.5). + +What this buys, in the terms the constraints are written in: + +- **Conflict handling exists and is not invented here.** A divergence is a merge + that git refuses to guess at, not a file quietly replaced. +- **The unattended case becomes safe by construction.** `git fetch` writes refs + and objects and touches not one file in the working tree. An unattended pull + can therefore move every byte of the server's work onto the laptop without + being able to destroy anything, and defer the only dangerous step — updating + the working tree — to a fast-forward it can prove, or to the operator. This + is the property the other two transports cannot offer, and it is the reason + for the recommendation. +- **History and review come free**, which matters when the sending side is + agents rather than a person. +- **Recovery from a bad sync is `git reflog`**, not a backup restore. + +Costs, stated plainly: + +- **The server has no commit identity yet.** `server/lib/26-git-identity.sh` + exists and defers until `local.conf` supplies `FLIT_GIT_NAME` and + `FLIT_GIT_EMAIL` — both values are identifying, so they have no project + default. Until that is supplied, `git config --global --list` fails on the box + and so does every commit, which means the pull direction has nothing to fetch. + This is a precondition of the design, not an optional extra (phase 2 below). +- **Work has to be committed on the server to move.** That is a behaviour change + for agents running there. It is also the behaviour that makes the work + reviewable, so it is being taken as a benefit with a cost rather than a cost. +- **The residue still needs rsync**, and the residue is where the arbiter + problem was — so §3.5 shrinks it by policy rather than solving it. + +### 2.3 A continuous sync daemon (Syncthing, Mutagen, or similar) — rejected + +In its favour, and it is not a weak case: no polling to design, conflicts +preserved as files rather than lost (Syncthing's `.sync-conflict-*` copies), +untracked and gitignored files handled natively, and latency measured in seconds +rather than in a poll interval. + +Against it: + +- **It requires the server to initiate connections**, which is the thing claim A + in §1.3 refuses. A daemon that reaches the laptop needs an identity and a + route to it on the box. +- **It is a long-running service on both machines**, so it needs provisioning, a + credential, and monitoring — and §1 criterion 9 requires every scheduled job to + be observable by absence. A daemon's silence is indistinguishable from "nothing + changed", which is the exact failure that criterion exists to prevent. +- **It writes into `~/.claude` from a third party, continuously.** Standing rule + 18 documents what that does to `deliver_rendered()`: a byte written by anyone + other than the delivery reads as a conflict and defers forever. A daemon makes + that state permanent by design rather than by accident. +- **Untracked churn is the bulk of a monorepo.** `node_modules`, `target/`, + `dist/` and build caches would have to be excluded by hand in a second + exclusion language, duplicating `EXCLUDES` — and a `node_modules` with compiled + bindings crossing the arm64/amd64 boundary is worse than an absent one (§4.5). + +The strongest version of the case for it is a project that is not a git +repository at all, where option 2 degrades to option 1. That case is worth +revisiting if it arrives; it is not the motivating case. + +--- + +## 3. The chosen design + +Four pieces: a config sync, a project sync, a scope file, and a policy for +untracked files. + +### 3.1 `laptop/sync-config.sh` — global Claude Code configuration, downward only + +Fixes both complaints in §1.1 without touching the provisioning path. + +**What it does.** Stages the laptop's `~/.claude` — including uncommitted +changes — into the server's `~/claude-config`, then re-runs +`server/lib/22-claude-config.sh` remotely. Same for `~/misc/chook` into +`~/chook`. + +**Uncommitted changes without committing them.** `git archive` takes a tree, and +a tree can be built from the working directory without touching the operator's +index or writing a commit: a temporary `GIT_INDEX_FILE`, `git add -A`, `git +write-tree`, then `git archive` that tree object. `check_clean()`'s refusal is +therefore removed from this path — it exists because `git archive HEAD` silently +omits uncommitted work, and a synthesized tree omits nothing. +`phase_2_deliver()` keeps `check_clean()` unchanged: a provisioning run records +what it delivered, and a dirty tree names no commit to record. + +**It must never write `.delivered-commit`.** `check_delivered_commit()` compares +that file against the laptop's `HEAD` to refuse running server-side code a +delivery never sent. A sync that wrote a commit id there — let alone one for a +tree that matches no commit — would make a later `--start-at N` resume against +code nobody delivered, and it would look like success. The sync writes +`~/claude-config/.synced-tree` instead, holding the tree object id and a +timestamp, and nothing reads it but the sync's own reporting. + +**It must not write `~/.claude` on the server directly.** `22-claude-config.sh` +is the only writer of that directory, and it is what rewrites the laptop's paths +into the box's: `$FLIT_LAPTOP_HOME` to `/home/$FLIT_USER`, `misc/chook` to +`~/chook`, and the `misc/**/handoff.md` permission glob to `workspace/**`. An +rsync straight onto `~/.claude` would deliver settings naming directories that +do not exist on the box. So the sync stages, then invokes that script — the same +sequence phase 2 uses, with the same `flit_remote_env()` forwarding, because +`FLIT_LAPTOP_HOME` is what the rewrite is keyed on. + +**It does not fight `deliver_rendered()`.** `settings.json` and `chook.toml` +keep the §5.8 contract by hand: create if absent, compare if present, defer on a +difference, never overwrite. Standing rule 18 is explicit that the comparison +has no scope to ignore a third party's byte, that Claude Code writing a `theme` +key at first run is a live case, and that the answer is `settings.local.json` +rather than a smarter comparison. The sync therefore does not attempt to +reconcile a deferral, and does report it with the date it was first seen (§5.1). + +**One way only.** Configuration syncs down, never up. Two reasons, and the first +is sufficient: the files `22-claude-config.sh` renders have had laptop paths +rewritten out of them, so carrying `~/.claude/settings.json` back up would +overwrite the laptop's paths with the box's — a corruption that looks like a +successful sync until the next Claude Code start on the laptop. Second, the +box-local state that legitimately differs (`settings.local.json`, the `theme` +key) is precisely the state that must not travel. A skill or command authored on +the box comes back the way any other work does: the `~/.claude` repository has a +checkout under `~/workspace`, and that checkout syncs as a project (§3.2). + +### 3.2 `laptop/sync-project.sh` — per-project, both directions + +`push-project.sh` is not replaced. It stays the cold-start move: first placement +of a directory the server has never seen, session-transcript re-keying, and the +`~/workspace/` creation. `sync-project.sh` is the steady state, and +assumes both sides already exist. + +Git as transport works here without any new delivery step, because the server's +copy is already a real repository: `EXCLUDES` in `push-project.sh` drops +`.git/modules` and `.worktrees` — both of which hold absolute laptop paths — but +not `.git` itself, so a pushed project arrives with its full object store and +its history. Nothing has to be cloned on the box, which would be a fetch by the +server (§1.3). + +**Downward, tracked files.** The laptop fetches from the server's checkout over +SSH into a namespace of its own: + + git fetch "$FLIT_USER@$FLIT_SERVER:workspace/" \ + "+refs/heads/*:refs/flit//*" + +This writes only objects and refs under `refs/flit/`. No working file changes, +no branch the operator uses moves, nothing can be lost. Every unattended pull +stops here unless it can prove the next step is safe: the laptop's working tree +is clean **and** the laptop's branch is an ancestor of the fetched ref, in which +case a `--ff-only` update runs. Anything else — a dirty laptop tree, a +divergence — leaves the work sitting in `refs/flit//` and reports +it. This is the mirror of `push-project.sh`'s confirm prompt, expressed as a +refusal because an unattended trigger cannot ask (§4.3). + +**Upward, tracked files.** The laptop pushes into the server's checkout the same +way, into `refs/flit/laptop/*` rather than onto a checked-out branch, so a push +can never move a branch out from under work in progress on the box. The +server-side merge is then a fast-forward the operator or an agent performs. + +**Untracked and gitignored files.** §3.5. + +**Scope.** §3.3 decides which paths participate and in which direction. + +### 3.3 The scope file: `.flit-sync` + +Lives in the project's own root, beside `.flit-push-exclude`. Line-oriented, +comments to `#`, blank lines ignored — the same shape as the exclusion file so +one project's two files read alike. + +Each line is a rule: + + + +`` is one of: + +| Direction | Meaning | +| --- | --- | +| `both` | laptop to server and server to laptop | +| `push` | laptop to server only | +| `pull` | server to laptop only | +| `none` | this path does not sync in either direction | + +`` is relative to the project root; a trailing `/` marks a directory and +covers everything under it; `.` means the whole tree. + +**First matching rule wins**, most specific first — the same discipline the +three-tier filter already uses, and for the same reason: order is the policy and +a reader should be able to see it without simulating a match algorithm. + +**Defaults, and why they are asymmetric.** A project with no `.flit-sync` at all +behaves as today: `push .`, nothing comes down. A project *with* the file and no +terminal `.` rule defaults to `none .` — nothing syncs but what is named. +Writing the file is the act of taking control of the project's scope, so the +unstated case inside it is "not declared, so not moved". A project that never +writes one is not opted in to anything new. + +**How it composes with the existing filter chain.** It does not replace it, and +does not overlap with it. `.flit-sync` answers *whether a path participates, and +which way*; `.flit-push-exclude` → `git_protect_list()` → `EXCLUDES` then answers +*what within a participating path is transferred*, unchanged and in that order. +The scope check runs first and yields a set of path arguments; the filter chain +runs on each, exactly as it does now. `.flit-push-exclude` continues to outrank +the tracked-file protection, and the post-transfer `git ls-files --deleted` +report continues to catch what that costs (standing rule 17). + +Two things change for the downward direction, both of them §13.2 "direction" +findings rather than conveniences: + +- **`git_protect_list()` must be computed on the sending side.** Its signature is + `git_protect_list ` and it runs `git -C "$dir" ls-files` locally. For a + pull, the facts about which files are tracked belong to the server's checkout, + not the laptop's, and the two differ precisely when the sync matters — a file + the agents added is tracked there and unknown here. The ancestor-expansion half + of that function has to be factored so it can consume a file list produced by + `remote "git -C ~/workspace/ ls-files -z"`. Reusing the laptop-local + version for a pull would protect the wrong set and read as working. +- **`EXCLUDES` applies downward with more force, not less.** The argument in §4.5 + is that a `node_modules` built on the arm64 laptop does not run on the amd64 + server. The mirror is also true and is the case that bites harder, because the + laptop is the machine that has to *build*: a `node_modules` or a `target/` + built on the server, landing on the laptop, produces a tree that fails at + runtime rather than at install, on the one machine that cannot fall back to + the other. + +### 3.4 Worked examples, one per shape + +**Shape 1 — config only.** A project whose code lives on the laptop, where only +the agent scaffolding is worth keeping level: + + # .flit-sync + both .claude/ + both CLAUDE.md + both handoff.md + none . + +Result: three paths move both ways; nothing else moves at all, in either +direction. A `push-project.sh` run is still how the directory first got there. + +**Shape 2 — the whole working directory, today's behaviour.** No `.flit-sync` +file, or, to state it rather than inherit it: + + # .flit-sync + push . + +Result: identical to `laptop/push-project.sh` today, filters and all. Change the +line to `both .` and the same tree round-trips. + +**Shape 3 — one named subdirectory only.** The portable half of the desktop +project in §1.2: + + # .flit-sync + both apps/server/ + none . + +Result: the server holds and works on `apps/server` alone. The Swift oracles, +the Xcode half and the Electron main process never leave the laptop, which is +right — they cannot be built or run on the box, and their presence there is an +invitation for an agent to try. + +**Shape 4 — combinations.** The same project once the agents also need the +schema package and the shared config, but the laptop must stay the only source +of credentials and the only place the iOS half exists: + + # .flit-sync + none .env + none .dev.vars + none apps/ios/ + both apps/server/ + both packages/schema/ + push .claude/ + pull docs/generated/ + none . + +Read top to bottom: two credential files never move; the iOS half never moves; +two packages round-trip; the agent configuration goes up but never comes back +(so a box-local `settings.local.json` cannot overwrite the laptop's); generated +docs only come down, because the box is where they are generated; everything +else, including the Electron main process and the Swift test oracles, stays put. + +### 3.5 Untracked and gitignored files, downward + +**Gitignored files carry live credentials by design.** That is what makes +`push-project.sh` work at all — a `.env`, a `.dev.vars`, a +`settings.local.json` — and §4.5 says so. + +The risk profile is not symmetric, and the asymmetry is the reason for the +policy. Pushing a credential up is a deliberate act placing a laptop secret on a +box the operator controls, with a known value. Pulling one down takes a file +that has been sitting on a machine that runs Claude Code against untrusted +repositories, and writes it over the laptop's authoritative copy. If anything on +the box altered it — an agent, a dependency's postinstall, a stray script — the +laptop's credential silently becomes whatever the server last said, and the +first symptom is an authentication failure somewhere unrelated, hours later. + +So: + +- **Gitignored files never sync downward by default.** Not "with a warning" — + they are not transferred. A project that has a genuine reason names the path + explicitly with a `pull` rule, one path at a time, never a directory. +- **Untracked-but-not-ignored files** sync downward into a quarantine directory, + `.flit/incoming/`, mirroring their relative paths, rather than into place. A + file that does not exist on the laptop, or that is byte-identical to the + laptop's copy, is a pure addition and lands directly; anything that would + overwrite goes to quarantine, and the run reports the list. Promotion is a + copy the operator makes, or a later interactive run. +- **Upward** is unchanged from `push-project.sh`: gitignored files travel, the + three-tier filter decides which. + +This shrinks the arbiter problem of §2.1 to a set the operator has named +one path at a time, which is the only version of it that can be reasoned about. + +### 3.6 Where success criterion 8 lands + +§1 criterion 8 is that no script destroys or overwrites an existing +configuration file on either machine, and `cfg_apply()` in `lib/common.sh` is +where that is enforced — it is the only path that writes one. Neither proposed +script becomes a second such path: + +- `sync-config.sh` writes `~/claude-config`, a staging directory that is not + anybody's configuration, and then calls `22-claude-config.sh` — which routes + `settings.json` and `chook.toml` through `deliver_rendered()`'s hand-written + version of the same contract, and the rest through entries that carry no + box-local state. The sync adds no writer of `~/.claude`. +- `sync-project.sh` writes project files, and a project's `.claude/` or `.env` + is a configuration file by any reading. It is kept out of criterion 8's way by + never writing one unconditionally: tracked files move only through a + fast-forward git refuses to fake, and untracked files land only when they are + absent or byte-identical, going to quarantine otherwise (§3.5). The result is + the same guarantee by a different mechanism — refuse and report, rather than + overwrite — which is what `cfg_apply()` does when it cannot apply a change + surgically. + +Neither script should call `cfg_apply()`. Its unit is a managed block inside a +file the operator also edits; a project file has no such block and no marker +syntax to carry one, and standing rule 18 is a record of what happens when a +whole-content comparison is asked to stand in for one. + +--- + +## 4. Triggers and scheduling + +### 4.1 The server still initiates nothing + +That property survives, and not for symmetry. A server-initiated sync needs a +credential on the box that reaches the laptop, which is claim A in §1.3 and §1 +criterion 3. It also needs the laptop to be addressable, and the laptop is the +machine that sleeps, roams, and loses its tailnet search domain — standing rule +15 is a record of what assuming otherwise costs. Every trigger below fires on the +laptop. + +### 4.2 Manual first + +`laptop/sync-config.sh` and `laptop/sync-project.sh ` are commands the +operator runs, with `--print-only` mutating nothing anywhere (standing rule 8). +Through phases 1–5 of §6 this is the only trigger, and it is enough to prove the +round trip in §1.2. + +### 4.3 Then an opt-in poll + +The laptop polls. Watching the filesystem (`fswatch`, FSEvents) is not an +alternative: it can see laptop edits, which is the direction that already has a +trigger, and cannot see server edits, which is the direction that needs one. One +poll covering both beats a watcher plus a poll. + +Mechanism: a `launchd` user agent with `StartInterval`, running +`laptop/sync-project.sh --all --unattended`. Five minutes, which is well inside +the human loop of "an agent finished, now build it" and far outside the cost of +an SSH round trip per project. + +`--unattended` is a distinct mode, not a quiet flag: + +- It never prompts. `confirm()` is not reachable from it (standing rule 16 makes + the prompt path delicate for a different reason; here it simply must not run). +- It performs only operations that cannot destroy: fetch always, `--ff-only` + update when the tree is clean and the branch is behind, additive-only untracked + transfer, quarantine for everything else. +- It writes a status line — timestamp, per-project outcome, anything deferred — + to `~/.flit/sync-status`, which the interactive commands print. +- It does not report to healthchecks.io. That spine is for the server's timers + (§6.3), and a laptop that sleeps overnight would flap a dead-man check into + noise, which standing rule 7 says is not a check. + +### 4.4 What the poll cannot fix + +A poll that stops — agent unloaded, laptop asleep for a week — is silent, and +silence reads as "nothing changed". §5.6 lists it as a failure mode, and the +mitigation is the status file's timestamp being surfaced by the interactive +commands, not another monitor. + +--- + +## 5. Execution context, all six dimensions (§13.2) + +Per §13.2's instruction to do this mechanically rather than by reading, one row +per proposed script. + +### `laptop/sync-config.sh` + +| Dimension | Finding | +| --- | --- | +| **Machine** | Laptop. It reads `~/.claude` and `~/misc/chook`, which exist only there, and it initiates SSH. A `server/lib/` counterpart could not read either. | +| **User** | The operator's account on the laptop; `$FLIT_USER` on the server. `22-claude-config.sh` already re-execs itself as `$FLIT_USER` under `sudo -u`, so the staged `~/claude-config` must be owned by `$FLIT_USER` — the sync writes it over SSH as that account, so this holds by construction, but the ownership of `~/.config` created along the way is the case §13.2 records for `20-toolchains.sh`. | +| **Environment** | Interactive at first. Under the §4.3 launchd agent there is no login shell and no profile: `git`, `rsync` and `ssh` must be found by absolute path or an explicit `PATH`, and `SSH_AUTH_SOCK` must be verified to exist in that context rather than assumed. Verify by running the script under `env -i`, not by reasoning about launchd. | +| **Binary** | Laptop: `git`, `ssh`, `rsync`, `tar`. Server: `tar`, `jq`, `sed`, `rsync` — all already required by `22-claude-config.sh`. Nothing new is installed on either side. | +| **Userland** | The tree-building half is git plumbing, identical on both. The delivery is `tar -x` on the server (GNU), matching `deliver_repo()`. `mktemp` for the temporary index is the BSD/GNU split `push-project.sh` already documents: an explicit `XXXXXX` template, never a bare prefix. | +| **Direction** | The risk is a stale or wrong *server* config, so the guard is the server reporting what it actually holds — the tree id in `.synced-tree` and `22-claude-config.sh`'s own deferral output — not the laptop asserting what it sent. Separately: `.delivered-commit` must not be written, or the guard against resuming a bootstrap onto undelivered code reads the sync's value and passes. | + +### `laptop/sync-project.sh` + +| Dimension | Finding | +| --- | --- | +| **Machine** | Laptop, for the §1.4 reason: only it can initiate. It also needs the laptop's clock, the laptop's git, and the laptop's checkout — none of which a server-side half could see. | +| **User** | The operator on the laptop; `$FLIT_USER` on the server. A pushed ref lands in a repository owned by `$FLIT_USER`; a fetched object lands in the laptop's repository. Neither side runs as root, and neither should — a root-owned unit executing a `$FLIT_USER`-writable path is the case §13.2 records for the tailscale clone guard. | +| **Environment** | The pull runs `git` on the *server* over plain SSH, which is the same non-interactive shell every scheduled job gets — the `drill_assert_fixtures()` case. `git` is in the default `PATH`, but `jj` (used by at least one project as its primary VCS, colocated with git) is not installed at all, and a colocated repository's working copy is not updated by a git-side ref move. See §7 open question 4. | +| **Binary** | Laptop: `git`, `ssh`, `rsync`. Server: `git`, `rsync`. `git` on the server is the new requirement of the pull direction, alongside the identity in `26-git-identity.sh`. | +| **Userland** | `git_protect_list()` already handles the null-delimited case for filenames containing newlines; the remote variant must preserve that (`ls-files -z`, `read -d ''`). `rsync` on the laptop may be openrsync, so only the flags `push-project.sh` already proves are usable — no `--info=stats1`, no `-F`. | +| **Direction** | The one that matters, and the one most likely to be got wrong. Four separate checks, each of which must test the direction its own risk runs in: (1) the *upward* guard is `push-project.sh`'s existing confirm against a dirty server tree; (2) the *downward* guard is its mirror — refuse to touch the laptop's working tree unless it is clean and the update is a fast-forward; (3) `git_protect_list()` must be computed on the **sending** side, so a pull cannot reuse the laptop-local call (§3.3); (4) the post-transfer `git ls-files --deleted` report must be asked of the **receiving** side, which for a pull is the laptop, not the server. | + +### `server/lib/26-git-identity.sh` — existing, and a precondition + +| Dimension | Finding | +| --- | --- | +| **Machine** | Server. It writes `~/.gitconfig` there. | +| **User** | Re-execs as `$FLIT_USER` so `cfg_apply()` sees the operator's own files and their symlinks. | +| **Environment** | Values arrive only through `flit_remote_env()`'s explicit `FLIT_GIT_NAME` / `FLIT_GIT_EMAIL` forwarding, `%q`-quoted; `sudo` carries nothing else across the re-exec. | +| **Binary** | `git` on the server. | +| **Userland** | `cfg_apply()` is the shared path and is the one function proven on both userlands by `lib/common.test.sh`. | +| **Direction** | Nothing new. What changes is the consequence of its deferral: today a missing identity is a routine deferral on a fresh clone, and after this design it silently disables the entire downward direction — no commit on the box means nothing to fetch. Phase 2 of §6 turns that from a deferral into a verified precondition. | + +### The `launchd` agent (§4.3) + +| Dimension | Finding | +| --- | --- | +| **Machine** | Laptop only; there is no server-side counterpart and there must not be. | +| **User** | A user agent in the operator's own bootstrap namespace, not a `LaunchDaemon`. A daemon runs as root and would write into the operator's repositories as root. | +| **Environment** | The dimension this one exists to expose. No login shell, no profile, minimal `PATH`, and `SSH_AUTH_SOCK` present only if the agent is in the per-user GUI namespace. Verify by running the plist's exact command under `env -i` and by `launchctl print gui/$UID/