Files
nucleic/docs/OFFLINE_SESSION_LIFECYCLE_OPUS-4-8.md
T

30 KiB
Raw Blame History

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 §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) is four streams of bytesworktreeSnapshot, 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) and are projected on the wire as SessionSummary (WireMessages.swift:529-601). 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). 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 §7.3, :269).
  • Carbon reconcile is additionally restricted to non-relay transports (AppStore.swift:10461-10468), 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) — 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 sendToPeerPeerClient.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

P9P11 were signed off by the project owner on 2026-07-22. P1P8 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.

/// 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):

/// 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

// 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).
  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 §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:

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. P1P8 review. Those are this author's proposals and have not had a review pass.
  2. Stale scope docs. 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 §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.