1	# M4-D5 — cross-node message + remote-drive + access control (JIT plan)
2
3	**Status:** D5a complete (`caab433`), D5b complete (`4ea3b37`), D5c complete (this change).
4	D5d next. D4 complete (broker owns endpoint/conns/streams; gapless exactly-once
5	stream survival; PresenceChannel seam; registry replication over the wire — CI-green at
6	`017d103`). D5 puts user-visible function on that substrate: send a message to an agent on
7	another node, drive a running instance from another node, and gate who may do either
8	(ADR-0009 node-tier).
9
10	## Goal
11
12	Drive an agent on machine A from machine B (loopback two-daemon in CI; two-host at D9):
13	a WAN message reaches a remote endpoint's spool/stream exactly-once; a byte-stream terminal
14	attach to a remote instance works as a viewport (compute + files stay remote); the driven
15	agent can detect it is network-driven and from which node; an endpoint with a whitelist
16	refuses unsolicited inbound from a non-listed origin node. Reqs: REQ-NET-1 (WAN-msg
17	aspects), REQ-INST-8, REQ-REACH-1, REQ-SEC-1.
18
19	## Decisions (locked)
20
21	- **WAN messages ride broker QUIC streams as NDJSON** — the D4d `RegistryUpdate`/`LineDecoder`
22	  pattern (`spt-net/src/net/replicate.rs`) extended with a `WanMessage` record
23	  `{ target, from, body, op_id }`. Receiver-side the record funnels into the **existing
24	  local path** `spt_msg::deliver(target, from, body, owlery)` — TCP-first/spool-fallback,
25	  deferred-row semantics (1.4/4.4) and codec ordering (4.1) all inherited, not rebuilt.
26	- **`origin_node` is transport truth, never payload.** The whitelist subject is the QUIC
27	  connection's authenticated remote node id (iroh `EndpointId` == Ed25519 node pubkey,
28	  REQ-NET-1's identity binding) — a `from`/origin field inside the NDJSON record is
29	  display metadata only and MUST NOT feed the gate. Register this as a new hazard
30	  (KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH`, rule 3) with a spoof negative test.
31	- **Exactly-once inherits D4 discipline** (REQ-HAZARD-RESTART-IDEMPOTENT): sends ride the
32	  journaled `KIND_NET_STREAM_SEND` path (`op_id` dedup at the effect); the receiver dedups
33	  `op_id` before spool/deliver so redelivery after a restart can't double-deliver (4.5's
34	  wire-side cousin).
35	- **Remote attach = OutputLog over the wire.** The broker-side `OutputLog` (seq'd ring,
36	  cursor resume) is already the gapless surface; D5b pumps its chunks over a broker QUIC
37	  stream (NDJSON control + base64 chunk records), input flows back over the same stream
38	  into the existing `KIND_INPUT` path. Cursor-resume on reattach gives brain-restart
39	  survival on both ends for free. Viewport only — no file or exec surface (REQ-REACH-2
40	  deferred).
41	- **Detection is a daemon fact, not an agent guess** (REQ-REACH-1): a net-attached surface
42	  registers `(session, origin_node)` in the daemon; exposed as an instance-status field +
43	  queryable over the `api` surface. The "node X is driving" light notification becomes a
44	  notif-producer when D8 lands — D5 emits the event seam only.
45	- **File transfer is substrate-level, progress-queryable** (CONTEXT §file-transfer
46	  progress): chunked NDJSON-framed records with `op_id`, both ends keep
47	  `{ bytes_done, bytes_total }` queryable mid-flight via a brain frame. v1 surface is
48	  to/from the driving node (the off-node reach-back case), shaped so the Shell text+file
49	  channel inherits it.
50	- **Whitelist = trust-store plumbing, opposite polarity** (ADR-0009): `AccessStore` next to
51	  `spt-store/src/trust.rs` (security material near the trust store, NOT the context repo);
52	  per-endpoint `{ nodes: [pubkey], users: [] /* inert until cross-user */ }`; **absent =
53	  open**, present = restrict. Enforced at the target node on the **unsolicited** inbound
54	  class only: outbound unrestricted; inbound correlated to the endpoint's own prior
55	  outbound (`__REPLY_TO__`) allowed from any visible node; same-node always allowed (the
56	  gate sits on the net-inbound path only, so the local path never sees it). Gate order:
57	  visibility → whitelist → grants (outer gate before grants).
58	- **Gate hook lands with D5a, enforcement with D5d:** the net-inbound dispatch calls one
59	  `access_check(endpoint, origin_node, class)` seam from day one (default-open), so D5b/c
60	  inbound paths are born gated and D5d only fills in store + reply-correlation + CLI.
61
62	## Pieces (build order)
63
64	1. **D5a — WAN message delivery** (REQ-NET-1 WAN-msg). `WanMessage` NDJSON over broker
65	   streams; sender resolves `id[@node]` via `SubnetRegistry` (D3c policy: refuse ambiguous);
66	   receiver dedups `op_id` → `spt_msg::deliver` → spool/TCP. `access_check` hook stubbed
67	   default-open. `spt send` grows the off-node leg transparently. Loopback test: message
68	   from daemon A lands in B's perch spool; replay after receiver brain restart → no dup;
69	   spoofed payload-origin ignored (hazard test).
70	2. **D5b — remote terminal attach** (REQ-INST-8). Attach-request record over a broker
71	   stream → target daemon subscribes its own `OutputLog` from cursor, pumps chunk records
72	   back; input records → `KIND_INPUT`. `spt attach <id>@<node>` CLI leg. Loopback test:
73	   drive a real PTY session cross-daemon; kill/restart a brain mid-attach → cursor resume,
74	   no gap, no dup.
75	3. **D5c — remote-drive detection + file transfer** (REQ-REACH-1). Net-attached surface
76	   registers `(session, origin_node)`; instance status + `api` query expose it. Chunked
77	   file transfer to/from the driving node, `op_id`-idempotent, progress queryable from
78	   both ends. Handoff seam (`git pull` + fresh-with-preload resume; remote goes dormant)
79	   documented as the D6-consumed shape — detection + transfer only here.
80	4. **D5d — endpoint access whitelist** (REQ-SEC-1, ADR-0009). `AccessStore` (+ inert
81	   `users` field); reply-vs-new classification (track recent outbound per endpoint for
82	   `__REPLY_TO__` correlation); `access_check` enforces origin-node gate; minimal
83	   `spt access allow|revoke|list` surface. Tests: default-empty open; whitelisted node
84	   passes; non-listed unsolicited refused; reply from non-listed node passes (stateful
85	   firewall); same-node always passes; origin spoof via payload fails.
86	5. **Fault-matrix rows** (maintenance rule): WAN-msg path, remote attach, file transfer
87	   each get a row in `docs/FAULT-MATRIX.md` in the same change that lands them.
88
89	## Requirement activation (rule 5)
90
91	- **REQ-INST-8** → activate `[impl, unit]` at D5b.
92	- **REQ-REACH-1** → activate `[impl, unit]` at D5c.
93	- **REQ-SEC-1** → activate `[impl, unit]` at D5d.
94	- **REQ-NET-1** stays `[impl, unit]` (new evidence tags on the WAN-msg path); `int` remains
95	  the D9 two-host E2E.
96	- **New (rule 3): `REQ-HAZARD-WAN-ORIGIN-AUTH`** — origin-node authenticated from transport
97	  identity, never payload — register at D5a with doc+impl+unit (the spoof negative test).
98
99	## THIS slice — D5a (WAN message delivery)
100
101	- `WanMessage` record + `LineDecoder` reuse in `spt-net` (or sibling module to
102	  `replicate.rs`); broker net-inbound dispatch routes message records → dedup → deliver.
103	- Sender leg: resolve via registry (local-first, refuse-ambiguous, `@node` pin) → dial →
104	  journaled stream send.
105	- `access_check` seam (default-open) on the unsolicited-inbound dispatch.
106	- KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH` registered and covered.
107	- Tests (`crates/spt-daemon/tests/wanmsg.rs`, hermetic two-daemon harness from
108	  `replicate.rs`): cross-daemon spool landing; receiver-restart no-dup; payload-origin
109	  spoof ignored; ambiguous bare id refused.
110
111	## Handoff seam — the D6-consumed shape (documented at D5c, built at D6)
112
113	<!-- [doc->REQ-REACH-1] -->
114	The smooth remote-control → local handoff (CONTEXT §Remote-control vs local operation)
115	composes from three pieces, **none of which is new machinery**:
116
117	1. **Files:** `git pull` on the local node — file state moves through the repo, never
118	   through the attach viewport (compute + files stayed remote the whole time; the D5c file
119	   channel is for individual reach-back payloads, not repo migration).
120	2. **Mind:** the **fresh-with-preload resume seam** — the local instance starts (or wakes)
121	   preloaded with the endpoint's latest synced Psyche context. D6a's P2P replication is
122	   what makes "latest" hold cross-node; the preload call shape is the existing
123	   resume-with-context path the daemon already drives locally.
124	3. **Remote goes dormant, no teardown:** the remote instance simply stops being driven —
125	   its `driven_by` marker clears when the viewport ends (D5c detection), it keeps its
126	   perch/spool/history, and the cross-instance context-freshness feed (D6) catches it up
127	   if it is ever driven again. Nothing is destroyed; dormancy is the default rest state
128	   (CONTEXT §instances).
129
130	D5c's deliverables are the two ingredients the seam needs from this slice: **detection**
131	(the daemon-stamped `driven_by` fact + the `spt api driven-by` query) and **file
132	transfer** (op-id-addressable, progress-queryable). D6 wires 2 and the freshness feed.
133
134	## NOT in D5 (this milestone)
135
136	- Psyche-context sync + the full remote→local handoff (fresh-with-preload) — D6.
137	- Remote command execution (REQ-REACH-2), instantiate-anywhere + consent UX — deferred
138	  (seams only).
139	- User-tier whitelist (ADR-0006 cross-user) — schema reserves `users`, inert.
140	- Notif primitive ("node X is driving" prompt UX) — D8; D5 emits the event seam.
141	- Two-host integration — D9 (loopback shapes only here).
142
143	## Conventions (carried)
144
145	- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence. `traceable-reqs
146	  check` from repo root before done. Local clippy can't see `#[cfg]` arms — Linux CI clippy
147	  `-D warnings` is the real gate. Push each slice → watch green before next.
148	  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
149

# M4-D5 — cross-node message + remote-drive + access control (JIT plan)

**Status:** D5a complete (`caab433`), D5b complete (`4ea3b37`), D5c complete (this change).
D5d next. D4 complete (broker owns endpoint/conns/streams; gapless exactly-once
stream survival; PresenceChannel seam; registry replication over the wire — CI-green at
`017d103`). D5 puts user-visible function on that substrate: send a message to an agent on
another node, drive a running instance from another node, and gate who may do either
(ADR-0009 node-tier).

## Goal

Drive an agent on machine A from machine B (loopback two-daemon in CI; two-host at D9):
a WAN message reaches a remote endpoint's spool/stream exactly-once; a byte-stream terminal
attach to a remote instance works as a viewport (compute + files stay remote); the driven
agent can detect it is network-driven and from which node; an endpoint with a whitelist
refuses unsolicited inbound from a non-listed origin node. Reqs: REQ-NET-1 (WAN-msg
aspects), REQ-INST-8, REQ-REACH-1, REQ-SEC-1.

## Decisions (locked)

- **WAN messages ride broker QUIC streams as NDJSON** — the D4d `RegistryUpdate`/`LineDecoder`
  pattern (`spt-net/src/net/replicate.rs`) extended with a `WanMessage` record
  `{ target, from, body, op_id }`. Receiver-side the record funnels into the **existing
  local path** `spt_msg::deliver(target, from, body, owlery)` — TCP-first/spool-fallback,
  deferred-row semantics (1.4/4.4) and codec ordering (4.1) all inherited, not rebuilt.
- **`origin_node` is transport truth, never payload.** The whitelist subject is the QUIC
  connection's authenticated remote node id (iroh `EndpointId` == Ed25519 node pubkey,
  REQ-NET-1's identity binding) — a `from`/origin field inside the NDJSON record is
  display metadata only and MUST NOT feed the gate. Register this as a new hazard
  (KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH`, rule 3) with a spoof negative test.
- **Exactly-once inherits D4 discipline** (REQ-HAZARD-RESTART-IDEMPOTENT): sends ride the
  journaled `KIND_NET_STREAM_SEND` path (`op_id` dedup at the effect); the receiver dedups
  `op_id` before spool/deliver so redelivery after a restart can't double-deliver (4.5's
  wire-side cousin).
- **Remote attach = OutputLog over the wire.** The broker-side `OutputLog` (seq'd ring,
  cursor resume) is already the gapless surface; D5b pumps its chunks over a broker QUIC
  stream (NDJSON control + base64 chunk records), input flows back over the same stream
  into the existing `KIND_INPUT` path. Cursor-resume on reattach gives brain-restart
  survival on both ends for free. Viewport only — no file or exec surface (REQ-REACH-2
  deferred).
- **Detection is a daemon fact, not an agent guess** (REQ-REACH-1): a net-attached surface
  registers `(session, origin_node)` in the daemon; exposed as an instance-status field +
  queryable over the `api` surface. The "node X is driving" light notification becomes a
  notif-producer when D8 lands — D5 emits the event seam only.
- **File transfer is substrate-level, progress-queryable** (CONTEXT §file-transfer
  progress): chunked NDJSON-framed records with `op_id`, both ends keep
  `{ bytes_done, bytes_total }` queryable mid-flight via a brain frame. v1 surface is
  to/from the driving node (the off-node reach-back case), shaped so the Shell text+file
  channel inherits it.
- **Whitelist = trust-store plumbing, opposite polarity** (ADR-0009): `AccessStore` next to
  `spt-store/src/trust.rs` (security material near the trust store, NOT the context repo);
  per-endpoint `{ nodes: [pubkey], users: [] /* inert until cross-user */ }`; **absent =
  open**, present = restrict. Enforced at the target node on the **unsolicited** inbound
  class only: outbound unrestricted; inbound correlated to the endpoint's own prior
  outbound (`__REPLY_TO__`) allowed from any visible node; same-node always allowed (the
  gate sits on the net-inbound path only, so the local path never sees it). Gate order:
  visibility → whitelist → grants (outer gate before grants).
- **Gate hook lands with D5a, enforcement with D5d:** the net-inbound dispatch calls one
  `access_check(endpoint, origin_node, class)` seam from day one (default-open), so D5b/c
  inbound paths are born gated and D5d only fills in store + reply-correlation + CLI.

## Pieces (build order)

1. **D5a — WAN message delivery** (REQ-NET-1 WAN-msg). `WanMessage` NDJSON over broker
   streams; sender resolves `id[@node]` via `SubnetRegistry` (D3c policy: refuse ambiguous);
   receiver dedups `op_id` → `spt_msg::deliver` → spool/TCP. `access_check` hook stubbed
   default-open. `spt send` grows the off-node leg transparently. Loopback test: message
   from daemon A lands in B's perch spool; replay after receiver brain restart → no dup;
   spoofed payload-origin ignored (hazard test).
2. **D5b — remote terminal attach** (REQ-INST-8). Attach-request record over a broker
   stream → target daemon subscribes its own `OutputLog` from cursor, pumps chunk records
   back; input records → `KIND_INPUT`. `spt attach <id>@<node>` CLI leg. Loopback test:
   drive a real PTY session cross-daemon; kill/restart a brain mid-attach → cursor resume,
   no gap, no dup.
3. **D5c — remote-drive detection + file transfer** (REQ-REACH-1). Net-attached surface
   registers `(session, origin_node)`; instance status + `api` query expose it. Chunked
   file transfer to/from the driving node, `op_id`-idempotent, progress queryable from
   both ends. Handoff seam (`git pull` + fresh-with-preload resume; remote goes dormant)
   documented as the D6-consumed shape — detection + transfer only here.
4. **D5d — endpoint access whitelist** (REQ-SEC-1, ADR-0009). `AccessStore` (+ inert
   `users` field); reply-vs-new classification (track recent outbound per endpoint for
   `__REPLY_TO__` correlation); `access_check` enforces origin-node gate; minimal
   `spt access allow|revoke|list` surface. Tests: default-empty open; whitelisted node
   passes; non-listed unsolicited refused; reply from non-listed node passes (stateful
   firewall); same-node always passes; origin spoof via payload fails.
5. **Fault-matrix rows** (maintenance rule): WAN-msg path, remote attach, file transfer
   each get a row in `docs/FAULT-MATRIX.md` in the same change that lands them.

## Requirement activation (rule 5)

- **REQ-INST-8** → activate `[impl, unit]` at D5b.
- **REQ-REACH-1** → activate `[impl, unit]` at D5c.
- **REQ-SEC-1** → activate `[impl, unit]` at D5d.
- **REQ-NET-1** stays `[impl, unit]` (new evidence tags on the WAN-msg path); `int` remains
  the D9 two-host E2E.
- **New (rule 3): `REQ-HAZARD-WAN-ORIGIN-AUTH`** — origin-node authenticated from transport
  identity, never payload — register at D5a with doc+impl+unit (the spoof negative test).

## THIS slice — D5a (WAN message delivery)

- `WanMessage` record + `LineDecoder` reuse in `spt-net` (or sibling module to
  `replicate.rs`); broker net-inbound dispatch routes message records → dedup → deliver.
- Sender leg: resolve via registry (local-first, refuse-ambiguous, `@node` pin) → dial →
  journaled stream send.
- `access_check` seam (default-open) on the unsolicited-inbound dispatch.
- KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH` registered and covered.
- Tests (`crates/spt-daemon/tests/wanmsg.rs`, hermetic two-daemon harness from
  `replicate.rs`): cross-daemon spool landing; receiver-restart no-dup; payload-origin
  spoof ignored; ambiguous bare id refused.

## Handoff seam — the D6-consumed shape (documented at D5c, built at D6)

<!-- [doc->REQ-REACH-1] -->
The smooth remote-control → local handoff (CONTEXT §Remote-control vs local operation)
composes from three pieces, **none of which is new machinery**:

1. **Files:** `git pull` on the local node — file state moves through the repo, never
   through the attach viewport (compute + files stayed remote the whole time; the D5c file
   channel is for individual reach-back payloads, not repo migration).
2. **Mind:** the **fresh-with-preload resume seam** — the local instance starts (or wakes)
   preloaded with the endpoint's latest synced Psyche context. D6a's P2P replication is
   what makes "latest" hold cross-node; the preload call shape is the existing
   resume-with-context path the daemon already drives locally.
3. **Remote goes dormant, no teardown:** the remote instance simply stops being driven —
   its `driven_by` marker clears when the viewport ends (D5c detection), it keeps its
   perch/spool/history, and the cross-instance context-freshness feed (D6) catches it up
   if it is ever driven again. Nothing is destroyed; dormancy is the default rest state
   (CONTEXT §instances).

D5c's deliverables are the two ingredients the seam needs from this slice: **detection**
(the daemon-stamped `driven_by` fact + the `spt api driven-by` query) and **file
transfer** (op-id-addressable, progress-queryable). D6 wires 2 and the freshness feed.

## NOT in D5 (this milestone)

- Psyche-context sync + the full remote→local handoff (fresh-with-preload) — D6.
- Remote command execution (REQ-REACH-2), instantiate-anywhere + consent UX — deferred
  (seams only).
- User-tier whitelist (ADR-0006 cross-user) — schema reserves `users`, inert.
- Notif primitive ("node X is driving" prompt UX) — D8; D5 emits the event seam.
- Two-host integration — D9 (loopback shapes only here).

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence. `traceable-reqs
  check` from repo root before done. Local clippy can't see `#[cfg]` arms — Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch green before next.
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


158-required_stages = []
159-
160-# ───────────────────────────── Instances ────────────────────────────────────
161-[[requirements]]
162-id = "REQ-INST-1"
163-title = "endpoint ID vs instance split (adapter-agnostic ID)"
164-required_stages = []
165-[[requirements]]
166:id = "REQ-INST-2"
167-title = "Per-node files, synced Psyche mind"
168-required_stages = []
169-[[requirements]]
170-id = "REQ-INST-3"
171-title = "Dormant (warm) / suspended (cold) resting states"
172-required_stages = []
173-[[requirements]]
174-id = "REQ-INST-4"
175-title = "active to dormant/suspended fires a transition echo commune"
176-required_stages = []
177-[[requirements]]
178:id = "REQ-INST-5"
179-title = "Two-tier context sync (live to all, project to same-project)"
180-required_stages = []
181-[[requirements]]
182-id = "REQ-INST-6"
183-title = "Deferred messages not delivered to dormant/suspended instances"
184-required_stages = []
185-[[requirements]]
186-id = "REQ-INST-7"
--
254-id = "REQ-NET-1"
255-title = "WAN messaging first-class, behind default-on net feature flag"
256-required_stages = ["impl", "unit"]  # activated M4-D1: spt-net NetEndpoint binds an iroh endpoint to the node's own spt-proto Ed25519 identity (EndpointId == node pubkey) behind the default-on `net` feature, with connect/accept over the SPT_NET_ALPN; unit proves identity-binding + a hermetic loopback QUIC bidi echo. The cross-node WAN `int` stage is the two-host E2E at D9 (precedent: REQ-MSG-1 int added at its E2E task)
257-[[requirements]]
258-id = "REQ-NET-2"
259-title = "n0 relay default + self-host knob + plain-language disclosure"
260-required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required
261-[[requirements]]
262:id = "REQ-NET-3"
263-title = "Cross-node Psyche sync over P2P replaces gh-repo-sync"
264-required_stages = []
265-[[requirements]]
266-id = "REQ-PAIR-1"
267-title = "TOTP-seeded SPAKE2 pairing"
268-[Omitted long context line]
269-[[requirements]]
270-id = "REQ-PAIR-2"
--
520-id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
521-title = "No hard-delete of a parent hosting non-empty children (6.3)"
522-required_stages = ["impl", "unit"]
523-[[requirements]]
524-id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
525-title = "Drop files are daemon-owned single-writer (6.4)"
526-required_stages = ["impl", "unit"]
527-[[requirements]]
528:id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
529-title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
530-required_stages = ["impl", "unit"]
531-# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
532-[[requirements]]
533-id = "REQ-HAZARD-CONPTY-DSR"
534-title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
535-required_stages = ["impl", "unit"]
536-[[requirements]]


Found 3 files
crates\spt-live\src\signoff.rs
crates\spt-live\src\ingest.rs
crates\spt-live\src\context.rs


8: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.
9: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.
10: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.
21:  tracked/                       ── git repo; synced across nodes over P2P ──
24:      live-context.md            per-agent live context           (branch a-<endpoint_id>)
27:        project-context.md       per-(agent,project) context      (branch p-<project_id>)
28:  perches/                       ── node-local runtime, NEVER tracked/synced ──
47:## tracked/ — the context git repo
49:- 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").
50:- **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.
53:## BranchStore (git-KV) — generalizing the context store (ADR-0011)
55:The context repo above is one instance of a general pattern: **a git branch used
59:reused beyond context, for **coarse / durable / audited** state only.
65:| context (`a-`/`p-`) | **BranchStore** | versioned, merge-synced cross-node, audited mind |
76:- **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`).
79:**GUI consequence — browsing context state = git rendering.** Raw inspection is
82:branches). The product GUI (R-FRONT-1 panes — live context / project context /
89:## Cross-node context sync
91:**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**.
93:[Omitted long matching line]
95:**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.
99:- **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).
101:**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.
106:- The `tracked/` remote is the **SSH form** (`git@github.com:<user>/<repo>.git`).
107:- 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.
108:- Private key lives in `$SPT_HOME/identity/` (node-local, never in `tracked/`), written `0600`.
110:- Flow on context update: `git fetch origin` → merge (driver + `--allow-unrelated-histories`) → `git push origin 'a-*' 'p-*'`.
116:[Omitted long matching line]
120:[Omitted long matching line]
125:- **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.
127:## Endpoint metadata & adapter history (`tracked/agents/<id>/meta.json`)
131:- The *currently-running* `adapter_name` lives in node-local `perches/<id>/info.json`; the *history* is endpoint identity in `tracked/`.
138:- **No `tracked/` context** — a Shell has no mind; never synced.
146:- **Back up / migrate:** `identity/` + `tracked/` (+ `history/` if wanted). That's identity, trust, subnet, and the full agent/project mind.
148:- Legacy `claude_skill_owl` migration maps its `psyches/tracked/` git repo into `tracked/` and its node identity/agents into `identity/` + `perches/`.
152:- Exact carry-over of modern SPT's git branch mechanics (worktrees vs branch-checkout) into `tracked/`.


85	history/diff/sync), and history / diff / time-travel are inherited from git
86	rather than built. The agent×project view is the doyle-tagged slice of
87	`p-<project>`, joined via the memformat INDEX.
88
89	## Cross-node context sync
90
91	**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**.
92
93	**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 on every write — `<!-- spt:source=direct|llm spt:routed_at=<iso> spt:node=<node_id> -->` (node-id added for cross-node) — **newest wins, per file**. Never a conflict marker. The marker is the merge authority; git topology is just versioning + the branch-worktree views.
94
95	**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.
96
97	**Two transport modes:**
98	- **P2P (default — gh off):** peers fetch each other's `a-`/`p-` branches over Iroh; merge with driver + `--allow-unrelated-histories`. No central point; the "no account" core promise.
99	- **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).
100
101	**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.
102
103	**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.
104
105	**Push mechanics (deploy key over SSH).** Fully programmatic, never touching the user's personal SSH/git config:
106	- The `tracked/` remote is the **SSH form** (`git@github.com:<user>/<repo>.git`).
107	- 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.
108	- Private key lives in `$SPT_HOME/identity/` (node-local, never in `tracked/`), written `0600`.
109	- GitHub's published host key is pre-seeded into a scoped `known_hosts` (safer than blind `accept-new`).
110	- Flow on context update: `git fetch origin` → merge (driver + `--allow-unrelated-histories`) → `git push origin 'a-*' 'p-*'`.
111	- **Port-22 blocked** → use GitHub's SSH-over-443 endpoint (`ssh.github.com:443`); SSH blocked entirely → fall to the P2P backstop.
112	- *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.
113
114	## Worktree portability
115
116	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.
117
118	## project_id
119
120	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.)
121
122	## perches/ — node-local runtime
123
124	- 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.

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 on every write — `<!-- spt:source=direct|llm spt:routed_at=<iso> spt:node=<node_id> -->` (node-id added for cross-node) — **newest wins, per file**. Never a conflict marker. 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 fetch each other's `a-`/`p-` branches over Iroh; merge with driver + `--allow-unrelated-histories`. 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.)

## 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.


3://! Every context write carries a **precedence marker** — `source` (`direct` vs
4://! `llm`) + a `routed_at` timestamp — recorded as the file's first line:
7://! <!-- spt:source=llm spt:routed_at_ms=1717200000000 -->
17://! **local** guard only: one node, `source` + `routed_at` (epoch-ms for exact
18://! window math; the ISO/`spt:node` form is the M4 sync concern). Keep the marker
55:/// The precedence marker stamped as a context file's first line.
59:    pub routed_at_ms: u64,
66:            "<!-- spt:source={} spt:routed_at_ms={} -->",
68:            self.routed_at_ms
74:    /// prior precedence, so any write proceeds).
78:        let mut routed_at_ms = None;
80:            if let Some(v) = tok.strip_prefix("spt:source=") {
82:            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
83:                routed_at_ms = v.parse::<u64>().ok();
88:            routed_at_ms: routed_at_ms?,
97:    Written { routed_at_ms: u64 },
114:        return WriteOutcome::Written { routed_at_ms: now_ms };
117:        if m.source == WriteSource::Direct && now_ms.saturating_sub(m.routed_at_ms) < window_ms {
119:                direct_at_ms: m.routed_at_ms,
123:    WriteOutcome::Written { routed_at_ms: now_ms }
126:/// Read the leading precedence marker of a context file, if present.
132:/// Apply a precedence-guarded write to a context file. On `Written`, the file is
146:        WriteOutcome::Written { routed_at_ms } => {
147:            let marker = Marker { source, routed_at_ms };
171:        let m = Marker { source: WriteSource::Llm, routed_at_ms: 1717200000000 };
178:        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
181:            WriteOutcome::Written { routed_at_ms: 1001 }
187:        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
196:        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
199:            WriteOutcome::Written { routed_at_ms: 61_001 }
205:        let prior = Some(Marker { source: WriteSource::Llm, routed_at_ms: 1000 });
208:            WriteOutcome::Written { routed_at_ms: 1001 }


Found 9 files
crates\spt-store\src\lib.rs
crates\spt-store\src\perch.rs
crates\spt-store\src\xfer.rs
crates\spt-store\src\info.rs
crates\spt-store\src\spool.rs
crates\spt-store\src\subnet.rs
crates\spt-store\src\epoch.rs
crates\spt-store\src\trust.rs
crates\spt-store\src\atomic.rs


Bash

List crate module trees


C:/Users/decid/Documents/projects/spt-core/crates/spt-live/src/:
context.rs
echo.rs
history.rs
ingest.rs
lib.rs
psyche.rs
pulse.rs
resume.rs
signoff.rs
test_support.rs

C:/Users/decid/Documents/projects/spt-core/crates/spt-net/src/net/:
attach.rs
endpoint.rs
ndjson.rs
pairing
pairing.rs
registry.rs
replicate.rs
wanmsg.rs
xfer.rs

C:/Users/decid/Documents/projects/spt-core/crates/spt-store/src/:
access.rs
atomic.rs
db.rs
epoch.rs
history.rs
info.rs
lib.rs
liveness.rs
nodeid.rs
perch.rs
proc.rs
registry.rs
rename.rs
seed.rs
spool.rs
subnet.rs
trust.rs
visibility.rs
xfer.rs


1	//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
2	//! registry lease (#8) and, later, sync precedence (#7).
3	//!
4	//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
5	//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
6	//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
7	//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
8	//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
9	//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
10	//!
11	//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
12	//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
13	//! writer; until then a single process bumps it. Cross-process serialization is a D4
14	//! concern, not modeled here.
15	// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
16
17	use std::path::{Path, PathBuf};
18
19	use crate::atomic::atomic_write_string;
20	use crate::perch::epoch_file;
21
22	/// A node's persisted monotonic epoch counter.
23	pub struct EpochSource {
24	    path: PathBuf,
25	    current: u64,
26	}
27
28	impl EpochSource {
29	    /// Load (or initialize) the counter from the canonical [`epoch_file`].
30	    pub fn load() -> EpochSource {
31	        Self::load_from(&epoch_file())
32	    }
33
34	    /// Load from an explicit path (the testable core). An absent file starts at `0`
35	    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
36	    /// rather than panicking — a damaged counter must not brick the node. Restarting
37	    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.
38	    pub fn load_from(path: &Path) -> EpochSource {
39	        let current = std::fs::read_to_string(path)
40	            .ok()
41	            .and_then(|s| s.trim().parse::<u64>().ok())
42	            .unwrap_or(0);
43	        EpochSource { path: path.to_path_buf(), current }
44	    }
45
46	    /// The last issued epoch without advancing (peek). `0` means none issued yet.
47	    pub fn current(&self) -> u64 {
48	        self.current
49	    }
50
51	    /// Advance and return the next epoch, persisting it **before** updating the
52	    /// in-memory value so a failed write leaves the counter unmoved (the error is
53	    /// returned, no epoch is consumed). Strictly greater than every prior return on
54	    /// this source — the monotonic guarantee callers depend on.
55	    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
56	        let n = self
57	            .current
58	            .checked_add(1)
59	            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
60	        if let Some(parent) = self.path.parent() {
61	            std::fs::create_dir_all(parent)?;
62	        }
63	        atomic_write_string(&self.path, &n.to_string())?;
64	        self.current = n;
65	        Ok(n)
66	    }
67	}
68
69	#[cfg(test)]
70	mod tests {
71	    use super::*;
72	    use tempfile::tempdir;
73
74	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
75	    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
76	    // monotonic counter, not a timestamp).
77	    #[test]
78	    fn next_is_strictly_increasing_from_one() {
79	        let dir = tempdir().unwrap();
80	        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
81	        let a = src.next_epoch().unwrap();
82	        let b = src.next_epoch().unwrap();
83	        let c = src.next_epoch().unwrap();
84	        assert_eq!((a, b, c), (1, 2, 3));
85	        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
86	    }
87
88	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
89	    // up where the prior source left off, never reissuing a consumed epoch.
90	    #[test]
91	    fn counter_persists_across_reload() {
92	        let dir = tempdir().unwrap();
93	        let path = dir.path().join("epoch");
94	        {
95	            let mut src = EpochSource::load_from(&path);
96	            assert_eq!(src.next_epoch().unwrap(), 1);
97	            assert_eq!(src.next_epoch().unwrap(), 2);
98	        }
99	        let mut reloaded = EpochSource::load_from(&path);
100	        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
101	        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
102	    }
103
104	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
105	    // a safe restart (0) rather than panicking.
106	    #[test]
107	    fn corrupt_or_absent_file_degrades_safely() {
108	        let dir = tempdir().unwrap();
109	        let absent = dir.path().join("nope");
110	        assert_eq!(EpochSource::load_from(&absent).current(), 0);
111
112	        let corrupt = dir.path().join("epoch");
113	        std::fs::write(&corrupt, "not-a-number").unwrap();
114	        let mut src = EpochSource::load_from(&corrupt);
115	        assert_eq!(src.current(), 0);
116	        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
117	    }
118	}
119

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

use std::path::{Path, PathBuf};

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next_epoch().unwrap();
        let b = src.next_epoch().unwrap();
        let c = src.next_epoch().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next_epoch().unwrap(), 1);
            assert_eq!(src.next_epoch().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
    }
}


1	//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
2	//!
3	//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
4	//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
5	//! streams a brain can drive across restarts. This module is the thin seam between
6	//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
7	//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
8	//! straight into the existing merge seam.
9	//!
10	//! ## Wire format: NDJSON
11	//!
12	//! One JSON object per `\n`-terminated line — the shared framing in
13	//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
14	//! second record type riding it). Unknown fields decode, undecodable lines are
15	//! skipped; the epoch lease makes replay/loss safe.
16	//!
17	//! ## Authoring and applying
18	//!
19	//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
20	//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
21	//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
22	//! only already-admitted state). Inbound updates go straight through
23	//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
24	//! documents for "replication of an existing instance via the D4 wire". An update for
25	//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
26	//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30
31	use serde::{Deserialize, Serialize};
32
33	use crate::net::ndjson::{self, NdjsonDecoder};
34	use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};
35
36	/// One registry replication record: an endpoint's instance state in one subnet,
37	/// as authored by the node it runs on (the `instance.epoch` is that node's
38	/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
39	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40	pub struct RegistryUpdate {
41	    /// The subnet whose registry this update targets.
42	    pub subnet: String,
43	    /// The endpoint the instance belongs to.
44	    pub endpoint_id: String,
45	    /// The instance state (node, status, author epoch).
46	    pub instance: Instance,
47	}
48
49	impl RegistryUpdate {
50	    /// Encode as one NDJSON line (the JSON document + `\n`).
51	    pub fn encode_line(&self) -> Vec<u8> {
52	        ndjson::encode_line(self)
53	    }
54	}
55
56	/// The registry feed's NDJSON reassembler — the shared
57	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
58	/// [`RegistryUpdate`] records. One decoder per stream.
59	// [impl->REQ-INST-7]
60	pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;
61
62	/// Apply one inbound update to this node's per-subnet registries: route by
63	/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
64	/// governs — a stale update over the wire is dropped exactly like a stale
65	/// local one). `None` when this node holds no registry for the subnet — a
66	/// non-member **drops** the update rather than materializing a subnet it was
67	/// never admitted to (fail-closed, the REQ-INST-13 posture).
68	// [impl->REQ-INST-7]
69	pub fn apply_update(
70	    registries: &mut BTreeMap<String, SubnetRegistry>,
71	    update: RegistryUpdate,
72	) -> Option<MergeOutcome> {
73	    let reg = registries.get_mut(&update.subnet)?;
74	    Some(reg.merge_instance(&update.endpoint_id, update.instance))
75	}
76
77	#[cfg(test)]
78	mod tests {
79	    use super::*;
80	    use crate::net::registry::Status;

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line — the shared framing in
//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
//! second record type riding it). Unknown fields decode, undecodable lines are
//! skipped; the epoch lease makes replay/loss safe.
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};
use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The registry feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`RegistryUpdate`] records. One decoder per stream.
// [impl->REQ-INST-7]
pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;


Found 8 files
crates\spt-daemon\src\wan.rs
crates\spt-store\src\perch.rs
crates\spt-store\src\access.rs
crates\spt-store\src\nodeid.rs
crates\spt-store\src\subnet.rs
crates\spt-store\src\trust.rs
crates\spt-net\src\net\pairing\ratelimit.rs
crates\spt-proto\src\endpoint.rs


Found 2 files
crates\spt-live\src\echo.rs
crates\spt-proto\src\envelope.rs


64-}
65-
66-\ Node-local **durable** identity\security material: `<spt_home>/identity/`
67-/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
68:/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
69-/// material (the node keypair + subnet TOTP seeds). Pure path composition.
70-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
71-pub fn identity_dir() -> PathBuf {
72-    spt_home().join("identity")
--
74-
75-\ The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
76-/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
77-/// *is* this keypair's public key, D1/D4) — per-node secret material, never
78:/// git-tracked, never synced.
79-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
80-pub fn node_key_file() -> PathBuf {
81-    identity_dir().join("node.key")
82-}
--
99-}
100-
101-\ The per-`(endpoint, subnet)` visibility + sync-membership config file:
102-/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
103:/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
104-\ its *effect*: presence/absence in the subnet registry).
105-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
106-pub fn visibility_file() -> PathBuf {
107-    identity_dir().join("visibility.json")
--
114-    identity_dir().join("trust")
115-}
116-
117-\ The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
118:/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
119-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
120-pub fn trust_file() -> PathBuf {
121-    trust_dir().join("peers.json")
122-}
123-
124-\ The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
125-/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
126:/// node-local, never git-tracked/synced.
127-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
128-pub fn access_file() -> PathBuf {
129-    trust_dir().join("access.json")
130-}


9-//!
10-//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
11-//! Echo communes synthesize *project-heavy* session logs, so they are
12://! **project-context-primary / live-context-conservative** — strong project
13-//! signal, few/no live edits. Every commune the echo writes is tagged
14-\! `Source: echo-commune` so the later memformat pass (deferred) inherits the
15-//! correct tiering. Getting provenance right now is cheap; retrofitting is not.


1	//! Precedence-guarded context store (M2b T4, KNOWN-HAZARDS 6.5).
2	//!
3	//! Every context write carries a **precedence marker** — `source` (`direct` vs
4	//! `llm`) + a `routed_at` timestamp — recorded as the file's first line:
5	//!
6	//! ```text
7	//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 -->
8	//! ```
9	//!
10	//! The guard: a **direct** write always proceeds; an **llm** write that lands
11	//! within the *protection window* after a recent **direct** write is suppressed
12	//! (and logged), so a stale LLM snapshot never clobbers a fresher direct write.
13	//!
14	//! ## Interim vs M4
15	//! 6.5's full form adds **node identity** to the marker and generalizes to the
16	//! distributed *newest-and-newer-than-mine* rule (ADR-0003, M4). M2b lands the
17	//! **local** guard only: one node, `source` + `routed_at` (epoch-ms for exact
18	//! window math; the ISO/`spt:node` form is the M4 sync concern). Keep the marker
19	//! parse/render here so the M4 generalization is an extension, not a rewrite.
20	//
21	// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]
22
23	use spt_store::atomic::atomic_write_string;
24	use std::path::Path;
25
26	/// Default protection window: an llm write within this long after a direct write
27	/// is suppressed. Interim default; per-endpoint override is a later knob.
28	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 60_000;
29
30	/// Who authored a context write. `Direct` = spt-core / operator / sync
31	/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
32	/// stale) — the case the guard protects against.
33	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34	pub enum WriteSource {
35	    Direct,
36	    Llm,
37	}
38
39	impl WriteSource {
40	    fn as_str(self) -> &'static str {
41	        match self {
42	            WriteSource::Direct => "direct",
43	            WriteSource::Llm => "llm",
44	        }
45	    }
46	    fn from_str(s: &str) -> Option<WriteSource> {
47	        match s {
48	            "direct" => Some(WriteSource::Direct),
49	            "llm" => Some(WriteSource::Llm),
50	            _ => None,
51	        }
52	    }
53	}
54
55	/// The precedence marker stamped as a context file's first line.

//! Precedence-guarded context store (M2b T4, KNOWN-HAZARDS 6.5).
//!
//! Every context write carries a **precedence marker** — `source` (`direct` vs
//! `llm`) + a `routed_at` timestamp — recorded as the file's first line:
//!
//! ```text
//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 -->
//! ```
//!
//! The guard: a **direct** write always proceeds; an **llm** write that lands
//! within the *protection window* after a recent **direct** write is suppressed
//! (and logged), so a stale LLM snapshot never clobbers a fresher direct write.
//!
//! ## Interim vs M4
//! 6.5's full form adds **node identity** to the marker and generalizes to the
//! distributed *newest-and-newer-than-mine* rule (ADR-0003, M4). M2b lands the
//! **local** guard only: one node, `source` + `routed_at` (epoch-ms for exact
//! window math; the ISO/`spt:node` form is the M4 sync concern). Keep the marker
//! parse/render here so the M4 generalization is an extension, not a rewrite.
//
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]

use spt_store::atomic::atomic_write_string;
use std::path::Path;

/// Default protection window: an llm write within this long after a direct write
/// is suppressed. Interim default; per-endpoint override is a later knob.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 60_000;

/// Who authored a context write. `Direct` = spt-core / operator / sync
/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
/// stale) — the case the guard protects against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteSource {
    Direct,
    Llm,
}

impl WriteSource {
    fn as_str(self) -> &'static str {
        match self {
            WriteSource::Direct => "direct",
            WriteSource::Llm => "llm",
        }
    }
    fn from_str(s: &str) -> Option<WriteSource> {
        match s {
            "direct" => Some(WriteSource::Direct),
            "llm" => Some(WriteSource::Llm),
            _ => None,
        }
    }
}

/// The precedence marker stamped as a context file's first line.


4://! drop context into the manifest's watched `commune_dir` / `signoff_dir` as
6://! ingests each drop into the perch's context store and then **deletes** it. The
8://! fix). The write is precedence-guarded (6.5, [`crate::context`]) because a
16:use crate::context::{write_context, WriteOutcome, WriteSource};
24:/// The per-perch context store file ingested drops fold into.
25:pub const CONTEXT_FILE: &str = "context.md";
43:/// The perch's context store path.
44:pub fn context_path(id: &str, hint: ParentHint<'_>) -> PathBuf {
49:/// `id`: fold each into the perch's context store (precedence-guarded, llm
62:    let ctx = context_path(id, hint);
77:        let outcome = write_context(&ctx, &body, WriteSource::Llm, now_ms, window_ms)?;
93:    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
111:            // Content folded into the perch's context store.
112:            let ctx = context_path("doyle", ParentHint::Infer);
136:            // Seed a fresh direct write into the context store first.
137:            let ctx = context_path("doyle", ParentHint::Infer);
139:            write_context(&ctx, "DIRECT", WriteSource::Direct, 1000, DEFAULT_PROTECTION_WINDOW_MS)


6-//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
7-//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
8-//! later folds into the perch's context.
9-//!
10-//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
11-//! Echo communes synthesize *project-heavy* session logs, so they are
12://! **project-context-primary / live-context-conservative** — strong project
13-//! signal, few/no live edits. Every commune the echo writes is tagged
14-\! `Source: echo-commune` so the later memformat pass (deferred) inherits the
15-//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
16-//!
17-//! ## Recursion guard
18-//! The echo child runs with the role's `recursion_guard_env` set (applied by


List ADRs


0001-clean-room-fork-from-claude-skill-owl.md
0002-bake-networking-into-core-collapse-spt-node.md
0003-multi-instance-node-anchored-identity-model.md
0004-single-daemon-broker-brain-split-and-self-update.md
0005-totp-seeded-spake2-node-pairing.md
0006-multi-subnet-membership-model.md
0007-subnet-notification-primitive.md
0008-live-activity-buffer-pty-digest.md
0009-endpoint-access-control-model.md
0010-immutable-home-subnet-fork-not-rehome.md
0011-branchstore-git-kv-state-substrate.md
0012-psyche-trust-boundary-stdout-event-outbound.md


1	# BranchStore (git-KV) as the coarse/durable state substrate
2
3	## Status
4
5	accepted (2026-06-02)
6
7	## Context
8
9	spt-core's durable state is spread across four mechanisms, each with its own
10	crash-recovery code: **SQLite** (`spool.db`, registry persistence), **flat files
11	with tmp-write + atomic-rename** (`info.json`, `status` — KNOWN-HAZARDS 5.2), a
12	**hand-rolled fsync-append journal** (the B5 broker effect anchor), and a
13	**separate git repository** for the Psyche context (`a-<id>` / `p-<project>`
14	branches + a custom precedence merge driver). So spt-core has *already* chosen
15	git-as-store for its most important state (the mind); it just hasn't named the
16	pattern or reused it.
17
18	Studying a sister project (BigscreenVR/attractor) and its reference Rust impl
19	(StrongDM's fabro, `fabro-checkpoint`) surfaced that pattern as a first-class
20	abstraction: a **git branch treated as a versioned key/value filesystem** —
21	`ensure_branch()`, one-commit-per-write, reads = tree lookups at the branch tip,
22	history = `git log`, recovery = read the ref tip, refs kept porcelain-compatible
23	under `refs/heads/<tool>/...`. The question this ADR answers: should that be a
24	shared internal substrate in spt-core, and where is its boundary against SQLite
25	and fsync-append?
26
27	## Decision
28
29	Adopt **`spt-store::BranchStore`** — a git branch used as a versioned key/value
30	filesystem — as the substrate for **coarse, durable, audited** state. It lives at
31	the bottom of the stack (`spt-store`) so context sync, the registry, and daemon
32	checkpointing share one abstraction rather than each re-inventing persistence.
33
34	**BranchStore is for:**
35	- the **context repo** — generalize the existing `a-`/`p-` design into the shared store;
36	- the **subnet registry** — snapshot + cross-node distribution, reusing the precedence merge driver + P2P-git sync instead of a separate replication scheme (M4);
37	- **daemon/brain coarse checkpoint** — the rehydrate anchor (ADR-0004): commit = checkpoint, ref tip = resume;
38	- **audited lifecycle state** — where a state's *trajectory* (not just current value) has operational value.
39
40	**BranchStore is NOT for (these stay as they are):**
41	- **hot-path / high-frequency writes** — the B5 per-effect PTY journal (per-keystroke) stays **fsync-append**; per-message spool churn stays **SQLite**. A commit per write is orders of magnitude too heavy here.
42	- **indexed / queried state** — spool drain-by-status, `id → address` resolution stay **SQLite** (BranchStore is a KV tree, no secondary indexes). SQLite may serve as a *derived view* rebuilt from a BranchStore source where both audit and queryability are needed.
43
44	**Rules:**
45	- **Single-writer-per-ref**, sharded (per run / session / endpoint — `refs/heads/spt/...`), matching the existing single-writer invariants (KNOWN-HAZARDS 6.4, active-instance-authoritative). No many-writers-one-ref contention.
46	- **commit = checkpoint, ref tip = resume** — the 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** (the tmp-write + atomic-rename dance, 5.2) for the state BranchStore covers.
47	- **Atomic multi-key commits** — perch + registry + cursor in one all-or-nothing commit.
48	- **Merge-native sync** — reuse the context precedence merge driver + `--allow-unrelated-histories` for any BranchStore replicated cross-node.
49	- **GUI consequence** — state browsing becomes *git rendering*: raw inspection is free via standard git tooling; the product GUI (R-FRONT-1 panes) is a memformat-aware renderer over git, inheriting history / diff / time-travel rather than building a versioning backend.
50
51	## Consequences
52
53	- New requirement **REQ-STORE-1** (`required_stages = []` until its milestone). Implementation lands **M4** (context realized via BranchStore; registry persistence + distribution) and optionally **M3c** (brain checkpoint). **No retroactive churn** — the B5 fsync journal and the SQLite spool stay exactly as built.
54	- The torn-write hazard class (5.2) is dissolved for BranchStore-covered state; the SQLite/fsync paths keep their existing guards.
55	- **De-risk before locking the abstraction:** spike a `BranchStore` over gitoxide/libgit2 and measure commit-per-write latency, to validate the coarse/hot boundary empirically (a natural first attractor build-harness pilot).
56	- Builds on the context-as-git design (STORAGE.md) and ADR-0004 (brain rehydrate). attractor / fabro are **study references only** — license-check before porting any fabro code.
57

# BranchStore (git-KV) as the coarse/durable state substrate

## Status

accepted (2026-06-02)

## Context

spt-core's durable state is spread across four mechanisms, each with its own
crash-recovery code: **SQLite** (`spool.db`, registry persistence), **flat files
with tmp-write + atomic-rename** (`info.json`, `status` — KNOWN-HAZARDS 5.2), a
**hand-rolled fsync-append journal** (the B5 broker effect anchor), and a
**separate git repository** for the Psyche context (`a-<id>` / `p-<project>`
branches + a custom precedence merge driver). So spt-core has *already* chosen
git-as-store for its most important state (the mind); it just hasn't named the
pattern or reused it.

Studying a sister project (BigscreenVR/attractor) and its reference Rust impl
(StrongDM's fabro, `fabro-checkpoint`) surfaced that pattern as a first-class
abstraction: a **git branch treated as a versioned key/value filesystem** —
`ensure_branch()`, one-commit-per-write, reads = tree lookups at the branch tip,
history = `git log`, recovery = read the ref tip, refs kept porcelain-compatible
under `refs/heads/<tool>/...`. The question this ADR answers: should that be a
shared internal substrate in spt-core, and where is its boundary against SQLite
and fsync-append?

## Decision

Adopt **`spt-store::BranchStore`** — a git branch used as a versioned key/value
filesystem — as the substrate for **coarse, durable, audited** state. It lives at
the bottom of the stack (`spt-store`) so context sync, the registry, and daemon
checkpointing share one abstraction rather than each re-inventing persistence.

**BranchStore is for:**
- the **context repo** — generalize the existing `a-`/`p-` design into the shared store;
- the **subnet registry** — snapshot + cross-node distribution, reusing the precedence merge driver + P2P-git sync instead of a separate replication scheme (M4);
- **daemon/brain coarse checkpoint** — the rehydrate anchor (ADR-0004): commit = checkpoint, ref tip = resume;
- **audited lifecycle state** — where a state's *trajectory* (not just current value) has operational value.

**BranchStore is NOT for (these stay as they are):**
- **hot-path / high-frequency writes** — the B5 per-effect PTY journal (per-keystroke) stays **fsync-append**; per-message spool churn stays **SQLite**. A commit per write is orders of magnitude too heavy here.
- **indexed / queried state** — spool drain-by-status, `id → address` resolution stay **SQLite** (BranchStore is a KV tree, no secondary indexes). SQLite may serve as a *derived view* rebuilt from a BranchStore source where both audit and queryability are needed.

**Rules:**
- **Single-writer-per-ref**, sharded (per run / session / endpoint — `refs/heads/spt/...`), matching the existing single-writer invariants (KNOWN-HAZARDS 6.4, active-instance-authoritative). No many-writers-one-ref contention.
- **commit = checkpoint, ref tip = resume** — the 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** (the tmp-write + atomic-rename dance, 5.2) for the state BranchStore covers.
- **Atomic multi-key commits** — perch + registry + cursor in one all-or-nothing commit.
- **Merge-native sync** — reuse the context precedence merge driver + `--allow-unrelated-histories` for any BranchStore replicated cross-node.
- **GUI consequence** — state browsing becomes *git rendering*: raw inspection is free via standard git tooling; the product GUI (R-FRONT-1 panes) is a memformat-aware renderer over git, inheriting history / diff / time-travel rather than building a versioning backend.

## Consequences

- New requirement **REQ-STORE-1** (`required_stages = []` until its milestone). Implementation lands **M4** (context realized via BranchStore; registry persistence + distribution) and optionally **M3c** (brain checkpoint). **No retroactive churn** — the B5 fsync journal and the SQLite spool stay exactly as built.
- The torn-write hazard class (5.2) is dissolved for BranchStore-covered state; the SQLite/fsync paths keep their existing guards.
- **De-risk before locking the abstraction:** spike a `BranchStore` over gitoxide/libgit2 and measure commit-per-write latency, to validate the coarse/hot boundary empirically (a natural first attractor build-harness pilot).
- Builds on the context-as-git design (STORAGE.md) and ADR-0004 (brain rehydrate). attractor / fabro are **study references only** — license-check before porting any fabro code.


No files found


60:id = "REQ-STORE-1"
61-title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)"
62-required_stages = []
63-
--
188:[Omitted long matching line]
189-[[requirements]]
190-id = "REQ-INST-8"
191-title = "Remote-control mode distinct from local operation"
--
207:id = "REQ-INST-12"
208-title = "Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync"
209-[Omitted long context line]
210-[[requirements]]
211:id = "REQ-INST-13"
212-title = "Subnet-exclusive sync + per-endpoint subnet-membership list"
213-[Omitted long context line]
214-[[requirements]]


186	id = "REQ-INST-7"
187	title = "Subnet registry + bare-id resolution policy"
188	required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run locally BEFORE emission. int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes + wire convergence/lease-over-the-wire (tests/replicate.rs).
189	[[requirements]]
190	id = "REQ-INST-8"
191	title = "Remote-control mode distinct from local operation"
192	required_stages = ["impl", "unit"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). The two-host int leg is D9
193	# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──
194	[[requirements]]
195	id = "REQ-INST-9"
196	title = "Multi-subnet membership (same-user N subnets; cross-user seam)"
197	required_stages = ["impl", "unit"]  # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease fall-through + per-subnet legality.
198	[[requirements]]
199	id = "REQ-INST-10"
200	title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
201	required_stages = ["impl", "unit"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9.
202	[[requirements]]
203	id = "REQ-INST-11"
204	title = "spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)"
205	required_stages = ["impl", "unit"]  # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested <id>-psyche/<id>-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename <old> <new>` wires the perch arm. Seams: daemon-held registry wiring = D4; a-<id> context branch = D6 context store; cross-node ripple = authored (node,epoch) op reconciled newest-wins per hazard 6.5 marker at D4/D6. unit = cross-subnet ripple-verbatim + all-or-nothing collision/NotFound/InvalidId/self-noop + perch tree ripple + refusals-untouched + live-child-blocks + CLI parse.
206	[[requirements]]
207	id = "REQ-INST-12"
208	title = "Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync"
209	required_stages = ["impl", "unit"]  # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate.
210	[[requirements]]
211	id = "REQ-INST-13"
212	title = "Subnet-exclusive sync + per-endpoint subnet-membership list"
213	required_stages = ["impl", "unit"]  # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths).
214	[[requirements]]
215	id = "REQ-INST-14"

id = "REQ-INST-7"
title = "Subnet registry + bare-id resolution policy"
required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run locally BEFORE emission. int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes + wire convergence/lease-over-the-wire (tests/replicate.rs).
[[requirements]]
id = "REQ-INST-8"
title = "Remote-control mode distinct from local operation"
required_stages = ["impl", "unit"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). The two-host int leg is D9
# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──
[[requirements]]
id = "REQ-INST-9"
title = "Multi-subnet membership (same-user N subnets; cross-user seam)"
required_stages = ["impl", "unit"]  # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease fall-through + per-subnet legality.
[[requirements]]
id = "REQ-INST-10"
title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
required_stages = ["impl", "unit"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9.
[[requirements]]
id = "REQ-INST-11"
title = "spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)"
required_stages = ["impl", "unit"]  # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested <id>-psyche/<id>-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename <old> <new>` wires the perch arm. Seams: daemon-held registry wiring = D4; a-<id> context branch = D6 context store; cross-node ripple = authored (node,epoch) op reconciled newest-wins per hazard 6.5 marker at D4/D6. unit = cross-subnet ripple-verbatim + all-or-nothing collision/NotFound/InvalidId/self-noop + perch tree ripple + refusals-untouched + live-child-blocks + CLI parse.
[[requirements]]
id = "REQ-INST-12"
title = "Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync"
required_stages = ["impl", "unit"]  # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate.
[[requirements]]
id = "REQ-INST-13"
title = "Subnet-exclusive sync + per-endpoint subnet-membership list"
required_stages = ["impl", "unit"]  # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths).
[[requirements]]
id = "REQ-INST-14"


1://! `visibility.json` — per-`(endpoint, subnet)` visibility + sync-membership config
40:/// subnet)` overrides, and the per-endpoint sync-membership lists.
140:    /// The endpoint's sync-membership list (`None` = not configured = sync nowhere).
145:    /// Set the endpoint's sync-membership list (the creation flow seeds `[home]`).


grep -n "git\|gix\|libgit" C:/Users/decid/Documents/projects/spt-core/Cargo.toml; echo ---; grep -rn "hub\|gh " C:/Users/decid/Documents/projects/spt-core/docs/adr/0002-bake-networking-into-core-collapse-spt-node.md | head -20

Check git deps + ADR-0002 hub stance


---
5:accepted (2026-05-28) — reverses the sister project's separate-daemon decision (claude_skill_owl CONTEXT.md §Networking) for the spt-core context. · amended (2026-05-31): GitHub re-admitted as an opt-in context-sync hub (see Amendment below).
7:## Amendment (2026-05-31): GitHub re-admitted as an opt-in context-sync hub
9:This ADR retired GitHub as the *required/default* cross-node sync. It is now re-admitted as an **opt-in alternative transport for the context repo only** (`tracked/`), enabled by `spt context-github-setup`:
12:- **Hub mode (opt-in):** every node pull→merge→pushes against a private GitHub remote (always-online sync hub + live GUI view), authed by a **shared deploy key** distributed as subnet secret material (only the setup node needs a GitHub account). **Prefer-GitHub, P2P backstop.**
13:- **Scope:** hub mode carries the **context repo only**. Messaging, registry, remote-drive, pairing, and presence always ride P2P/Iroh; gh-on never removes the P2P requirement. So the original decision (networking baked-in, first-class, P2P) stands in full — the hub is a context-sync convenience layered on top, not a return to GitHub-as-infrastructure.


No matches found


Grep

struct Instance|pub struct Info|pub project|pub node|pub status|pub epoch


crates\spt-runtime\src\runtime.rs:154:    pub status_code: Option<i32>,
crates\spt-proto\src\addr.rs:60:    pub node: Option<String>,
crates\spt-proto\src\envelope.rs:100:    pub project: Option<String>,
crates\spt-store\src\access.rs:52:    pub nodes: Vec<String>,
crates\spt-store\src\access.rs:162:    pub node: String,
crates\spt-store\src\info.rs:38:pub struct InfoJson {
crates\spt-store\src\info.rs:61:    pub status: Option<String>,
crates\spt-daemon\src\msg.rs:219:    pub node_id_hex: Option<String>,
crates\spt-net\src\net\registry.rs:65:pub struct Instance {
crates\spt-net\src\net\registry.rs:67:    pub node: String,
crates\spt-net\src\net\registry.rs:69:    pub status: Status,
crates\spt-net\src\net\registry.rs:72:    pub epoch: u64,
crates\spt-net\src\net\registry.rs:154:    pub nodes: Vec<String>,
crates\spt-store\src\subnet.rs:75:    pub epoch: u64,
crates\spt-net\src\net\pairing\transcript.rs:80:    pub epoch: u64,


AskUserQuestion

Question: D6b: when two nodes wrote the same context file concurrently (version vectors conflict — neither dominates), how should the conflict surface? Locked already: never silent newest-wins; wall-clock only a human hint.

Choices:
Local file untouched (active mind never clobbered). Remote version saved as conflict artifact beside it with both vectors + wall-clock hints. `spt context conflicts` lists; `resolve --keep local|remote` settles. Event seam emitted for D8 notif. Psyche-assisted semantic merge = later upgrade on same seam.
Write both versions into the context file under explicit '## CONFLICT' section headers (never git <<<< markers). Agent sees it next resume and self-heals via commune. Risk: bloated/confusing mind file until reconciled.
Feed both versions to a Psyche turn immediately; its output becomes the merged file. No human step. Risk: needs live-Psyche driver machinery (D7.5, unbuilt), silent semantic drift, no audit pause.

Question: D6 sync transport shape: how does context move between nodes? STORAGE.md currently sketches git-branch fetch over Iroh + --allow-unrelated-histories merges; implementation reality offers a simpler proven path. Doc amended same change either way.

Choices:
ContextUpdate NDJSON over broker QUIC streams — exact D4d registry / D5a wanmsg pattern. File content + version vector in the record; receiver runs D6b merge, commits to its own local tracked/ repo. Single-writer invariant makes state-transfer sufficient. Simple, hermetic-testable. Tradeoff: each node's git history is local-only (no shared DAG).
git bundle over the D5c xfer channel; receiver fetches + merges with vector-aware driver + --allow-unrelated-histories. Pros: shared cross-node history DAG, cleaner future hub-mode interop. Cons: much more plumbing (bundle exchange, multi-parent commit construction), git CLI on the wire path, matches STORAGE.md prose as written.

Question: BranchStore v1 backing (REQ-STORE-1, ADR-0011): drive the system `git` binary, or pull in gitoxide library? ADR-0011 suggested a spike to validate; D6 is the first consumer.

Choices:
Shell out to system git (sister project's proven approach; `git pull` handoff seam already assumes git present). BranchStore trait shaped so gitoxide swap is later drop-in. Zero new deps, fastest to land.
In-process library — no external binary dependency, faster per-commit, hermetic. Cons: big new dep tree, API learning curve, ADR-0011 spike becomes part of D6 critical path.