# $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?
        shell.launch.json        atomic PID + native birth + launch wall-clock; pid 0 clears custody
        shell.pid                numeric compatibility mirror for older core readers
        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
    <adapter>/web/               core-owned adapter served root; output bytes survive deactivation
  serve/registry                 authoritative serving entries + persistent name ownership
```

<!-- [doc->REQ-SHELL-PERSISTENT-BOOT-RESTORE] -->
Shell launch identity is published as one `shell.launch.json` record before the
best-effort numeric mirror. Readers prefer its `pid` and `pid_started_at` together;
old records without `pid` retain the legacy two-file read. Clearing publishes
`pid: 0` and no birth while preserving `launched_ms`, so a stale numeric mirror
cannot resurrect a closed process. Failed replacement leaves the previous
identity intact rather than stripping its protection against recycled PIDs.

### 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.

<!-- [doc->REQ-ROSTER-WAIT-ATTRIBUTION] -->
### Diagnosing a slow complete roster

The standalone `spt endpoint list --show-all` path combines persisted subnet
snapshots, local perch metadata and contact-ledger evidence. UNLISTED presence
probes are a separate stage; a complete final table does not establish which
stage delayed its publication.

The scoped roster instrumentation separates those reads, registry database
open/tuning/schema/query work, and the presence-probe worklist and waits.
Enable it through `SPT_PUMP_TRACE=1` for the investigated command and retain
the `ROSTER_WAIT` stderr records alongside complete stdout and command exit
status. Records name process/thread, stage, elapsed time, budget, outcome and
progress. Normal stdout and the roster's inclusion rules are unchanged.

Read each elapsed value with its named budget and producer progress. A wall
deadline is not an iteration budget or a sum of retry sleeps. An outer probe
observation cutoff does not prove that its worker or remote peer stopped;
the last observed stage is not a diagnosis of the remote cause. Tracing adds
measurement work, so retain its enabled state when comparing runs. These
observations do not establish a numerical full-roster latency guarantee.

### 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).

## serve/registry — authoritative serving assignments

<!-- [doc->REQ-WEB-SERVING-REGISTRY] -->

`$SPT_HOME/serve/registry` is schema-versioned JSON, not a regenerable discovery
cache. The broker and brain are separate writer processes: broker control
requests register, remove, and reconcile entries; the brain's live-host pulse
reaps expired entries. They publish durable atomic snapshots; the HTTP listener
reads those snapshots without a writer lock. Only a genuinely missing file
initializes an empty registry. Unreadable, corrupt, unsupported, or inconsistent
history refuses the operation rather than recycling stale URLs.

Schema 1 carries `entries`, `next_suffix` (per requested-name family), and
`name_owners` (every assigned name mapped to its prior absolute `path` and
`kind`, including removed entries). Each entry has
`id`, `kind`, absolute `path`, `served_name`, `registered_at_ms`, optional
`ttl_ms`, optional receiving-endpoint `audience`, optional `origin`, optional
owning `adapter`, and `short_alias`. Lifetime and audience are per-entry,
independent of kind: live files and directories can carry them as well as
attachment snapshots (ADR-0058 Amendment 1). W0's explicit registrations leave
both unset; helper registration, expiry, and audience enforcement belong to W2.
Removal deletes only the live entry, never its name history or source bytes.
A retired name may be reclaimed only by its prior absolute path with the same
kind. Other paths still consume the next suffix; reclamation never rewinds
that counter (ADR-0057 Amendment 2).

Each active base adapter has one directory entry referring to the core-created
`adapters/<adapter>/web/`, never its install tree. Core creates the checked root
before committing registration, including while the daemon is stopped; only
the daemon writes serving assignments, at startup and after adapter changes.
The adapter facet `/<node>/a/<adapter>/` always names the entry. Its optional
short alias addresses that same entry; no second row, profile root, or directory
is created. Redirected roots and symlinks escaping the subtree are refused.

Repeated reconciliation and adapter updates preserve a live entry and alias;
changes to the alias declaration apply after deactivation/reactivation.
Deactivation or removal retires the entry while retaining the root and all
output bytes. With the same declaration, reactivation reclaims the root's prior
served name and alias from ownership history. Updates never replace this
output with files from an adapter package.

Back up this registry with any adapter output roots you need to preserve.
Explicit references point outside SPT_HOME too: copying SPT_HOME alone does not
copy their bytes or relocate their absolute paths. Losing the registry loses
the history which prevents an old URL from naming a different resource.

### Writer serialization

The releases#308 repair preserves schema 1, the CLI, TTLs, and the existing
save decisions.
See [KNOWN-HAZARDS §6.14](KNOWN-HAZARDS.md#614-serving-registry-writers-must-serialize-across-processes)
for the lost-update evidence and its limits.

Every writer acquires `spt_store::serving::lock_registry_at(registry_path)`
**before a fresh load**, and keeps its returned `std::fs::File` alive through
the complete read-modify-write operation: load, mutation, associated snapshot
creation or cleanup, and any explicit `save_at`. Lock acquisition failure
refuses the operation; it never permits an unlocked write. The lock is
non-reentrant: a guarded caller must not reacquire it for the same registry.

The guard opens a fresh file handle and takes an `fs2` exclusive lock on a
stable sibling formed by appending `.lock` to the **full registry path**:
`$SPT_HOME/serve/registry.lock`. Never lock the registry inode itself: atomic
publication replaces that file. Never unlink the sentinel, including on
unlock or cleanup: waiters and new writers must lock the same file. Dropping
the guard or exiting the process releases the lock without deleting it.

Atomic publication and mutual exclusion solve different problems. Atomic
replacement gives lock-free HTTP readers a complete old or new snapshot; it
does not stop a stale writer from replacing a newer snapshot. Locking only
`save_at` is too late, and a process-local mutex cannot coordinate the broker
with the brain. Direct writers, including live-daemon fixture mutations, must
follow the same full-operation guard contract.

The guard **never auto-saves**, including on drop. Read-only listing, an
unchanged input-reference replay, and a reaper pass with no expired entries
do not save the registry; a missing registry remains missing after a no-op
reap. Existing operations that explicitly save, such as reconciliation, still
do so. Snapshot unlink-before-save ordering and user-source protection remain
the [attachment lifetime contract](../docs-site/src/serving/attachments.md#lifetime)
(`REQ-WEB-ATTACHMENT-PULL`); locking does not make snapshot deletion and registry
publication one atomic transaction.

Protection is active only when **both running writer implementations** use
this protocol. Updating only the brain while an old broker survives does not
fully activate it; a new broker paired with an old brain is likewise unsafe.
The sentinel's presence is not proof that both writers participate.

## 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).
