160	**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).
161
162	### Endpoint types
163
164	Each perch advertises an **endpoint type** — a tag that says what shape of entity lives at that perch and what operations it accepts. The set of day-one types:
165
166	**ReadyAgent**:
167	Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".
168
169	**LiveAgent**:
170	A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".
171
172	**Psyche**:
173	The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).
174
175	**Worker**:
176	A subagent's perch under a parent LiveAgent. Created on subagent start, torn down on subagent stop. Replaces today's "working perch" concept; first-class type so cross-communication between a Self and its workers (and worker↔worker) is addressable.
177
178	**SptNode**:
179	A machine's participation in an SPT subnet, identified by an Ed25519 public key generated on first run. First-class so networking primitives can address nodes directly as message targets, not only as transport peers. The node identity and network endpoint are hosted by the machine's `spt-daemon` (see Networking), not a separate process.
180
181	**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
182	A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.
183
184	Three interaction styles:
185	- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.
186	- **bind** — sustained drive: "give me a Shell of capability X"; channel instantiates and the agent drives it directly until teardown. Supports operating a *specific* Shell regardless of where the user currently is (agent transience).
187	- **thread** — the persistent conversation that floats across Shells; the user can pick it up from any Shell, and 2-way payloads (text/audio/image/video, subject to the Shell's supported types) flow both directions.
188
189	Presence datum: `(last_active_node, last_active_endpoint, timestamp)`. The `last_active_endpoint` field lets an agent choose between messaging that specific endpoint vs. driving a parallel instance of itself.
190
191	The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set. Closed-vs-open semantics for capability advertisement (what operations each type accepts, how routing decides eligibility) are deferred to the design phase.
192
193	### Agent endpoints vs Shells
194
195	Endpoint types split into two families:

**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).

### Endpoint types

Each perch advertises an **endpoint type** — a tag that says what shape of entity lives at that perch and what operations it accepts. The set of day-one types:

**ReadyAgent**:
Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".

**LiveAgent**:
A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".

**Psyche**:
The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

**Worker**:
A subagent's perch under a parent LiveAgent. Created on subagent start, torn down on subagent stop. Replaces today's "working perch" concept; first-class type so cross-communication between a Self and its workers (and worker↔worker) is addressable.

**SptNode**:
A machine's participation in an SPT subnet, identified by an Ed25519 public key generated on first run. First-class so networking primitives can address nodes directly as message targets, not only as transport peers. The node identity and network endpoint are hosted by the machine's `spt-daemon` (see Networking), not a separate process.

**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.

Three interaction styles:
- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.
- **bind** — sustained drive: "give me a Shell of capability X"; channel instantiates and the agent drives it directly until teardown. Supports operating a *specific* Shell regardless of where the user currently is (agent transience).
- **thread** — the persistent conversation that floats across Shells; the user can pick it up from any Shell, and 2-way payloads (text/audio/image/video, subject to the Shell's supported types) flow both directions.

Presence datum: `(last_active_node, last_active_endpoint, timestamp)`. The `last_active_endpoint` field lets an agent choose between messaging that specific endpoint vs. driving a parallel instance of itself.

The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set. Closed-vs-open semantics for capability advertisement (what operations each type accepts, how routing decides eligibility) are deferred to the design phase.

### Agent endpoints vs Shells

Endpoint types split into two families:


1	# ADR Format
2
3	ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
4
5	Create the `docs/adr/` directory lazily — only when the first ADR is needed.
6
7	## Template
8
9	```md
10	# {Short title of the decision}
11
12	{1-3 sentences: what's the context, what did we decide, and why.}
13	```
14
15	That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
16
17	## Optional sections
18
19	Only include these when they add genuine value. Most ADRs won't need them.
20
21	- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
22	- **Considered Options** — only when the rejected alternatives are worth remembering
23	- **Consequences** — only when non-obvious downstream effects need to be called out
24
25	## Numbering
26
27	Scan `docs/adr/` for the highest existing number and increment by one.
28
29	## When to offer an ADR
30
31	All three of these must be true:
32
33	1. **Hard to reverse** — the cost of changing your mind later is meaningful
34	2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
35	3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
36
37	If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
38
39	### What qualifies
40
41	- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
42	- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
43	- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
44	- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
45	- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
46	- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
47	- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
48

# ADR Format

ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.

Create the `docs/adr/` directory lazily — only when the first ADR is needed.

## Template

```md
# {Short title of the decision}

{1-3 sentences: what's the context, what did we decide, and why.}
```

That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.

## Optional sections

Only include these when they add genuine value. Most ADRs won't need them.

- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
- **Considered Options** — only when the rejected alternatives are worth remembering
- **Consequences** — only when non-obvious downstream effects need to be called out

## Numbering

Scan `docs/adr/` for the highest existing number and increment by one.

## When to offer an ADR

All three of these must be true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons

If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."

### What qualifies

- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.


1	# CONTEXT.md Format
2
3	## Structure
4
5	```md
6	# {Context Name}
7
8	{One or two sentence description of what this context is and why it exists.}
9
10	## Language
11
12	**Order**:
13	{A one or two sentence description of the term}
14	_Avoid_: Purchase, transaction
15
16	**Invoice**:
17	A request for payment sent to a customer after delivery.
18	_Avoid_: Bill, payment request
19
20	**Customer**:
21	A person or organization that places orders.
22	_Avoid_: Client, buyer, account
23	```
24
25	## Rules
26
27	- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid.
28	- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution.
29	- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
30	- **Show relationships.** Use bold term names and express cardinality where obvious.
31	- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
32	- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
33	- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts.
34
35	## Single vs multi-context repos
36
37	**Single context (most repos):** One `CONTEXT.md` at the repo root.
38
39	**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
40
41	```md
42	# Context Map
43
44	## Contexts
45
46	- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
47	- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
48	- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
49
50	## Relationships
51
52	- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
53	- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
54	- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
55	```
56
57	The skill infers which structure applies:
58
59	- If `CONTEXT-MAP.md` exists, read it to find contexts
60	- If only a root `CONTEXT.md` exists, single context
61	- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
62
63	When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
64

# CONTEXT.md Format

## Structure

```md
# {Context Name}

{One or two sentence description of what this context is and why it exists.}

## Language

**Order**:
{A one or two sentence description of the term}
_Avoid_: Purchase, transaction

**Invoice**:
A request for payment sent to a customer after delivery.
_Avoid_: Bill, payment request

**Customer**:
A person or organization that places orders.
_Avoid_: Client, buyer, account
```

## Rules

- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid.
- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution.
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
- **Show relationships.** Use bold term names and express cardinality where obvious.
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts.

## Single vs multi-context repos

**Single context (most repos):** One `CONTEXT.md` at the repo root.

**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:

```md
# Context Map

## Contexts

- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping

## Relationships

- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
```

The skill infers which structure applies:

- If `CONTEXT-MAP.md` exists, read it to find contexts
- If only a root `CONTEXT.md` exists, single context
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved

When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.


be careful to not conflate the echo commune summarizer and the psyche. summarizer is a lightweight model separate from psyche that builds the echo commune. its output is fed into the psyche. psyche wo…


Formats clear. Reading the KNOWN-HAZARDS tail + a sample REQ-HAZARD block to match conventions.


210	---
211
212	## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)
213
214	These were absent from the sister-project harvest; codex surfaced them as load-bearing gaps for spt-core's new daemon/network surface.
215
216	### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
217	- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).
218	- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.
219	- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.
220	- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).
221
222	### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
223	- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
224	- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
225	- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
226	- **Source:** codex Stage A #14.
227
228	---
229
230	## Conformance checklist (condensed)
231
232	| # | Invariant | spt-core surface |
233	|---|---|---|
234	| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
235	| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
236	| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
237	| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
238	| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
239	| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
240	| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
241	| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
242	| 4.2 | Parser panic-free + tolerant | spt-proto |
243	| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
244	| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |
245	| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
246	| 5.3 | Timeout every harness subprocess | manifest invocations |
247	| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
248	| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
249	| 6.1 | Single path/registry source of truth | storage layout |
250	| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
251	| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
252	| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
253	| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
254

---

## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)

These were absent from the sister-project harvest; codex surfaced them as load-bearing gaps for spt-core's new daemon/network surface.

### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).
- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.
- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.
- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).

### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

---

## Conformance checklist (condensed)

| # | Invariant | spt-core surface |
|---|---|---|
| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |
| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |
| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
| 5.3 | Timeout every harness subprocess | manifest invocations |
| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 6.1 | Single path/registry source of truth | storage layout |
| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |


514	required_stages = ["impl", "unit"]
515	[[requirements]]
516	id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
517	title = "Drop files are daemon-owned single-writer (6.4)"
518	required_stages = ["impl", "unit"]
519	[[requirements]]
520	id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
521	title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
522	required_stages = ["impl", "unit"]
523	# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
524	[[requirements]]
525	id = "REQ-HAZARD-CONPTY-DSR"
526	title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
527	required_stages = ["impl", "unit"]
528	[[requirements]]
529	id = "REQ-HAZARD-LOCAL-API-AUTH"
530	title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
531	required_stages = ["impl", "unit"]
532	[[requirements]]
533	id = "REQ-HAZARD-RESTART-IDEMPOTENT"
534	title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
535	required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)
536	[[requirements]]
537	id = "REQ-HAZARD-UPDATE-ROLLBACK"
538	title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
539	required_stages = ["impl", "unit"]  # activated M3c-C1: release::verify_metadata enforces monotonic version (rollback), metadata expiry, channel pinning, and key revocation. Adapter content signing is the C3 half (REQ-UPD-5/SEAM-UPDATE)
540	[[requirements]]
541	id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
542	title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
543	required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline

required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = ["impl", "unit"]
# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
[[requirements]]
id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-RESTART-IDEMPOTENT"
title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)
[[requirements]]
id = "REQ-HAZARD-UPDATE-ROLLBACK"
title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
required_stages = ["impl", "unit"]  # activated M3c-C1: release::verify_metadata enforces monotonic version (rollback), metadata expiry, channel pinning, and key revocation. Adapter content signing is the C3 half (REQ-UPD-5/SEAM-UPDATE)
[[requirements]]
id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline


All formats captured. Writing the five artifacts. Starting with the new ADR.


Write

# Psyche trust boundary: sandboxed tools + stdout `<EVENT>` outbound, daemon-proxied

## Status

accepted (2026-06-03)

## Context

The Psyche (a LiveAgent's companion mind) is an LLM session. If it held the
agent's full capability — sending messages, reaching the network, running
arbitrary commands — a confused or adversarially-steered Psyche could exfiltrate
or message arbitrary endpoints on the agent's behalf. The sister project
(`claude_skill_owl`) resolved this by **sandboxing the Psyche** and making the
wrapper its sole proxy; spt-core inherits the model but had not yet documented or
ported it, and its interim spawn path silently breaks it (see Consequences).

This decision was surfaced while grilling the Psyche I/O model and was found to be
uncovered by prior design passes.

## Decision

**The Psyche is a sandboxed actor whose sole outbound channel is its stdout, which
the daemon parses and relays as its proxy.**

- **Sandbox.** The Psyche session is restricted to `Read`/`Write`/`Edit` tools
  only — no `Bash`, no network, no message-send tool (sister: `claude.rs:34`,
  `--tools "Read,Edit,Write"`). It **cannot send messages itself.**
- **Inbound (two paths, neither is stdout-of-Psyche):** (1) events/messages the
  daemon composes onto the Psyche's **stdin**; (2) **commune/signoff file-drops**
  carrying context deltas (Self → daemon → Psyche; the *Summarizer* authors the
  commune delta — see CONTEXT.md). The Psyche does not read the spool itself.
- **Outbound (sole channel): stdout.** The Psyche emits the **same `<EVENT>`
  envelope grammar** the rest of SPT uses (ADR-0001, `spt-proto::event`), with two
  types it is allowed to author: `<EVENT type="reply">body</EVENT>` and
  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
  (that is the Summarizer's inbound delta, not a Psyche output).
- **The daemon is the outbound proxy + sanitizer.** It captures the Psyche's
  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
  it **strips any `from=`/target/routing attribute the Psyche supplied** and
  **re-stamps `from=<self_id>`**, then applies **constrained routing** — a `reply`
  goes **only to the `__REPLY_TO__` sender** of the message being answered; a
  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
  target and cannot address arbitrary endpoints. Body validation reuses the
  `<EVENT>` rules (escape `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity-decode, size cap).

The trust boundary is therefore **enforced by the daemon at relay time**, not by
the markup: the Psyche may *write* a spoofed `from=`, but the daemon discards it.

## Considered options

- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
  commune) — rejected: file-drops are the *inbound* commune/signoff travel path
  (Self → daemon → Psyche), not a Psyche-authored outbound. Conflating them was the
  original error this ADR corrects.
- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
  (keeps the Psyche syntactically unable to author a wire `<EVENT>`), but rejected
  in favour of one envelope grammar everywhere; the daemon's strip-and-re-stamp
  gives the same anti-spoof guarantee defensively.
- **Targeted reply (`<EVENT type="reply" to="<id>">`)** — rejected for v1: lets the
  Psyche address beyond the inbound sender, widening the trust surface (the daemon
  would have to authorize every target). Reply-to-sender only.
- **Pass-through relay** (no sanitization) — rejected: defeats the point of
  sandboxing the Psyche.

## Consequences

- **The live-Psyche turn driver MUST capture stdout.** spt-core's interim
  `runtime.spawn_session` sets `stdout(Stdio::null())` (`crates/spt-runtime/src/
  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
  detached null-stdout spawn is **not** the live-Psyche turn driver; the turn must
  be a **bounded, stdin-fed, stdout-captured** invocation (mirroring the sister's
  per-turn `--resume`), like the echo-commune's `run_bounded_stdin`. Tracked as
  KNOWN-HAZARDS 7.3 / `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY`.
- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
  parse-and-relay path yet (the sister's `parse_markers`, `claude.rs:594`). It is
  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
  the cross-node reply path (the `reply` half). See M4-PLAN.md task **D7.5**.
- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
  daemon is the **sole trusted author** of wire `<EVENT>` envelopes carrying a real
  `from=`; Psyche-authored attributes are untrusted input.
- **Anti-spoof is a conformance invariant**, not best-effort: a Psyche-supplied
  `from=`/target that survived to the wire would be an identity-spoof bug.

## References

- Builds on **ADR-0004** (Psyche = loop inside the daemon; Summarizer subprocess
  ephemeral) and **ADR-0001** (the `<EVENT>` wire format is copy-verbatim from the
  sister). CONTEXT.md *Psyche* / *Summarizer* glossary entries. KNOWN-HAZARDS 7.3.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0012-psyche-trust-boundary-stdout-event-outbound.md (file state is current in your context — no need to Read it back)

# Psyche trust boundary: sandboxed tools + stdout `<EVENT>` outbound, daemon-proxied

## Status

accepted (2026-06-03)

## Context

The Psyche (a LiveAgent's companion mind) is an LLM session. If it held the
agent's full capability — sending messages, reaching the network, running
arbitrary commands — a confused or adversarially-steered Psyche could exfiltrate
or message arbitrary endpoints on the agent's behalf. The sister project
(`claude_skill_owl`) resolved this by **sandboxing the Psyche** and making the
wrapper its sole proxy; spt-core inherits the model but had not yet documented or
ported it, and its interim spawn path silently breaks it (see Consequences).

This decision was surfaced while grilling the Psyche I/O model and was found to be
uncovered by prior design passes.

## Decision

**The Psyche is a sandboxed actor whose sole outbound channel is its stdout, which
the daemon parses and relays as its proxy.**

- **Sandbox.** The Psyche session is restricted to `Read`/`Write`/`Edit` tools
  only — no `Bash`, no network, no message-send tool (sister: `claude.rs:34`,
  `--tools "Read,Edit,Write"`). It **cannot send messages itself.**
- **Inbound (two paths, neither is stdout-of-Psyche):** (1) events/messages the
  daemon composes onto the Psyche's **stdin**; (2) **commune/signoff file-drops**
  carrying context deltas (Self → daemon → Psyche; the *Summarizer* authors the
  commune delta — see CONTEXT.md). The Psyche does not read the spool itself.
- **Outbound (sole channel): stdout.** The Psyche emits the **same `<EVENT>`
  envelope grammar** the rest of SPT uses (ADR-0001, `spt-proto::event`), with two
  types it is allowed to author: `<EVENT type="reply">body</EVENT>` and
  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
  (that is the Summarizer's inbound delta, not a Psyche output).
- **The daemon is the outbound proxy + sanitizer.** It captures the Psyche's
  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
  it **strips any `from=`/target/routing attribute the Psyche supplied** and
  **re-stamps `from=<self_id>`**, then applies **constrained routing** — a `reply`
  goes **only to the `__REPLY_TO__` sender** of the message being answered; a
  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
  target and cannot address arbitrary endpoints. Body validation reuses the
  `<EVENT>` rules (escape `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity-decode, size cap).

The trust boundary is therefore **enforced by the daemon at relay time**, not by
the markup: the Psyche may *write* a spoofed `from=`, but the daemon discards it.

## Considered options

- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
  commune) — rejected: file-drops are the *inbound* commune/signoff travel path
  (Self → daemon → Psyche), not a Psyche-authored outbound. Conflating them was the
  original error this ADR corrects.
- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
  (keeps the Psyche syntactically unable to author a wire `<EVENT>`), but rejected
  in favour of one envelope grammar everywhere; the daemon's strip-and-re-stamp
  gives the same anti-spoof guarantee defensively.
- **Targeted reply (`<EVENT type="reply" to="<id>">`)** — rejected for v1: lets the
  Psyche address beyond the inbound sender, widening the trust surface (the daemon
  would have to authorize every target). Reply-to-sender only.
- **Pass-through relay** (no sanitization) — rejected: defeats the point of
  sandboxing the Psyche.

## Consequences

- **The live-Psyche turn driver MUST capture stdout.** spt-core's interim
  `runtime.spawn_session` sets `stdout(Stdio::null())` (`crates/spt-runtime/src/
  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
  detached null-stdout spawn is **not** the live-Psyche turn driver; the turn must
  be a **bounded, stdin-fed, stdout-captured** invocation (mirroring the sister's
  per-turn `--resume`), like the echo-commune's `run_bounded_stdin`. Tracked as
  KNOWN-HAZARDS 7.3 / `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY`.
- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
  parse-and-relay path yet (the sister's `parse_markers`, `claude.rs:594`). It is
  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
  the cross-node reply path (the `reply` half). See M4-PLAN.md task **D7.5**.
- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
  daemon is the **sole trusted author** of wire `<EVENT>` envelopes carrying a real
  `from=`; Psyche-authored attributes are untrusted input.
- **Anti-spoof is a conformance invariant**, not best-effort: a Psyche-supplied
  `from=`/target that survived to the wire would be an identity-spoof bug.

## References

- Builds on **ADR-0004** (Psyche = loop inside the daemon; Summarizer subprocess
  ephemeral) and **ADR-0001** (the `<EVENT>` wire format is copy-verbatim from the
  sister). CONTEXT.md *Psyche* / *Summarizer* glossary entries. KNOWN-HAZARDS 7.3.


ADR written. Now CONTEXT.md — extend the **Psyche** entry with the I/O + trust model and add the **Summarizer** term (glossary-level, no impl detail).


Edit

The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

**Worker**:

The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).

**Summarizer**:
The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
_Avoid_: conflating with the Psyche; "echo-commune model".

**Worker**:


The file C:\Users\decid\Documents\projects\spt-core\CONTEXT.md has been updated successfully. (file state is current in your context — no need to Read it back)

The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

**Worker**:

The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).

**Summarizer**:
The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
_Avoid_: conflating with the Psyche; "echo-commune model".

**Worker**:

The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

+*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).

+The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.

+_Avoid_: conflating with the Psyche; "echo-commune model".

A subagent's perch under a parent LiveAgent. Created on subagent start, torn down on subagent stop. Replaces today's "working perch" concept; first-class type so cross-communication between a Self and its workers (and worker↔worker) is addressable.


KNOWN-HAZARDS — adding hazard 7.3 + its conformance row.


- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

---

## Conformance checklist (condensed)

- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.
- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.
- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

---

## Conformance checklist (condensed)


The file C:\Users\decid\Documents\projects\spt-core\docs\KNOWN-HAZARDS.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

---

## Conformance checklist (condensed)

- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.
- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.
- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

---

## Conformance checklist (condensed)

+### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`

+- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.

+- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.

+- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.

+- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).


| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |

| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |


| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |

| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |

| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |

+| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |


traceable-reqs — registering the hazard req (inactive until the M4 task delivers it, per rule 5).


required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline

required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it

title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"

+# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---

+title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"

+required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it


M4-PLAN — inserting the D7.5 task (feeds D8) + the coverage-map row.


- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D8 — Subnet notification primitive

- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
  (4.1). Anti-spoof is a conformance invariant, not best-effort.
- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
  stripped (anti-spoof negative test); a null-stdout driver fails the guard.

### D8 — Subnet notification primitive


The file C:\Users\decid\Documents\projects\spt-core\M4-PLAN.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D8 — Subnet notification primitive

- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
  (4.1). Anti-spoof is a conformance invariant, not best-effort.
- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
  stripped (anti-spoof negative test); a null-stdout driver fails the guard.

### D8 — Subnet notification primitive

+### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012

+> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit

+> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the

+> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing

+> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`

+> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).

+- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness

+  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached

+  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.

+- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`

+  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).

+- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied

+  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the

+  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body

+  (4.1). Anti-spoof is a conformance invariant, not best-effort.

+- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8

+  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent

+  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the

+  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is

+  stripped (anti-spoof negative test); a null-stdout driver fails the guard.

- **D8a** Notif as a first-class kind (user-directed, dismissable, resurfacing): per-subnet

replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates


Updating the coverage map + noting D8's dependency on D7.5.


| D7 | REQ-UPD-1 |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 |

| D7 | REQ-UPD-1 |
| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |


| D7 | REQ-UPD-1 |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 |

| D7 | REQ-UPD-1 |
| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |

| D5 | REQ-INST-8, REQ-REACH-1, REQ-SEC-1, REQ-NET-1 (WAN msg) |

| D6 | REQ-NET-3, REQ-INST-2, REQ-INST-5, `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend) |

+| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |

+| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |

| D9 | REQ-INST-3,4 (+14,15 if in line); sweep |

**Deferred to M5 (out of M4, per Scope §Out):** REQ-EP-5 (shell instantiation), REQ-REACH-2


All five artifacts written (ADR-0012, CONTEXT terms, KNOWN-HAZARDS 7.3, plan D7.5, REQ-HAZARD). Verifying the registry stays valid + green.


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && traceable-reqs check >/dev/null 2>&1; echo "trace_exit=$?"; traceable-reqs check 2>&1 | grep -i "PSYCHE-OUTBOUND\|error\|invalid" | head

Validate traceable-reqs after hazard add


Command running in background with ID: b1jm7muos. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b1jm7muos.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>b1jm7muos</task-id>
<tool-use-id>toolu_01XJUnc2XdRYRa5kcvFt8whV</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b1jm7muos.output</output-file>
<status>completed</status>
<summary>Background command "Validate traceable-reqs after hazard add" completed (exit code 0)</summary>
</task-notification>


1	trace_exit=0
2	  [OK] REQ-HAZARD-PSYCHE-OUTBOUND-PROXY  required: []  stages: -doc -impl -unit -int
3

trace_exit=0
  [OK] REQ-HAZARD-PSYCHE-OUTBOUND-PROXY  required: []  stages: -doc -impl -unit -int


Prompt:
<task-notification>
<task-id>b1jm7muos</task-id>
<tool-use-id>toolu_01XJUnc2XdRYRa5kcvFt8whV</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b1jm7muos.output</output-file>
<status>completed</status>
<summary>Background command "Validate traceable-reqs after hazard add" completed (exit code 0)</summary>
</task-notification>


Green — REQ registered, inactive, trace exit 0. Committing the documentation pass.


cd "C:/Users/decid/Documents/projects/spt-core" && git add docs/adr/0012-psyche-trust-boundary-stdout-event-outbound.md CONTEXT.md docs/KNOWN-HAZARDS.md M4-PLAN.md traceable-reqs.toml && git commit -q -F - <<'EOF'
docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5

Crystallize the Psyche I/O channel model (grill-with-docs regimen), surfaced
as uncovered by prior design passes and corrected against the sister source.

The model (verified in claude_skill_owl):
- The Psyche is SANDBOXED (tools Read/Write/Edit only) — it cannot send
  messages or reach the network itself.
- Inbound: events the daemon hands it + commune/signoff FILE-DROPS (Self ->
  daemon -> Psyche; the SUMMARIZER — a distinct cheap ephemeral model — authors
  the commune delta, NOT the Psyche).
- Outbound (SOLE channel): the Psyche's STDOUT, emitting <EVENT type="reply"> /
  <EVENT type="notify"> in the shared envelope grammar. The daemon is the
  outbound PROXY: it parses, STRIPS any Psyche-supplied from=/target, re-stamps
  from=<self>, and CONSTRAINS routing (reply -> __REPLY_TO__ sender only;
  notify -> own user/subnet only). Anti-spoof enforced at relay, not markup.

Artifacts:
- ADR-0012 (new) — the trust boundary + stdout-<EVENT> outbound decision, with
  considered options (file-drop reply, terse intent tags, targeted reply,
  pass-through all rejected) and consequences.
- CONTEXT.md — extend the Psyche glossary entry with the I/O+trust model; add
  the Summarizer term (distinct actor, no perch, commune-delta only).
- KNOWN-HAZARDS 7.3 + REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (inactive, rule 5) — the
  live-Psyche driver MUST capture stdout (interim spawn_session Stdio::null
  silently drops all replies/notifs) AND the daemon MUST strip/re-stamp from=.
- M4-PLAN.md D7.5 — psyche outbound relay channel (bounded stdin-fed/
  stdout-captured driver + <EVENT type=reply|notify> parse + sanitizing relay),
  prerequisite feeding D8's notify-from-psyche path.

No code yet; D7.5 builds + activates the REQ-HAZARD. traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "exit=$?"; git log --oneline -3

Commit psyche-outbound documentation pass


Command running in background with ID: bbd69pzcl. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbd69pzcl.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bbd69pzcl</task-id>
<tool-use-id>toolu_013iC2wFyR9UdnygERJwgtAC</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbd69pzcl.output</output-file>
<status>completed</status>
<summary>Background command "Commit psyche-outbound documentation pass" completed (exit code 0)</summary>
</task-notification>


1	warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
2	warning: in the working copy of 'M4-PLAN.md', LF will be replaced by CRLF the next time Git touches it
3	warning: in the working copy of 'docs/KNOWN-HAZARDS.md', LF will be replaced by CRLF the next time Git touches it
4	warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
5	warning: in the working copy of 'docs/adr/0012-psyche-trust-boundary-stdout-event-outbound.md', LF will be replaced by CRLF the next time Git touches it
6	exit=0
7	d4c04d6 docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5
8	51981c4 feat(net): M4-D1c mDNS LAN discovery + connect-by-id
9	3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
10

warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M4-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/KNOWN-HAZARDS.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/adr/0012-psyche-trust-boundary-stdout-event-outbound.md', LF will be replaced by CRLF the next time Git touches it
exit=0
d4c04d6 docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5
51981c4 feat(net): M4-D1c mDNS LAN discovery + connect-by-id
3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy


Prompt:
<task-notification>
<task-id>bbd69pzcl</task-id>
<tool-use-id>toolu_013iC2wFyR9UdnygERJwgtAC</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bbd69pzcl.output</output-file>
<status>completed</status>
<summary>Background command "Commit psyche-outbound documentation pass" completed (exit code 0)</summary>
</task-notification>


Done — grill-with-docs regimen complete, all three steps landed in commit **d4c04d6**.

**Design resolved (via grilling):** Psyche outbound = stdout `<EVENT type="reply">` / `<EVENT type="notify">` (one envelope grammar with inbound). Daemon = sanitizing proxy: strips Psyche-supplied `from=`/target, re-stamps `from=<self>`, constrains routing (reply→`__REPLY_TO__` sender, notify→own user/subnet). Anti-spoof enforced at relay.

**Artifacts:**
| File | Change |
|---|---|
| `docs/adr/0012-…md` | **New ADR** — psyche trust boundary + stdout-`<EVENT>` outbound; 4 rejected options, consequences |
| `CONTEXT.md` | Psyche entry extended (I/O + trust); **Summarizer** term added (distinct actor, commune-delta only, no perch) |
| `KNOWN-HAZARDS.md` | **7.3** + conformance row — capture stdout + strip/re-stamp `from=` |
| `traceable-reqs.toml` | `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (inactive, rule 5 — D7.5 activates) |
| `M4-PLAN.md` | **D7.5** task (bounded stdout-captured driver + parse + sanitizing relay), feeds D8; coverage-map row |

`traceable-reqs check` green.

**Record correction:** I was wrong twice this thread — "sync = blocking" framing, then "psyche sends via its own perch." You drove both corrections; the true model (sandboxed psyche, summarizer≠psyche, stdout-`<EVENT>`-only outbound, daemon-proxied) is now in the durable docs, not just this conversation.

**Session state:** 6 commits ahead of `a14639a`, unpushed —
```
d4c04d6 docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5
51981c4 feat(net): M4-D1c mDNS LAN discovery + connect-by-id
3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
c49e522 docs(m4): record D6 merge-model decision (#7)
c47d2c6 feat(net): M4-D0 scaffold spt-net crate
ca6522b docs(m4): expand M4-PLAN D-task layer
```

M4 build state: D0 + D1 done; D2 pairing next (register the 3 `REQ-HAZARD-PAIR-*` first). D7.5 now queued before D8.

Next move — your call: **push the 6 commits**, **continue to D2 pairing**, or **stop here**?


for the docs regimen, is there anything we should capture from our discussion regarding the importance of non-blocking infra for psyche and echo communes?


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Yes — there's a real invariant from that thread worth pinning, and it's not yet captured anywhere.

**The invariant:** echo-commune (`run_bounded_stdin`) and the D7.5 psyche-turn driver are both **bounded LLM calls that block their calling thread** (up to timeout). Today that's harmless — each agent drives its own pulse from its **own process** (`api/live.rs`, `api/startup.rs`). But ADR-0004's target is the **daemon hosting N per-agent loops**, and `run_pulse_loop` is def+test only (fan-out unwired). If that fan-out is ever written as **one thread iterating all agents** calling the blocking invocations inline, a slow/hung LLM call on agent A **stalls every other agent's heartbeat, commune, and reply**. The per-call timeout only bounds worst-case *if* the work is isolated per-agent; serial makes the stalls additive.

So: **each agent's bounded LLM-bearing work (echo-commune summarizer, psyche turn) must run on its own thread / off the shared scheduler.** Distinct from ADR-0002 #6 (that's crash blast-radius; this is scheduling-latency serialization).

It's an invariant, not a glossary term — so CONTEXT is wrong; KNOWN-HAZARD (conformance-gated) is the right home. One choice on scope:


AskUserQuestion

Question: Where to capture the daemon non-blocking-scheduling invariant (one agent's bounded LLM call must not stall others)?

Choices:
New hazard 7.4 'Per-agent pulse/psyche/echo scheduling must not serialize across agents' + REQ-HAZARD-DAEMON-SCHED-NONBLOCKING (inactive until the multi-agent pulse fan-out is wired, rule 5), cross-ref ADR-0004. Conformance-gated: N agents, a hung echo on A must not delay B's tick. No new ADR — serial would just be a bug, not a defensible alternative.
Same hazard/REQ, PLUS a short amendment to ADR-0004 (which owns 'daemon hosts psyche loops together') stating the loops run concurrently per-agent, never one serial driver. Heavier, but pins the decision where the daemon architecture lives.
Standalone ADR on daemon per-agent scheduling concurrency (thread-per-agent vs async vs pool), with the non-blocking invariant as its core. Treats the concurrency model as a first-class decision with alternatives worth recording.


Your questions have been answered: "Where to capture the daemon non-blocking-scheduling invariant (one agent's bounded LLM call must not stall others)?"="KNOWN-HAZARD + REQ-HAZARD (Rec)". You can now continue with these answers in mind.

Question: Where to capture the daemon non-blocking-scheduling invariant (one agent's bounded LLM call must not stall others)?

Choices:
New hazard 7.4 'Per-agent pulse/psyche/echo scheduling must not serialize across agents' + REQ-HAZARD-DAEMON-SCHED-NONBLOCKING (inactive until the multi-agent pulse fan-out is wired, rule 5), cross-ref ADR-0004. Conformance-gated: N agents, a hung echo on A must not delay B's tick. No new ADR — serial would just be a bug, not a defensible alternative.
Same hazard/REQ, PLUS a short amendment to ADR-0004 (which owns 'daemon hosts psyche loops together') stating the loops run concurrently per-agent, never one serial driver. Heavier, but pins the decision where the daemon architecture lives.
Standalone ADR on daemon per-agent scheduling concurrency (thread-per-agent vs async vs pool), with the non-blocking invariant as its core. Treats the concurrency model as a first-class decision with alternatives worth recording.


Capturing as hazard 7.4 + REQ-HAZARD. Three edits.


Edit

- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

---

## Conformance checklist (condensed)

- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).

---

## Conformance checklist (condensed)


- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

---

## Conformance checklist (condensed)

- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).

---

## Conformance checklist (condensed)

- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.

+### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`

+- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.

+- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.

+- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.

+- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).


| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |

| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |


| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |

| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |

| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |

+| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |


required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it

required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops


required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops

title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"

+title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"

+required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && traceable-reqs check >/dev/null 2>&1; echo "trace=$?" && git add docs/KNOWN-HAZARDS.md traceable-reqs.toml && git commit -q -F - <<'EOF'
docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)

From the grill-with-docs discussion on Psyche/echo-commune infra. echo-commune
(run_bounded_stdin) and the D7.5 Psyche-turn driver are bounded LLM calls that
block their calling thread until the child answers or times out. Today each
agent drives its own pulse from its OWN process, so blocking is isolated. But
ADR-0004's target is the daemon hosting N per-agent loops (run_pulse_loop
fan-out is def+test only). If that fan-out is ever written as ONE serial thread
calling these invocations inline, a slow/hung LLM call on one agent stalls every
other agent's heartbeat, commune, and reply — the per-call timeout only bounds
worst-case when work is isolated; serial makes stalls additive.

Invariant (7.4): each agent's bounded LLM-bearing work runs on its own thread /
off the shared scheduler — no single-threaded driver iterating all agents calls
a blocking invocation inline. REQ-HAZARD-DAEMON-SCHED-NONBLOCKING inactive
(rule 5) until the multi-agent pulse fan-out is wired. Distinct from ADR-0002 #6
(crash blast-radius, not scheduling latency). traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
echo "commit=$?"

Validate + commit hazard 7.4


Command running in background with ID: bmt38jgno. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bmt38jgno.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bmt38jgno</task-id>
<tool-use-id>toolu_01Kf2mTSa3HSud14uvgwPLbk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bmt38jgno.output</output-file>
<status>completed</status>
<summary>Background command "Validate + commit hazard 7.4" completed (exit code 0)</summary>
</task-notification>


1	trace=0
2	warning: in the working copy of 'docs/KNOWN-HAZARDS.md', LF will be replaced by CRLF the next time Git touches it
3	warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
4	commit=0
5

trace=0
warning: in the working copy of 'docs/KNOWN-HAZARDS.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
commit=0


Prompt:
<task-notification>
<task-id>bmt38jgno</task-id>
<tool-use-id>toolu_01Kf2mTSa3HSud14uvgwPLbk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bmt38jgno.output</output-file>
<status>completed</status>
<summary>Background command "Validate + commit hazard 7.4" completed (exit code 0)</summary>
</task-notification>


Captured. Committed.

**KNOWN-HAZARDS 7.4** — *Per-agent pulse/psyche/echo scheduling must not serialize across agents* + conformance row + `REQ-HAZARD-DAEMON-SCHED-NONBLOCKING` (inactive, rule 5 — binds when the daemon multi-agent fan-out is wired).

Core invariant pinned: echo-commune + the D7.5 psyche driver block their calling thread (bounded LLM call). Safe today (each agent = own process); the hazard is the **future** `run_pulse_loop` fan-out — if written as one serial thread, stalls go additive. Each agent's bounded work must run off the shared scheduler. Marked distinct from ADR-0002 #6 (crash blast-radius ≠ scheduling latency). `traceable-reqs check` green.

Docs regimen now covers the full psyche/echo infra surface from this thread: trust boundary (ADR-0012 + 7.3) **and** scheduling isolation (7.4).

7 commits ahead of `a14639a`, unpushed. Next: **push**, **D2 pairing**, or **stop**?


push the outstanding commits, then /commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear


Three steps: push, /commune, /self-clear. Pushing first.


cd "C:/Users/decid/Documents/projects/spt-core" && git log --oneline origin/dev-freeform..dev-freeform 2>/dev/null | head -20; echo "---PUSH---"; git push origin dev-freeform 2>&1 | tail -15; echo "push_exit=$?"

Push outstanding commits to dev-freeform


Command running in background with ID: blid8qezg. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\blid8qezg.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>blid8qezg</task-id>
<tool-use-id>toolu_01TwcSrQ2pd5Dm2KPUrXk9Rh</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\blid8qezg.output</output-file>
<status>completed</status>
<summary>Background command "Push outstanding commits to dev-freeform" completed (exit code 0)</summary>
</task-notification>


1	bfacfb8 docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)
2	d4c04d6 docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5
3	51981c4 feat(net): M4-D1c mDNS LAN discovery + connect-by-id
4	3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
5	c49e522 docs(m4): record D6 cross-node merge-model decision (#7)
6	c47d2c6 feat(net): M4-D0 scaffold spt-net crate
7	ca6522b docs(m4): expand M4-PLAN D-task layer at M4 start
8	---PUSH---
9	To https://github.com/SaberMage/spt-core.git
10	   a14639a..bfacfb8  dev-freeform -> dev-freeform
11	push_exit=0
12

bfacfb8 docs(m4): capture daemon non-blocking-scheduling invariant (hazard 7.4)
d4c04d6 docs(m4): psyche outbound model — ADR-0012 + CONTEXT + hazard 7.3 + D7.5
51981c4 feat(net): M4-D1c mDNS LAN discovery + connect-by-id
3f726ed feat(net): M4-D1 iroh endpoint + identity binding + relay policy
c49e522 docs(m4): record D6 cross-node merge-model decision (#7)
c47d2c6 feat(net): M4-D0 scaffold spt-net crate
ca6522b docs(m4): expand M4-PLAN D-task layer at M4 start
---PUSH---
To https://github.com/SaberMage/spt-core.git
   a14639a..bfacfb8  dev-freeform -> dev-freeform
push_exit=0


Prompt:
<task-notification>
<task-id>blid8qezg</task-id>
<tool-use-id>toolu_01TwcSrQ2pd5Dm2KPUrXk9Rh</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\blid8qezg.output</output-file>
<status>completed</status>
<summary>Background command "Push outstanding commits to dev-freeform" completed (exit code 0)</summary>
</task-notification>


Pushed: `a14639a..bfacfb8` → `dev-freeform` (7 commits). CI now running on bfacfb8 (first iroh build slow; mDNS test is `#[ignore]` so no multicast flake). Now /commune.


Skill

SESSION HANDOFF — M4 (spt-net) in progress, pushed to dev-freeform @ bfacfb8 (CI running).

END GOAL (unchanged): harness-independent agent-ecosystem core (messaging, live-agent lifecycle, terminal hosting, first-class P2P networking, runtime-manifest harness contract) as Rust crates + one `spt` binary. Proof = the same agent runs natively across all a user's machines with a synced mind, reachable/operable from anywhere, seamless signed self-update (already built in M3). First thing built atop it: rebuilt `spt` Claude-Code plugin at parity. Clean-room rebuild of claude_skill_owl.

STATUS: M0-M3 COMPLETE + CI-green. M4 STARTED this session (user gave explicit go).
- D0 (c47d2c6): crates/spt-net scaffolded, sibling above spt-msg, whole transport behind default-on `net` feature.
- D1 (3f726ed + 51981c4): NetEndpoint over iroh =0.98.2 (pinned EXACT — Spike #2 version). Keyed by node's OWN spt-proto Ed25519 seed → iroh EndpointId == node pubkey (address == name). ALPN spt-core/net/0. RelayPolicy {N0Default|SelfHosted(RelayMap)|Disabled} + RELAY_DISCLOSURE (REQ-NET-2). LocalDiscovery {Mdns|Off} + connect_by_id. NetEndpoint::iroh() = the D4 broker-ownership seam. REQ-NET-1 [impl,unit] + REQ-NET-2 [impl]. 2 hermetic tests in CI (identity-binding, loopback echo); mDNS-by-id test #[ignore] but passes locally (3.1s).
- D6 merge-model decision (c49e522): #7 resolved — per-node monotonic epoch + version vector; concurrent = explicit conflict; unifies with #8 epoch-lease.
- DOCS PASS (d4c04d6 + bfacfb8) via grill-with-docs: corrected the Psyche I/O model (I had it wrong twice; user drove correction). Psyche is SANDBOXED (Read/Write/Edit only), CANNOT send. Sole outbound = STDOUT <EVENT type="reply">/<EVENT type="notify"> markers, daemon-proxied (strips/re-stamps from=, constrains reply→__REPLY_TO__ sender / notify→own user). Summarizer (cheap ephemeral model) ≠ Psyche; summarizer emits [COMMUNE] delta fed INTO psyche. Captured as ADR-0012, CONTEXT.md (Psyche entry + new Summarizer term), KNOWN-HAZARDS 7.3 (live-psyche driver MUST capture stdout — interim spawn_session Stdio::null discards replies) + 7.4 (per-agent pulse/psyche/echo must not serialize across agents — bounded LLM calls block their thread; future run_pulse_loop fan-out must be per-agent-threaded). New M4 task D7.5 (psyche outbound relay, feeds D8). REQ-HAZARD-PSYCHE-OUTBOUND-PROXY + REQ-HAZARD-DAEMON-SCHED-NONBLOCKING registered, inactive (rule 5).

KEY FACT: spt-net is the workspace's FIRST async crate (iroh+tokio, net-gated); rest is thread-based/sync. D4 broker must host the tokio runtime.

IMMEDIATE NEXT STEPS (resume here):
1. Confirm CI green on bfacfb8 (iroh adds build time; ubuntu+windows+traceability).
2. D2 pairing (security-critical) — FIRST register 3 hazard reqs in traceable-reqs.toml (rule 3): REQ-HAZARD-PAIR-TRANSCRIPT-BIND (#12), REQ-HAZARD-PAIR-SEED-ROTATION (#10), REQ-HAZARD-PAIR-RATE-LIMIT (#11). Then TOTP-seeded SPAKE2 (ADR-0005) + full transcript binding + seed epoch/rotation + subnet-global rate limit + trust store/TOFU + multi-subnet ceremony. See M4-PLAN.md D2 sub-tasks.
3. Task order after D2: D3 registry → D4 broker QUIC ownership → D5 cross-node msg/remote-drive → D6 psyche sync → D7 update propagation → D7.5 psyche outbound relay → D8 notif → D9 closeout/E2E.

RESUME PROTOCOL: read M4-PLAN.md (full D-task layer + red-team obligations table) + ADR-0005/0006/0012. iroh API churns — grep the registry source at ~/.cargo/registry/src/index.crates.io-*/iroh-0.98.2/ rather than guess; Spike #2/#3 working source at ../spt-spikes. Each D-task is milestone-sized — expect /self-clear + commune between most.

GOTCHAS: spt_home() writes must create_dir_all the parent; never name a bin/test/staged-update artifact *update*/*setup*/*install*/*patch* on Windows (UAC os error 740). Cross-machine D9 E2E rig: gravity-linux (Ubuntu/forkpty) + a Windows host.


Launching skill: spt:commune