# $SPT_HOME on-disk layout

> Draft design doc (2026-05-30). Implementation detail — neither glossary ([`../CONTEXT.md`](../CONTEXT.md)) nor decision ([`adr/`](./adr/)). Resolves PRD §17.1.

## Principles

1. **Single relocatable root.** One `$SPT_HOME` dir holds everything (default `%LOCALAPPDATA%\spt-core` / `~/.spt-core`; override via `$SPT_HOME`). The `spt-core/` default is deliberately distinct from the modern spt runtime's `spt/` home so a dev build never collides with a running spt-plugin during parity build-out (may stay `spt-core/` for good). One dir to back up, move, or repackage for a marketplace/handheld. No XDG three-way split.
2. **Endpoint IDs are adapter-agnostic.** `ling` can run under `claude-spt`, then later under `spt-codex` or `spt-pi`. Perches and context key on bare `endpoint_id`; the currently-running `adapter_name` is a *property of the instance* recorded in `info.json`, never a path segment. **At most one instance of an ID per node** — a second attempt (any adapter) is rejected.
3. **Two separate trees: `tracked/` vs `perches/`.** The synced *mind* (context, a git repo) and the node-local *plumbing* (spool/status/info) are physically separate. Perch runtime is **never** git-tracked — otherwise node-local spools and statuses would clobber across nodes when the context repo syncs.
4. **Durable vs ephemeral is explicit.** `identity/`, `tracked/`, `history/` are durable (the backup/migrate set). `registry/`, `daemon/`, `updates/` are regenerable. Migration carries the durable set only.
5. **One path resolver.** A single centralized, idempotent resolver maps `(endpoint_id[, child][, project_id][, kind])` → path. Nothing constructs paths ad-hoc, so any future relayout carries to all dependents (KNOWN-HAZARDS 6.1 — no flat/nested ambiguity).

## Layout

```
$SPT_HOME/
  identity/                      ── node-local, durable, NEVER git-tracked ──
    node.key                     Ed25519 node identity (per-node; must not sync)
    trust/                       authorized peer pubkeys (trust store)
    subnet.json                  subnet name + TOTP seed (replicated via pairing, not git)
  tracked/                       ── git repo; synced across nodes over P2P ──
    agents/<endpoint_id>/
      meta.json                  endpoint identity metadata: type, ordered adapter history, created
      live-context.md            per-agent live context           (branch a-<endpoint_id>)
    projects/<project_id>/
      <endpoint_id>/
        project-context.md       per-(agent,project) context      (branch p-<project_id>)
  perches/                       ── node-local runtime, NEVER tracked/synced ──
    <endpoint_id>/
      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
      spool.db                   SQLite spool (typed + binary payloads)
      status                     active | dormant | suspended    (agent endpoints)
      nested/<child_id>/         Worker / Psyche perches (own info.json, spool)
      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
  history/                       node-local logs (mirrors perch structure)
    <endpoint_id>/
      sessions/<session_id>.jsonl    agent Path-B native logs
      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
  registry/                      ── ephemeral ── local cache of the subnet registry
  index/                         ── derived, regenerable ── materialized indexes (ADR-0037)
    project-index.json           endpoint→project attribution (schema-versioned; daemon-written)
  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
  updates/                       ── ephemeral ── staged binaries pending handoff
  adapters/                      registered adapter manifests / pointers
```

### The contact ledger — `identity/trust/contacts.json` (DOORBELL W3)

<!-- [doc->REQ-UNLISTED-CONTACT-LEDGER] -->

Node-local evidence that this node has actually exchanged traffic with an
off-node endpoint, read by the UNLISTED rows of `spt endpoint list`. Rows are
`(direction, endpoint id, origin node key, last-seen)`; the direction is part
of the KEY, so a peer we wrote to and a peer that wrote to us are two
distinct pieces of evidence rather than one row overwriting the other.

**Three bounds, all load-bearing and all fixed at mint:**

- **Retention window: 14 days.** Enforced on both sides — a write prunes what
  has aged out, and a read filters what a stale file still holds, so a ledger
  nobody has written for a year reads empty rather than ancient.
- **Hard cap: 512 rows**, pruned oldest-first, so the file is bounded
  regardless of traffic shape.
- **Never synced.** It sits in `identity/trust/` beside the other
  node-sovereign stores — outside `tracked/` and carried by no wire record.
  The siting *is* the mechanism; a unit test scans the wire-record modules to
  keep it true.

**Write sites.** Inbound is recorded in the WAN receive funnel **after the
access gate admits** the message — a message the gate refused is not contact,
or a stranger could write themselves onto the operator's evidence surface by
sending one message that bounces — and it records the **daemon-stamped**
`sender_proven`, never `from` (KNOWN-HAZARDS 7.5). An unstamped (N-1) arrival
records nothing: its origin node is proven, its endpoint is not. Outbound is
recorded only on a **receiver-confirmed** send, the same honesty the
`SENT(WAN)` token carries.

**This is not `identity/trust/recent-outbound.json`.** That one is the
stateful-firewall correlation log the access gate reads (REQ-SEC-1): keyed
*(local endpoint, remote node)* with no peer endpoint id, and windowed at 15
minutes. A firewall's memory and a UI's memory must not be the same row —
widening its key or its retention so a listing could reuse it would silently
widen an admit verdict.

### Registry snapshots + the resource-advertisement blurb (D9)

<!-- [doc->REQ-INST-14] -->

The daemon's live per-subnet registries ([`RegistryHost`]) mirror to **atomic
JSON snapshots at `identity/registry/<subnet>.json`** for out-of-process
readers (`spt send`'s WAN resolution, `spt resources list`) — stale-tolerant
by contract (KNOWN-HAZARDS 4.3), regenerable from re-advertisement.

Each instance row carries an optional **`resources` blurb** (REQ-INST-14,
CONTEXT §resource advertisement): a free-text "what I can serve" line. It is
**a field on the row, not a separate store** — blurb updates ride the same
epoch lease and `RegistryUpdate` replication as any instance update.
**Both-authored:** the endpoint's own `info.json` `resources` (written via
`spt resources set`) wins; `daemon.json`'s `resources_blurb` seeds a node
default for endpoints that never authored one. The "subnet resource registry"
is a **projection** of these rows — `spt resources list` renders
`(subnet, id, node, status, blurb)` over **visible** rows only (the same
exclusion closure resolution uses, so discovery can never list what routing
would refuse).

## tracked/ — the context git repo

- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.
- **Two-tier sync maps onto the structure:** `agents/<id>/live-context.md` (the `a-` view) syncs to **all** instances of the endpoint; `projects/<project_id>/<id>/project-context.md` (the `p-` view) syncs only to instances sharing that project.
- **P2P replaces the *remote*, not the local git.** Modern SPT pushed this repo to a private GitHub remote; spt-core keeps the local git tracking + branch views and moves the data over the built-in P2P layer instead (no `gh`/account/setup). The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — arbitrates concurrent writes from multiple nodes/instances.

## BranchStore (git-KV) — generalizing the context store (ADR-0011)

The context repo above is one instance of a general pattern: **a git branch used
as a versioned key/value filesystem** — write = one commit (tree = prev +
mods), read = tree lookup at the branch tip, history = `git log`, recovery = read
the ref tip. ADR-0011 promotes this to a first-class `spt-store::BranchStore`
reused beyond context, for **coarse / durable / audited** state only.

**Store mapping — which mechanism owns which state:**

| State | Store | Why |
|---|---|---|
| context (`a-`/`p-`) | **BranchStore** | versioned, merge-synced cross-node, audited mind |
| subnet registry (snapshot + distribution) | **BranchStore** | audited; rides the precedence merge driver instead of a separate replication scheme (M4) |
| daemon/brain coarse checkpoint (rehydrate anchor, ADR-0004) | **BranchStore** | commit = checkpoint, ref tip = resume |
| endpoint lifecycle / status *audit trail* | **BranchStore** (or a derived view) | the *trajectory* matters, not just current value |
| spool (queue, drain-by-status, `id→address`) | **SQLite** | indexed queries + hot churn |
| broker effect journal (per-PTY-effect, B5) | **fsync-append** | hot path, per-keystroke — a commit per write is far too heavy |
| `info.json` / `status` (current value) | flat + atomic-rename (today) | tiny, read-hot; *may* move under BranchStore later, low priority |

**Rules:**
- **Single-writer-per-ref**, sharded per run/session/endpoint under a porcelain-compatible namespace (`refs/heads/spt/...`) — matches the single-writer invariants (6.4, active-instance-authoritative); no many-writers-one-ref contention.
- **commit = checkpoint, ref tip = resume** — atomic ref update means a crash mid-write never advances the ref, so recovery is "read the tip." This **dissolves the torn-write hazard class** (tmp-write + atomic-rename, 5.2) for BranchStore-covered state.
- **Atomic multi-key commits** (perch + registry + cursor in one all-or-nothing commit) and **merge-native sync** (reuse the context precedence driver + `--allow-unrelated-histories`).
- **Boundary caution:** never the hot path (per-effect/per-message) and never indexed queries — those stay fsync-append / SQLite.

**Reading the effect journal (`<spt_home>/daemon-effects.log`) — operator note.** The broker fsync-appends two line kinds per durable effect: `PENDING <key> <kind>` at attempt, `DONE <key>` on success (durable kinds only — an ephemeral `PtyWrite` writes no lines). A line with **`PENDING` and no matching `DONE`** is **expected**, not a leak: it is a durable effect that did NOT complete — for a `net-send` (a WAN write to an offline/unreachable peer) it is an **unconfirmed send retained as the re-drive recovery marker** (a brain restart or the next reachability re-drives the same `key`; the failure is also surfaced to the brain as a `KIND_ERROR`, never silent-dropped). Two reading caveats: (1) the log is **append-only** — a failed-then-re-driven effect leaves its old `PENDING` line in place *and* appends a fresh `PENDING`+`DONE`, so a raw `grep -c PENDING` **over-reads** the live pending set; the authoritative count is **by key** — `recover()` marks a key applied the moment ANY `DONE` for it appears (see `spt-daemon/src/effect.rs`). (2) `PENDING`-`DONE` is a *durability* record, not a delivery guarantee — a `net-send` PENDING to a peer that never returns stays PENDING forever by design (honest offline-peer bookkeeping), which is correct: writing `DONE` on a failed send would falsely mark it applied (a drop).

**Delivery vs propagation — latency envelope (operator note).** Two classes of cross-node traffic have deliberately different latency floors, so a minutes-scale delay is not automatically a defect: (1) **messages** (`spt send`) are **event-driven** — delivered on the events the daemon owns (WAN ingress + the ACTIVE→IDLE edge, F-023), so a healthy path is near-immediate and never waits on an adapter hook-poll cadence; (2) **anti-entropy context-sync** (the mind pull) is **periodic** — it rides the sync-pull cadence and reconverges *within* one cadence, so minutes-scale is by design, not starvation. The cadence knobs (`DaemonConfig` defaults): sync-pull **60 s**, registry-pump **30 s**, notif-pump **15 s**; a per-peer supervise backoff climbs **5 s → 300 s cap** on a flaky/​re-resolving link (so a marginal peer's next successful pull can lag several cadences). A prime-all-on-restart means a fresh daemon pulls on its first tick — it does not wait a full cadence to start (see `spt-daemon/src/pump/mod.rs` `due`).

**GUI consequence — browsing context state = git rendering.** Raw inspection is
free via standard git tooling (`git log a-<id>` / `git log p-<project>`, `git
diff <c1>..<c2>`, `git show <commit>:<path>`, or any git GUI — refs are ordinary
branches). The product GUI (R-FRONT-1 panes — live context / project context /
psyche log) is a **memformat-aware renderer over git**: per-agent (`a-<id>`) and
per-project (`p-<project>`) are separate refs (hard isolation, independent
history/diff/sync), and history / diff / time-travel are inherited from git
rather than built. The agent×project view is the doyle-tagged slice of
`p-<project>`, joined via the memformat INDEX.

## Cross-node context sync

**Single-writer invariant.** Each context file has exactly one logical writer — the owning agent's *active* instance (the active-instance-authoritative rule). Two nodes never legitimately write the same file simultaneously, so sync is mostly **propagation**, and disjoint files (node A has `agents/ling`, node B has `agents/momo`) simply **union**.

**Merge = precedence marker, never raw 3-way.** A raw git merge of LLM-authored markdown would inject `<<<<<<<` conflict markers into an agent's mind. Instead `tracked/` ships a `.gitattributes` declaring context files use a **custom spt merge driver** that resolves by the precedence marker, per file — `<!-- spt:source=direct|llm spt:routed_at_ms=<ms> spt:node=<node_id> spt:vector=<node:epoch,…> -->` (node id + **version vector** added for cross-node; vector entries come from each node's monotonic `EpochSource`, the same counter behind the D3b registry lease — wall-clock is never the ordering authority). Rule (ADR-0013): incoming vector **dominates** → accept; **dominated** → drop (stale); **concurrent** (neither dominates) → **surface, never silent newest-wins** — both versions persist as **tracked conflict artifacts** (deterministic content-hash names, replicated like any context file) while the local working file stays untouched, then the endpoint's **own Psyche reconciles** them in one bounded stdout-captured turn on the **active instance's node** (single reconciler; fallback lowest node id). The merged write carries `join(vA,vB)` + a bump of the reconciler's entry — it dominates both parents and clears the artifacts subnet-wide. Reconcile failure/unavailability leaves the artifacts for retry; both versions are never lost. Never a conflict marker in a mind file. The marker is the merge authority; git topology is just versioning + the branch-worktree views.

**Divergent / unrelated histories are expected.** Each node independently seeds its own `tracked/` repo on first run, so `a-ling` on A and on B share **no common ancestor**. Sync therefore merges with **`--allow-unrelated-histories`** + the driver. History becomes a multi-parent DAG (one merge commit per sync) — cosmetic; periodic `git gc` keeps it tidy.

**Two transport modes:**
- **P2P (default — gh off):** peers exchange **incremental `git bundle`s over broker QUIC streams** — **pull-based, ref-scoped**: a node requests `a-<id>` for endpoints it has `synced(E,S)` and `p-<project>` only for projects it hosts (the requester names refs, so two-tier scoping falls out of the pull model); receiver fetches from the bundle + merges with driver + `--allow-unrelated-histories`. **The context DAG is shared across nodes** (one history, one merge commit per sync — divergent per-node histories are rejected, ADR-0013). No central point; the "no account" core promise.
- **Hub (opt-in — gh on, via `spt context-github-setup`):** every node **pull → merge → push** against a private GitHub remote — always-online sync hub *and* live GUI view (no stale-mirror-node problem). Push-retry on non-fast-forward contention. **Prefer-GitHub, P2P backstop** when GitHub/SSH is unreachable (the P2P stack is always present anyway).

**Scope:** hub mode is the **context-repo sync transport only.** Messaging, the subnet registry, remote-drive, pairing, and presence always ride P2P/Iroh — GitHub never carries real-time agent traffic, and gh-on never removes the P2P requirement.

**Hub auth — shared deploy key.** The gh-authed setup node creates the private repo, generates a **read-write deploy key**, registers its public half on the repo, and distributes the **private half as subnet secret material** (via pairing / P2P, like the TOTP seed). Other nodes need **no GitHub account** — they use the deploy key over SSH. New-node linking never blocks (auto key + P2P backstop). Revocation = rotate the key (remove from repo, regenerate, redistribute) when a node is de-trusted or hub mode is disabled.

**Push mechanics (deploy key over SSH).** Fully programmatic, never touching the user's personal SSH/git config:
- The `tracked/` remote is the **SSH form** (`git@github.com:<user>/<repo>.git`).
- spt-core scopes key selection to *this* repo via `core.sshCommand` (or per-invocation `GIT_SSH_COMMAND`): `ssh -i $SPT_HOME/identity/context_deploy_key -o IdentitiesOnly=yes` — `IdentitiesOnly=yes` stops the agent offering the user's other keys; the scoping prevents any collision with the user's personal git ops.
- Private key lives in `$SPT_HOME/identity/` (node-local, never in `tracked/`), written `0600`.
- GitHub's published host key is pre-seeded into a scoped `known_hosts` (safer than blind `accept-new`).
- Flow on context update: `git fetch origin` → merge (driver + `--allow-unrelated-histories`) → `git push origin 'a-*' 'p-*'`.
- **Port-22 blocked** → use GitHub's SSH-over-443 endpoint (`ssh.github.com:443`); SSH blocked entirely → fall to the P2P backstop.
- *Alternative (escape hatch only):* a fine-grained repo-scoped PAT over HTTPS via a credential helper — dodges port-22 but is account-tied; SSH deploy key is primary.

## Worktree portability

The `a-<id>` / `p-<project_id>` branch views are **linked git worktrees** off the bare seed repo. Linked worktrees store **absolute** back-links by default (`/home/<user>/…`, `C:\Users\<user>\…`) → break on relocation, a different user account, or migration. Use **relative worktree paths** (`git config worktree.useRelativePaths true`, Git ≥ 2.48) so `tracked/` is relocatable. **Fallback** for older git: spt-core runs `git worktree repair` after any relocation to re-point links locally (spt-core detects git version and picks). Cross-node *sync* transfers branch **objects**, not worktree plumbing, so sync is already path-agnostic — relative paths matter for `$SPT_HOME` relocation, backup/restore, and legacy migration.

## project_id

Derived **from the git remote URL** of the project if it has one (stable + path-independent, so the *same* project at different absolute paths on different machines — `/home/x/proj` vs `C:\Users\x\proj` — resolves to the *same* `project_id`, which is what makes project-context sync match across instances). If the project has no git repo, fall back to the project **folder name**. (Folder-name fallback is not guaranteed path-independent across machines — a known limitation for non-git projects.)

## index/ — materialized indexes (derived, regenerable)

<!-- [doc->REQ-PROJECT-INDEX-STORE] -->

`index/project-index.json` is the **materialized project index** (ADR-0037):
endpoint→project attribution as a single compact, **schema-versioned** JSON
snapshot (`spt_store::projindex`). It is DERIVED state — node-local, never
synced, never tracked, safe to delete wholesale (the daemon's single-flight
writer rebuilds it). Readers (list, picker, endpoint-info, hooks) read this
one file, join it with the perch roster, and return immediately; they **never
run git** and never fall back to synchronous derivation. Degradation is a
rendered state, not an error: a missing/truncated/schema-mismatched index
serves no attribution (`-`) while reads stay fast, and the writer's next
atomic publish (temp + rename — readers see old or new, never torn) repairs
it. Daemon-offline reads consume the last persisted snapshot unchanged.

<!-- [doc->REQ-PROJECT-INDEX-READER-CUTOVER] -->

Each endpoint row carries the head attribution (`project_id`, `display`,
`source`, `updated_ms`) **plus** — additive since W3, same `schema = 1` — a
`refs` list: the FULL precedence-ordered project history (newest first; each
entry `{id, dir, display, source}`, `dir` empty for a branch-membership-only
ref). The picker projects history from `refs`; the list/status surfaces
render the head. A W2-shaped row (no `refs` key, written by a pre-W3 daemon
that has not republished) deserializes with an empty history and readers
degrade to head-only-or-`-` — the mixed-version window is a working state,
never an error.

<!-- [doc->REQ-PROJECT-INDEX-WRITER] -->

Two writer-owned siblings live beside it (PROJECT-INDEX W2):

- `index/project-index-stats.json` — the writer's **observability sidecar**
  (atomic, additive-schema): generated time, source generation (the
  branch-tip fingerprint), pending refresh, last duration/error, row counts,
  cwd-cache hits/misses, stale reads, repairs, and the per-cycle complexity
  counters. Surfaced through `spt daemon status` — index **presence alone is
  not health**, this file is the health surface.
- `index/invalidations/` — the **event drop-dir**: mutation seams (session
  bind/boundary, context commit, rename, fork, purge) drop one tiny scope
  file each; the daemon's writer drains, debounces, and coalesces them into
  one refresh. Fire-and-forget and daemon-offline-safe (events accumulate
  and are consumed at the next brain start).
  <!-- [doc->REQ-PROJECT-INDEX-INVALIDATION] -->

## perches/ — node-local runtime

- Holds the clobber-sensitive, node-specific state: `info.json` (identity, type, current `adapter_name`, status), the SQLite `spool.db`, the `status` marker, and `nested/` Worker/Psyche child perches.
- **Never** part of `tracked/` and never synced — each node owns its own perch runtime. This is the clean alternative to mixing perch files into the context repo and excluding them via gitignore.

### Perch garbage collection — `spt endpoint gc`

<!-- [doc->REQ-PERCH-GC-REFUSAL-REPORT] -->

A perch directory can outlive the endpoint it belonged to: `purge` removes the context rows, the ready marker, the registry row and the trust rows and only **then** `remove_dir_all`s the perch, so a purge blocked at that last step leaves the directory behind, and a bringup abandoned before `api bind` leaves one that never had a record at all. `spt endpoint gc` is the census of that residue.

**What decides it.** The perch directory plus its parseable `info.json` **is** the endpoint record on a node — `roster::enumerate`, the daemon's project writer and `perch::list_self_perch_ids_checked` all derive the endpoint set from `read_dir(owlery)`, and a dir with no `info.json` is not a perch. So the predicate is record **presence**, positive and present-tense: no `info.json` on any read attempt ⇒ residue. Age is never consulted (a dormant endpoint, a suspended session and a long-idle live agent all look "old"), the registry is never consulted (every offline endpoint is legitimately absent from it — KNOWN-HAZARDS 4.3), and no liveness resolver is consulted (`is_perch_alive` is inverted on this population — KNOWN-HAZARDS 3.5).

**Residue is wider than what may be deleted.** Only recordless **and** spool-less **and** non-empty directories, with no record-write in flight, no held child and no refused ancestor, are removable. A refused directory **shields its whole subtree** (`SHIELDED-BY-REFUSED-PARENT`): refusing has to mean the contents are not modified either, or the sweep destroys part of what its own refusal asked the operator to look at — and its verdicts move between passes. A recordless dir carrying a `spool.db` holds the only surviving copy of what was queued for it, and an empty dir is also what a bringup owns mid-create; both are refused **permanently** and reported by path under their own token with the manual remedy (KNOWN-HAZARDS 3.4's doctrine: loudness, not a self-heal).

**Surface.** `spt endpoint gc` reports and deletes nothing; `spt endpoint gc --reap` removes exactly the ruled population, deepest-first, re-checking each subject immediately before removing it. There is no `--dry-run` — the default *is* the dry run. The census prints identically either way, so the two runs compare line for line. An unreadable owlery root is a no-op that says so and exits non-zero, which is a different answer from an empty owlery.

### `info.json` — what the recorded `pid` MEANS (`pid_role`)

<!-- [doc->REQ-PID-ROLE-EVIDENCE] -->

A perch record carries a `pid`, its birth stamp `pid_started_at`, and `pid_role` — **what that process IS to the endpoint**. The role is stamped by the seam that writes the pid and is *never inferred at read time*:

| `pid_role` | Written by | What the pid is | What its DEATH proves |
|---|---|---|---|
| `relay` | `api listen` | a process that **holds** the endpoint | the endpoint's relay is gone — the one case relay-death convergence may act on |
| `binder` | `api bind` (agent or non-agent) | the **announcing CLI**, expected to exit within seconds | **nothing.** The hosting life it announced (a broker PTY session) is not this process, and is not recorded in the row at all |
| *absent* | any pre-`pid_role` writer | unknown | nothing — **no knowledge**, see below |

**Why the field exists.** The same `pid` field carried both meanings, decided by write path, and every reader had to guess. Measured on one node (2026-07-27): three `claude-spt` live agents were online and working with recorded pids long dead (binder pids), while `omp-spt` and `mobile` endpoints held genuinely live relay pids. Two separate proposed fixes for a dead-relay bug were keyed on bare pid death; both would have offlined those three live agents. A record that states the meaning ends the guessing.

**Legacy rows (`pid_role` absent) are "no knowledge", and every consumer fails toward alive:** the `controllable` carry-forward stands, and relay-death convergence never fires. Such a row heals at its next re-bind, when the role is stamped. There is no migration sweep — the failure direction is a missed heal, never a wrongly offlined endpoint.

**Consumers, and which rows reach them** (the structural answer, not a per-node snapshot):

- **Relay-death convergence** (`livehost::reconcile_hosted_liveness`) is reached only by rows with `controllable != Some(true)`, and now acts only on `pid_role == relay`. So: harness relay rows converge on a proven relay death; `binder` rows (every `api bind` product of an agent or non-agent path, including gateway and shell binds) are **never** convergeable no matter how dead their pid; legacy rows are never convergeable; rows with a live broker PTY (`controllable == Some(true)`) never reach the branch at all.
- **`controllable` carry-forward** (`api/startup.rs`) drops an earned `Some(true)` on a listener re-bind **only** when the prior row's `relay` pid is provably `Gone`. A dead `binder` pid, an `Unproven` verdict, and a legacy row all inherit.

Both consumers route their liveness verdict through the single authority (`liveness::relay_liveness` over the `proc::process_identity` oracle); `Unproven` never kills.

## Endpoint metadata & adapter history (`tracked/agents/<id>/meta.json`)

- Records the endpoint's durable identity: type, creation, and an **ordered adapter history** (most-recent first). Synced, so any node knows it.
- Drives the **resume UX**: the latest adapter is the default resume offer, the next-latest is second, … the oldest is second-to-last, and "choose a different adapter" is the final option.
- The *currently-running* `adapter_name` lives in node-local `perches/<id>/info.json`; the *history* is endpoint identity in `tracked/`.

## Shells in the layout

Shells are endpoints but a different shape from agent endpoints:
- **Nested perch under the owner:** `perches/<endpoint_id>/shells/<adapter>-<n>/` — one agent owns many shells. `info.json` carries `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — **not** the capability set (that lives in the shell adapter manifest, resolved by `adapter_name`). `spool.db` holds the durable command + text/file channels; the sensory channel is REST-only and never spooled.
- **Not in the subnet registry** — private to the agent↔shell link, not a general messaging surface.
- **No `tracked/` context** — a Shell has no mind; never synced.
- **Logs** in node-local `history/<endpoint_id>/shells/<adapter>-<n>.jsonl`.
- **Adapter/platform-bound, not adapter-agnostic** — capability surface comes from the providing shell adapter; it does not move harnesses.
- **Lifecycle = online / offline / torn-down.** Link-break always closes the binary (optional pre-close instruction + termination timeout). **Ephemeral** (manifest property) ⇒ perch torn down *and removed from the agent's shell history*; **persistent** ⇒ perch kept offline for relink.
- **Cross-node links:** the shell perch lives on the shell's node (nested under the owner id as a label there); commands ride Iroh from the owner's node.

## Migration & backup

- **Back up / migrate:** `identity/` + `tracked/` (+ `history/` if wanted). That's identity, trust, subnet, and the full agent/project mind.
- **Regenerate on the target:** `perches/`, `registry/`, `daemon/`, `updates/` rebuild from the durable set + the live subnet.
- Legacy `claude_skill_owl` migration maps its `psyches/tracked/` git repo into `tracked/` and its node identity/agents into `identity/` + `perches/`.

## Open sub-items

- Exact carry-over of modern SPT's git branch mechanics (worktrees vs branch-checkout) into `tracked/`.
- Whether `history/` ever needs cross-node fetch (today node-local; history-lookup over the network would serve remote reads).
