# Nucleic — Offline Session Lifecycle (delete / archive / view without the owning host)

> **Status (2026-07-22): design plan, nothing built.** Authored by **Opus 4.8** — a
> non-mythos-class model. Treat the decisions in §1 as *proposals awaiting owner sign-off*,
> not as locked decisions in the sense [CARBON_SHARDING.md](CARBON_SHARDING.md) §0 uses that
> phrase. Every code citation below was read directly at the commit this was written against.
>
> **Goal.** Deleting, archiving, and viewing a session should work from any device regardless
> of whether the session's owning Mac is online. The owning Mac picks up the new state when it
> reconnects.

---

## 0. What is and isn't true today

The framing that motivated this plan was *"all sessions now live in Carbon, so this should
already be possible."* That is half right, and the wrong half is load-bearing.

**Carbon does not hold session lifecycle state.** `CarbonStreamKind`
([`CarbonMessages.swift:22-35`](../Sources/NucleicProtocol/Sync/CarbonMessages.swift)) is four
streams of *bytes* — `worktreeSnapshot`, `history`, `nativeTranscript`, `lock`. The archived
flag, the session's existence, its title and status live in each owner's GRDB
(`session` table, [`GRDBMetadataStore.swift:47`](../Sources/NucleicCore/Persistence/GRDBMetadataStore.swift))
and are projected on the wire as `SessionSummary`
([`WireMessages.swift:529-601`](../Sources/NucleicProtocol/Sync/WireMessages.swift)). Two
consequences that constrain every option below:

- **Phones hold no Carbon at all.** Every Carbon verb is inert on iOS by contract — D13, no
  shard store, no key custody
  ([`RemoteStore.swift:2837-2851`](../ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift)).
  So "put it in Carbon" cannot by itself give the phone offline authority.
- **Carbon has no delete verb, deliberately.** D11 makes GC mirror-autonomous:
  "There is no 'evict now' message" ([CARBON_SHARDING.md](CARBON_SHARDING.md) §7.3, `:269`).
- Carbon reconcile is additionally restricted to **non-relay** transports
  ([`AppStore.swift:10461-10468`](../Sources/NucleicCore/AppStore.swift)), so it would not
  cover a phone on cellular anyway.

Carbon is therefore the **design precedent** to copy — heads gossiped as *state*, not events,
so a missed push is irrelevant ([`CarbonMessages.swift:59-61`](../Sources/NucleicProtocol/Sync/CarbonMessages.swift))
— not the storage to reuse.

### 0.1 Viewing already works. Do not rebuild it.

| Surface | Offline behavior | Citation |
|---|---|---|
| iOS transcript | Seeds from `SessionCache` before subscribing — "including fully offline, where the subscribe below is a no-op" | `RemoteStore.swift:2172-2180` |
| iOS cache | `summaries.json` (cap 50) + `transcripts/<id>.json` (cap 1500 events); "a projection cache, never canonical state" | `SessionCache.swift:1-27` |
| macOS transcript | Mirror seed happens *above* the `guard let pc = peerClient` on purpose — "with no sync server running the mirror is all we have" | `AppStore.swift:8234-8242` |
| Third-device serving | `transcriptEvents` three-tier fallback: live controller → owned on-disk → **mirror synced from another device** | `AppStore.swift:11898-11938` |
| Composer honesty | Controls `.disabled(!store.connectivity.isLive)` — "reads as 'offline / read-only history' rather than broken" | `SessionDetailView.swift:364,497,521,595` |

**Residual view gaps** (§7 — deliberately out of the main scope): transcript *prefetch*
warming still requires the owner online (`RemoteStore.swift:1858` routes through
`connection(owningSession:)`), so a session never opened while connected has no cached tail;
and the 50-session / 1500-event caps mean a long history truncates offline.

### 0.2 The actual gap: delete and archive are fire-and-forget RPCs

| Path | Gate | Failure mode |
|---|---|---|
| iOS delete / archive | `send()` → `connection(owningSession: id)?.send(msg)` (`RemoteStore.swift:2846`) | Owner offline ⇒ **silently dropped**; the row reappears on the next `sessionList` |
| macOS remote row | `sendToPeer` → `PeerClient.sendCommand` `guard let client = liveClients[deviceID] else { return false }` (`PeerClient.swift:825-829`, `AppStore.swift:7280-7286`) | `lastError` = "…'s Mac isn't reachable right now — nothing was changed." Honest, but the intent is lost |
| iOS buffer | `pendingActions`, `optimisticWindow = 5s`, `expireOptimisticWindow()` drops rather than firing late (`RemoteStore.swift:772-870`) | Correct as designed — a 5-second connect-jank smoother, *not* durable intent |

Delete and archive are modeled as **events against a live owner**, while Carbon heads and
transcript mirrors are modeled as **convergent state**. That mismatch is the defect.

A second, subtler defect compounds it: `sessionList` is a **wholesale replacement**
(`ConnectionHandler.swift:958` — "a deletion: clients replace their mirrored list"). So even a
delete that a device applies locally is resurrected the moment the owner reconnects and
broadcasts, unless the owner itself folds a tombstone first.

---

## 1. Decisions

P9–P11 were **signed off by the project owner on 2026-07-22**. P1–P8 remain proposals from
this document's author and still want a review pass.

| # | Decision | Proposal | §|
|---|---|---|---|
| P1 | Where lifecycle state lives | A **new convergent projection** ("lifecycle ledger"), *not* a Carbon stream — Carbon is Mac/runner-only (D13) and non-relay, and phones are first-class actors here | §2 |
| P2 | Convergence model | `deleted` = **grow-only flag** (absorbing, never resurrects); `archived` / `favorite` / `title` = **LWW registers** keyed by a Lamport `(counter, deviceID)` | §2.1 |
| P3 | Third-party carry | **Allowed between Macs**: a mesh Mac holds and forwards a record for a session it does not own. Phones originate and hold, but do **not** carry other devices' intents | §4 |
| P4 | Destructive work | Stays **owner-only**. `deleteSession`'s teardown (`AppStore.swift:7406-7466`) never runs anywhere but the owner | §5 |
| P5 | Delete-while-transferring | The existing refusal (`AppStore.swift:7411-7426`) becomes a **deferral**: the tombstone persists and retries after the transfer, rather than being rejected to a device that may not be listening | §5.2 |
| P6 | Tombstone retention | Retained **indefinitely** on Macs (bounded to the newest 10 000 rows); phones keep a separate tombstone list capped at 500, independent of the 50-session summary cache | §6 |
| P7 | Relay | **Allowed.** Records are tens of bytes and content-free — unlike Carbon shards, there is no reason to exclude relay | §3.3 |
| P8 | Rollout | Additive and capability-gated; a mesh of mixed versions degrades to today's behavior, never to data loss | §8 |
| **P9** | **Signed records** *(owner, 2026-07-22)* | **Every record is Ed25519-signed by its originating device.** An owner rejects a record whose signature is absent, malformed, or from a non-paired originator | §4.2 |
| **P10** | **Ledger verb set** *(owner, 2026-07-22)* | **Rename and favorite join delete and archive on the ledger** — four registers, one record | §2, §7 |
| **P11** | **Phone scope** *(resolved from code, 2026-07-22)* | Phones already hold `.control`; the ledger verbs gate on `requireControl`, inheriting the existing fence rather than adding one | §4.1 |

### 1.1 Why not a Carbon stream kind

A `sessionLifecycle` `CarbonStreamKind` is superficially attractive — it would inherit the
head-advance fence and the reconcile loop. It is rejected because Carbon is *encrypted project
content addressed by shard*, replicated only to eligible members holding the project key, over
non-relay transports, and never to phones. A lifecycle record is the opposite on every axis:
public-ish metadata, a single small value per session, needed on exactly the devices Carbon
excludes. Forcing it into Carbon would require weakening D13, D5, and the key-custody model to
carry ~40 bytes.

---

## 2. Data model

New file: `Sources/NucleicProtocol/Sync/SessionLifecycle.swift`.

```swift
/// A Lamport-ordered revision stamp. `counter` is per-device monotonic; `deviceID` breaks ties
/// deterministically so every device converges on the same winner without a clock.
public struct LifecycleRev: Sendable, Codable, Hashable, Comparable {
    public let counter: UInt64
    public let deviceID: String
    public static func < (a: Self, b: Self) -> Bool {
        a.counter == b.counter ? a.deviceID < b.deviceID : a.counter < b.counter
    }
}

/// One last-writer-wins register: a value, the stamp that orders it, and the signature of the
/// device that wrote it. Merging keeps the higher stamp.
///
/// **Signatures live here, not on the record** (P9). A merged record is assembled from
/// whichever originator won each register, so a single record-level signature could not
/// survive a merge — the merging device would have to re-sign, laundering away the
/// authorship P9 exists to prove. Per-register signatures are merge-invariant: the winning
/// register carries its own originator's signature forever, through any number of hops.
public struct LifecycleRegister<Value: Codable & Hashable & Sendable>: Sendable, Codable, Hashable {
    public let value: Value
    public let rev: LifecycleRev
    /// Ed25519 over the canonical encoding of `(sessionID, field, value, rev)`. The signer is
    /// `rev.deviceID`, so the verifier knows which pinned key to check without a hint.
    public let signature: Data
}

/// The convergent lifecycle state of one session. Gossiped as *state*, not as an event, so a
/// missed push is irrelevant — the shape Carbon heads use (CarbonMessages.swift:59-61).
public struct SessionLifecycle: Sendable, Codable, Hashable {
    public let sessionID: SessionID
    /// Which Mac owns the session. Needed so a carrier (§4) knows where to forward.
    public let ownerDeviceID: String
    /// Grow-only: once true, never false. Delete is terminal and cannot be undone by a stale
    /// peer's older state (P2).
    public let deleted: Bool
    /// Ordering stamp for `deleted`. Display only for the tombstone; `deleted` needs no
    /// ordering because it is absorbing.
    public let deletedRev: LifecycleRev?
    /// The three LWW registers (P2, P10). `title` is `nil` until a user explicitly renames —
    /// host auto-titling never originates a rev (§2.2).
    public let archived: LifecycleRegister<Bool>
    public let favorite: LifecycleRegister<Bool>?
    public let title: LifecycleRegister<String>?
    /// Signature over `(sessionID, "deleted", deletedRev)`, by `deletedRev.deviceID`. Present
    /// iff `deleted`.
    public let deletedSignature: Data?
    /// Wall-clock, for UI ("deleted 2h ago") only. NEVER used for ordering.
    public let at: Date
}
```

### 2.1 Merge

```
merge(a, b) =
    deleted    = a.deleted || b.deleted                 // grow-only join
    deletedRev = deleted ? min(non-nil revs) : nil      // first deleter wins the record
    archived   = maxByRev(a.archived, b.archived)       // LWW, signature travels with it
    favorite   = maxByRev(a.favorite, b.favorite)       // nil loses to non-nil
    title      = maxByRev(a.title,    b.title)
```

Verification precedes merge: a register whose signature fails is dropped *before* it can win,
so an unverifiable record degrades to the receiver's existing state rather than poisoning it.

This is a join-semilattice: commutative, associative, idempotent — per register, and therefore
for the record. Order of arrival cannot change the result, which is exactly the property
today's RPC model lacks. **Delete beats everything** — a session concurrently deleted and
renamed is deleted, and the surviving tombstone still carries the (now moot) title register.

To originate a change, a device sets `counter = max(all counters it has seen for this session) + 1`
and `deviceID = its own`. Never a wall clock.

### 2.2 Rename vs. host auto-titling (P10's one sharp edge)

Adding `title` to the ledger collides with the host's automatic titling
(`AppStore.swift:9074-9109`). The collision is already solved on the host and the ledger must
not undo it: auto-titling re-checks `session.title == autoTitle` immediately before writing
(`:9101`, `:9107`), so it **backs off the moment a human renames**.

The rule that preserves this: **only an explicit user rename originates a `title` rev.**
Auto-titling stays a host-local write and never touches the ledger. So `title` is `nil` for
every auto-titled session, a rename sets it once, and from then on both mechanisms agree — the
host stops auto-titling and the ledger holds the human's value. `renameSession`'s existing
`AppStore.swift:7251-7257` remote path is what moves to the ledger; the auto-title path at
`:9074` is deliberately left alone.

Get this backwards — have auto-titling originate revs — and every host re-title races every
other device's cached rev, and a stale peer can revert a rename. That is the failure mode this
subsection exists to prevent.

---

## 3. Wire

### 3.1 Capability bit

`WireCapabilities` gains, following the documented gating contract at
`WireMessages.swift:128-175` (decode-default `false`; a client must not send the verb unless
the peer advertises it, because `ClientMsg` **throws** on unknown tags —
`MessageEnvelope.swift:435-438`):

```swift
/// Whether this peer speaks the lifecycle ledger: it answers `fetchLifecycle` and accepts
/// `pushLifecycle`, folding tombstones before it publishes a session list. A client must not
/// send either verb unless this is set. Omitted ⇒ `false`.
public let canSyncLifecycle: Bool
```

`WireClientCapabilities` gains the mirror bit so a Mac knows whether a *phone* can hold state.

### 3.2 Verbs

```swift
// ClientMsg
case fetchLifecycle(LifecycleFetch)     // requestID; optional sinceRev watermark
case pushLifecycle([SessionLifecycle])  // idempotent state push, not a command

// HostMsg
case lifecycleState(LifecycleState)     // requestID + records — the fetch reply
case lifecycleAdvanced([SessionLifecycle])  // unsolicited push; missing one is harmless
```

Both directions are **idempotent state transfer**. There is no "delete this" command anywhere
in the new protocol — that is the whole point, and it is the same reasoning behind Carbon's
D11.

> **Implementation cost warning** (from `CARBON_SHARDING.md:83-85`, verified still true): every
> new `HostMsg` case needs a `SyncClient.Event` case **plus branches in all four exhaustive
> switches, two of which are iOS-only and compile solely under `xcodebuild`**. Budget a host
> build for this; a container-only `swift build` will not catch the iOS switches.

### 3.3 Carriage

Three carriers, in order of latency:

1. **Piggyback.** `SessionSummary` gains `lifecycleRev: LifecycleRev?` (decodeIfPresent, like
   `revertEpoch` at `WireMessages.swift:540-545`). Existing `sessionUpdated` / `sessionList` /
   `dashboard` broadcasts then carry current state with no new round trip.
2. **On connect.** `fetchLifecycle` in the same place the client already re-subscribes after a
   drop (§7 of [SYNC_PROTOCOL.md](SYNC_PROTOCOL.md)).
3. **On change.** `pushLifecycle` to every live peer; loss is self-healing via (2).

Relay is permitted for all three (P7).

---

## 4. Who may carry an intent

The hard question, stated plainly: for a phone to delete a session whose Mac is offline, some
*other* device must hold the intent and deliver it later.

**Proposal (P3).**

- **A mesh Mac may hold and forward a record for a session it does not own.** Justification:
  mesh Macs already grant each other control scope and already relay every one of these verbs
  today (`PeerClient.sendCommand`, `:817-822` — "favorite / archive / delete / interrupt /
  rename … mesh Macs grant each other control scope"). Holding a `(sessionID, deleted: true)`
  pair is strictly *less* authority than the live `deleteSession` verb a Mac can already send,
  because the carrier cannot execute anything — only the owner runs teardown (P4).
- **A phone may originate and hold its own view, and may push to any live Mac** — that is the
  mechanism that makes phone→offline-owner work. It must **not** act as a carrier for records
  it did not originate; a phone is a viewer with `approve` scope by default
  ([SYNC_PROTOCOL.md](SYNC_PROTOCOL.md) §1.5), and turning every phone into a gossip node for
  the whole mesh's tombstones is a scope expansion with no offsetting benefit.
### 4.1 Scope (P11 — resolved from the code, no change needed)

`ConnectionHandler` gates `deleteSession` / `setArchived` / `renameSession` / `setFavorite`
behind `requireControl(id)`, which is `grantedScope >= .control` (`:780-786`). Phones clear it
today: iOS connects through `SyncClient`, whose `scopeClaim` defaults to **`.control`**
(`SyncClient.swift:165`), and first pairing grants `min(hello.scopeClaim, .control)`
(`ConnectionHandler.swift:324`), persisting that on the device record for later reconnects
(`:221`, `:312`). The `.approve` default on `PairedDeviceRecord` (`PairedDeviceStore.swift:36`)
is an initializer default that the pairing path overwrites — it is not what phones run with.

So `pushLifecycle` and `fetchLifecycle` gate on `requireControl` exactly like the verbs they
replace. No scope widening, and no back door around the existing fence. **SYNC_PROTOCOL §1.5
and §10.5 are stale on this point** ("v1 iPhone = `approve`"); worth correcting there
separately.

### 4.2 Signed records (P9)

Every register is Ed25519-signed by its originator using the identity key already established
at pairing (SYNC_PROTOCOL §4.1), verified against the pinned public key the receiver holds in
its paired-device table.

**Why it matters here specifically.** A compromised or buggy mesh Mac can fabricate
`(sessionID, deleted: true)` for any session it knows of, and because `deleted` is absorbing,
that is unrecoverable through the ledger. Deletion authority is not itself new — that Mac can
already call `sendCommand(.deleteSession(id))` today — but the ledger makes it **durable and
replayable**, which an RPC is not. Signing bounds the damage to devices the owner has actually
paired with, and makes every tombstone attributable after the fact.

Rules:

- An owner **rejects** any register whose signature is absent, malformed, or signed by a device
  not in its paired-device table. Rejection is per register, not per record.
- A **revoked** device's records are rejected from the moment its pin is dropped
  (SYNC_PROTOCOL §7). Records it originated *before* revocation and that were already folded
  stay folded — revocation is not retroactive, and un-deleting on revocation would violate P2.
- A **carrier does not re-sign** and does not need the originator's key beyond the pin it
  already holds; it forwards registers verbatim (§2.1).
- Verification failures are **logged, not silent** — a mesh where signatures are being dropped
  is a mesh with a real problem, and the symptom would otherwise be "my delete didn't stick."

---

## 5. Owner-side fold

### 5.1 The reconcile

On connect, and on every `pushLifecycle`, the owner folds records for sessions it owns:

```
for record in incoming where record.ownerDeviceID == self:
    verified = dropUnverifiableRegisters(record)          // §4.2, before anything else
    merged   = merge(local[record.sessionID], verified)
    persist(merged)                                       // BEFORE execution or broadcast
    if merged.deleted && sessionExists:
        await deleteSession(id)                                        // AppStore.swift:7406
    else:
        if advanced(merged.archived): await setSessionArchived(id, merged.archived.value)  // :7337
        if advanced(merged.favorite): await setSessionFavorite(id, merged.favorite.value)  // :7228
        if advanced(merged.title):    await mutateSession(id) { $0.rename(merged.title.value) }  // :7255
```

**Persist before execute, execute before broadcast.** If the owner broadcasts a `sessionList`
computed before the fold, every device re-learns the deleted row as alive
(`ConnectionHandler.swift:958`), and the phone that deleted it watches it come back. Two
concrete changes:

- `sessionSummaries()` must exclude ids with a `deleted` tombstone, so the exclusion holds even
  if teardown is deferred (§5.2) or partially failed.
- `broadcastSessionListChanged()` (`AppStore.swift:7457`) must run *after* the fold, not
  interleaved with it.

There is precedent for a summary that is a tombstone: `movedTo` already documents "the summary
is then a tombstone (`archived == true`)" (`WireMessages.swift:594-597`). Reuse that vocabulary
rather than inventing a second one.

### 5.2 Deferral, not refusal (P5)

`deleteSession` currently refuses mid-transfer, twice
(`AppStore.swift:7411-7414`, `:7423-7426`), by setting `lastError`. Under the ledger the
originating device may be a phone that is now offline and will never see `lastError`. So the
guard becomes a deferral: the tombstone stays persisted and unexecuted, and the delete is
retried when `transferringSessions` clears and on the next reconcile. The user-visible error
stays for the *local* interactive path, where someone is actually looking at it.

The same reasoning applies to a teardown that throws (`controller.discard(force: true)` sets
`lastError = "Delete failed: …"` at `:7435-7437`): the tombstone must survive a failed teardown
so the next reconcile retries, rather than being lost with the error message.

### 5.3 Cascades

`deleteSession` and `setSessionArchived` both cascade to orchestra workers
(`AppStore.swift:7429-7433`, `:7351+`) and `cascadeChildren`. Cascades are computed **on the
owner at fold time**, from the owner's own `summaries` — never shipped in the ledger. A remote
device does not know the worker set, and a stale worker list in a replayed record would delete
the wrong rows.

---

## 6. Persistence

### macOS — GRDB

New migration, next free identifier is **`v35-session-lifecycle`** (current tail is
`v34-tool-summary`, `GRDBMetadataStore.swift:333`). Follow the `mesh_dispatch` precedent
(`:286-296`) — a durable intent table is not a new idea in this codebase:

```sql
CREATE TABLE session_lifecycle (
  session_id       TEXT PRIMARY KEY,
  owner_device_id  TEXT NOT NULL,
  deleted          INTEGER NOT NULL DEFAULT 0,
  deleted_counter  INTEGER,
  deleted_device   TEXT,
  deleted_sig      BLOB,
  -- one (value, counter, device, sig) quad per LWW register (P10)
  archived         INTEGER NOT NULL DEFAULT 0,
  archived_counter INTEGER NOT NULL,
  archived_device  TEXT NOT NULL,
  archived_sig     BLOB NOT NULL,
  favorite         INTEGER,
  favorite_counter INTEGER,
  favorite_device  TEXT,
  favorite_sig     BLOB,
  title            TEXT,
  title_counter    INTEGER,
  title_device     TEXT,
  title_sig        BLOB,
  updated_at       DATETIME NOT NULL,
  executed         INTEGER NOT NULL DEFAULT 0   -- owner-side: teardown has run
);
CREATE INDEX session_lifecycle_owner ON session_lifecycle(owner_device_id);
```

The row **outlives** the `session` row — that is the point of a tombstone. `deleteSession`'s
`database.deleteSession(id:)` (`:7452`) must not cascade it away.

### iOS — SessionCache

A new sibling file to `summaries.json` / `transcripts/`
(`SessionCache.swift:11-13`): `lifecycle.json`, capped at 500 records (P6) and pruned
oldest-first by `at`. It is **not** subject to the 50-session summary cap — a tombstone must
outlive the summary it kills, or the phone forgets it deleted something. Keep the same
`Task.detached(priority: .utility)` off-main-actor write discipline the file already uses.

### Retention (P6)

Macs keep the newest 10 000 tombstones. A Mac offline longer than it takes to churn 10 000
sessions can resurrect a deleted row; that is an accepted, documented limit, not a silent one —
if a fold ever drops a record for capacity, log it, per Carbon's "no silent caps" discipline.

---

## 7. UI

- **Both platforms:** the row disappears (or archives, or renames, or stars) **immediately** on
  the acting device, because the local ledger is applied before the network is consulted. No
  spinner, no "isn't reachable" error for these four verbs.
- **Pending surface.** The mesh/peers settings pane gains a line — "3 changes waiting for
  Studio (offline)" — so a deferred delete is visible rather than merely invisible. Nucleic's
  house style is honest degradation (`SessionDetailView.swift:364`), and a silently-queued
  destructive action is the opposite of that.
- **`sendToPeer`'s error message** (`AppStore.swift:7281-7284`) stays for the verbs that remain
  RPCs — model, effort, integrate, discard, interrupt, transfer. Those are genuinely
  *operations on a running process*, not state a device can assert offline: integrating or
  interrupting a session on a Mac that is powered off is meaningless, so failing loudly stays
  correct for them. The four that move to the ledger (P10) are the ones that are pure metadata
  about a session rather than instructions to it.
- **View:** unchanged (§0.1). If the residual gaps matter, the fix is orthogonal — prefetch a
  bounded tail for *every* summary rather than only opened sessions (`RemoteStore.swift:1827-1895`).

---

## 8. Phases

Each phase is independently shippable and leaves the tree working.

| # | Scope | Exit criterion |
|---|---|---|
| **1** | `SessionLifecycle` / `LifecycleRev` / `LifecycleRegister` types, merge function, sign+verify (P9), `canSyncLifecycle` bits, GRDB `v35`, iOS `lifecycle.json`. No behavior change. | Merge-property and signature tests pass; existing tests untouched |
| **2** | Wire verbs + `SyncClient.Event` case + all four exhaustive switches (**needs a host/iOS build**). Piggyback `lifecycleRev` on `SessionSummary`. | Two peers converge on lifecycle state with no behavior change yet |
| **3** | **Delete + archive** move to the ledger: acting device applies locally + persists intent; owner verifies and folds on connect; `sessionSummaries()` filters tombstones; `broadcastSessionListChanged()` ordered after the fold. | **Delete a session on Mac A while its owner Mac B is quit → B honors it on next launch and the row does not resurrect anywhere** |
| **4** | Deferral replaces refusal (§5.2); tombstone survives failed teardown. | Delete during a transfer completes after the transfer, unattended |
| **5** | **Rename + favorite** move to the ledger (P10), including the auto-title back-off rule (§2.2). | Rename offline on the phone → lands on the owner; auto-titling never reverts it |
| **6** | Third-party carry (P3) + phone→any-live-Mac push. | **Delete from the phone with the owner offline and the phone then backgrounded → lands via a third Mac** |
| **7** | Retention/GC, pending-changes UI, `lastError` cleanup. | Caps enforced and logged, never silent |

Phase 3 is where the user-visible promise is met for Mac↔Mac; phase 6 is where it is met for
the phone. Phase 5 is separable — it reuses phase 3's machinery on two lower-stakes registers,
so it can slip without blocking anything. If the effort is cut short, cutting after 4 is
coherent.

---

## 9. Tests

- **Merge algebra** (`Tests/NucleicProtocolTests/`): commutativity, associativity, idempotence
  over randomized record sequences, across all four registers; `deleted` absorbing; LWW
  tie-break by `deviceID` stable.
- **Signatures** (P9): a tampered value/rev fails verification; a valid register from a
  non-paired originator is rejected; **a register survives a merge with its original signature
  intact after three hops through two carriers** (the merge-invariance property §2.1 depends
  on); a revoked device's new records are rejected while its already-folded ones persist.
- **Auto-title back-off** (§2.2): auto-titling never originates a rev; a user rename stops
  auto-titling; a stale peer holding a pre-rename cached summary cannot revert the rename.
- **Resurrection regression** — the specific bug this plan exists to prevent: device deletes
  offline → owner reconnects → owner broadcasts `sessionList` → assert the row is absent on
  *all three* of owner, actor, and an uninvolved third device.
- **Concurrent archive/unarchive** across two devices while both are partitioned → converge to
  the higher `LifecycleRev`, identically on both.
- **Delete vs. concurrent unarchive** → deleted wins.
- **Deferral**: delete arrives mid-transfer → not executed, tombstone persisted, executed after
  `transferringSessions` clears.
- **Teardown failure**: `controller.discard` throws → tombstone survives → next reconcile
  retries.
- **Mixed-version mesh**: a peer without `canSyncLifecycle` never receives the new verbs, and
  today's RPC path still works against it (`ClientMsg` throws on unknown tags — this is the
  test that catches a missed capability gate).
- **iOS cold launch**: tombstone in `lifecycle.json`, no host reachable → deleted row absent
  from the cached list at first paint.

---

## 10. Open questions

Resolved 2026-07-22: signed records (P9, §4.2), rename/favorite on the ledger (P10, §2.2),
phone scope (P11, §4.1 — no change needed). Remaining:

1. **P1–P8 review.** Those are this author's proposals and have not had a review pass.
2. **Stale scope docs.** [SYNC_PROTOCOL.md](SYNC_PROTOCOL.md) §1.5 and §10.5 still say "v1
   iPhone = `approve`", which §4.1 shows is not what ships. Worth a separate correction —
   this plan depends on the real behavior, and a future reader trusting the doc would
   reasonably conclude the design is unsound.
3. **Carbon GC coupling.** `CarbonShardStore.collectGarbage` (`:119`) is implemented but has
   **no call site outside its own tests**, and the archive-time ref cleanup specified at
   [CARBON_SHARDING.md](CARBON_SHARDING.md) §7.3 (`:479-480`) is likewise unwired — nothing in
   `setSessionArchived` deletes `refs/nucleic/carbon/<sessionID>/*` despite
   `CarbonSnapshotter.swift:287` providing the helper. A tombstone is the natural trigger for
   both. Out of scope here, but this plan makes the hook available and someone should decide
   whether to take it.
