# Nucleic — File Locks & Parent-Scoped Worktrees (v0)

A dedicated, explicit file-lock system for parallel sessions, scoped to each session's
**parent worktree/branch**, with all-or-nothing acquisition, a fair waiter queue, and a
release that fires on *merge-into-parent* no matter who performed the merge.

This replaces the current **implicit** lock — where a session's "lock" is an emergent
property of a live `git diff` (`AppStore.gatherActiveWork` over `SessionController.unmergedFiles`)
— with authoritative records owned by a `LockManager` actor. It also introduces **nested
worktrees** (a session whose parent is another session) and the cascade behavior that keeps
the tree consistent as work lands upward.

**Status:** design draft. Amends WORKTREE_MANAGER §1 (adds a parent pointer; "one session ↔
one worktree ↔ one branch" still holds) and §4, and RUNTIME §4 (two new tables). Git commands
are spelled out so the plumbing is reviewable; the Swift surface (§7) is the stable contract.

---

## 0. Problem & motivation

### 0.1 The leak

Sessions whose **agent** performs the merge itself (`git merge` from its own shell, outside
the app's autoship `MergeQueue`) frequently never release their file locks. Autoship sessions
*usually* release, but inconsistently. The result: a stale lock blocks other agents
indefinitely until someone hits the manual "Release File Locks" button.

### 0.2 Root cause — there is no real lock

Today a "lock" is inferred, not held. `SessionController.unmergedFiles(into:)` computes
"files this session changed that aren't yet in `target`," and `gatherActiveWork` treats that
set as the session's held locks. A file is "locked" only while that diff keeps reporting it.
Release happens two ways, **both brittle**:

1. **Explicit suppression** — `AppStore.forceReleaseLocks` inserts the session into a
   `forciblyReleased` set. Wired into exactly one place: the `.merged` case of
   `handleShipUpdate` (autoship only).
2. **Natural emptying** — the `target...HEAD` three-dot diff goes empty when the work lands.

The leak follows from three compounding facts:

| # | Fact | Consequence |
| --- | --- | --- |
| 1 | The agent process has a shell and can `git merge` itself | Agent-driven merges never call `forceReleaseLocks` — release depends *entirely* on the diff emptying |
| 2 | Merge target + lock computation are **hardcoded to `project.defaultBranch`** (`SessionController.integrate`, `unmergedFiles`, `shipIfCompleted`) | Work that lands in a *different* parent never empties the diff against `defaultBranch`; and **every session shares one lock namespace**, so "separate parents" don't actually isolate |
| 3 | `unmergedFiles` unconditionally unions in untracked files + `git diff HEAD` | A stray untracked/dirty file keeps the footprint non-empty → lock pinned forever |

### 0.3 Design decisions (from review, 2026-06-15)

| Question | Decision |
| --- | --- |
| Lock domain (file mutual-exclusion scope)? | **Per root tree** (resolved 2026-06-15, reversing per-immediate-parent). Mutual exclusion spans every session whose lineage flows to the same top-level project branch (`rootRef`) — parent↔child, siblings, cousins all contend; different top-level branches don't. |
| Release target / parent pointer? | **Immediate parent.** A session releases when its work lands in its own `parentRef` (a project branch, or a parent session's branch for a nested child). Cascade re-targets `parentRef`; `rootRef` is invariant. |
| Do worktree-less sessions lock? | **Yes** (resolved 2026-06-15). A session editing a branch checkout directly still acquires/holds locks; for it, "landed in parent" = committed to its branch. |
| How is release triggered when the agent merges itself? | **Both** — release immediately when Nucleic mediates the merge, *and* a git-ancestry poll backstop that detects agent/user merges, keyed on landed content so dirty residue can't pin a lock. |
| Mid-work the agent needs a new, contended file. Hold or release? | **Hold edited, queue for delta** — keep locks on files already held, queue only for the new file. All-or-nothing is strict at *initial* acquisition. |
| On grant, how is the worktree updated, and what on conflict? | **Merge parent in; pause on conflict** — `git merge <parent>` into the session branch; on conflict keep the lock held and surface a paused/conflict state. |
| When a parent merges up / is removed, what happens to its children? | **Re-target to grandparent** — rewrite the child's parent to the grandparent; the child updates against the new parent on its next grant. |
| Build scope | **Full nested end-to-end** — plumbing *and* the create-child flow, tree view, cascade UX, orphan handling, iOS parity. |

---

## 1. Invariants

1. **Locks are authoritative and explicit.** A lock is a record in the `LockManager`, not a
   value derived from a live `git diff`. The diff is used only to *detect* release (§4.4), and
   only over the specific locked files.
2. **A lock is scoped to the root tree.** `domain = rootRef` — the top-level project branch
   the session's lineage flows to. Every session in that tree (parent, child, sibling, cousin)
   contends for a file: at most one may hold/edit it at once — if a file is being edited it is
   locked. Two sessions under *different* top-level branches never contend. A session still
   *releases* against its own immediate `parentRef` (§4.4).
3. **All-or-nothing at acquisition.** A session with no locks yet acquires its full requested
   set atomically or nothing — it never holds a subset while blocked. (Mid-work expansion is
   the one bounded exception, §4.2.)
4. **Release only on merge-into-parent.** A lock releases when its file's content has landed
   in the parent (mediated or detected), or on explicit/lifecycle release — **never on a bare
   commit** inside the worktree.
5. **One session ↔ one worktree ↔ one branch ↔ one parent.** Extends WORKTREE_MANAGER §1 with
   an explicit parent pointer. The parent may be a project branch or another session's branch.
6. **Survivable.** Lock records and waiter queues persist (§6) and are reconciled against
   `git` + session state on launch; a crash never strands a lock or silently reorders a queue.
7. **Fail toward progress, not deadlock.** A blocked next-up waiter yields its place (§4.3); a
   gone/terminal holder or waiter is reaped; granting is gated by *readiness*, never by a stuck
   predecessor.

---

## 2. Domain model

### 2.1 Lock domains and the worktree tree

A **lock domain** is identified by a session's **root ref** — the top-level project branch its
whole lineage flows to. Every session in one tree shares that single domain, so file
mutual-exclusion spans the entire tree (parent, child, sibling, cousin). A session's *release*
target is its own immediate `parentRef` (which varies per node); the *lock domain* is the
shared `rootRef` (invariant under cascade).

```
project branch: dev ═══════════════════════ LOCK DOMAIN "dev"  (rootRef for the whole tree)
   ├── session Q   parentRef=dev        rootRef=dev
   └── session P   parentRef=dev        rootRef=dev
         └── branch nucleic/P
               ├── child C1  parentRef=nucleic/P   rootRef=dev
               └── child C2  parentRef=nucleic/P   rootRef=dev

• One domain "dev":  Q, P, C1, C2 all contend on a file — only one may edit it at a time
• Parent/child:      P and C1 DO contend (same tree); holder edits, the other waits (§2.2, §4.5)
• Release target:    each clears its lock when its file lands in its own parentRef
                     (P→dev, C1/C2→nucleic/P) — though the lock domain is the shared root "dev"
• Cascade:           P merges into dev & is removed ⇒ C1,C2 re-target parentRef "nucleic/P"→"dev";
                     rootRef "dev" never changes (§5.2)
```

| Term | Meaning |
| --- | --- |
| **Parent ref** | The branch a session merges into / **releases against** (also its update-on-grant source). Project branch for a top-level session; parent session's branch for a child. Cascade rewrites it (§5.2). |
| **Root ref** | The top-level project branch a lineage flows to. The **lock-domain key**. Invariant under cascade. |
| **Lock domain** | All locks + the waiter queue for one `rootRef`. The unit of isolation: one tree = one domain. |
| **Member** | Any session in the tree (its `rootRef` matches). **All** members contend on a file. |
| **Holder** | The session that currently owns a lock on a given `(domain, path)`. |
| **Waiter** | A session suspended in a domain's queue until it can acquire its delta. |
| **Delta** | The contended files a mid-work session is still waiting on (it already holds the rest). |

### 2.2 The whole tree contends; parent/child don't deadlock

**No two sessions in a tree may edit the same file at once** — if a file is being edited it is
locked, period. So `P` and its child `C1` both wanting `auth.swift` contend: whoever holds it
edits, the other waits. This is broader than sibling-only coordination, and it is the explicit
requirement.

The earlier worry was a parent/child *deadlock* — "P can't release until it merges up; C1
can't proceed until P releases." It doesn't occur, because the holder's progress never depends
on the waiter:

- `P` holds `auth.swift` and releases it when `auth.swift` lands in **P's** parent (`dev`) —
  i.e. when P ships. P's ability to ship never depends on C1.
- Once P ships, P is removed and C1 **re-targets** to `dev` (§5.2); the lock releases, C1 is
  granted, updates its worktree (now merging `dev`, which carries P's change), and proceeds.

So the holder always makes independent progress, the waiter is always eventually granted, and
at no instant are both editing the file. Cross-subtree divergence (cousins editing a file in
turn from different bases) is still possible and resolves where it belongs — at the merge into
the shared root — by the existing conflict flow, **not** by allowing simultaneous edits.

---

## 3. The parent pointer (data-model change)

### 3.1 `Session` gains three fields

`Session` (`Sources/NucleicCore/Session.swift`) currently stores its own `branch` and the
fork commit `baseSHA`, but **never the parent branch**. Add:

```swift
/// The branch this session merges into and **releases its locks against** — its release
/// target + update-on-grant source (LOCKING §4.4–4.5). A project branch (e.g. "dev") for a
/// top-level session, or a parent session's branch ("nucleic/<slug>") for a nested child.
/// Cascade rewrites this to the grandparent (§5.2). For a worktree-less session it is the
/// branch it edits — "landing in the parent" means committing to that branch.
public var parentRef: String?

/// The top-level project branch this session's lineage flows to — its **lock domain**
/// (LOCKING §2). All sessions sharing a `rootRef` contend on files; different roots don't.
/// Invariant under cascade (a child re-targets `parentRef`, never `rootRef`).
public var rootRef: String?

/// Set when `parentRef` is another session's branch — the parent session's id. Drives the
/// dependency tree and cascade (LOCKING §5). nil when the parent is a project branch.
public var parentSessionID: SessionID?
```

### 3.2 `createSession` records the parent

In `AppStore.createSession` the base is already resolved
(`let resolvedBase = base ?? project.defaultBranch`). Record it as the parent, and detect a
session parent:

```swift
let parent = liveSession(whoseBranchIs: resolvedBase.value)   // a live session ⇒ nested child
let session = Session(
    …,
    branch: worktree?.branch ?? resolvedBase.value,
    baseSHA: worktree?.baseSHA,
    parentRef: resolvedBase.value,                    // release target (worktree-less: its own branch)
    rootRef:  parent?.rootRef ?? resolvedBase.value,  // inherit the tree's root, else this base IS the root
    parentSessionID: parent?.id,
    …)
```

*(The detection scan is illustrative; the implementation resolves it without an actor-hop per
controller — e.g. a `branch → SessionID` index maintained alongside `controllers`.)*

### 3.3 Repoint merge / unmerged / autoship targets to the parent

Three call sites are hardcoded to `project.defaultBranch` and must use the session's
`parentRef` (falling back to `defaultBranch` only for legacy rows where it's unset):

| Call site | Today | Change |
| --- | --- | --- |
| `SessionController.integrate` | `into: project.defaultBranch` | `into: GitRef(parentRef) ?? project.defaultBranch` |
| `SessionController.unmergedFiles` | `into: project.defaultBranch` | same |
| `AppStore.shipIfCompleted` (autoship enqueue) | `target: project.defaultBranch` | `target: parentRef ?? project.defaultBranch` |
| `AppStore.shipTargetLabel` (note text) | `project.defaultBranch.value` | `parentRef ?? …` |

This repoint fixes the release half of root-cause #2 (§0.2): merge/release now point at each
session's real `parentRef`, not a shared default. The *lock-domain* half is handled separately —
mutual exclusion is keyed by `rootRef` (§2, §4), so independent trees don't interfere while
everything within a tree does.

### 3.4 Worktree-less sessions participate

A session created with `useWorktree: false` edits a branch checkout directly (no isolated
worktree). It **still acquires and holds locks** — if a file is being edited it must be locked,
worktree or not. Its `parentRef` and `rootRef` are the branch it edits (e.g. `dev`), so it joins
that tree's lock domain and contends with every other session there. Because its working copy
*is* the branch, "landed in the parent" collapses to "committed to the branch": its lock on a
file releases once that file is committed (the `landed` check, §4.4, naturally sees the working
tree match `HEAD`). Invariant 4 (release-on-merge-not-commit) governs worktree sessions whose
branch is distinct from the parent; for a worktree-less session the branch *is* the parent, so a
commit is the landing.

### 3.5 Migration `v14-parent-refs`

```sql
ALTER TABLE session ADD COLUMN parent_ref TEXT;
ALTER TABLE session ADD COLUMN root_ref TEXT;
ALTER TABLE session ADD COLUMN parent_session_id TEXT;
```

A one-time backfill sets both refs for existing worktree sessions to `project.default_branch`
(they were all top-level under one branch), so they immediately share that root's lock domain and
show a parent. A nil `parentRef`/`rootRef` falls back to `defaultBranch` at read time for safety.
`SessionRow` / `SessionRow.toSession()` (`Persistence/GRDBMetadataStore.swift`) gain the three
columns.

---

## 4. `LockManager` — the dedicated system

A new `actor LockManager` (`Sources/NucleicCore/LockManager.swift`) owns all lock state. It is
decoupled from git/session internals via **injected collaborators** (the same pattern as
`MergeQueue.setIntegrator`), so it is unit-testable with fakes:

```swift
public actor LockManager {
    /// Resolve a session's lock context (worktree path, parentRef, and rootRef = lock domain).
    var context: (@Sendable (SessionID) async -> LockContext?)?
    /// Update-on-grant: `git merge <parentRef>` into the session's worktree (LOCKING §4.5).
    var updateWorktree: (@Sendable (SessionID) async -> UpdateResult)?
    /// Release detector (batched): which of `paths` have landed in the session's parent? (§4.4)
    var landed: (@Sendable (_ session: SessionID, _ paths: [String]) async -> Set<String>)?
}

public struct LockContext: Sendable { let worktree: String; let branch: String; let domain: LockDomain }
public struct LockDomain: Hashable, Sendable { let ref: String }   // value = parent branch ref
public enum UpdateResult: Sendable { case clean; case conflicted([String]); case failed(String) }
public enum AcquireOutcome: Sendable { case granted; case cancelled }
```

### 4.1 State

```swift
// Authoritative, persisted (LOCKING §6).
private var held: [LockKey: LockRecord]               // (domain, path) → holder
private var queues: [LockDomain: [Waiter]]            // ordered, FIFO with demotion
private var heldBySession: [SessionID: Set<LockKey>]  // reverse index for fast release

struct LockKey: Hashable { let domain: LockDomain; let path: String }
struct LockRecord: Sendable { let holder: SessionID; let acquiredAt: Date }
struct Waiter {                                       // continuation is transient (not persisted)
    let session: SessionID; let domain: LockDomain
    var delta: Set<String>; let enqueuedAt: Date
    let continuation: CheckedContinuation<AcquireOutcome, Never>
}
```

### 4.2 Acquisition — all-or-nothing, hold-edited-queue-for-delta

```
acquire(S, files):
    D       = context(S).domain
    paths   = normalize(files)                       // ConflictDetector.normalize / pathsOverlap
    mine    = paths ∩ heldBySession[S]               // already held — no-op
    needed  = paths − mine
    blocked = { p ∈ needed : held[(D,p)].holder ∉ {nil, S} }

    if blocked is empty:
        for p in needed: held[(D,p)] = (S, now)      // grant the whole delta
        return grant(S)                              // → §4.5 update-on-grant, then resume .granted

    else:                                            # something is contended
        if heldBySession[S] is empty:                # FRESH acquisition → strict all-or-nothing
            enqueue Waiter(S, D, delta: needed)      # take NOTHING; hold no locks while blocked
        else:                                        # MID-WORK expansion → hold edited, queue delta
            enqueue Waiter(S, D, delta: blocked)     # keep `mine`; queue only the contended files
        suspend on continuation                      # resumed by poll() (§4.3) when delta frees
```

Two properties this guarantees:

- **No "hold A while blocked on B" at first acquisition** (invariant 3). A fresh session takes
  zero locks until its *entire* set is free.
- **Mid-work never loses committed progress.** Once a session holds locks (has started
  editing), a newly-needed contended file only *queues* — the session keeps what it has. This
  is the bounded exception the user chose, and it can't reintroduce the fresh-acquisition
  deadlock because the held set is monotonic and already "earned."

Overlap uses the existing `ConflictDetector.pathsOverlap` (component-wise, so `src` ≠
`src2/x`) and `normalize` (`Sources/NucleicCore/Conflict.swift`), preserving directory-prefix
semantics. Intent-only overlap (no files given) is handled as today, surfaced as a soft prompt
rather than a hard lock.

### 4.3 Queue & demotion

`poll(D)` runs whenever a lock in `D` releases and on a low-frequency timer backstop:

```
poll(D):
    reserved = {}                                    # files granted earlier in THIS pass
    for w in queues[D]  (in order):
        free = { p ∈ w.delta : held[(D,p)].holder ∈ {nil} and p ∉ reserved }
        if free == w.delta:                          # readiness, not position, gates the grant
            for p in w.delta: held[(D,p)] = (w.session, now); reserved ∪= w.delta
            remove w; grant(w.session)               # → §4.5, resume .granted
        else if w is the head and still blocked:
            rotate w back one place                  # "demote by a place" (user's rule)
    reap any waiter whose session is terminal/gone → resume .cancelled
```

- **"Demote by a place."** A next-up waiter that can't get its whole delta moves back one slot
  so the following waiter becomes next-up. Over successive passes a perpetually-blocked head
  keeps yielding.
- **No starvation.** Because a grant is gated by *readiness* (its delta is free) and not by
  queue position, a ready waiter behind a blocked one is granted in the same pass. Demotion
  exists to keep FIFO fairness *among waiters competing for the same contended file* from
  degrading into a stuck head — it is not the thing that prevents starvation; readiness is.
- **Per-pass reservation** (`reserved`) stops two waiters from both being granted the same file
  in one pass — the analog of today's `aheadFootprints`.

The poll timer replaces today's 2-second `waiterPollInterval`; with release now event-driven
(a release calls `poll(D)` directly), the timer is only a crash/skew backstop and can be
slower (e.g. 5 s).

### 4.4 Release — mediated + interceptor event + git-ancestry poll

> **Scope (2026-06-17): locking applies ONLY to Nucleic Control projects.** `AppStore.lockDomain`
> returns `nil` for any non-control project, so its sessions never acquire a lock (`arbitrate`
> fails open) and none are reconstructed at launch. Rationale: a lock must release when its work
> *lands* (a `git merge`), and the only place Nucleic observes that merge **with certainty** is
> inside the control container, via the `git` interceptor shim ([[git-event]] / RUNTIME). Outside
> it we could not reliably unlock, so we don't lock at all rather than strand files.

A lock releases when its file's work has **landed in the parent**, by any path:

1. **Mediated (immediate).** When Nucleic performs the merge, release on success. In
   `AppStore.handleShipUpdate` the `.merged` case calls `lockManager.releaseAll(session)`
   instead of `forceReleaseLocks` — **after** `integrate` returns `.clean` (so a lock is never
   released before the merge fully completes). A future Nucleic-initiated manual merge does the
   same.
1b. **Interceptor event (immediate, certain).** When the in-container `git` shim reports a
   successful landing-capable op (merge/rebase/reset/commit/cherry-pick/revert/restore),
   `AppStore.observeGitOp` kicks `reconcileLocks` *immediately* — the agent's own `git merge`
   lands its work without Nucleic mediating, and the shim is the certain signal the poll only
   approximated. Reconcile is still content-verified (below), so the event is purely a fast-path
   trigger — only files that actually match the parent release. An agent-performed merge that
   fully lands an autoship session's work is also recorded as a ship (`markShippedIfLanded`).
2. **Detected (backstop poll).** For each holder `S`, the poll asks `landed(S, paths)` once over
   all of S's held paths (batched):

   ```
   landed(S, paths)  ≡  `git diff --name-only <S.parentRef> -- A B C`  in S.worktree → the
                        complement (paths it does NOT list) have landed   # one process per holder
   ```

   The worktree's content for `path` matches the parent ⇒ the work is reflected upstream ⇒
   release. This is **per-file and content-based**, which is exactly why it is robust where
   the old footprint was not:
   - Works for **merge, squash, and rebase** (content lands regardless of commit ancestry).
   - Detects **agent-performed and user-performed merges** — the core leak — because it never
     depended on `forceReleaseLocks` firing.
   - **Untracked/dirty residue can't pin it:** only the locked `path` is checked, so a stray
     untracked file elsewhere is irrelevant (root-cause #3, §0.2).
   - A **bare commit** that hasn't merged still differs from the parent ⇒ lock retained
     (invariant 4) — *except* a worktree-less session, whose branch **is** the parent, so its
     commit is the landing and releases the lock (§3.4).

   *Active* uncommitted edits to a locked file also still differ from the parent ⇒ the session
   legitimately keeps holding its own in-flight file (this is "active work," not "residue").

3. **Lifecycle.** A terminal or archived holder releases everything (`releaseAll`), preserving
   today's behavior that "a finished/put-away session holds nothing."
4. **Manual.** The "Release File Locks" button calls `releaseAll(openSessionID)` — kept as the
   escape hatch.

Each release calls `poll(D)` so blocked waiters are granted immediately, not on the next tick.

### 4.5 Update-on-grant — bring parent (and ship target) in; re-ground the agent; pause on conflict

`grant(S)` does **not** resume the agent until its worktree reflects the latest of the refs S's work
is anchored to — and, because the agent `Read` the files *before* the lock, until the agent is told
what moved underneath it. Detection is **decoupled from the merge**: the agent's edits are uncommitted
mid-turn (commits are deferred to ship time), so a working-tree `git merge` frequently refuses on a
dirty tree, precisely when divergence is highest. So "did a requested file move?" is a commit-to-commit
diff (immune to the dirty tree), and the merge itself is best-effort.

```
grant(S, files):                        # `files` = the edit's locked set
    moved = {}
    for ref in dedup([S.parentTarget, S.shipTarget]):   # one ref when they coincide (the default)
        if ref is an ancestor of HEAD:  continue                     # nothing to pull (fast-path)
        moved ∪= `git diff --name-only preHEAD <ref> -- files`       # detection: dirty-tree-immune
        result = updateWorktree(S, ref)   ≡  best-effort `git merge <ref>` in S.worktree  (local; no fetch)
        switch result:
            case .clean:        continue                       # worktree now reflects ref
            case .conflicted:   keep S's locks; mark S .updateConflict (paused); surface; STOP
            case .dirtyRefusal: leave worktree; proceed on current base (still re-grounded by `moved`)
    if moved is empty:  resume S with .granted                       # agent's files didn't move
    else:               resume S with .grantedNeedsReground(files: moved, diff: scoped patch)
```

- **Two sources, deduped.** `parentTarget` (= `S.parentRef`, the fork base) is the **primary** source
  and the anchor for the lock domain, release accounting (§4.4), and the §5.2 cascade — it is how a
  parent session's in-progress work reaches a nested child. `shipTarget` (= `S.shipDestination`: a
  per-session `shipBranch`, else the project's `autoShipBranch`, else the fork base) is where S's
  work — and its siblings' — actually *lands*. When the two **diverge** (an override is set),
  update-on-grant reconciles against **both**. Otherwise a sibling that shipped its work to the ship
  branch and then released its lock would be invisible to the next granted session (which pulls only
  from `parentTarget`), and the staleness would surface only at S's own ship time. Reconciling against
  the ship target front-loads the same merge `resyncBranch` already performs *after* a ship. With no
  overrides the two coincide and this stays the single historical `git merge <parentRef>`.
- **Order & first-conflict-wins.** `parentTarget` is merged **first** (cascade + lock anchor), then
  `shipTarget`. The first ref that conflicts pauses S holding its lock; any earlier clean merge stays
  applied (monotonic progress toward the destination). Re-ground detection (`moved`) accumulates across
  every reconciled ref.
- **Local-only — no fetch.** Every reconciled ref is a *local* branch (fork base / cascade parent /
  ship target are all checked out and merged into locally, never an `origin/*` ref), and this runs on
  the grant hot path, so update-on-grant never `git fetch`es: a network round-trip per grant would add
  latency and a hang risk, and it would be redundant since sibling coordination is entirely local
  (every session shares this repo and lands work in local branches via `integrate`). A remote that
  advanced out-of-band is reconciled by the explicit sync path, not here.
- **Immediate grants run update-on-grant too.** A session that acquires with *no waiting* (its files
  were immediately free) still runs this, **guarded by** the `is-ancestor` fast-path (a cheap no-op
  when a ref hasn't advanced), so a freshly-granted session is never working against a stale parent —
  whether or not it had to wait. Only an already-held re-acquire short-circuits with no merge.
- **Re-ground.** `.grantedNeedsReground` keeps the lock but **blocks the agent's in-flight edit once**:
  the backend denies it with the scoped diff and a *transient/re-issue* message (not "stop"). The agent
  re-grounds and re-issues; the lock is held, so the retry re-grants instantly (no merge) and proceeds.
  This fires **at most once per file per session** — sound, because a held lock prevents any same-domain
  session from advancing that file's parent copy while held. Detection is scoped to the locked `files`,
  so an unrelated parent move stays silent; the diff is capped (~8 KB / 5 files), falling back to a
  re-read instruction. Coverage is the structured edit tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit`);
  the real payoff is `Write`/`MultiEdit`, which would otherwise silently clobber the merged-in change.
- **Clean** is the common path (the lock guaranteed no *sibling* touched these files; the
  parent may still have advanced via the parent session or unrelated files).
- **Conflict** is surfaced like an autoship conflict (a transcript note + a sidebar
  "needs attention" affordance, reusing the `autoShipFailed` UX vocabulary) and the session is
  **paused holding its lock** — it does not proceed and does not release until the human (or a
  follow-up agent turn) resolves it. This satisfies "once a session receives the lock, update
  its worktree to capture/propagate changes," with a safe failure mode.

### 4.6 Backward-compatible agent surface

The agent-facing contract is unchanged. `AppStore.arbitrate(sessionID:task:files:)` — invoked
by the `check_conflict` MCP tool and by edit interception — becomes a thin wrapper:

```swift
public func arbitrate(sessionID:task:files:) async -> ConflictResolution {
    switch await lockManager.acquire(sessionID, files: files) {
        case .granted:   return .proceed
        case .cancelled: return .cancelled
    }
}
```

The `Defer` / `Cancel` / `Wait for Access` prompt choices map onto: file a to-do / cancel /
enter the queue. `lockQueueSnapshot()` reads from `LockManager.snapshot(domain:)` instead of
`gatherActiveWork`.

---

## 5. Nested worktrees (end-to-end)

### 5.1 Creating a child session

A child is created exactly like any session, with the parent's branch as the base:

```swift
createSession(in: project, title: …, base: GitRef(parent.session.branch), …)
// → parentRef = parent.branch, parentSessionID = parent.id  (auto-detected, §3.2)
```

UI entry points:
- **Session context menu / dashboard:** "New child session…" on any active session.
- **Add / Move flows:** a parent picker in the create sheet (extends the flows touched in
  `a897474 "Add clone + Nucleic Control to Add Project and Move flows"`), defaulting to a
  project branch (top-level) but allowing "under <session>".

### 5.2 Cascade — re-target to grandparent

When a parent `P` lands (merges into its own parent `G`) and its worktree is discarded — or
`P` is archived/removed — every child `C` with `parentSessionID == P` is re-targeted:

```
onParentResolved(P):
    G = (P.parentSessionID, P.parentRef)             # grandparent (may be a project branch)
    for C in children(P):                            # parentSessionID == P
        C.parentRef       = G.ref                    # new release target + update-on-grant source
        C.parentSessionID = G.sessionID              # nil if G is a project branch
        lockManager.retarget(C, to: G.ref)           # §5.3 — rootRef (lock domain) is unchanged
        note(C, "parent ‘\(P.title)’ landed — now based on \(G.ref)")
```

If `P` was top-level (`G` is a project branch), children re-target to that branch — the tree
collapses toward the root as work lands. Cascade is recursive only in the sense that each level
re-targets to *its* grandparent when *its* parent lands; a single merge re-targets one level.

### 5.3 Re-target only moves the release target

Because the lock domain is `rootRef` and **`rootRef` is invariant under cascade** (C's root was
always the tree's top-level branch, not P's branch), re-target does **not** migrate locks between
domains — there is nothing to move. It only rewrites C's *release target*:

```
retarget(C, to: newParentRef):
    C.parentRef = newParentRef     # release condition + update-on-grant source now point here
    # held locks stay exactly where they are (same rootRef domain, same queue)
    poll(C.rootRef)                # newParentRef may already contain C's work ⇒ release/grant now
```

The only observable change: C now releases a lock when its file lands in the **grandparent** (its
new `parentRef`) rather than the gone parent, and its next update-on-grant merges the grandparent
in. No file is re-contended and no work is lost, because the domain — and therefore every
holder/waiter relationship — is unchanged. (This is simpler than the original per-domain sketch
precisely because per-root-tree keeps the whole subtree in one domain.)

### 5.4 Orphan handling

Startup reconciliation (extends WORKTREE_MANAGER §8) and live checks:
- A child whose `parentSessionID` no longer resolves (parent record gone without a clean
  cascade — e.g. a crash mid-merge) is re-targeted to the nearest surviving ancestor, or to
  `project.defaultBranch` if none survive, and flagged in the UI.
- A child whose `parentRef` branch no longer exists in `git` is likewise re-pointed to
  `defaultBranch` and flagged.
- The dependency tree view (§8) renders orphans distinctly until resolved.

---

## 6. Persistence — reconstruct held locks from git on launch

Lock **ownership** is reconstructable from git: a session holds a lock on each file it has
changed but not yet landed in its parent — exactly its `unmergedFiles` (§4.4), in its root-tree
domain. So rather than a persisted table, held locks are **rebuilt at launch** from that
authoritative state. This is *not* the old ongoing git-derivation (at runtime locks are explicit
and authoritative in the `LockManager`); it is a one-time cold-start seed.

### 6.1 Launch reconstruction (extends WORKTREE_MANAGER §8)

After sessions load (`AppStore.loadSessions` reconstructs the controllers), `reconstructLocks`
runs:

1. For each **live** session (non-terminal, non-archived, worktree-backed), compute its
   `unmergedFiles` and seed a held lock on each path, keyed by its `rootRef` domain.
2. `LockManager.restore` grants each path to the first claimant and **skips a path already
   held**, so a stale double-claim can't overwrite a live one.
3. Start the reconcile loop, so any reconstructed lock whose work has since landed releases on
   the next pass.

It is **self-correcting**: already-landed work isn't in `unmergedFiles`, so no stale lock
survives a restart — without the launch-time landed-sweep a persisted table would need.

### 6.2 Why queue order isn't persisted

A waiter is a *live* `CheckedContinuation` for an agent blocked mid-turn. A restart kills those
agent processes, so there's no continuation to resume and no agent waiting — a persisted queue
slot would be inert. Queued sessions simply re-acquire (and re-queue) on their next edit; held
*ownership* is what matters across a restart, and §6.1 restores it. (A SQLite `file_lock` table
could replace reconstruction later if launch-time git cost ever matters; it would still need the
same landed-sweep that reconstruction gets for free.)

---

## 7. Swift surface (the stable contract)

```swift
public actor LockManager {
    // Wiring (set once by AppStore, mirrors MergeQueue.setIntegrator).
    public func setCollaborators(
        context: @escaping @Sendable (SessionID) async -> LockContext?,
        updateWorktree: @escaping @Sendable (SessionID) async -> UpdateResult,
        landed: @escaping @Sendable (SessionID, [String]) async -> Set<String>)

    // Acquisition (LOCKING §4.2). Suspends until granted or the caller is gone.
    public func acquire(_ session: SessionID, files: [String]) async -> AcquireOutcome

    // Release (LOCKING §4.4).
    public func release(_ session: SessionID, paths: [String])   // partial
    public func releaseAll(_ session: SessionID)                 // mediated / lifecycle / manual

    // Cascade (LOCKING §5).
    public func retarget(_ session: SessionID, to newParent: GitRef) async

    // Maintenance + introspection.
    public func poll(_ domain: LockDomain) async                 // also runs on a backstop timer
    public func snapshot(domain: LockDomain?) async -> LockQueueSnapshot   // for the UI (§8)
}
```

`LockQueueSnapshot` / `FileLock` / `LockParticipant` / `WaitingSession`
(`Sources/NucleicCore/Conflict.swift`) are reused unchanged, now sourced from `LockManager`
records rather than `gatherActiveWork`, and filterable per domain.

### 7.1 What gets retired

| Removed / refactored | Replaced by |
| --- | --- |
| `AppStore.gatherActiveWork` (as the lock source) | `LockManager.held` records |
| `forciblyReleased`, `grantedFootprints`, `conflictWaiters`, `pollWaiters`, `startWaiterPollIfNeeded`, `waiterPollInterval` | `LockManager.queues` + `poll` + persisted state |
| `forceReleaseLocks` semantics (footprint suppression) | `LockManager.releaseAll` (true release) |
| `handleShipUpdate(.merged)` → `forceReleaseLocks` | → `lockManager.releaseAll` after clean integrate |
| `arbitrate` body | thin wrapper over `acquire` (§4.6) |
| `lockQueueSnapshot` body | `LockManager.snapshot` |

`SessionController.unmergedFiles` survives but is repointed to `parentRef` and is used for the
diff/UI and as the basis of `hasLanded`, not as the lock authority.

---

## 8. UI (macOS + iOS)

- **Dependency tree view** (dashboard): sessions rendered as a tree by `parentSessionID`, each
  node showing its domain's lock state (holders / waiters) and ship status. Orphans (§5.4)
  marked distinctly.
- **Lock queue viewer:** the existing holders/waiters view, now per-domain, reading
  `snapshot(domain:)`. "Blocked by" links resolve within the domain.
- **Update-on-grant conflict / pause:** reuse the autoship-conflict surface (transcript note +
  sidebar "needs attention") for `.updateConflict`; offer "resolve in worktree" or "hand to a
  new agent turn" (WORKTREE_MANAGER §9.5 conflict-to-agent loop).
- **Create-child flow:** parent picker in the new-session sheet + "New child session…" action.
- **iOS parity (SYNC_PROTOCOL):** sync `parentRef`, `parentSessionID`, and per-domain lock
  snapshots so the remote app renders the tree and lock/wait state read-only. New fields are
  additive to the wire model.

---

## 9. State transitions

### 9.1 A lock's life

```
        acquire (delta free)            merge into parent (mediated OR detected)
 (none) ──────────────────▶ held ───────────────────────────────────────────▶ released
            │                  ▲                                                   │
            │ delta contended  │ poll grants when delta frees                      │
            ▼                  │                                                    │
         queued ──────────────┘                                                    │
            │  demote one place each pass while next-up & blocked                  │
            │  session terminal/archived/gone ─────────────────────────▶ released ◀┘
```

### 9.2 Release-trigger truth table

| Event | Lock on landed file? | Mechanism |
| --- | --- | --- |
| Autoship merge succeeds | Released | Mediated (`releaseAll` after `.merged`) |
| Agent runs `git merge` itself | Released | Detected (`hasLanded` poll) |
| User merges in a terminal | Released | Detected |
| Session commits but doesn't merge | **Held** | Still differs from parent |
| Session has uncommitted edits to the file | **Held** | Active work differs from parent |
| Unrelated untracked file appears | Unaffected | Per-file check ignores it |
| Session archived / finished / interrupted | Released (all) | Lifecycle |
| "Release File Locks" pressed | Released (all) | Manual |

---

## 10. Test matrix

Built on the existing hermetic git-temp-repo harness (`AppStoreTests`, `AutoshipMergeTests`,
`MergeQueueTests`, `ConflictDetectorTests`). New: `LockManagerTests`, `NestedWorktreeTests`.

| # | Scenario | Asserts |
| --- | --- | --- |
| 1 | Two siblings, disjoint files | Both acquire immediately; no queue |
| 2 | Two siblings, same file | Second queues; granted after first's file lands |
| 3 | All-or-nothing | Fresh session needing {A,B}, B held → holds **neither** A nor B while queued |
| 4 | Hold-edited-queue-for-delta | Session holding {A}, needs contended {B} → keeps A, queues for B only |
| 5 | Demotion | Blocked next-up yields; a ready waiter behind it is granted same pass; FIFO kept for the contended file |
| 6 | Release on mediated merge | Autoship `.merged` → `releaseAll`; waiter granted |
| 7 | **Release on agent merge** (the bug) | Agent `git merge` in worktree → `hasLanded` poll → released; waiter granted |
| 8 | No release on bare commit | Commit without merge → lock retained |
| 9 | Residue can't pin | Untracked junk present, file landed → released |
| 10 | Parent isolation | Sessions in domains "dev" vs "main" editing same path → no contention |
| 11 | Update-on-grant clean | Granted waiter's worktree contains parent's latest before it proceeds |
| 12 | Update-on-grant conflict | Conflicting parent advance → lock held, session paused, surfaced |
| 13 | Cascade re-target | Parent merges → child's `parentRef`/domain rewritten to grandparent |
| 14 | Lock migration on re-target | Child's held locks carried to grandparent domain; contended delta queued |
| 15 | Persistence/reconcile | Locks + queue survive restart; landed-during-downtime locks reaped; order preserved |
| 16 | Orphan | Parent vanished → child re-targeted to nearest ancestor / default, flagged |

---

## 11. Rollout / phasing

Each phase builds + `swift test --build-system native` green before the next; commits land on
`dev` per the gated flow.

| Phase | Scope | Outcome |
| --- | --- | --- |
| **A — Parent model + target repoint** (§3) | `parentRef`/`parentSessionID`, migration `v14`, repoint integrate/unmerged/autoship to the parent | *By itself* stops most leaks and all cross-parent interference. Low risk, no new subsystem. |
| **B — `LockManager`** (§4.1–4.3, §6, §7) | Authoritative locks, all-or-nothing, demotion queue, persistence; retire the implicit machinery; rewire `arbitrate`/`lockQueueSnapshot` | Dedicated lock system; deterministic, testable. |
| **C — Release + update-on-grant** (§4.4–4.5) | Mediated `releaseAll`, `hasLanded` poll, merge-parent-in on grant + conflict pause | Closes the agent-merge leak; worktrees refresh on grant. |
| **D — Nesting end-to-end** (§5, §8) | Cascade re-target, lock migration, create-child flow, tree view, orphan UX, iOS parity | Full nested capability. |

Phase A is independently shippable and is the fastest path to stopping the reported leak;
B–D layer the dedicated system and nesting on top.

---

## 12. Considerations / open questions

1. **Poll cost.** ✅ Resolved: **batch** per holder — one `git diff --name-only <parent> -- A B C`
   per worktree per tick — and keep the timer a slow backstop (5–10 s) since release is
   event-driven (`poll(D)` runs on every release). Negligible at dozens of locks.
2. **Squash + identical content edge.** Content-based release also fires if the parent
   *coincidentally* reaches identical content for a file. That's harmless (the lock isn't
   needed if the parent already matches), but worth noting it's "content landed," not "this
   session merged." ✅ Accepted.
3. **Update-on-grant strategy.** Decision was **merge** parent in (preserves the agent's
   commit history). A future per-project toggle could offer rebase for linear history; out of
   scope for v0. 🟡
4. **Deep trees / cascade storms.** A tall tree where the root merges could re-target many
   descendants at once. Re-target is O(children) per level and cheap (metadata + a domain
   re-check), but the tree view should debounce. 🟡 Validate at depth > 3.
5. **Tree-wide contention.** ✅ Resolved (reverses the earlier carve-out): **no two sessions in a
   tree ever edit the same file at once** — parent and child contend like any siblings; the lock
   domain is the whole root tree (§2, §2.2). If a file is being edited it is locked. Cross-subtree
   divergence (cousins editing a file in turn from different bases) still resolves at the merge
   into the shared root, never by simultaneous edits.
6. **Intent-only overlap.** Locks are file-based; the existing intent/task-text overlap
   (`ConflictDetector` with no files) stays a soft advisory prompt, not a hard lock. ✅
7. **Worktree-less sessions.** ✅ Resolved: they **participate** in locking (§3.4) — a file under
   edit is always locked, worktree or not. Their `parentRef`/`rootRef` are the branch they edit,
   so they share that tree's domain; release = commit to the branch.
```