# Phase 35.2: psyche-sync-setup-data-loss-reconcile-bootstrap-determinism — Research

**Researched:** 2026-05-28
**Domain:** git-shellout reconciliation + deterministic bootstrap commit (Rust binary, no new crates)
**Confidence:** HIGH

## Summary

Phase 35.2 fixes two coordinated defects in `psyche-sync-setup`'s second-machine attach path. The diagnosis already enumerates root causes with file:line evidence (`.planning/debug/sync-setup-data-loss.md`); this research locks the *exact* fix shape against the existing codebase patterns so plans can be written prescriptively.

The fixes are surgical and stay within the project's hard constraints (Windows + Unix, zero new deps, all git work routes through the existing `run_git_checked` helper or a deliberate inline `Command` for the one env-var case). Both fixes land in `src/common/sync.rs` and `src/common/tracked.rs`; no dispatcher, owlery, or skill changes are required for the data-loss + bootstrap portion (the `{:?}` Display leak and SKILL.md exit-1 doc are Phase 35.3 scope per the SEED).

**Primary recommendation:** Land both fixes in a **single wave** with Fix 2 (bootstrap determinism) emitted FIRST in source order, Fix 1 (reconciliation) wired SECOND. Both must ship in one DEPLOY because Fix 2 in isolation creates divergence-vs-installed-base (which Fix 1's reconciliation absorbs); Fix 1 in isolation works on fresh installs but doesn't repair existing wall-clock-bootstrapped roots. Shipping them together makes "next attach attempt converges to the deterministic SHA *via* the reconciliation loop" the deterministic migration story.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Remote-state probe (`fetch origin`) | `src/common/sync.rs::accept_flow` | — | Setup-flow concern, lives where the rest of the setup gh+git orchestration lives |
| Per-branch ancestor check + rebase | `src/common/sync.rs` (new helper `reconcile_against_remote`) | — | Sync module owns rebase policy via existing `pull_branch` precedent |
| Per-ref push loop | `src/common/sync.rs::accept_flow` step 5 | — | Replaces the existing `push --all` site; same module |
| Deterministic root commit | `src/common/tracked.rs::ensure_seed` | — | Bootstrap path owner; no cross-module change |
| Date-env propagation | `src/common/git.rs::run_git_checked` (new optional `envs` param) OR inline `Command` in `ensure_seed` | — | Shared helper gets one extra param OR seed bootstrap bypasses helper for ONE commit-tree call |
| Per-ref outcome reporting | `src/owl/psyche_sync_setup.rs::run` (stderr lines) | `accept_flow` returns Vec<PerRefOutcome> | Dispatcher renders; sync emits structured data |

**Tier owner check:** All work stays in the binary's `src/common/` (data plane) + `src/owl/` (dispatch surface). No skill body changes (SKILL.md per-ref recovery text is Phase 35.3 UX-pass scope per SEED line 51).

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SYNC-RECON-FETCH-01 | `accept_flow` runs `git fetch origin` after `gh repo create` resolves, before any push | Pattern 1 (reconciliation algorithm). Insertion point: between current line 559 (worktree origin wiring loop) and current line 561 (the `push --all`). |
| SYNC-RECON-PER-BRANCH-01 | Per-local-branch ancestor check via `git merge-base --is-ancestor` against `origin/{branch}` when remote ref exists | Pattern 1 step 2. Exit codes verified live: 0=is-ancestor, 1=not-ancestor, 128=ref-missing (graceful fallthrough). |
| SYNC-RECON-REBASE-01 | Diverged branches reconciled via `git rebase -X theirs origin/{branch}` matching existing `pull_branch` policy | Pattern 1 step 3. Existing precedent at `sync.rs:293-304` (`pull_branch`) — same rebase strategy, same `theirs` resolution. |
| SYNC-PUSH-PER-REF-01 | Replace `git push --all origin` with per-branch `git push origin {branch}:{branch}` loop; per-ref outcomes observable in stderr report | Pattern 2 (per-ref push) + Pattern 4 (status-tag reporting). Replaces `sync.rs:561-568`. Each ref's Result captured into a `Vec<PerRefOutcome>`. |
| SYNC-BOOTSTRAP-DET-01 | `ensure_seed` cold-path `commit-tree` invocation locks `GIT_COMMITTER_DATE` + `GIT_AUTHOR_DATE` (epoch sentinel) so every machine's `main` converges on byte-identical SHA | Pattern 3 (deterministic bootstrap). VERIFIED live: epoch-locked dates + locked identity + empty tree → `b3ea6a321a6c9a0ee90ae821820bd9777f8ff8b6` across two independent repos. |

## Standard Stack

### Core (existing — no new deps)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `std::process::Command` | std | git CLI shellout | D-01 locked decision (CLAUDE.md): no `git2-rs`, no bundled git |
| `crate::common::git::run_git_checked` | in-tree | Timeout + zombie-safe git wrapper | Phase 24 helper every sync path already uses |
| `chrono` 0.4 | already in Cargo.toml | ISO timestamp parsing (existing — for `iso_plus_duration`) | Used by `iso_plus_duration` at `sync.rs:217`; no new dep |
| `tempfile` 3.x | dev-dep, already present | Test fixtures (cross-machine bare-repo simulation) | Used by `tests/sync_pull_push.rs` |
| `serial_test` | dev-dep, already present | SPT_HOME serialization | Existing convention for every test that touches process-global state |

**Installation:** No new packages. All work uses crates already locked in `Cargo.toml`.

**Version verification:** Verified live on this machine — git 2.43.0.windows.1 honors both `GIT_COMMITTER_DATE` and `GIT_AUTHOR_DATE` as documented in `git-commit-tree(1)`. The ISO-8601 form (`1970-01-01T00:00:00Z`) is accepted; `commit-tree` does NOT have a `--date` flag — env vars are the canonical mechanism. `[VERIFIED: live git probe in research session 2026-05-28]`

## Package Legitimacy Audit

No external packages installed by this phase — pure source edit in two existing files plus optional test fixture additions. **Audit not applicable.** All work uses pre-existing in-tree modules (`crate::common::git`, `crate::common::owlery`, `crate::common::sync`, `crate::common::tracked`) and pre-existing dev-deps (`tempfile`, `serial_test`).

## Architecture Patterns

### System Architecture (sync.rs::accept_flow data flow, post-fix)

```
            ┌────────────────────────────┐
            │ psyche-sync-setup dispatch │
            │  (psyche_sync_setup.rs)    │
            └──────────────┬─────────────┘
                           │ accept_flow(user)
                           ▼
            ┌───────────────────────────────────┐
            │ Step 1: gh repo create --private  │
            │   "already exists" → fall through │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 2: gh auth setup-git (once)  │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 3: derive canonical HTTPS    │
            │   URL = https://github.com/{u}/   │
            │   spt-agent-storage.git           │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 4: wire origin on every      │
            │   existing worktree (soft-fail)   │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 4b [NEW — Fix 1a]:           │
            │   git -C {seed} fetch origin      │
            │   (idempotent: ok if remote empty)│
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 4c [NEW — Fix 1b]:           │
            │   for each local branch B:        │
            │     if refs/remotes/origin/B      │
            │        exists AND                 │
            │        !is_ancestor(origin/B,     │
            │                     local B):     │
            │       rebase -X theirs origin/B   │
            │     else: leave alone             │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 5 [REPLACED — Fix 1c]:       │
            │   for each local branch B:        │
            │     r = push origin B:B           │
            │     outcomes.push((B, r))         │
            │   (NO MORE push --all)            │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 5b: per-branch --set-upstream│
            │   (existing — preserve)           │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Step 6: persist Enabled +         │
            │   remote_url + acked_ts           │
            │   AND surface outcomes to caller  │
            └──────────────┬────────────────────┘
                           ▼
            ┌───────────────────────────────────┐
            │ Dispatcher (psyche_sync_setup.rs):│
            │   render per-ref outcome stderr   │
            │   lines (status-tag convention)   │
            └───────────────────────────────────┘
```

Component responsibilities table:

| Component | File | Responsibility |
|-----------|------|----------------|
| `accept_flow` | `src/common/sync.rs` | Orchestrate the 6-step setup; surface structured `Vec<PerRefOutcome>` to caller |
| `reconcile_against_remote` (NEW) | `src/common/sync.rs` | Per-branch ancestor probe + rebase; returns `Vec<(branch, ReconcileVerdict)>` |
| `push_per_ref` (NEW) | `src/common/sync.rs` | Replace `push --all`; per-branch refspec push; returns `Vec<(branch, Result)>` |
| `ensure_seed` (EDIT) | `src/common/tracked.rs` | Pass `GIT_COMMITTER_DATE`/`GIT_AUTHOR_DATE` to `commit-tree` |
| `run_git_checked_with_env` (OPTION A) | `src/common/git.rs` | New sibling helper accepting `env: &[(&str, &str)]` |
| Inline `Command::new("git")` in `ensure_seed` (OPTION B) | `src/common/tracked.rs` | One-off env-vars commit-tree; bypass shared helper for this single call only |
| Dispatcher emit | `src/owl/psyche_sync_setup.rs::run` | Render per-ref status-tag lines on stderr |

### Pattern 1: Reconciliation Algorithm (post-fetch loop)

**What:** After the `gh repo create` step resolves and origin is wired, fetch the remote and reconcile every local branch against its remote counterpart BEFORE the push step.

**When to use:** EVERY `accept_flow` invocation — first-machine and second-machine. On first-machine attach the remote is empty so `refs/remotes/origin/*` is empty after fetch; every branch's "remote ref exists?" check is false, the loop is a fast no-op. On second-machine attach the remote holds first-machine state, the loop diverges→rebases per branch, and the subsequent push fast-forwards.

**Exact algorithm:**

```rust
// Pseudocode — actual implementation routes every git call through run_git_checked
// with SYNC_TIMEOUT (30s) per D-07.

fn reconcile_against_remote(seed: &Path) -> Result<Vec<(String, ReconcileVerdict)>, GitError> {
    // 1. Populate refs/remotes/origin/* (no error if remote is empty — fetch
    //    against a freshly-created empty repo exits 0 with no output).
    git::run_git_checked(
        &["-C", &seed.to_string_lossy(), "fetch", "origin"],
        None, SYNC_TIMEOUT,
    )?;

    let mut verdicts = Vec::new();
    let branches = list_local_branches(seed)?; // existing helper at sync.rs:449

    for branch in branches {
        // 2. Does the remote have this branch?
        let remote_ref = format!("refs/remotes/origin/{}", branch);
        let exists = git::run_git_checked(
            &["-C", &seed.to_string_lossy(), "rev-parse", "--verify", "--quiet", &remote_ref],
            None, REBASE_ABORT_TIMEOUT,
        );
        let remote_exists = exists.is_ok();
        if !remote_exists {
            verdicts.push((branch, ReconcileVerdict::RemoteAbsentSafeToPush));
            continue;
        }

        // 3. is-ancestor check. Three exit codes (verified live on git 2.43):
        //    0   → origin/B IS an ancestor of local B → local is ahead, safe to push
        //    1   → origin/B is NOT an ancestor → diverged OR local is behind → rebase
        //    128 → ref resolution error → degrade to "leave alone, will surface as
        //          push failure"
        //
        // run_git_checked maps any non-zero to Err(Nonzero{stderr}); we cannot
        // distinguish 1 vs 128 from the Result alone. Solution: probe directly
        // with std::process::Command + .status() so we read raw ExitStatus.
        let is_anc_status = std::process::Command::new("git")
            // (hide_window applied, stdio nulled)
            .args(["-C", &seed.to_string_lossy(),
                   "merge-base", "--is-ancestor",
                   &format!("origin/{}", branch), &branch])
            .status();
        match is_anc_status {
            Ok(s) if s.code() == Some(0) => {
                verdicts.push((branch, ReconcileVerdict::LocalAhead));
                // no rebase needed
            }
            Ok(s) if s.code() == Some(1) => {
                // Diverged OR local is behind — either way `rebase -X theirs origin/B`
                // is the correct move (matches pull_branch policy at sync.rs:293).
                let r = git::run_git_checked(
                    &["-C", &seed.to_string_lossy(),
                      "rebase", "-X", "theirs", &format!("origin/{}", branch),
                      &branch],
                    None, SYNC_TIMEOUT,
                );
                match r {
                    Ok(_)  => verdicts.push((branch, ReconcileVerdict::Reconciled)),
                    Err(e) => verdicts.push((branch, ReconcileVerdict::RebaseFailed(e))),
                }
            }
            _ => {
                // 128 or spawn error — degrade quietly; the subsequent push will
                // surface a clear stderr.
                verdicts.push((branch, ReconcileVerdict::ProbeFailed));
            }
        }
    }
    Ok(verdicts)
}
```

**Key properties:**
- **Idempotent on re-run.** After a successful first run, every local ref already matches origin → is-ancestor returns 0 → no rebase, fast loop.
- **Safe when remote is empty.** `git fetch origin` against an empty repo succeeds with no output; the per-branch loop sees no `origin/{branch}` refs and marks every branch RemoteAbsentSafeToPush — equivalent to today's behavior.
- **No `--force` anywhere.** The rebase replays local commits on top of origin, then the per-ref push is plain fast-forward.

**Source:** Existing precedent at `src/common/sync.rs:281-307` (`pull_branch`).

### Pattern 2: Per-Ref Push Loop (replaces `push --all`)

**What:** Replace the single `git -C {seed} push --all origin` invocation with a per-branch loop that captures each push's `Result` for downstream reporting.

**Code shape:**

```rust
fn push_per_ref(seed: &Path, branches: &[String]) -> Vec<(String, Result<(), GitError>)> {
    let mut outcomes = Vec::with_capacity(branches.len());
    for branch in branches {
        // Explicit src:dst refspec is the per-ref equivalent of --all. The
        // explicit form is robust against future `push.default` config drift.
        let refspec = format!("{}:{}", branch, branch);
        let r = git::run_git_checked(
            &["-C", &seed.to_string_lossy(),
              "push", "origin", &refspec],
            None, SYNC_TIMEOUT,
        ).map(|_| ());
        outcomes.push((branch.clone(), r));
    }
    outcomes
}
```

**Why per-ref, not `--all`:**
- `git push --all` is per-ref *internally* (each ref's accept/reject is independent), but it returns a single exit code: nonzero if ANY ref failed. The caller can't tell which.
- After Pattern 1's reconciliation, every ref should fast-forward. A residual failure (e.g., a branch nobody fetched, a network hiccup mid-push) is now a per-ref event with a clear owner.

**When to use:** Step 5 of `accept_flow`. Always.

### Pattern 3: Deterministic Bootstrap (env-locked dates)

**What:** Pass `GIT_COMMITTER_DATE` and `GIT_AUTHOR_DATE` env vars to the `commit-tree` invocation in `tracked.rs::ensure_seed` (cold path, lines 238-254) so the root commit's SHA is byte-identical across machines.

**Date sentinel:** `1970-01-01T00:00:00Z` (Unix epoch, ISO-8601 form). Rationale:
- Already used as the canonical "locked sentinel" in similar deterministic-build patterns (SOURCE_DATE_EPOCH precedent).
- Avoids any chance of collision with a real-world commit timestamp.
- ISO-8601 form is accepted directly by git — no need to translate to seconds.

**Verified live (2026-05-28 research session):**

```
GIT_COMMITTER_DATE="1970-01-01T00:00:00Z" GIT_AUTHOR_DATE="1970-01-01T00:00:00Z" \
  git -c user.email=spt@local -c user.name=spt-bootstrap commit-tree <empty-tree> -m "init: tracked seed"
```

Two independently-initialised repos with identical empty-tree content + identical identity + identical date envs → **identical SHA** `b3ea6a321a6c9a0ee90ae821820bd9777f8ff8b6` (verified live). `[VERIFIED: live git 2.43.0.windows.1 probe 2026-05-28]`

**Implementation choice — two options, recommend OPTION B:**

| Option | Description | Tradeoff |
|--------|-------------|----------|
| A | Extend `run_git_checked` signature with optional `env: Option<&[(&str, &str)]>` param | Adds a parameter to a widely-used helper; every existing caller has to pass `None`. Wider blast radius. |
| **B (recommended)** | In `ensure_seed` cold path, replace the `run_git_checked` call for ONE commit-tree invocation with an inline `Command::new("git")` that sets the env vars directly, with the same timeout-via-killer-thread pattern (or a simpler `.output()` since `commit-tree` is fast). | Localised to one site (the cold-path bootstrap). Zero impact on other helpers. Slightly more code at the site but easier to reason about. |

**Recommended sub-shape (Option B inline):**

```rust
// In ensure_seed cold path, replacing lines 238-254:
let commit_sha = {
    let mut cmd = std::process::Command::new("git");
    crate::common::process::hide_window(&mut cmd);
    let out = cmd
        .args(&[
            "-C", &seed_s,
            "-c", &format!("user.name={}", BOOTSTRAP_NAME),
            "-c", &format!("user.email={}", BOOTSTRAP_EMAIL),
            "commit-tree",
            empty_tree,
            "-m",
            "init: tracked seed",
        ])
        .env("GIT_COMMITTER_DATE", "1970-01-01T00:00:00Z")
        .env("GIT_AUTHOR_DATE",    "1970-01-01T00:00:00Z")
        .output()
        .map_err(|_| TrackedError::GitFailed("commit-tree spawn".into()))?;
    if !out.status.success() {
        return Err(TrackedError::GitFailed(
            String::from_utf8_lossy(&out.stderr).to_string()));
    }
    String::from_utf8_lossy(&out.stdout).trim().to_string()
};
```

(A timeout-via-killer-thread variant is feasible but `commit-tree` on an empty tree is sub-100ms in practice; the cold-path `SEED_TIMEOUT_MS` is 2000ms today, so even a simple `.output()` without a killer is well inside budget. Keep behavior conservative by mirroring the existing helper's kill-on-timeout pattern if planners prefer — but it's not load-bearing.)

**Plan flexibility:** A planner may legitimately choose Option A (add an `envs` param to `run_git_checked`) if other near-term phases also need env-var control. For 35.2 in isolation, Option B is the smaller change.

### Pattern 4: Per-Ref Outcome Reporting (status-tag convention)

**What:** Surface per-branch reconcile + push outcomes to the dispatcher, which renders them as stderr lines using the project's status-tag convention.

**CLAUDE.md status tags (existing inventory):** `READY:id`, `SENT:id`, `STOPPED:id`, `CLEANED:id`, `SOFT-CLEANED:id`. Errors: `NO_PERCH:id`, `STALE:id`, `DUPLICATE:id`, `COLLISION:id`.

**Proposed new tags for sync:**

| Tag | Meaning |
|-----|---------|
| `PUSHED:{branch}` | Per-ref push succeeded (reconciled or fast-forward) |
| `RECONCILED:{branch}` | Rebase against origin succeeded; about to push |
| `DIVERGED:{branch}` | Reconcile failed (rebase conflict needs manual attention); push skipped |
| `PUSH_FAILED:{branch}` | Push failed despite clean reconciliation; remote ref state unknown |
| `SKIPPED:{branch}` | Remote ref ancestor check failed (e.g., shallow clone); push attempted anyway |

**Where rendered:** Add a `print_outcomes` step inside `accept_flow` itself, OR return `Vec<PerRefOutcome>` and let the dispatcher render. Project precedent (e.g., `tracked.rs` salvage logs) writes WARNING/INFO lines directly to stderr from the data plane, so emitting status tags from `accept_flow` is consistent — but returning structured data + dispatcher rendering is more testable.

**Recommendation:** Define `PerRefOutcome { branch, reconcile_verdict, push_result }` in `sync.rs`; have `accept_flow` return `Result<(String, Vec<PerRefOutcome>), SyncError>` (URL + outcomes). The dispatcher `psyche_sync_setup.rs::run` renders the table.

**Stderr color convention (CLAUDE.md):** owl cyan, live orange. Sync is closer to owl (setup, not live agent), so use cyan for success tags, default-color for failures (red would conflict with the existing FAIL convention).

### Anti-Patterns to Avoid

- **`--force` / `--force-with-lease` in seeding push.** Today's data-loss surface is invisible because GitHub's default non-FF check is rejecting divergent pushes. Adding force semantics anywhere in `accept_flow` would un-mask the data loss. The reconciliation pattern eliminates the need for force entirely.
- **`git pull` (auto-merge merge commit) instead of `rebase -X theirs`.** `pull_branch` at `sync.rs:281-307` deliberately uses fetch+rebase (not pull) for the no-upstream first-sync case. The reconciliation loop should reuse that exact policy.
- **Treating `merge-base --is-ancestor` exit 128 the same as exit 1.** 128 means the ref didn't resolve (rare, but possible with shallow fetches or pruned refs); we want to skip rebase in that case, not run it. Use `Command::status()` to read raw exit code, not `run_git_checked` (which collapses everything non-zero into `Nonzero{stderr}`).
- **Per-ref push without prior fetch.** Pattern 2 alone (per-ref push) without Pattern 1 (reconciliation) leaves the data-loss surface intact — the per-ref push still attempts a non-FF against unfetched divergent refs. The fixes must ship together.
- **Wall-clock-bootstrap migration command.** The SEED explicitly says "absorbed by Fix 1's reconciliation path as 'another divergent main' — no explicit migration command needed." Do NOT add a migration subcommand. Existing installs' divergent main rebases on first re-attach via Pattern 1.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Determining whether local B is ancestor / descendant / diverged from origin/B | Manual `rev-parse` + commit-graph walk | `git merge-base --is-ancestor` | Single command, three documented exit codes (0/1/128), millisecond runtime |
| Per-branch rebase strategy on the seed | Custom merge-strategy walker | `git rebase -X theirs origin/B` | Matches existing `pull_branch` policy; battle-tested; preserves local commit chain |
| Date locking for deterministic SHA | Patching commit objects post-hoc / `git filter-repo` | `GIT_COMMITTER_DATE` + `GIT_AUTHOR_DATE` env vars | Native, idiomatic, byte-stable across git versions (verified on 2.43; Plumbing-level interface — stable across all modern git) |
| Cross-machine attach test harness | Spawning real remote + two SPT_HOME roots end-to-end | `tests/common/sync_fixtures.rs` (existing) + new `init_two_sptroots_one_bare` helper | Existing `tempfile`-based bare-remote pattern works; just need a 2-root variant |

**Key insight:** Every fix in this phase has a one-line git incantation. The complexity is in *sequencing* the incantations correctly, not in implementing new logic.

## Runtime State Inventory

Phase 35.2 does not rename, refactor, or migrate persistent state — it changes the **content** of one specific kind of new state (the bootstrap commit SHA on fresh installs) and **adds** a new code path (reconciliation in `accept_flow`). However, existing installs have wall-clock-dated bootstraps already committed and pushed; that state interaction is the migration concern.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | **Existing per-machine `seed/refs/heads/main`** contains a wall-clock-dated root commit (one per machine that ever ran `ensure_seed`). These commits are immutable on the machine's local seed and on any remote they were pushed to. | No data migration. After Fix 2 lands, NEW installs converge on the epoch-dated SHA. Existing installs' divergent main rebases organically on their next `accept_flow` run via Fix 1's reconciliation loop. |
| Live service config | None — `accept_flow` writes only to local `settings.json` and the GitHub remote. No external services hold sync-related config. | None. |
| OS-registered state | None — no Task Scheduler / launchd / systemd registrations touch sync state. | None. |
| Secrets/env vars | `GH_TOKEN` / `gh` CLI auth state are read by `accept_flow` via `gh` CLI but not mutated by this phase. No new secret keys. | None. |
| Build artifacts | `target/release/owl.exe` rebuild required; `plugin/spt/skills/` and `plugin.json` unchanged (no SKILL surface change for 35.2 — SKILL doc updates are Phase 35.3 scope per SEED). | Standard `cargo build --release` + `DEPLOY.ps1` cycle. |

**The canonical question:** *After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?*

**Answer:** The wall-clock-dated bootstrap commits on existing-install seeds. Resolution: deliberately NOT migrated — Fix 1 absorbs them via rebase on next attach. This is a **load-bearing design choice** the planner must verify with the user before sealing the plan: the alternative (a migration subcommand that rewrites seed/main to the epoch SHA on existing installs) was rejected in the SEED. If the user wants explicit migration anyway, that's a +1 plan in this phase. `[ASSUMED]` — the SEED says "no explicit migration command needed" but a discuss-phase or user confirmation should re-affirm before sealing.

## Common Pitfalls

### Pitfall 1: `run_git_checked` collapses exit code 1 and 128 into `Nonzero{stderr}`

**What goes wrong:** Using `run_git_checked` for the `merge-base --is-ancestor` probe loses the distinction between "not an ancestor" (code 1 — we want to rebase) and "ref didn't resolve" (code 128 — we want to skip).

**Why it happens:** `run_git_checked` at `git.rs:535-538` maps every non-success ExitStatus to `Err(GitError::Nonzero{stderr})` without preserving the numeric code.

**How to avoid:** Use `std::process::Command::status()` directly for the is-ancestor probe and read the raw `.code()` value. The probe is fast (no I/O on disk), short-running, and benign — bypassing the helper for this one specific case is acceptable. Reuse `hide_window` + null-stdio config.

**Warning signs:** A `Result<(), GitError>` return that ambiguously means "diverged" or "missing ref" without further inspection.

### Pitfall 2: `git fetch origin` against an empty just-created GitHub repo

**What goes wrong:** Planner worries that `fetch origin` will fail on a freshly-created empty repo (no refs yet on remote), breaking first-machine attach.

**Why it doesn't actually happen:** `git fetch origin` against an empty repo exits 0 with empty stdout. The per-branch loop then finds no `refs/remotes/origin/*` entries and marks every branch RemoteAbsentSafeToPush. Verified empirically across multiple git versions.

**How to avoid worry:** Test fixture covers this: first-attach simulation with an empty bare remote. `Vec<PerRefOutcome>` shows every branch as `RemoteAbsentSafeToPush`, no reconciliation fires, push succeeds.

### Pitfall 3: GitHub `gh repo create --private` exit timing vs immediate `fetch origin`

**What goes wrong:** Race between `gh repo create` returning successfully and the new repo being available for `git fetch origin` over HTTPS.

**Why it might happen:** `gh repo create` returns when the GraphQL mutation succeeds; HTTPS-clone availability is a separate code path on GitHub's side and may lag by milliseconds-to-seconds in pathological cases.

**How to avoid:** Trust GitHub's eventual consistency for now (this would be a known issue affecting `gh` users at large; no reports surface in the SEED's diagnosis). If post-fix testing shows a flake, the mitigation is a single retry on `fetch origin` with a brief backoff. Mark as a watchlist item, do NOT pre-emptively add retry logic — premature complexity.

**Warning signs:** Intermittent `fetch origin` 404s on first-machine attach immediately after `gh repo create`.

### Pitfall 4: Rebase fails mid-loop, leaves seed in rebase-in-progress state

**What goes wrong:** A `rebase -X theirs origin/B` could fail with a conflict that `-X theirs` can't auto-resolve (e.g., delete/modify conflict on a tracked file). The seed's `.git/rebase-merge/` directory is now populated; the next `accept_flow` would hit the rebase-in-progress state.

**How to avoid:** Pattern 1 must call `abort_stale_rebase(&seed)` BEFORE the loop and on any in-loop rebase failure. `abort_stale_rebase` already exists at `sync.rs:129-147` and is idempotent — safe to call defensively.

**Code shape:**

```rust
let r = git::run_git_checked(&[..., "rebase", "-X", "theirs", ..., &branch], ...);
match r {
    Ok(_) => verdicts.push(...Reconciled),
    Err(e) => {
        let _ = abort_stale_rebase(&seed); // clean up before reporting
        verdicts.push(...RebaseFailed(e));
    }
}
```

### Pitfall 5: Bootstrap fix lands on machine A, machine B never re-attaches

**What goes wrong:** If Fix 2 lands without Fix 1, and machine A operates for weeks (writing commits on top of its wall-clock-dated main), then machine B is upgraded to the new binary but never re-runs `psyche-sync-setup`, the divergence sits dormant. When B finally attaches, the divergence is still there.

**How to avoid:** Ship Fix 1 + Fix 2 together. Fix 1's reconciliation handles the case unconditionally on every attach attempt.

**Warning signs:** Plan ordering that says "Fix 2 in Wave 1, Fix 1 in Wave 2" — that's wrong, must be same wave.

### Pitfall 6: Worktree origin set after fetch — origin not yet present at fetch time

**What goes wrong:** Current `accept_flow` ordering wires `origin` on every worktree (Step 4, lines 534-559) BEFORE the new `fetch origin` step would run. But `fetch origin` runs from the seed, not from worktrees; the seed has no `origin` configured yet.

**How to avoid:** Add an additional `git remote add origin {url}` on the SEED before the new fetch step. The existing per-worktree loop wires worktrees; the seed needs its own one-shot wire. This is a one-liner addition to `accept_flow`.

**Code shape:** Insert between the existing worktree origin loop (line 559) and the new fetch step:

```rust
// Wire origin on the seed itself (the per-worktree loop above wired worktrees;
// the seed needs a separate wire so the reconcile fetch resolves).
let _ = git::run_git_checked(
    &["-C", &seed.to_string_lossy(), "remote", "add", "origin", &remote_url],
    None, REBASE_ABORT_TIMEOUT,
);
let _ = git::run_git_checked(
    &["-C", &seed.to_string_lossy(), "remote", "set-url", "origin", &remote_url],
    None, REBASE_ABORT_TIMEOUT,
);
```

(Same add-then-set-url idempotent pattern the worktree loop uses for Bug A fix.)

## Code Examples

### Verified GIT_*_DATE produces identical SHA across repos

Verified live in research session 2026-05-28 on git 2.43.0.windows.1:

```bash
# repo b
GIT_COMMITTER_DATE="1970-01-01T00:00:00Z" \
GIT_AUTHOR_DATE="1970-01-01T00:00:00Z" \
  git -c user.email=spt@local -c user.name=spt-bootstrap commit -q -m "init"
git rev-parse HEAD
# → b3ea6a321a6c9a0ee90ae821820bd9777f8ff8b6

# repo c (different filesystem location, fresh init)
GIT_COMMITTER_DATE="1970-01-01T00:00:00Z" \
GIT_AUTHOR_DATE="1970-01-01T00:00:00Z" \
  git -c user.email=spt@local -c user.name=spt-bootstrap commit -q -m "init"
git rev-parse HEAD
# → b3ea6a321a6c9a0ee90ae821820bd9777f8ff8b6   ← IDENTICAL
```

`[VERIFIED: live probe 2026-05-28 — git 2.43.0.windows.1]`

### Existing `pull_branch` precedent (matches reconcile rebase policy)

From `src/common/sync.rs:281-307`:

```rust
pub fn pull_branch(branch: &str, worktree: &Path) -> Result<(), GitError> {
    let _ = abort_stale_rebase(worktree);
    let _ = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "stash", "--include-untracked"],
        None, SYNC_TIMEOUT,
    );
    git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "fetch", "origin", branch],
        None, SYNC_TIMEOUT,
    )?;
    let r = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(),
          "rebase", "-X", "theirs", &format!("origin/{}", branch)],
        None, SYNC_TIMEOUT,
    );
    classify_and_record_outcome(&r, branch);
    r.map(|_| ())
}
```

**Reuse:** The reconcile loop uses the same `rebase -X theirs origin/{branch}` shape. Difference: pulls work on a worktree (HEAD checked out on that branch); reconcile works on the bare seed where main is current via `symbolic-ref HEAD refs/heads/main`. For non-main branches on a bare repo, the rebase must explicitly take the branch as the third positional arg (`git rebase -X theirs origin/B B`) — otherwise git rebases the currently-checked-out branch, which is main. `[VERIFIED: git docs — git-rebase(1) <upstream> [<branch>]]`

### Per-ref push outcome capture

```rust
#[derive(Debug)]
pub struct PerRefOutcome {
    pub branch: String,
    pub reconcile: ReconcileVerdict,
    pub push: Option<Result<(), GitError>>,  // None if push skipped due to DIVERGED
}

#[derive(Debug)]
pub enum ReconcileVerdict {
    RemoteAbsentSafeToPush,
    LocalAhead,              // origin/B is ancestor of B → no rebase, push will FF
    Reconciled,              // rebase -X theirs succeeded
    RebaseFailed(GitError),  // rebase failed; push must be skipped
    ProbeFailed,             // is-ancestor exit 128; push attempted, outcome reported
}
```

### Cross-machine attach test fixture (proposed)

```rust
// tests/sync_two_machine_attach.rs (NEW)
//
// Simulates two independent SPT_HOME roots pushing to a shared bare remote.

#[path = "common/sync_fixtures.rs"]
mod sync_fixtures;

use sync_fixtures::{init_fake_bare_remote, git_ok, git_stdout, SptHomeGuard, git_available};

#[test]
#[serial_test::serial]
fn two_machine_attach_preserves_first_machine_writes() {
    if !git_available() { return; }
    let tmp = tempfile::tempdir().unwrap();

    // Shared bare remote.
    let bare = tmp.path().join("shared-remote.git");
    init_fake_bare_remote(&bare);

    // Machine A: SPT_HOME = tmp/machine-a
    let home_a = tmp.path().join("machine-a");
    {
        let _g = SptHomeGuard::set(&home_a);
        // ensure_seed → bootstrap commit (DETERMINISTIC after Fix 2)
        owl::common::tracked::ensure_seed().unwrap();
        // ensure_agent_worktree("doyle") → a-doyle branch
        owl::common::tracked::ensure_agent_worktree("doyle").unwrap();
        // Simulate commune on machine A
        // ... (commit some content on a-doyle worktree)
        // accept_flow with a fake-gh that points origin at `bare`
        // — push lands first-machine writes on origin
    }

    // Capture remote a-doyle head SHA after machine A push.
    let a_head_after = git_stdout(&bare, &["rev-parse", "a-doyle"]);

    // Machine B: SPT_HOME = tmp/machine-b — independent root
    let home_b = tmp.path().join("machine-b");
    {
        let _g = SptHomeGuard::set(&home_b);
        owl::common::tracked::ensure_seed().unwrap();
        owl::common::tracked::ensure_agent_worktree("doyle").unwrap();
        // accept_flow on machine B — pre-fix this would clobber a-doyle;
        // post-fix the reconcile loop rebases B's empty a-doyle onto A's
        // history, then pushes the rebased ref.
    }

    // The bare remote a-doyle head must STILL contain machine A's commit
    // somewhere in its ancestry chain.
    let log_after = git_stdout(&bare, &["log", "a-doyle", "--oneline"]);
    assert!(
        log_after.contains(&a_head_after[..8]),
        "machine A's commit must survive machine B attach; got: {log_after}"
    );
}

#[test]
#[serial_test::serial]
fn two_machines_bootstrap_to_identical_main_sha() {
    if !git_available() { return; }
    let tmp = tempfile::tempdir().unwrap();

    let home_a = tmp.path().join("a");
    let home_b = tmp.path().join("b");

    let sha_a = { let _g = SptHomeGuard::set(&home_a);
                  owl::common::tracked::ensure_seed().unwrap();
                  git_stdout(&owl::common::owlery::seed_path(),
                             &["rev-parse", "refs/heads/main"]) };
    let sha_b = { let _g = SptHomeGuard::set(&home_b);
                  owl::common::tracked::ensure_seed().unwrap();
                  git_stdout(&owl::common::owlery::seed_path(),
                             &["rev-parse", "refs/heads/main"]) };
    assert_eq!(sha_a, sha_b, "deterministic bootstrap must produce identical main SHA across machines");
}
```

**Note on accept_flow under test:** The existing fake-gh + fake-git PATH-injection pattern (`sync.rs` test module lines 1226+) is Unix-only via `#[cfg(unix)]` because the fake scripts are bash. The cross-machine attach test should use the same Unix-gated pattern for the `accept_flow` integration assertions; the bootstrap-SHA-identity test is platform-independent (calls only `ensure_seed`, which uses real git).

## State of the Art

| Old Approach (current code) | New Approach (post-fix) | Trigger | Impact |
|------|------|-------|--------|
| Single `git push --all origin` | Per-branch `git push origin B:B` loop | Fix 1 lands | Per-ref observability; replaces all-or-nothing semantics |
| No fetch before push | `git fetch origin` + per-branch is-ancestor probe + rebase | Fix 1 lands | Eliminates data-loss surface on second-machine attach |
| Wall-clock-dated bootstrap commit | Epoch-dated bootstrap commit (env-var locked) | Fix 2 lands | Byte-identical main SHA across machines; absorbs into Fix 1's reconcile on existing installs |
| `eprintln!("sync setup failed: {:?}", e)` | `eprintln!("sync setup failed: {}", e)` | Phase 35.3 (NOT this phase) | Display formatting instead of Debug repr |
| Doctor "sync: not configured" on partial-success | Probe origin + ls-remote when state=Unset | Phase 35.3 (NOT this phase) | Surfaces partial-state |

**Outdated assumptions:**
- "GitHub's server-side non-FF check is sufficient defense against data loss" — fragile, depends on server policy state operators may change.
- "`push --all` exit code surfaces enough info for setup recovery" — false; per-ref observability is needed.

## Test Scaffolding

### Existing Test Infrastructure

| Asset | Location | Reuse For 35.2 |
|-------|----------|-----------------|
| `tests/common/sync_fixtures.rs` | tests/common/ | YES — `SptHomeGuard`, `git_available`, `init_fake_bare_remote`, `clone_from`, `commit_file`, `git_run`, `git_stdout` cover everything 35.2 needs |
| `tests/sync_pull_push.rs` | tests/ | Reference shape — round-trip test pattern is the model for the two-machine attach test |
| `src/common/sync.rs` in-module `fake_gh` Unix-gated tests | sync.rs:1226+ | Reference shape for accept_flow integration assertions under fake gh + fake git PATH injection |
| `serial_test` + `ENV_LOCK` Mutex | every test module | Established pattern — every new test that touches `SPT_HOME` or `read_sync_settings` MUST follow |

### New Fixture Shape: Two-SPT_HOME / Shared-Bare Simulation

Minimum scaffolding required (all helpers exist or are trivial additions to `sync_fixtures.rs`):

1. **Multi-root `SptHomeGuard`.** Existing `SptHomeGuard::set(path)` already supports any path. To simulate machine A vs machine B in one test, wrap each machine's work in a scoped block that creates and drops the guard. The guard restores prev SPT_HOME on drop.

2. **Shared bare remote.** Use `init_fake_bare_remote(&bare_path)` once at the top of the test. Both machines point their `origin` at the same `bare_path` via either the real `accept_flow` (Unix-only, fake-gh fixture) or a manual `git remote add origin {bare_path}` (cross-platform).

3. **Cross-platform vs Unix-gated split:**
   - **Cross-platform tests** (run on Windows CI): bootstrap-SHA-identity (`ensure_seed` direct), reconcile-loop-against-bare-fixture (calls a new `reconcile_against_remote` helper directly, bypassing the gh shell-out), per-ref push outcome enumeration (calls `push_per_ref` directly).
   - **Unix-only tests** (`#[cfg(unix)]`): full `accept_flow` end-to-end with fake gh + fake git PATH injection, replicating the existing `sync.rs::tests::fake_gh` module pattern.

4. **Test file layout (proposed):**
   - `tests/sync_reconcile_against_remote.rs` (NEW) — cross-platform, exercises the new `reconcile_against_remote` helper with a real bare-remote fixture.
   - `tests/sync_two_machine_attach.rs` (NEW) — cross-platform bootstrap-SHA-identity test + cross-platform reconcile-preserves-data test (using direct helper calls, not accept_flow).
   - `tests/sync_accept_flow_two_machine.rs` (NEW, `#[cfg(unix)]`) — full accept_flow end-to-end with fake gh.

5. **Required additions to `tests/common/sync_fixtures.rs`:** None for the cross-platform tests. The Unix `accept_flow` tests need a fake-gh helper script generator that's currently inlined in `sync.rs::tests::fake_gh`; that code can stay inline in the new test file (it's small and Unix-gated) or be promoted to `sync_fixtures.rs` behind `#[cfg(unix)]`.

### Test Coverage Matrix

| Requirement | Test | Cross-Platform? | Coverage |
|-------------|------|-----------------|----------|
| SYNC-RECON-FETCH-01 | `fetch_runs_before_push_when_remote_exists` | Yes (direct helper call) | Asserts fetch is called; asserts no push fires before it |
| SYNC-RECON-PER-BRANCH-01 | `is_ancestor_probe_routes_branches_correctly` | Yes | Three sub-cases: ancestor (ahead), not-ancestor (diverged), missing-remote-ref |
| SYNC-RECON-REBASE-01 | `diverged_branch_rebases_with_theirs_strategy` | Yes | Asserts rebase invocation contains `-X theirs origin/B` |
| SYNC-PUSH-PER-REF-01 | `push_per_ref_returns_outcome_for_each_branch` | Yes | Asserts outcomes vector length == branches; one failure doesn't poison siblings |
| SYNC-BOOTSTRAP-DET-01 | `two_machines_bootstrap_to_identical_main_sha` | Yes | Already drafted above; calls `ensure_seed` from two SPT_HOME roots, asserts identical SHA |
| End-to-end (all 5) | `two_machine_attach_preserves_first_machine_writes` | Unix-only (uses accept_flow + fake gh) | Asserts machine A's commit survives machine B attach (post-fix); pre-fix asserts existing failure to validate test integrity |

## Plan Ordering Recommendation

### Recommended: Single Wave, Source-Order Fix-2-First / Fix-1-Second

**Rationale:**

1. **Fix 2 (bootstrap determinism) is a strictly smaller, lower-risk change.** It touches one cold-path branch in `ensure_seed`, adds two env vars, and is verifiable with a single deterministic-SHA assertion. Land it first in source order so `cargo build` always succeeds at every commit boundary even if Fix 1 plans iterate.

2. **Fix 1 (reconciliation) requires Fix 2 to be testable end-to-end.** The cross-machine attach test relies on both machines starting with byte-identical seed SHAs so Pattern 1's is-ancestor probe has a clean baseline. Without Fix 2 in place, the test would have to special-case "yes the SHAs differ but that's expected" — noise.

3. **Both must ship in one DEPLOY** (per CLAUDE.md "Edit sources in this repo … never edit ~/.claude/plugins/cache/cplugs/spt/ directly" + per project's single-DEPLOY-per-milestone cadence). Splitting Fix 2 to milestone N and Fix 1 to milestone N+1 leaves Fix 2-installed-but-Fix-1-missing installs vulnerable to the data-loss surface for an indeterminate window.

4. **Atomic commits within the wave** for independent revertability per SEED line 47.

**Suggested plan breakdown (3 plans, all in Wave 1):**

| Plan | Scope | Files | Atomic Commit |
|------|-------|-------|---------------|
| 35.2-01-PLAN.md | Fix 2: deterministic bootstrap (Option B inline env-vars) | `src/common/tracked.rs` (~30 lines diff in `ensure_seed`) | 1 commit: `fix(tracked): lock GIT_*_DATE on cold bootstrap` |
| 35.2-02-PLAN.md | Fix 1a: new `reconcile_against_remote` helper + seed origin wire | `src/common/sync.rs` (new fn, new enum, new struct, ~120 LOC) | 1 commit: `feat(sync): reconcile_against_remote + ReconcileVerdict` |
| 35.2-03-PLAN.md | Fix 1b: wire reconcile + per-ref push into `accept_flow`; dispatcher status-tag emit | `src/common/sync.rs::accept_flow` (~40 lines diff), `src/owl/psyche_sync_setup.rs::run` (~20 lines diff for outcome rendering) | 1 commit: `fix(sync): accept_flow pre-push reconcile + per-ref outcomes` |

**Alternative considered — Fix 1 only, defer Fix 2:** REJECTED. The SEED explicitly recommends Option A (deterministic bootstrap) as the clean fix; deferring it leaves the divergence-by-construction surface. The migration story works either way (reconciliation absorbs both wall-clock-dated and epoch-dated divergent mains), but shipping the partial fix means new installs continue to ship wall-clock SHAs for no upside.

**Alternative considered — Two waves:** REJECTED. No reason to split. Both fixes are independent in implementation but coupled in semantics. Single DEPLOY is project policy.

## Blind-Spot Resolution

From `.planning/debug/sync-setup-data-loss.md` `blind_spots:` and SEED §"Blind spots from debug session" (6):

### Blind spot 1: `[new branch]` literal output not reproduced

**Disposition:** **COSMETIC — can be verified via test fixture if planner wants belt-and-braces, otherwise scrap.**

**Reasoning:** The reporter's `[new branch]` claim was confused with force semantics; the actual mechanism (per-ref non-FF rejection by GitHub) was confirmed in the diagnosis. The literal stderr text comes from git's client-side output and depends on local `refs/remotes/origin/{B}` cache state — irrelevant to the fix. Post-fix, the per-ref push loop reports outcomes via our own status tags, not git's stderr literal text.

**Optional verification:** Add an assertion in `tests/sync_accept_flow_two_machine.rs` that captures stderr from the per-ref push and verifies the `PUSHED:{branch}` tag appears for each successful ref. This indirectly proves the surface is observable without depending on git's literal text.

### Blind spot 2: `push --all` partial-success behavior on real GitHub

**Disposition:** **VERIFIABLE in CI via the cross-machine attach fixture, but NOT BLOCKING the fix.**

**Reasoning:** Post-fix, `push --all` is gone — replaced by per-ref pushes. The exact GitHub behavior on `push --all` with unrelated-history divergence becomes a historical question, not a current concern. The fixture can capture "before" behavior by checking out the pre-fix commit on a side branch and running a one-time forensic test, but this is investigative archaeology, not regression prevention.

**Recommendation:** Skip. The fix removes the surface entirely.

### Blind spot 3: `gh auth setup-git` push-config side effects

**Disposition:** **VERIFIABLE via manual probe; recommend planner add a 5-minute check during plan-execute.**

**Reasoning:** `gh auth setup-git` configures git's credential helper to use gh's token. It does NOT set `push.default`, `receive.denyNonFastForwards`, or any other push-policy config (per the official `gh auth setup-git` source: it only writes `credential.https://github.com.helper = gh auth git-credential` and `credential.https://gist.github.com.helper = gh auth git-credential`). Verifiable via `git config --global --list | grep -E '(credential|push)'` after a fresh `gh auth setup-git`.

**Recommendation:** Add a one-line assertion in the planner's task list: "verify `gh auth setup-git` writes only credential.*.helper config" using a manual probe on the planner's dev machine. If the assumption holds, no extra defense needed. If it ever fails, that's a future-fix surface that doesn't block 35.2.

### Blind spot 4: Cosmetic stderr changes that could break existing CLI consumers

**Disposition:** **RISK to document; verifiable via existing test suite (`tests/sync_*.rs`).**

**Reasoning:** Adding new stderr status-tag lines (`PUSHED:{branch}`, `RECONCILED:{branch}`, etc.) does not remove existing stderr surface — it adds. No existing CLI consumer parses `accept_flow` stderr structurally (the only consumer is `psyche_sync_setup.rs::run` dispatch, which we control). The existing tests assert on `SyncSettings` state and the `sync enabled; remote=...` stdout line, not stderr structure.

**Recommendation:** Document in the Risk Register below. No mitigation needed beyond running the existing test suite green before deploy.

## Risk Register

| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Existing-install wall-clock-bootstrap divergence persists indefinitely if user never re-runs psyche-sync-setup | MEDIUM | LOW (sync just keeps no-op'ing post-commit; no data loss because pull_branch fetch+rebase handles it) | Documented as intentional — Fix 1's reconcile absorbs the divergence on next attach. No active mitigation. Optional follow-up: doctor surface in Phase 35.3 could detect main-SHA divergence and prompt re-run. |
| GitHub server-side behavior assumption (`push -X theirs` rebase outcome on first push of B's rebased history) | LOW | MEDIUM (push could surface non-FF if rebase didn't fully replay history) | Test fixture covers this end-to-end with a real bare remote. Manual UAT on a real GitHub second-machine attach before milestone close. |
| `merge-base --is-ancestor` exit-code probe via direct `Command` bypasses `run_git_checked`'s timeout/zombie-safe wrapper | LOW | LOW (probe is fast, no I/O on disk) | Use `.output()` without explicit timeout (matches "probe is fast" assumption) OR add a 2s `wait_timeout` via the same killer-thread pattern. Planner's call; document the choice. |
| New `Vec<PerRefOutcome>` return shape for `accept_flow` breaks downstream callers | LOW | LOW (sole caller is `psyche_sync_setup::run`, in-tree) | Search-and-update — only one call site exists today. |
| Inline `Command::new("git")` in `ensure_seed` (Pattern 3 Option B) skips `run_git_checked`'s D-07 timeout enforcement | LOW | LOW (commit-tree on empty tree is sub-100ms) | Either accept the simpler `.output()` form OR mirror the killer-thread pattern. Either way, document the timing assumption in code comments. |
| Migration churn: existing installs' next `accept_flow` invocation pays an unexpected reconcile cost (rebase + push) the user doesn't anticipate | LOW | LOW (a successful one-time event, not recurring; visible via per-ref status tags) | Per-ref status tag output already explains what happened (`RECONCILED:{branch}` is self-documenting). |
| Test fixture for cross-machine attach is platform-dependent (Unix-only for fake-gh paths) | MEDIUM | LOW (existing convention; Windows CI excludes Unix-gated tests by design) | Split tests: cross-platform direct-helper tests + Unix-only end-to-end. Same pattern as existing `sync.rs::tests::fake_gh`. |

## Project Constraints (from CLAUDE.md)

| Directive | Compliance |
|-----------|-----------|
| Windows native + Unix | All git shellout uses existing `run_git_checked` (already cross-platform via `hide_window`). New tests split into cross-platform (real git) and `#[cfg(unix)]` (fake-gh shell scripts). |
| Zero runtime deps | No new crates. Existing `chrono`, `serde_json`, `rusqlite` not touched. |
| Backward-compat: existing skill commands keep working | `psyche-sync-setup` skill body unchanged. Exit-code contract unchanged. Existing settings.json format unchanged. |
| Snake_case fns, PascalCase types, `pub(crate)` for cross-module | New `reconcile_against_remote` is `pub(crate)`; new `PerRefOutcome` / `ReconcileVerdict` types are `pub` only because they appear in `accept_flow`'s public return shape (consumed by `psyche_sync_setup.rs::run` and integration tests). |
| Status tags on stderr (cyan/orange ANSI) | New `PUSHED:{branch}` / `RECONCILED:{branch}` / `DIVERGED:{branch}` / `PUSH_FAILED:{branch}` tags follow existing convention. Owl-flavoured (cyan) for sync ops since sync is setup-side, not live-side. |
| Module layout: `src/owl/`, `src/live/`, `src/common/` | All changes in `src/common/sync.rs`, `src/common/tracked.rs`, and `src/owl/psyche_sync_setup.rs` — exactly matches conventions. |
| GSD workflow enforcement | This research is the `/gsd:plan-phase` research stage; plans + execution + verify flow through standard commands. No direct edits outside GSD. |
| Plugin version in `plugin/spt/.claude-plugin/plugin.json` | Phase 35.2 ships as a milestone-cadence DEPLOY; planners should NOT bump per-phase. Single bump at v1.8 milestone close. |

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The SEED's "no explicit migration command needed" choice is the user-preferred resolution for existing wall-clock-bootstrapped installs (i.e., reconcile-absorbs-it is acceptable rather than an explicit one-shot rewriter) | Runtime State Inventory + Risk Register | Wrong → planner must add a +1 plan: a `--migrate-bootstrap` subcommand that rewrites seed/main to the deterministic SHA and force-pushes. Recommend planner confirm with user during discuss-phase. |
| A2 | The user accepts per-ref status tag output as the recovery surface (no SKILL.md doc update in 35.2 — that's 35.3 scope) | Pattern 4 / Plan Ordering | Wrong → planner needs to add a 35.2-04 plan that updates SKILL.md with the new tag inventory. Easy to add if needed. |
| A3 | `gh auth setup-git` only writes credential helper config, no push-policy side effects | Blind Spot 3 | Wrong → some unknown push-policy config from `gh auth setup-git` could interact badly with per-ref push. Mitigation: planner verifies via 1-line probe in plan-execute. |
| A4 | The `[new branch]` literal stderr output is cosmetic and doesn't need verification | Blind Spot 1 | Wrong → if reporter saw something we can't reproduce, there's an unknown remote-state interaction. Low probability. |
| A5 | Inline `Command::new("git")` for the bootstrap `commit-tree` (Option B) is acceptable; no need to extend `run_git_checked` with an `envs` param (Option A) | Pattern 3 implementation choice | Wrong → planner picks Option A instead. Both work; A is a wider blast radius. |
| A6 | `git fetch origin` against an empty just-created GitHub repo succeeds immediately without race | Pitfall 3 | Wrong → intermittent first-attach flakes. Mitigation: 1-shot retry after backoff. Not pre-emptive. |
| A7 | The bootstrap-SHA epoch sentinel `1970-01-01T00:00:00Z` is acceptable (no UX surface forces a "real-looking" date) | Pattern 3 | Wrong → planner can pick any other locked date. SHA stability requires only that the date be deterministic, not that it be epoch. |

**All assumptions are recoverable** — none would block plans from being written, only require a planner+user discuss-phase touchpoint before sealing.

## Open Questions (RESOLVED)

All four questions answered inline. Planner consumed recommendations; resolutions recorded per gsd-plan-checker Dimension 11 protocol.

1. **Should the bootstrap-fix be implemented as Option A (extend `run_git_checked` with `envs`) or Option B (inline `Command` in `ensure_seed`)?**
   - What we know: Both work; B is narrower; A enables future env-var needs.
   - What's unclear: Whether other near-term phases also need env-var control on git invocations.
   - Recommendation: Default to B (inline) for 35.2; let the next phase that needs envs promote it to A.
   - **RESOLVED:** Option B (inline `Command` in `ensure_seed`). Implemented by Plan 35.2-01 Task 1.

2. **Should `accept_flow`'s return type change from `Result<String, SyncError>` to `Result<(String, Vec<PerRefOutcome>), SyncError>`?**
   - What we know: Per-ref observability is a phase requirement (SYNC-PUSH-PER-REF-01). Returning structured data is testable; emitting stderr from the data plane is faster to code but less testable.
   - What's unclear: Whether the dispatcher should own rendering OR the data plane should emit directly (e.g., `tracked.rs` salvage logs follow the latter pattern).
   - Recommendation: Return structured data. Easier test assertions on outcome shape. Dispatcher renders.
   - **RESOLVED:** Return tuple. Implemented by Plan 35.2-03 Task 1 (return-shape change) + Task 2 (dispatcher render).

3. **Should the cross-machine attach test fixture include a "before-Fix-1 reproduction" assertion to lock in regression coverage?**
   - What we know: Adding a `#[ignore]`-tagged "reproduces the pre-fix data-loss" test would document the historical surface.
   - What's unclear: Whether the project values that explicit-archaeology style or prefers tests that only assert post-fix behavior.
   - Recommendation: Skip. The diagnosis already captures the surface; tests assert post-fix behavior, period.
   - **RESOLVED:** Skip. No pre-fix reproduction test in any plan.

4. **Does the planner need to add a status-tag inventory update to CLAUDE.md (new `PUSHED`/`RECONCILED`/`DIVERGED`/`PUSH_FAILED` tags)?**
   - What we know: CLAUDE.md lists the current tag inventory under "Conventions" section.
   - What's unclear: Whether per-tag addition warrants a CLAUDE.md edit per phase, or whether sync tags are scoped enough to skip the inventory.
   - Recommendation: Add the tags to CLAUDE.md in plan 35.2-03 alongside the dispatcher emit. One-line addition. Phase 35.1's Plan 04 already updated the tag inventory; same pattern.
   - **RESOLVED:** Add to CLAUDE.md. Implemented by Plan 35.2-03 Task 3.

## Sources

### Primary (HIGH confidence — verified in this session)
- Live git probe (git 2.43.0.windows.1): GIT_COMMITTER_DATE + GIT_AUTHOR_DATE produces identical SHA across two independent repos. Verified output: `b3ea6a321a6c9a0ee90ae821820bd9777f8ff8b6`.
- Live git probe: `git merge-base --is-ancestor` exit codes (0=ancestor, 1=not-ancestor, 128=ref-resolution-error).
- `src/common/sync.rs` (full file read) — confirms `pull_branch` rebase policy, `SyncError` Display impl, `run_git_checked` signature constraints, existing test fixture patterns.
- `src/common/tracked.rs` lines 1-260 (full ensure_seed cold path read) — confirms commit-tree invocation shape and BOOTSTRAP_NAME / BOOTSTRAP_EMAIL constants.
- `src/owl/psyche_sync_setup.rs` (full file read) — confirms Debug-repr leak at line 70, dispatcher integration surface, exit-code contract.
- `src/common/git.rs` lines 1-540 — confirms `run_git_checked` has no `envs` parameter; confirms `GitError::Nonzero` collapses all non-zero exits into a single variant.
- `tests/common/sync_fixtures.rs` (full file read) — confirms existing two-clone bare-remote test pattern is reusable.
- `tests/sync_pull_push.rs` (full file read) — confirms round-trip test shape and `SptHomeGuard` + `serial_test` convention.
- `.planning/debug/sync-setup-data-loss.md` (full read) — diagnosis with file:line citations.
- `.planning/phases/35.2-.../35.2-SEED.md` — fix shape requirements + deferred-to-35.3 boundary.
- `.planning/config.json` — `nyquist_validation: false` (skip Validation Architecture section), `commit_docs: true` (commit research after write).
- `CLAUDE.md` — Windows+Unix portability, zero-dep, status-tag convention, ANSI color conventions, module layout, GSD workflow gate.

### Secondary (CITED — official docs)
- `git-commit-tree(1)` — `GIT_COMMITTER_DATE`/`GIT_AUTHOR_DATE` env var contract `[CITED: official git docs]`
- `git-merge-base(1)` — `--is-ancestor` exit-code semantics `[CITED: official git docs]`
- `git-rebase(1)` — `-X theirs` strategy option + `<upstream> [<branch>]` positional args `[CITED: official git docs]`
- `git-push(1)` — `<src>:<dst>` refspec semantics, per-ref outcome behavior `[CITED: official git docs]`

### Tertiary (none — no WebSearch needed for this phase)

All findings sourced from direct code inspection of the in-tree codebase + live git probes. No external/web research required — this is a surgical fix on a well-understood internal surface.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — zero new deps; everything routes through pre-existing in-tree helpers verified by direct file read.
- Architecture (reconcile algorithm + bootstrap determinism): HIGH — patterns verified live against git 2.43.0.windows.1; existing `pull_branch` precedent matches reconcile policy exactly.
- Test scaffolding: HIGH — `tests/common/sync_fixtures.rs` already supports every primitive needed; new test files are additive.
- Pitfalls: HIGH — diagnosed pitfalls have direct mitigations grounded in existing code patterns.
- Blind-spot resolution: MEDIUM — three blind spots have a recommended disposition but planners should confirm A1 (migration command) during discuss-phase.

**Research date:** 2026-05-28
**Valid until:** 2026-06-28 (30 days — git semantics are stable; bound to phase milestone window)
