Skip to content

archive

Provenance

  • Source: .spec/spexcode/spec-cli/sessions/lifecycle/archive/spec.md
  • Source SHA-256: 2761dd51a29b1571888bdf15e07054e6ac388906fd058476895cf5570d26ac5c

archive

raw source

The board had two ends and nothing between them. stop gives the process back but leaves the row sitting in the list; close clears the row by destroying the work. Neither says the ordinary thing a human means about most sessions: not this week, but don't throw it away. Without that verb the list is where every session that was ever interesting goes to accumulate, and the cost is attention, not disk.

The instinct is to make shelving mean "delete the worktree, keep the branch" — the files are only ~13MB and a branch rebuilds them in about a second. That instinct is wrong, and measuring says why: a branch ref carries committed work only. What a retained worktree buys is the uncommitted state — the half-finished edit the human will want exactly as they left it. stop already understood this ("step away, come back later" — it keeps worktree, branch, transcript, and record). Archive is the cold-storage form of that promise: it first reuses the exact stop/cleanup seam, then files the retained identity.

expanded spec

Archive is cold-storage filing with one hard invariant: archived => offline. A session keeps its agent-authored lifecycle, worktree, branch, transcript, and conversation identity, but an archive may be written only after the existing exact-instance stop guard has safely stopped that session-owned leaf, tmux, and adapter transport. The shared project app-server/control plane is never touched; sibling references remain loaded. If ownership or the stop proof is unprovable, archive fails loudly and leaves the record unarchived and visible. An app-server's transport-local census refusal may retry its complete target proof within the one bounded archive operation, but never reuses a prior census or retries a semantic ownership, live-turn, descendant, or generation refusal. A lasting transport failure is still a loud refusal with the record and retained work unchanged. Before a shared app-server publishes its exact PID/receipt/socket generation, that incomplete observation is a transport-local publication interval: close or archive may wait within the same bounded proof budget and begin a fresh census only once all three witnesses agree. A replacement after an established proof is a generation change, not publication lag, and remains an immediate refusal. Native thread ownership is unique whether the target is currently loaded or not. A native descendant collection is runtime owned by its exact ancestor, not a separate Spex session: archive includes every uniquely-owned active or already-archived descendant at any depth in the same cold transition, while preserving each native conversation for later same-thread resume. For adapters whose archive RPC can race native child creation, the complete target subtree census runs again after the mutation and before success. A new, missing, duplicated, or reassigned member in that interval refuses and compensates instead of filing a false zero-runtime proof. Archive eligibility reads the target's live native turn state, not its public lifecycle/status projection: a turn in flight refuses with zero mutation, while a loaded target the runtime reports idle may archive even if a hook-authored public status still reads working. That live state comes from the census the ownership proof already performs, so eligibility never costs a transcript read — a target alive for weeks stays archivable. That rule is for reversible archive. A human-confirmed terminal close first passes the same exact target-ownership guard, then may invoke the adapter's exact native interrupt for its recorded target turn, wait for that turn to settle, and then repeat the complete cold proof before deleting anything. An unsupported, rejected, generation-swapped, unknown, or still-active interrupt is a loud close refusal; it never substitutes a PID signal, changes a sibling, or skips the ordinary cold proof.

For a shared resident adapter, lifecycle mutation uses a target-scoped proof rather than the resource report's full projection. It first proves the shared PID/start/detached-receipt/socket generation, obtains the paginated loaded-ID set, and checks the target's active and archived descendant collections. Ordinary stop reads only the exact target and still refuses a resident descendant subtree. Archive additionally reads each loaded member of the exact owned subtree, then archives every member that was initially active under one generation fence; members already in the archived collection are verified but not mutated. Each member's archive budget follows the work that member's archive actually does. Only a loaded member's archive flushes that thread's persisted rollout before the runtime commits, so the wait it is given scales with that rollout's measured size, while a member the runtime already reports unloaded flushes nothing and keeps the ordinary bounded budget. A rollout not yet persisted is zero work rather than an unknown, because a thread that has started but written nothing has nothing to flush; a rollout that exists and cannot be measured is unknown and fails closed before any mutation. One fixed per-request ceiling is not a bound on this operation at all — it only names the transcript size above which archive stops working — so the ceiling is derived rather than assigned, at a deliberately pessimistic flush rate whose job is to catch a wedged runtime rather than to predict a flush. An absent target with no descendants may therefore stop or archive even while an unrelated loaded or unowned sibling is slow or unresponsive; those sibling IDs still protect the shared root, which the session mutation never signals. A loaded active subtree member, an unknown exact member read, a member present in both or neither native collection, changed ancestry, duplicate ownership, or a shared-generation identity change fails closed. A sent archive request that is never answered leaves commit state unknown for the same reason a generation change does: the runtime may still be executing it. Compensation is not attempted on an unknown commit, because an unarchive issued behind work the runtime has not finished is itself starved — that would turn one slow member into a false compensation failure — so the operation reports the unknown commit and leaves the recorded recovery token for resume to reconcile. Success requires a post-mutation recensus proving parent plus the identical descendant closure uniquely archived, every subtree member unloaded, and every pre-existing unrelated loaded sibling retained. The resource report remains the complete projection and may inspect every loaded reference; its latency or an unrelated turn-read failure is not mutation authority. Archive compensation is fenced to that same exact original generation at connection, request, and response boundaries. If the shared generation changes after the archive request, commit state is reported unknown and no thread/unarchive is sent to the replacement generation; recovery remains explicit rather than borrowing new authority from a process that was never proved by the original preflight. On an unchanged generation, compensation restores only members that were active before this attempt; an already-archived descendant is never unarchived. The same opaque preflight receipt remains available until the session record and final offline proof are committed. If native cold teardown succeeds but record lookup, final liveness, or filing then fails, outer compensation returns that exact receipt to the adapter so the complete originally-active subtree is restored on the same generation. Ordinary later resume carries no receipt and restores only the parent conversation.

It is the attention verb backed by the resource stop. This is the line that must not blur:

  • stop is the resource verb — give the process back. Reversible by resume.
  • archive is the cold-storage attention verb — first perform the same exact stop, then file the record.
  • close is terminal destruction, but it first completes that same exact cold-runtime transition for every launched session. It never deletes the worktree, record, branch, or Codex generation binding while the target native runtime is still live or unproven; a failed cold transition leaves the session visible.

After all cold/queued ownership proof and every refusing Codex generation-binding fence pass, close writes an append-only close-authorized project runtime ledger entry before resource deletion. The entry names the exact target record and a non-authoritative request source: user means no session claim was supplied, while unverified-session-claim preserves a CLI-provided id without asserting that the id exists, owns the target, or authenticated the request. The close route rejects the authoritative-looking session kind rather than silently upgrading a forged value. The ledger outlives the target session directory, so the request context remains inspectable after a successful terminal close but never grants deletion authority. A refused close writes no entry and never changes a Codex generation binding into its record-removing phase. Archive remains reversible only through resume, which unarchives before recreating the runtime. Close is not reversible.

Close has three ownership-proof entries into that one terminal result. A live row first uses the ordinary exact stop proof, then removes its record, worktree, and branch. A proven-cold archived row must not pretend to be live again just to retire: it verifies that the record's cold proof still binds the target adapter/thread and that every target-owned PID, tmux window, rendezvous transport, and loaded thread remains absent, then removes the record, worktree, and branch directly. That cold retirement path sends no signal and neither probes nor requires ownership of unrelated references on the shared project app-server; archive already returned the target's runtime. Any target runtime that has reappeared, stale/swapped target identity, unreadable cold proof, or ambiguous target ownership fails loudly before deletion and leaves the shelf row intact. A live PID is not ownership by number alone: the shared leaf-identity seam requires a readable process-start token and harness owner argv. A live PID whose argv proves a different process is a stale artifact and does not block cold retirement; malformed or unreadable PID/start/argv evidence remains unknown and fails closed, while an argv owner match remains live-owned and fails. Continuing-cold proof may list loaded IDs and the target's own native collection/descendants, but it never thread/reads or waits on an unrelated loaded sibling.

A prepared queued row that has never launched takes the other target-only retirement path. Close serializes with the drainer on the same session transition/record lock; if close wins while the record is still queued, it also holds the exact recorded branch/path create-resource lock and proves any matching private creation receipt retired before stop or deletion. An unretirable receipt leaves the public row and every resource intact, because the record is the fence that prevents old create authority from reaching a later collision. Once that proof lands, it verifies that no harness thread identity, tmux window, live/recycled leaf PID, rendezvous transport, ahead commit, or dirty work exists, then removes the prepared prompt, record, worktree, and branch before releasing capacity. It sends no signal and asks nothing of unrelated shared references because no target runtime was ever created. If the drainer wins first, status is no longer queued and ordinary live close owns teardown. Any target/runtime/work ambiguity fails loudly with the queued row intact. Close reports success and releases capacity only after worktree, branch, prompt, and record removal have each been proven complete.

Archiving never removes or moves the worktree/branch and writes no timeline row. Success means the exact leaf is stopped and the record is archived; a failed stop means no archive field change. An archived record is therefore always offline and consumes no active slot or loaded-thread reference of its own.

For adapters with a shared resident control plane, the read projection consumes one project-wide exact loaded-ID census, not one RPC per row. A cold proof is current only when that census is healthy and the exact thread is absent; an externally reloaded or ambiguous thread projects visible with archiveHazard even if its historical proof still matches. A deliberately absent shared root is a healthy empty census only when its registered PID is dead and its socket has no live listener; stale files alone never prove absence. Explicit close resolves the all-record store, including cold rows. For a proven-cold row it judges only the target's continuing cold ownership facts, never unrelated shared-root references, and succeeds/nonzero only according to whether the record is actually removed.

The copy states the cold result plainly: the retained worktree/branch/conversation can be resumed, while the session-owned runtime is gone. A failed ownership proof is a visible hazard, never a successful archive.

The record stays a projection, never a log. archived is a declared field in session.json's closed key set, written like every other. That key set is rebuilt from the typed record on each write and never merged over what was read, so the file self-cleans: a field retired from the code leaves disk the next time anything touches that record, and no migration verb or GC pass is needed. Cold filing carries three deliberate projection fields: archived is the visible filing bit, cold_proof is the versioned witness bound to the resolved adapter and exact session/thread identity and is written only after the full leaf/adapter cold proof, and adapter_recovery records a partial/unknown adapter mutation that resume must reconcile before relaunch. Resume clears the recovery token only after the active collection is confirmed and clears the cold proof before launch; a failed reconciliation leaves both visible and retryable. These fields are not redundant metadata or a second archive store: they are the durable visibility and recovery contract that prevents a live runtime being hidden by a boolean.

Enumeration is existence; filtering is a view. The board keeps enumerating shelved records — the store is the existence truth ([[state]]) and a view preference must never decide what exists. Consumers filter:

  • the spec-delta is skipped for a shelved row. That per-worktree git-history probe is the board's dominant per-row cost, and shelving is precisely the human saying to stop spending it here — so a retained archive costs one enumerated record and no git walk. Its cached delta is evicted on the next board read, so the cache stays bounded by the working set rather than by everything ever shelved.
  • spex session ls hides shelved rows; --all includes them, and naming one explicitly always shows it — an explicit selector is the human already saying which row they mean. The default API/graph/session projections likewise exclude archived rows; an explicit archive/history read is the only way to request them.
  • the console's list shows one population at a time.

The console gives the archive a door, not a zone. Archived history is a flat cold-storage list: it has no needs-you/running/offline status partitions, no lifecycle triage, and no active subtree counts. The door is a star, the third of three equal pills in the list header beside New and Search, and it is permanent: a control that appears only when it has contents cannot be found when you want it, and its absence would be the only thing telling you the archive exists at all. New and the archive door are mutually exclusive destinations, so opening the shelf clears New's visual active treatment without changing the selected tab. The star carries no numeric count — the archive is a destination, not a backlog meter — and stays live: a permanently-visible control that silently does nothing reads as broken, so pressing it at zero opens the archive and says nothing archived rather than swallowing the press. Reaching an archived session from outside the list (URL, search, an originator chip) lands the view on the side that holds it, the same promise the ancestor-unfold makes within a list.

The trap was never the empty room — it was a dead exit. Everything that can open this view reads a board snapshot, and a snapshot can be stale: the board serves the pre-write value for close to a second after a record flips, and a session can be restored from the CLI or another tab while you are looking at it. So you CAN end up looking at an archive that just emptied. That is harmless as long as the star still works; it was a trap only while the star was inert, because then the one way out did not respond. Guarding the view instead of the exit fixed the symptom and produced a worse bug — a visible control that does nothing. The human's toggle is therefore authoritative, and an emptied archive is simply somewhere you can leave. Replacing the archive/history rows during a refresh must not override that toggle while the selected session id and that session's archived state are unchanged. Automatic side selection still follows a real selection change or a change in the selected session's archived state, so deep links and external archive/resume transitions land on the side that actually owns the selected row without turning ordinary refreshes into navigation.

Selecting an archived session shows the archive card, which is an offline cold-storage card with restore as its only lifecycle exit. There is no relaunch action while archived; resume first clears archived, then follows the normal starting -> online state machine and preserves the conversation.

One vocabulary everywhere. /archive is the filing command and /resume is the sole restore action. The CLI spex session archive <SEL> and POST /api/sessions/:id/archive perform cold filing; the legacy unarchive spelling is only a loud signpost to resume and never a record-only mutation. The dashboard card uses the same resume endpoint, so every restore observes starting -> online and preserves the conversation.

Filing is a row decision as much as a console one, so it is also on the session row's right-click menu — one item that names the move OUT of the row's current state rather than a pair where one is always inert. The row menu treats archive and close as the same deliberate lifecycle boundary: both live in its danger group and both open a confirm pop-out. Archive remains reversible, but filing a session out of the active working set is important enough to make the action explicit; the confirm commits through the existing /archive route, never a record-only shortcut. Resume is the inverse cold-row action and remains outside this danger pair.