Locked. Full generalization it is. Grill converged — here's the shared-understanding artifact.

## Forum / group-messaging — converged design

**Form.** Not a Shell (Shells are single-owner-exclusive, non-intelligent). It's a multi-party channel. spt-core stays substrate-agnostic; Discord is one substrate, bound by a `spt-discord` **endpoint adapter**.

**Core/adapter split — minimal core.**
- **spt-core gains exactly one thing: the generalized wake seam** (below). Everything else reuses existing primitives (`endpoint wake`, digest-subscribe cadence, messaging, open type system).
- **`spt-discord` adapter owns all forum logic**: membership, fan-out, thread model, routing, echo-loop filtering, Discord binding.

**Routing (Discord-native, structural — no fuzzy text-parse).**
- per-agent **thread**, auto-created on join, **locked on leave** (history preserved, = durable inbox)
- main channel, plain → **ambient** (fan-out to all members)
- `#"doyle"` thread-mention in channel → **directed-public**
- inside doyle's thread → **directed-private (DM)**
- agent↔agent direct rides the same thread mechanism

**Identity.** One bot app + channel **webhooks** → N agent identities (per-message `username`/`avatar`), zero per-agent bot registration. Inbound = one bot gateway (`MESSAGE_CONTENT` intent).

**Delivery (hybrid, per-member).** directed → **push + on-demand catch-up digest + reset cadence**; ambient → digest at member interval; interval configurable, **0 = no auto-digest**; **mute = separate knob** from interval-0.

**The wake seam generalization (the core change), sized.** Three relaxations of today's shell `wake_command`:
1. `kind="shell"` → **endpoint adapters too** (today hard-filtered at `shellwake.rs:407`)
2. one-shot exit-opcode → **long-running supervised contact binary** (`[forum] contact_command`), boot-launched + supervised by spt-core, **running independent of any agent's liveness** (so it can draw in the first agent), calling `endpoint wake <id>` in-band
3. owner-target → **explicit arbitrary target id** (all underlying fns already id-parameterized)

Reuses free: backoff / give-up latch / one-per-instance lock / orphan-kill / brain-side reconcile. **Authorization is target-side** (`resolve_wake`/`forward_wake`: "the target's access gate decides"; `shell_wake_spawn_anywhere` = draw-in consent) — no caller-ownership guard to build.

**Remaining gray areas — all adapter-level, none core:**
- echo-loop prevention (bot must filter its own webhook posts)
- bot-token secrets (adapter strings / secret store)
- payload types (text + Discord attachments → text+file channel)
- single-bridge-node SPOF (the Discord connection lives on one node)
- thread auto-archive window + rejoin semantics + Manage-Threads perm

**One residual code-verify:** confirm the `spt endpoint wake <id>` CLI entrypoint has no caller-auth beyond the target-side gate.

---

We're squarely in new-core-feature territory now — which you flagged earlier as the `/grill-with-docs` trigger. Recommend handing off: capture the generalized wake seam + Forum concept in CONTEXT.md + an ADR, sharpen terminology against the existing Shell/wake model, and run that residual verify. Want me to switch to `/grill-with-docs` and start the writeup, or keep poking adapter-level gray areas first?


yes we can switch. note that `spt-discord` info/docs should go in a new sister directory `../spt-discord/`. only information directly relevant to spt-core should go here.


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


Organize spt-discord documentation into sister directory


Switching. Noted the split: spt-core repo gets only the **generalized wake seam** (core) + terminology/ADR; all `spt-discord` adapter design goes in new sister dir `../spt-discord/`.


Skill

Converged design from a /grill-me session: a forum/group-messaging concept, realized as a Discord-substrate endpoint adapter (`spt-discord`), requiring ONE core change in spt-core — generalizing the shell wake seam.

DIRECTORY SPLIT (binding): spt-core repo (here) gets ONLY spt-core-relevant material — the generalized wake seam (core change), terminology reconciliation against the existing Shell/wake model, and an ADR. ALL `spt-discord` adapter design (webhooks, per-agent threads, routing, delivery, echo-loop, secrets) goes in a NEW sister directory `../spt-discord/`, NOT in spt-core.

Core change to capture (spt-core): generalize today's shell `wake_command` seam (crates/spt-daemon/src/shellwake.rs) with three relaxations:
1. kind="shell" -> endpoint adapters too (today hard-filtered at shellwake.rs:407 shell_section_of via AdapterKind::Shell)
2. one-shot exit-opcode watcher -> long-running supervised contact binary that runs independent of any agent's liveness and calls `endpoint wake <id>` in-band
3. owner-bound target -> explicit arbitrary target id (underlying fns daemon_rest_event/remote_owner_node/forward_wake/REST_EVENT_WAKE already id-parameterized)

Key established facts: wake authorization is TARGET-side, not caller-side (resolve_wake + forward_wake "the target's access gate decides"; shell_wake_spawn_anywhere flag = draw-in/instantiate-anywhere pre-consent). The owner-binding in WakeSet (keyed owner/shell_id) is structural, not an auth boundary. Supervision scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile per BROKER-BRAIN-SPLIT-RESTORATION.md) is reusable.

RESIDUAL CODE-VERIFY needed: confirm the `spt endpoint wake <id>` CLI entrypoint has no caller-auth check beyond the target-side gate.

Terminology to sharpen against CONTEXT.md: this Forum/group concept vs Shell (single-owner-exclusive), PresenceChannel (deferred user<->agent thread), and the existing wake-watcher/sleep-wake model (CONTEXT.md:319-336). The forum is NOT a shell. Goal: grill the core-relevant decisions, then update CONTEXT.md + write an ADR for the generalized wake seam (likely a new REQ in traceable-reqs.toml).


Launching skill: grill-with-docs


Base directory for this skill: C:\Users\decid\.claude\skills\grill-with-docs

<what-to-do>

Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

Ask the questions one at a time, waiting for feedback on each question before continuing.

If a question can be answered by exploring the codebase, explore the codebase instead.

</what-to-do>

<supporting-info>

## Domain awareness

During codebase exploration, also look for existing documentation:

### File structure

Most repos have a single context:

```
/
├── CONTEXT.md
├── docs/
│   └── adr/
│       ├── 0001-event-sourced-orders.md
│       └── 0002-postgres-for-write-model.md
└── src/
```

If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:

```
/
├── CONTEXT-MAP.md
├── docs/
│   └── adr/                          ← system-wide decisions
├── src/
│   ├── ordering/
│   │   ├── CONTEXT.md
│   │   └── docs/adr/                 ← context-specific decisions
│   └── billing/
│       ├── CONTEXT.md
│       └── docs/adr/
```

Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.

## During the session

### Challenge against the glossary

When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"

### Sharpen fuzzy language

When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."

### Discuss concrete scenarios

When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.

### Cross-reference with code

When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"

### Update CONTEXT.md inline

When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).

`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.

### Offer ADRs sparingly

Only offer to create an ADR when all three are true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why 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 any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).

</supporting-info>

ARGUMENTS: Converged design from a /grill-me session: a forum/group-messaging concept, realized as a Discord-substrate endpoint adapter (`spt-discord`), requiring ONE core change in spt-core — generalizing the shell wake seam.

DIRECTORY SPLIT (binding): spt-core repo (here) gets ONLY spt-core-relevant material — the generalized wake seam (core change), terminology reconciliation against the existing Shell/wake model, and an ADR. ALL `spt-discord` adapter design (webhooks, per-agent threads, routing, delivery, echo-loop, secrets) goes in a NEW sister directory `../spt-discord/`, NOT in spt-core.

Core change to capture (spt-core): generalize today's shell `wake_command` seam (crates/spt-daemon/src/shellwake.rs) with three relaxations:
1. kind="shell" -> endpoint adapters too (today hard-filtered at shellwake.rs:407 shell_section_of via AdapterKind::Shell)
2. one-shot exit-opcode watcher -> long-running supervised contact binary that runs independent of any agent's liveness and calls `endpoint wake <id>` in-band
3. owner-bound target -> explicit arbitrary target id (underlying fns daemon_rest_event/remote_owner_node/forward_wake/REST_EVENT_WAKE already id-parameterized)

Key established facts: wake authorization is TARGET-side, not caller-side (resolve_wake + forward_wake "the target's access gate decides"; shell_wake_spawn_anywhere flag = draw-in/instantiate-anywhere pre-consent). The owner-binding in WakeSet (keyed owner/shell_id) is structural, not an auth boundary. Supervision scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile per BROKER-BRAIN-SPLIT-RESTORATION.md) is reusable.

RESIDUAL CODE-VERIFY needed: confirm the `spt endpoint wake <id>` CLI entrypoint has no caller-auth check beyond the target-side gate.

Terminology to sharpen against CONTEXT.md: this Forum/group concept vs Shell (single-owner-exclusive), PresenceChannel (deferred user<->agent thread), and the existing wake-watcher/sleep-wake model (CONTEXT.md:319-336). The forum is NOT a shell. Goal: grill the core-relevant decisions, then update CONTEXT.md + write an ADR for the generalized wake seam (likely a new REQ in traceable-reqs.toml).


In grill-with-docs. First the residual code-verify (factual → explore, don't ask), plus docs structure.


Bash

ls -la C:/Users/decid/Documents/projects/spt-core/CONTEXT-MAP.md 2>/dev/null; echo "---map-above---"; ls C:/Users/decid/Documents/projects/ 2>/dev/null; echo "---siblings-above---"; ls C:/Users/decid/Documents/projects/spt-discord/ 2>/dev/null && echo "SPT-DISCORD-EXISTS" || echo "NO-SPT-DISCORD-DIR"

Check docs structure + sibling dirs


---map-above---
,claude-bigscreen
AppData
HMDUtility
Ubers-SD-Files
_3functions
attractor
bey-closer-t1
beyond-eye-tracking-client
beyond_bootloader
beyond_synaptics
big-eye-utils
bigscreen
bigscreen10
bigstack
biodroid-auth
broker-A.log
bs2_eyetracking
calibot
check-canting
claude-screen-timelapse
claude_skill_owl
clipboard-speaker.exe
cloud
config.toml
cplugs-everything
experimplate
exput
extended-mode-display
eyetrack_mipi_fpga
get-shit-done
holohue-gui
lettem-in
mic-map
mmi_test
openvr-resilient
oship
own-ledger
pi-agent-memory
playdial
qt-py-haptics
quick-beyond-updater
rc-A.log
rc-B.log
rckeys.log
rebno
rustHello
sauna
spt-claude-code
spt-core
spt-core-doyle
spt-core-wedgeval
spt-lecturn
spt-releases
spt-shell-notify
traceable-reqs
tundra-mag-plate
vertical-alignment-proximity
vmulti-client-testing
website
wit-what
---siblings-above---
NO-SPT-DISCORD-DIR


68-        /// Reply to this sender; sets the target and labels output REPLIED.
69-        #[arg(long)]
70-        reply_to: Option<String>,
71:        /// Spool only (deferred / hook channel) — no live wake.
72-        #[arg(long)]
73-        deferred: bool,
74-        /// Request the `user-msg` type (the user's authority). Honored only from
--
179-    /// The driven surfaces this agent owns. `spawn` MINTS a new instance
180-    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing
181-    /// an existing offline instance back is `relink` / `persistent` /
182:    /// wake.
183-    Shell {
184-        #[command(subcommand)]
185-        action: ShellCmd,
--
306-        /// The endpoint id (qualified `id@node` reaches a paired peer).
307-        id: String,
308-    },
309:    /// Wake a resting endpoint in place.
310-    ///
311-    /// Re-activates the existing seat (state's already there — no fresh
312-    /// spawn), resurfaces undismissed notifications, and requests an
313-    /// immediate context freshness pull from trusted peers. Accepts a
314-    /// qualified `id@node` for an instance on a paired peer.
315:    Wake {
316-        /// The endpoint id (qualified `id@node` reaches a paired peer).
317-        id: String,
318-    },
--
1105-            EndpointCmd::Suspend { id } => {
1106-                cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED")
1107-            }
1108:            EndpointCmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
1109-            EndpointCmd::Shutdown { id } => cmd_shutdown(id),
1110-            EndpointCmd::Stop { id } => cmd_stop(&id),
1111-            EndpointCmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
--
1837-    out
1838-}
1839-
1840:/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
1841-\ (REQ-INST-3): apply the event against the local endpoint's durable record
1842-/// through the daemon-side transition host (echo gate-armed for the
1843:/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
1844-\ pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
1845-\ REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
1846-/// the TARGET node's transition host run the edge — exit codes mirror local
--
1851-    if id.contains('@') || id.contains(':') {
1852-        let tag = match event {
1853-            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
1854:            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
1855-            // Only the manual verbs route here; the other events are
1856-            // daemon-internal feeds with no CLI surface.
1857-            _ => {
--
3069-        }
3070-    };
3071-    \ Event-driven advertisement (M8 decision 15, REQ-CONV-2): coming

[Showing results with pagination = limit: 60]


1840	/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
1841	/// (REQ-INST-3): apply the event against the local endpoint's durable record
1842	/// through the daemon-side transition host (echo gate-armed for the
1843	/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
1844	/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
1845	/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
1846	/// the TARGET node's transition host run the edge — exit codes mirror local
1847	/// (edge / NO_EDGE = 0, refusal or failure = 1).
1848	// [impl->REQ-INST-3]
1849	// [impl->REQ-INST-6]
1850	fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
1851	    if id.contains('@') || id.contains(':') {
1852	        let tag = match event {
1853	            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
1854	            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
1855	            // Only the manual verbs route here; the other events are
1856	            // daemon-internal feeds with no CLI surface.
1857	            _ => {
1858	                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
1859	                return 1;
1860	            }
1861	        };
1862	        return match crate::wansend::wan_rest(id, tag) {
1863	            crate::wansend::WanRestOutcome::Edge { node, detail } => {
1864	                eprintln!("{verb}:{id} ({detail}) at {node}");
1865	                0
1866	            }
1867	            crate::wansend::WanRestOutcome::NoEdge { node } => {
1868	                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
1869	                0
1870	            }
1871	            crate::wansend::WanRestOutcome::NoReply { node } => {
1872	                eprintln!(
1873	                    "{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)"
1874	                );
1875	                1
1876	            }
1877	            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
1878	                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
1879	                1
1880	            }
1881	            crate::wansend::WanRestOutcome::Ambiguous(a) => {
1882	                eprintln!("AMBIGUOUS:{id} — {a}");
1883	                1
1884	            }
1885	            crate::wansend::WanRestOutcome::NotFound => {
1886	                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
1887	                1
1888	            }
1889	            crate::wansend::WanRestOutcome::Failed(e) => {
1890	                eprintln!("{verb}_FAIL:{id}: {e}");
1891	                1
1892	            }
1893	        };
1894	    }
1895	    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
1896	    match spt_daemon::daemon_rest_event(id, event, knob) {
1897	        Ok(Some(report)) => {
1898	            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
1899	            0
1900	        }
1901	        Ok(None) => {
1902	            // Idempotent UX: the machine reports no edge (already there).
1903	            eprintln!("NO_EDGE:{id} (already in the target state)");
1904	            0
1905	        }
1906	        Err(e) => {
1907	            eprintln!("{verb}_FAIL:{id}: {e}");
1908	            1
1909	        }
1910	    }
1911	}
1912
1913	/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
1914	/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
1915	/// → suspended; fires the transition echo-commune; cascades its persistent
1916	/// shells offline"). Two legs through existing machinery: (1) soft-stop the
1917	/// listener (drop the ready marker — the session-over half `spt suspend`
1918	/// does not do); (2) the suspend edge via the daemon-side transition host —
1919	/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
1920	/// every rest verb.
1921	// [impl->REQ-SHELL-2]
1922	fn cmd_shutdown(id: Option<String>) -> i32 {
1923	    let Some(id) = id.or_else(roster::detect_self_id) else {
1924	        eprintln!(
1925	            "NO_SELF: shutdown needs an endpoint (spt endpoint shutdown <id> or $OWL_SESSION_ID)"
1926	        );
1927	        return 1;
1928	    };
1929	    if id.contains('@') || id.contains(':') {

/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
/// (REQ-INST-3): apply the event against the local endpoint's durable record
/// through the daemon-side transition host (echo gate-armed for the
/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
/// the TARGET node's transition host run the edge — exit codes mirror local
/// (edge / NO_EDGE = 0, refusal or failure = 1).
// [impl->REQ-INST-3]
// [impl->REQ-INST-6]
fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
    if id.contains('@') || id.contains(':') {
        let tag = match event {
            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
            // Only the manual verbs route here; the other events are
            // daemon-internal feeds with no CLI surface.
            _ => {
                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
                return 1;
            }
        };
        return match crate::wansend::wan_rest(id, tag) {
            crate::wansend::WanRestOutcome::Edge { node, detail } => {
                eprintln!("{verb}:{id} ({detail}) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoEdge { node } => {
                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoReply { node } => {
                eprintln!(
                    "{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)"
                );
                1
            }
            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
                1
            }
            crate::wansend::WanRestOutcome::Ambiguous(a) => {
                eprintln!("AMBIGUOUS:{id} — {a}");
                1
            }
            crate::wansend::WanRestOutcome::NotFound => {
                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
                1
            }
            crate::wansend::WanRestOutcome::Failed(e) => {
                eprintln!("{verb}_FAIL:{id}: {e}");
                1
            }
        };
    }
    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
    match spt_daemon::daemon_rest_event(id, event, knob) {
        Ok(Some(report)) => {
            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
            0
        }
        Ok(None) => {
            // Idempotent UX: the machine reports no edge (already there).
            eprintln!("NO_EDGE:{id} (already in the target state)");
            0
        }
        Err(e) => {
            eprintln!("{verb}_FAIL:{id}: {e}");
            1
        }
    }
}

/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
/// → suspended; fires the transition echo-commune; cascades its persistent
/// shells offline"). Two legs through existing machinery: (1) soft-stop the
/// listener (drop the ready marker — the session-over half `spt suspend`
/// does not do); (2) the suspend edge via the daemon-side transition host —
/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
/// every rest verb.
// [impl->REQ-SHELL-2]
fn cmd_shutdown(id: Option<String>) -> i32 {
    let Some(id) = id.or_else(roster::detect_self_id) else {
        eprintln!(
            "NO_SELF: shutdown needs an endpoint (spt endpoint shutdown <id> or $OWL_SESSION_ID)"
        );
        return 1;
    };
    if id.contains('@') || id.contains(':') {


Verify **confirmed** — `endpoint wake <id>` has no caller-auth beyond the target-side gate:

`cli.rs:1850 cmd_rest` (the `EndpointCmd::Wake` handler):
- **local** (`id`): `daemon_rest_event(id, Wake, knob)` — **no caller-ownership check**. Any caller on the node wakes any local endpoint (node-local trust).
- **remote** (`id@node`): `wan_rest(id, REST_EVENT_WAKE)` → *"the TARGET node's transition host runs the edge"*; refusal = `"{node} closed the stream without a reply (access gate)"` (cli.rs:1873). **Target node's access gate is the sole authorization.**

So your "draw in arbitrary agents" needs **zero new auth** — target-side gating already governs it. Good.

Docs structure: single-context repo (no `CONTEXT-MAP.md`), root `CONTEXT.md`. `../spt-discord/` doesn't exist yet — I'll create it lazily for adapter material, keep it out of here.

**But the verify reframes the core change — it's narrower than "generalize the wake seam," and the first decision is what it even IS.** I read the supervision scaffolding: `reconcile_once` (shellwake.rs:457) is hard-coupled to **shell-offline status** (`info.status == SHELL_STATUS_OFFLINE`) and **owner-perch enumeration** (`list_self_perch_ids`/`list_shells`). The wake-watcher's whole identity is the **offline half** of a mutual-exclusivity flip — it dies when the thing comes online (shellwake.rs:475). Your forum contact binary is the opposite: **always-on, owner-less, agent-independent**. Bending the offline-watcher into an always-on binary fights its design (you'd neuter the online/offline flip + the owner enumeration).

Since wake is already ungated, the contact binary doesn't need the *wake-watcher* at all — it needs a **host path for an always-on, agent-independent endpoint binary** that then just calls `endpoint wake <id>`. That's the real gap. So the core terminology + substance fork:


AskUserQuestion

Question: What IS the core change, canonically — and what do we call it in CONTEXT.md? (The verify showed wake is already ungated, so the gap is hosting the always-on contact binary, not the wake call.)

Choices:
spt-core gains a host path for a long-running, agent-independent, owner-less endpoint binary that the daemon supervises (reuse backoff/give-up/lock, but NOT the offline-flip or owner enumeration). It calls the already-ungated 'endpoint wake <id>'. Clean separation from the offline wake-watcher. CONTEXT.md term: e.g. 'service endpoint' / 'resident binary'.
core: host an always-on adapter binary
  - owner-less, agent-independent
  - daemon-supervised (reuse backoff/lock)
  - NOT offline-flip, NOT owner-enum
  - calls endpoint wake <id> (ungated)
CONTEXT.md: define 'service endpoint'
wake-watcher: untouched
Generalize the wake-watcher (your prior pick)
Lift shellwake.rs: drop offline-only + owner-binding, allow kind!=shell, persistent mode. One seam covers both offline-half shell wakers AND always-on contact binaries. Fewer concepts, but overloads a mechanism built as the offline half — the reconcile's online/offline flip + owner enumeration must both grow modes.
generalize shellwake.rs:
  offline-only -> + persistent mode
  owner-bound  -> + arbitrary target
  kind=shell   -> + endpoint kinds
one seam, two roles
reconcile grows online/offline + always-on
risk: overloaded mechanism
Model forum binary as an always-online shell
Reuse the EXISTING shell host (shellhost.rs) by making the contact binary a shell that's simply always online (never flips offline, so the watcher never runs). Owned by a system/node endpoint. Smallest code change, but stretches 'shell' (single-owner, driven) to mean 'autonomous always-on service' — the exact taxonomy mismatch we've been avoiding.
contact binary = a shell, always online
  watcher never fires (never offline)
  owner = node/system endpoint
reuses shellhost.rs as-is
BUT: 'shell' now means autonomous service
  -> taxonomy drift (single-owner/driven)
Before naming it, read shellhost.rs launch/supervise + how agent-endpoint sessions are hosted, to see whether an always-on owner-less host path already exists or is genuinely new. Ground the concept in what's actually there.
read first:
  shellhost.rs (launch_shell, supervise)
  spawn-session / livehost hosting
  any existing 'service'/'resident' host?
then name the concept from evidence


Your questions have been answered: "What IS the core change, canonically — and what do we call it in CONTEXT.md? (The verify showed wake is already ungated, so the gap is hosting the always-on contact binary, not the wake call.)"=(no option selected) notes: new: it's simply an adapter manifest definition. something like [always-on]. You can now continue with these answers in mind.

Question: What IS the core change, canonically — and what do we call it in CONTEXT.md? (The verify showed wake is already ungated, so the gap is hosting the always-on contact binary, not the wake call.)

Choices:
spt-core gains a host path for a long-running, agent-independent, owner-less endpoint binary that the daemon supervises (reuse backoff/give-up/lock, but NOT the offline-flip or owner enumeration). It calls the already-ungated 'endpoint wake <id>'. Clean separation from the offline wake-watcher. CONTEXT.md term: e.g. 'service endpoint' / 'resident binary'.
core: host an always-on adapter binary
  - owner-less, agent-independent
  - daemon-supervised (reuse backoff/lock)
  - NOT offline-flip, NOT owner-enum
  - calls endpoint wake <id> (ungated)
CONTEXT.md: define 'service endpoint'
wake-watcher: untouched
Generalize the wake-watcher (your prior pick)
Lift shellwake.rs: drop offline-only + owner-binding, allow kind!=shell, persistent mode. One seam covers both offline-half shell wakers AND always-on contact binaries. Fewer concepts, but overloads a mechanism built as the offline half — the reconcile's online/offline flip + owner enumeration must both grow modes.
generalize shellwake.rs:
  offline-only -> + persistent mode
  owner-bound  -> + arbitrary target
  kind=shell   -> + endpoint kinds
one seam, two roles
reconcile grows online/offline + always-on
risk: overloaded mechanism
Model forum binary as an always-online shell
Reuse the EXISTING shell host (shellhost.rs) by making the contact binary a shell that's simply always online (never flips offline, so the watcher never runs). Owned by a system/node endpoint. Smallest code change, but stretches 'shell' (single-owner, driven) to mean 'autonomous always-on service' — the exact taxonomy mismatch we've been avoiding.
contact binary = a shell, always online
  watcher never fires (never offline)
  owner = node/system endpoint
reuses shellhost.rs as-is
BUT: 'shell' now means autonomous service
  -> taxonomy drift (single-owner/driven)
Before naming it, read shellhost.rs launch/supervise + how agent-endpoint sessions are hosted, to see whether an always-on owner-less host path already exists or is genuinely new. Ground the concept in what's actually there.
read first:
  shellhost.rs (launch_shell, supervise)
  spawn-session / livehost hosting
  any existing 'service'/'resident' host?
then name the concept from evidence


Good — `[always-on]` as a manifest section, a daemon-supervised service binary. Capturing that. Note one architectural fact for the ADR: CONTEXT.md:237 currently calls the wake-watcher *"the one class of third-party binary spt-core boot-launches"* — `[always-on]` makes it **two classes**. That's exactly the kind of hard-to-reverse, surprising-without-context decision an ADR exists for.

Before I write the glossary entry, one decisive fork on what `[always-on]` *is* — because it changes the definition and whether it touches the endpoint-type system at all:


Question: Is an `[always-on]` binary a faceless supervised SERVICE (no perch/identity), or an always-on ENDPOINT (perch + registry row + addressable)?

Choices:
Pure process supervision: daemon keeps the declared binary alive, keyed by adapter_name. No perch, no registry row, not in the endpoint-type system. Outbound-only (calls 'endpoint wake <id>', talks to Discord). Any addressable forum-endpoint-ness is adapter's own concern via existing 'api bind'. Most minimal; orthogonal to the taxonomy.
[always-on]
  command = '...'
daemon: supervise, key=adapter_name
NO perch, NO registry row
NOT an endpoint type
outbound: endpoint wake <id>, Discord
addressable forum = adapter's job (api bind)
It's an endpoint that never sleeps: gets a perch, registry row, endpoint_type, addressable. Integrates with presence/addressing. Richer, but pulls the core piece INTO the endpoint-type taxonomy we said the forum lives outside of — and most of that identity is unused if the binary is outbound-only.
[always-on] -> an endpoint
  perch + info.json + registry row
  endpoint_type, addressable
  presence integration
richer, but: taxonomy entanglement
  much identity unused (outbound-only)
Core ships faceless-service supervision now. If a binary wants identity, it binds a perch itself via existing 'api bind' (already supported) — so endpoint-ness is opt-in by the adapter, not baked into [always-on]. Keeps [always-on] = pure supervision; identity is composable on top.
[always-on] = pure supervision (faceless)
adapter MAY: api bind -> get a perch
  (opt-in identity, already works)
core stays minimal
identity composes on top, not baked in


Your questions have been answered: "Is an `[always-on]` binary a faceless supervised SERVICE (no perch/identity), or an always-on ENDPOINT (perch + registry row + addressable)?"=(no option selected) notes: always-on endpoint, forcibly addressed by a preceding `#` to distinguish it from agent endpoints. will NOT be outbound-only, given agents will message it to interact with the discord channel/threads. You can now continue with these answers in mind.

Question: Is an `[always-on]` binary a faceless supervised SERVICE (no perch/identity), or an always-on ENDPOINT (perch + registry row + addressable)?

Choices:
Pure process supervision: daemon keeps the declared binary alive, keyed by adapter_name. No perch, no registry row, not in the endpoint-type system. Outbound-only (calls 'endpoint wake <id>', talks to Discord). Any addressable forum-endpoint-ness is adapter's own concern via existing 'api bind'. Most minimal; orthogonal to the taxonomy.
[always-on]
  command = '...'
daemon: supervise, key=adapter_name
NO perch, NO registry row
NOT an endpoint type
outbound: endpoint wake <id>, Discord
addressable forum = adapter's job (api bind)
It's an endpoint that never sleeps: gets a perch, registry row, endpoint_type, addressable. Integrates with presence/addressing. Richer, but pulls the core piece INTO the endpoint-type taxonomy we said the forum lives outside of — and most of that identity is unused if the binary is outbound-only.
[always-on] -> an endpoint
  perch + info.json + registry row
  endpoint_type, addressable
  presence integration
richer, but: taxonomy entanglement
  much identity unused (outbound-only)
Core ships faceless-service supervision now. If a binary wants identity, it binds a perch itself via existing 'api bind' (already supported) — so endpoint-ness is opt-in by the adapter, not baked into [always-on]. Keeps [always-on] = pure supervision; identity is composable on top.
[always-on] = pure supervision (faceless)
adapter MAY: api bind -> get a perch
  (opt-in identity, already works)
core stays minimal
identity composes on top, not baked in


Always-on endpoint, `#`-sigil addressed, two-way (agents message it to drive the channel). That's a real addressing-grammar change — let me ground the `#` sigil against the existing id grammar before grilling it.


1	//! Endpoint-id charset contract — the addressable-name validator.
2	//!
3	//! Every addressable endpoint id/name is restricted to a deliberately narrow
4	//! charset so that:
5	//!   - `:` and `@` stay **reserved** as the future address delimiters of the
6	//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
7	//!     no bare id can ever contain them, so qualified-address parsing is
8	//!     unambiguous and no permissive id-data accumulates that would need a
9	//!     later migration;
10	//!   - path separators, whitespace, and control characters cannot smuggle into
11	//!     a perch directory name or a wire target string.
12	//!
13	//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
14	//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
15	//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
16	//! only `-` + alphanumerics, so composite ids validate cleanly.
17	// [impl->REQ-HAZARD-ID-CHARSET]
18
19	/// Maximum endpoint-id length, in Unicode scalar values.
20	pub const MAX_ENDPOINT_ID_LEN: usize = 64;
21
22	/// Why an endpoint id was rejected.
23	#[derive(Debug, Clone, PartialEq, Eq)]
24	pub enum IdError {
25	    /// The id was the empty string.
26	    Empty,
27	    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
28	    TooLong { len: usize, max: usize },
29	    /// A disallowed character was found (with its scalar position).
30	    BadChar { ch: char, pos: usize },
31	}
32
33	impl std::fmt::Display for IdError {
34	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35	        match self {
36	            IdError::Empty => write!(f, "endpoint id must not be empty"),
37	            IdError::TooLong { len, max } => {
38	                write!(f, "endpoint id too long ({len} chars; max {max})")
39	            }
40	            IdError::BadChar { ch, pos } => write!(
41	                f,
42	                "endpoint id has a disallowed character {ch:?} at position {pos} \
43	                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
44	                 `:` and `@` are reserved address delimiters)"
45	            ),
46	        }
47	    }
48	}
49
50	impl std::error::Error for IdError {}
51
52	/// True if `c` is permitted in an endpoint id.
53	pub fn is_allowed_id_char(c: char) -> bool {
54	    c.is_ascii_alphanumeric()
55	        || c == '-'
56	        || c == '_'
57	        || matches!(c,
58	            '\u{3040}'..='\u{309F}'   // Hiragana
59	            | '\u{30A0}'..='\u{30FF}' // Katakana
60	            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
61	        )
62	}
63
64	/// Validate an addressable endpoint id/name against the charset contract.
65	/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
66	pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
67	    if id.is_empty() {
68	        return Err(IdError::Empty);
69	    }
70	    let len = id.chars().count();
71	    if len > MAX_ENDPOINT_ID_LEN {
72	        return Err(IdError::TooLong {
73	            len,
74	            max: MAX_ENDPOINT_ID_LEN,
75	        });
76	    }
77	    for (pos, ch) in id.chars().enumerate() {
78	        if !is_allowed_id_char(ch) {
79	            return Err(IdError::BadChar { ch, pos });
80	        }
81	    }
82	    Ok(())
83	}
84
85	/// Convenience boolean form of [`validate_endpoint_id`].
86	pub fn is_valid_endpoint_id(id: &str) -> bool {
87	    validate_endpoint_id(id).is_ok()
88	}
89
90	#[cfg(test)]
91	mod tests {
92	    use super::*;
93
94	    // [unit->REQ-HAZARD-ID-CHARSET]
95	    #[test]
96	    fn accepts_plain_and_composite_ids() {
97	        for id in [
98	            "alice",
99	            "doyle",
100	            "snake_case",
101	            "kebab-case",
102	            "doyle-psyche", // Psyche suffix scheme
103	            "doyle-w331",   // Worker suffix scheme
104	            "Node1",
105	            "a",
106	        ] {
107	            assert!(is_valid_endpoint_id(id), "{id} should be valid");
108	        }
109	    }
110
111	    // [unit->REQ-HAZARD-ID-CHARSET]
112	    #[test]
113	    fn accepts_japanese_scripts() {
114	        for id in [
115	            "こんにちは",    // Hiragana
116	            "ミックス",      // Katakana
117	            "日本語",        // Kanji
118	            "agent-日本-01", // mixed with ASCII + hyphen
119	        ] {
120	            assert!(is_valid_endpoint_id(id), "{id} should be valid");
121	        }
122	    }
123
124	    // [unit->REQ-HAZARD-ID-CHARSET]
125	    #[test]
126	    fn reserves_address_delimiters() {
127	        // The whole point: `:` and `@` can never appear in a bare id, so
128	        // `[subnet:]id[@node]` parsing is unambiguous.
129	        assert!(!is_valid_endpoint_id("home:ling"));
130	        assert!(!is_valid_endpoint_id("ling@hfenduleam"));
131	        assert!(!is_valid_endpoint_id("home:ling@node"));
132	    }
133
134	    // [unit->REQ-HAZARD-ID-CHARSET]
135	    #[test]
136	    fn rejects_path_whitespace_and_control() {
137	        for bad in [
138	            "a/b", "a\\b", "a.b", "a b", "a\tb", "a\nb", "..", ".", "a!b", "a#b", "🦀",
139	        ] {
140	            assert!(!is_valid_endpoint_id(bad), "{bad:?} should be rejected");
141	        }
142	    }
143
144	    // [unit->REQ-HAZARD-ID-CHARSET]
145	    #[test]
146	    fn rejects_empty_and_overlong() {
147	        assert_eq!(validate_endpoint_id(""), Err(IdError::Empty));
148	        let long = "a".repeat(MAX_ENDPOINT_ID_LEN + 1);
149	        assert!(matches!(
150	            validate_endpoint_id(&long),
151	            Err(IdError::TooLong { .. })
152	        ));
153	        assert!(is_valid_endpoint_id(&"a".repeat(MAX_ENDPOINT_ID_LEN)));
154	    }
155
156	    // [unit->REQ-HAZARD-ID-CHARSET]
157	    #[test]
158	    fn reports_bad_char_position() {
159	        assert_eq!(
160	            validate_endpoint_id("ab:cd"),
161	            Err(IdError::BadChar { ch: ':', pos: 2 })
162	        );
163	    }
164	}
165

//! Endpoint-id charset contract — the addressable-name validator.
//!
//! Every addressable endpoint id/name is restricted to a deliberately narrow
//! charset so that:
//!   - `:` and `@` stay **reserved** as the future address delimiters of the
//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
//!     no bare id can ever contain them, so qualified-address parsing is
//!     unambiguous and no permissive id-data accumulates that would need a
//!     later migration;
//!   - path separators, whitespace, and control characters cannot smuggle into
//!     a perch directory name or a wire target string.
//!
//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
//! only `-` + alphanumerics, so composite ids validate cleanly.
// [impl->REQ-HAZARD-ID-CHARSET]

/// Maximum endpoint-id length, in Unicode scalar values.
pub const MAX_ENDPOINT_ID_LEN: usize = 64;

/// Why an endpoint id was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdError {
    /// The id was the empty string.
    Empty,
    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
    TooLong { len: usize, max: usize },
    /// A disallowed character was found (with its scalar position).
    BadChar { ch: char, pos: usize },
}

impl std::fmt::Display for IdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IdError::Empty => write!(f, "endpoint id must not be empty"),
            IdError::TooLong { len, max } => {
                write!(f, "endpoint id too long ({len} chars; max {max})")
            }
            IdError::BadChar { ch, pos } => write!(
                f,
                "endpoint id has a disallowed character {ch:?} at position {pos} \
                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
                 `:` and `@` are reserved address delimiters)"
            ),
        }
    }
}

impl std::error::Error for IdError {}

/// True if `c` is permitted in an endpoint id.
pub fn is_allowed_id_char(c: char) -> bool {
    c.is_ascii_alphanumeric()
        || c == '-'
        || c == '_'
        || matches!(c,
            '\u{3040}'..='\u{309F}'   // Hiragana
            | '\u{30A0}'..='\u{30FF}' // Katakana
            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
        )
}

/// Validate an addressable endpoint id/name against the charset contract.
/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
    if id.is_empty() {
        return Err(IdError::Empty);
    }
    let len = id.chars().count();
    if len > MAX_ENDPOINT_ID_LEN {
        return Err(IdError::TooLong {
            len,
            max: MAX_ENDPOINT_ID_LEN,
        });
    }
    for (pos, ch) in id.chars().enumerate() {
        if !is_allowed_id_char(ch) {
            return Err(IdError::BadChar { ch, pos });
        }
    }
    Ok(())
}

/// Convenience boolean form of [`validate_endpoint_id`].
pub fn is_valid_endpoint_id(id: &str) -> bool {
    validate_endpoint_id(id).is_ok()
}

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

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn accepts_plain_and_composite_ids() {
        for id in [
            "alice",
            "doyle",
            "snake_case",
            "kebab-case",
            "doyle-psyche", // Psyche suffix scheme
            "doyle-w331",   // Worker suffix scheme
            "Node1",
            "a",
        ] {
            assert!(is_valid_endpoint_id(id), "{id} should be valid");
        }
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn accepts_japanese_scripts() {
        for id in [
            "こんにちは",    // Hiragana
            "ミックス",      // Katakana
            "日本語",        // Kanji
            "agent-日本-01", // mixed with ASCII + hyphen
        ] {
            assert!(is_valid_endpoint_id(id), "{id} should be valid");
        }
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn reserves_address_delimiters() {
        // The whole point: `:` and `@` can never appear in a bare id, so
        // `[subnet:]id[@node]` parsing is unambiguous.
        assert!(!is_valid_endpoint_id("home:ling"));
        assert!(!is_valid_endpoint_id("ling@hfenduleam"));
        assert!(!is_valid_endpoint_id("home:ling@node"));
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn rejects_path_whitespace_and_control() {
        for bad in [
            "a/b", "a\\b", "a.b", "a b", "a\tb", "a\nb", "..", ".", "a!b", "a#b", "🦀",
        ] {
            assert!(!is_valid_endpoint_id(bad), "{bad:?} should be rejected");
        }
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn rejects_empty_and_overlong() {
        assert_eq!(validate_endpoint_id(""), Err(IdError::Empty));
        let long = "a".repeat(MAX_ENDPOINT_ID_LEN + 1);
        assert!(matches!(
            validate_endpoint_id(&long),
            Err(IdError::TooLong { .. })
        ));
        assert!(is_valid_endpoint_id(&"a".repeat(MAX_ENDPOINT_ID_LEN)));
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn reports_bad_char_position() {
        assert_eq!(
            validate_endpoint_id("ab:cd"),
            Err(IdError::BadChar { ch: ':', pos: 2 })
        );
    }
}


Grounded — and it's hazard-territory, which firms up the ADR. The id charset (`spt-proto/src/id.rs`, guarded by **REQ-HAZARD-ID-CHARSET**) currently **rejects `#`** outright — test `rejects_path_whitespace_and_control` lists `"a#b"` as invalid (id.rs:138). `:` and `@` are reserved as address delimiters precisely so "no bare id can ever contain them" (id.rs:5-9).

So your `#` sigil slots into an **existing, deliberate design pattern**: reserve a character at the address-grammar layer, keep the bare id charset narrow. `#` becomes a **reserved leading sigil** — *not* an id char. `#general` parses as sigil `#` + clean id `general`; the perch dir / wire id stays `general`; mid-id `#` stays hazard-rejected. And per your "forcibly addressed to distinguish from agent endpoints," the sigil is **bijective + mandatory**: `#name` ⟺ always-on endpoint, bare `name` ⟺ agent endpoint — the sigil *is* the class discriminant in the address, so the router knows the kind before any registry lookup.

This amends a HAZARD-protected contract → clear ADR + a REQ change. One grammar decision before I write it up — where `#` sits relative to the existing `[subnet:]id[@node]` qualifiers:


Question: Where does the `#` sigil sit in the qualified address grammar `[subnet:]id[@node]`?

Choices:
Sigil sits adjacent to the id it classifies; subnet stays the outermost scope, node the suffix locator. Parse: split subnet on first ':', strip leading '#' from the id token, split '@' for node. Unambiguous (# only ever leads the id token). Consistent with id being the core that subnet/node qualify.
#general                 (local)
#general@node            (remote)
home:#general@node       (subnet-qualified)
parse: [subnet:] then #id then [@node]
# always leads the id token
Sigil always leads the whole address (Discord-like '#...'). Reads consistently as '#' first, but separates the sigil from the id across the subnet delimiter (#home:general), which is visually odd and splits the class marker from what it marks.
#general
#general@node
#home:general@node
sigil always first
BUT: #home:general splits # from id
Local-only sigil for now, defer qualified form
Ship '#id' (and maybe '#id@node') now; defer subnet-qualified '#' addressing until cross-node always-on endpoints are actually needed. Smaller grammar surface first; risk of a later grammar revision if deferred shape conflicts.
#general        (now)
#general@node   (maybe now)
home:#... / #home:...  (DEFERRED)
smaller surface first
risk: revisit grammar later


Your questions have been answered: "Where does the `#` sigil sit in the qualified address grammar `[subnet:]id[@node]`?"="Hugs the id: [subnet:]#id[@node]" selected preview:
#general                 (local)
#general@node            (remote)
home:#general@node       (subnet-qualified)
parse: [subnet:] then #id then [@node]
# always leads the id token. You can now continue with these answers in mind.

Question: Where does the `#` sigil sit in the qualified address grammar `[subnet:]id[@node]`?

Choices:
Sigil sits adjacent to the id it classifies; subnet stays the outermost scope, node the suffix locator. Parse: split subnet on first ':', strip leading '#' from the id token, split '@' for node. Unambiguous (# only ever leads the id token). Consistent with id being the core that subnet/node qualify.
#general                 (local)
#general@node            (remote)
home:#general@node       (subnet-qualified)
parse: [subnet:] then #id then [@node]
# always leads the id token
Sigil always leads the whole address (Discord-like '#...'). Reads consistently as '#' first, but separates the sigil from the id across the subnet delimiter (#home:general), which is visually odd and splits the class marker from what it marks.
#general
#general@node
#home:general@node
sigil always first
BUT: #home:general splits # from id
Local-only sigil for now, defer qualified form
Ship '#id' (and maybe '#id@node') now; defer subnet-qualified '#' addressing until cross-node always-on endpoints are actually needed. Smaller grammar surface first; risk of a later grammar revision if deferred shape conflicts.
#general        (now)
#general@node   (maybe now)
home:#... / #home:...  (DEFERRED)
smaller surface first
risk: revisit grammar later


`[subnet:]#id[@node]`, sigil hugs the id — locked. Enough resolved to capture the glossary terms. Let me find the right homes in CONTEXT.md (endpoint types + the addressing grammar).


337-
338-**remote attach (`spt rc`)** (ratified 2026-06-12 — own milestone, rides the built R-TERM substrate):
339:[Omitted long matching line]
340-_Avoid_: "screen share"; resize-per-viewer; silent controller displacement.
341-
--
447-- **Topology-independent.** Because the source is logs, the digest works for harness-hosted endpoints too — Claude Code's per-session JSONL is Path A's worked example. Freshness = file-watch + adapter hook nudges; harnesses with no usable logs push entries directly via **`api digest-entry`** (the Path-B-style door). Near-realtime tailing needs an *incremental* normalize story (design-phase concern: Path A's normalize-command was shaped for occasional pulls).
448-[Omitted long context line]
449:[Omitted long matching line]
450-- **"Option (C)" logging surface:** the digest may also be *persisted* (opt-in per-endpoint) as a coarse activity log appended to the native history store (Path B), on-boundary or periodic — off by default.
451-[Omitted long context line]
--
559-[Omitted long context line]
560-
561:[Omitted long matching line]
562-
563-[Omitted long context line]
--
689-
690-**node label**:
691:[Omitted long matching line]
692-
693-**subnet naming**:
--
738-- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
739-
740:[Omitted long matching line]
741-
742-**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.


684	**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets. This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).
685
686	**self-elevating re-launch (cross-platform)**:
687	<!-- [doc->REQ-ELEVATE-1] -->
688	When a gated command is run unelevated, spt does not just print "run as administrator" — it **re-launches itself with privilege** so the user reaches the result in one step. The path is chosen by a pure decision seam (`elevation::decide_elevation_path`) from the OS, the current elevation, and the environment: an **interactive Unix TTY** re-execs inline under `sudo`; a **Linux desktop without a TTY** prefers **`pkexec`** (native polkit GUI auth, clean stdio) and falls back to a **terminal-emulator** (`x-terminal-emulator -e sudo …`, then gnome-terminal/konsole/xterm); **Windows** pops a **UAC console** via `ShellExecuteW("runas")`; anything else (headless / no path) prints the absolute-path command for the human. The elevated child runs in its own console — on Windows it self-pauses ("you can close this window") so a fresh UAC console stays legible, since the unprivileged parent **never captures the elevated child's output across the privilege boundary**. The security discipline is `REQ-HAZARD-SELF-ELEVATE` (KNOWN-HAZARDS 5.11): the re-launch re-runs the **exact** invocation with the binary's **absolute** path, **never** widening args, resolving a bare name, or interpolating a crafted arg into a shell string (every launcher passes an argv array; the Windows params string MSVC-quotes each verbatim arg). The user's UAC/polkit/sudo prompt is the only consent gate, and an already-elevated process **never re-elevates** (loop-safe). The mechanism is generic — reused by every gated command, not subnet-specific. On **create** and **join** (and `show-code`), the elevated output includes the subnet's code, `otpauth://` URI, and a **terminal QR** so the seed can be stored straight into an authenticator app from either side of the desk.
689
690	**node label**:
691	A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.
692
693	**subnet naming**:
694	The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.
695
696	**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.
697
698	Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).
699
700	**subnet attachment (attached / detached)**:
701	Whether this node's daemon is **actively serving** a held subnet membership right now — pairing responder reachable, rendezvous meet listener rotating, registry gossip pumping. **Attached** = serving; **detached** = the membership record (seed) is held on disk but deliberately not served (the daemon neither advertises into nor connects to that subnet). Detachment is a *chosen* per-subnet state — `spt subnet detach/attach <NAME> [--save]` (shipped M8-D2; `--save` persists the startup default in daemon config, renamed from the once-planned `--auto`; an unsaved flip deliberately does not survive a daemon restart). A *degraded* daemon that cannot serve at all (net-less broker — endpoint bind failed) is rendered as **no connection**, never conflated with deliberate detachment. Corollary, ratified 2026-06-06: **membership implies reachability** — the membership-creating verbs (`subnet create`, `subnet join`) ensure the daemon is running, because a sole seed-holder with no responder is a contradiction of the subnet's purpose.
702
703	**endpoint description** (canonical CLI term for the resource-advertisement blurb):
704	The per-endpoint free-text "yellow-pages" line (see §resource advertisement). Surface term is **description**; "blurb" survives only as the internal field name.
705
706	**whoami** (alias for endpoint list):
707	<!-- [doc->REQ-WHOAMI-1] -->
708	`spt whoami` is a thin **alias for `spt endpoint list`** — it prints the full view with the session's own endpoint **SELF-pinned first**, that pin carrying the endpoint's id, liveness state, and its authored **endpoint description** (the "who am I" answer). There is no separate bare-id command: nothing captured `id=$(spt whoami)` (environment variables don't persist between an agent's tool calls), so there is no scripting contract to preserve. `whoami` stays a top-level hot-path verb (its parse is unchanged, REQ-MSG-9); only the SELF pin's new description line is added behavior.
709
710	**endpoint list always merges local perches**:
711	<!-- [doc->REQ-ENDPOINT-LIST-MERGE-LOCAL] -->
712	`spt endpoint list` (and therefore `whoami`) **always** appends this node's **LOCAL perch roster** as a trailing section, in addition to the SELF pin and the subnet groups. The subnet groups are the WAN registry snapshot, which lags a just-bound perch by a pump cadence — so without the merge a freshly-online endpoint (or the caller's own, under `whoami`) could be **absent** from its own listing, which reads as lost. The earlier `--local` flag (a separate this-node-only view) is **removed**: the local view is no longer a mode, it is unconditionally part of the merged listing. `--subnet`/`--detail` still shape the subnet portion.
713
714	**local-link authentication**:
715	Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.
716
717	**binary-trust disclosure (accepted risk)**:
718	A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**
719
720	## Consent & security gates
721
722	Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).
723
724	**Consent model — hybrid (grants + interactive escalation):**
725	- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
726	- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
727	- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).
728
729	**What is gated vs not:**
730	- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
731	- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
732	- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).
733
734	**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
735	A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:
736	- **Outbound** from the endpoint → any **visible** node (the whitelist never restricts who it talks *to*).
737	- **Inbound** → a **reply** correlated to the endpoint's own prior outbound (reply traffic, keyed on the inbound `from`) is allowed from any visible node ("established/related"); an **unsolicited/direct** control or message ("new inbound") is allowed only from a **whitelisted** node.
738	- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
739
740	**Default empty = open** (any subnet-visible node, current behavior); setting a whitelist *restricts*. **Node-tier ships now (M4)** — whitelist Ed25519 node pubkeys. **User-tier is deferred** (the per-(subnet,user) model, ADR-0006 cross-user seam) — the schema reserves an inert `users` field until then. Enforced **at the target endpoint's node**, synced as **security material near the trust store** (not the context repo), subnet-settable, revocable — same plumbing as the grant store, **different table + polarity** (origin-node/default-open vs agent-subject/default-deny). **Composition — three orthogonal gates, nested:** subnet *visibility* (is it routable here at all?) → *access whitelist* (may your node reach it?) → *capability grants* (may this agent do this high-risk thing?). Discovery (*resource advertisement*) is gated by the first two.
741
742	**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.
743

**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets. This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline).

**self-elevating re-launch (cross-platform)**:
<!-- [doc->REQ-ELEVATE-1] -->
When a gated command is run unelevated, spt does not just print "run as administrator" — it **re-launches itself with privilege** so the user reaches the result in one step. The path is chosen by a pure decision seam (`elevation::decide_elevation_path`) from the OS, the current elevation, and the environment: an **interactive Unix TTY** re-execs inline under `sudo`; a **Linux desktop without a TTY** prefers **`pkexec`** (native polkit GUI auth, clean stdio) and falls back to a **terminal-emulator** (`x-terminal-emulator -e sudo …`, then gnome-terminal/konsole/xterm); **Windows** pops a **UAC console** via `ShellExecuteW("runas")`; anything else (headless / no path) prints the absolute-path command for the human. The elevated child runs in its own console — on Windows it self-pauses ("you can close this window") so a fresh UAC console stays legible, since the unprivileged parent **never captures the elevated child's output across the privilege boundary**. The security discipline is `REQ-HAZARD-SELF-ELEVATE` (KNOWN-HAZARDS 5.11): the re-launch re-runs the **exact** invocation with the binary's **absolute** path, **never** widening args, resolving a bare name, or interpolating a crafted arg into a shell string (every launcher passes an argv array; the Windows params string MSVC-quotes each verbatim arg). The user's UAC/polkit/sudo prompt is the only consent gate, and an already-elevated process **never re-elevates** (loop-safe). The mechanism is generic — reused by every gated command, not subnet-specific. On **create** and **join** (and `show-code`), the elevated output includes the subnet's code, `otpauth://` URI, and a **terminal QR** so the seed can be stored straight into an authenticator app from either side of the desk.

**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.

**subnet naming**:
The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.

**subnet icon** (GUI metadata): an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in future GUIs. Stored **inline** in subnet metadata (so it syncs for free over the same subnet-material channel as the name; the cap keeps inline-sync trivial — it's an icon, not a media library), **editable by any node in the subnet, any time** (seeded at create/name). **GUI-only consumer** (frontend milestone); data distribution rides M4.

Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).

**subnet attachment (attached / detached)**:
Whether this node's daemon is **actively serving** a held subnet membership right now — pairing responder reachable, rendezvous meet listener rotating, registry gossip pumping. **Attached** = serving; **detached** = the membership record (seed) is held on disk but deliberately not served (the daemon neither advertises into nor connects to that subnet). Detachment is a *chosen* per-subnet state — `spt subnet detach/attach <NAME> [--save]` (shipped M8-D2; `--save` persists the startup default in daemon config, renamed from the once-planned `--auto`; an unsaved flip deliberately does not survive a daemon restart). A *degraded* daemon that cannot serve at all (net-less broker — endpoint bind failed) is rendered as **no connection**, never conflated with deliberate detachment. Corollary, ratified 2026-06-06: **membership implies reachability** — the membership-creating verbs (`subnet create`, `subnet join`) ensure the daemon is running, because a sole seed-holder with no responder is a contradiction of the subnet's purpose.

**endpoint description** (canonical CLI term for the resource-advertisement blurb):
The per-endpoint free-text "yellow-pages" line (see §resource advertisement). Surface term is **description**; "blurb" survives only as the internal field name.

**whoami** (alias for endpoint list):
<!-- [doc->REQ-WHOAMI-1] -->
`spt whoami` is a thin **alias for `spt endpoint list`** — it prints the full view with the session's own endpoint **SELF-pinned first**, that pin carrying the endpoint's id, liveness state, and its authored **endpoint description** (the "who am I" answer). There is no separate bare-id command: nothing captured `id=$(spt whoami)` (environment variables don't persist between an agent's tool calls), so there is no scripting contract to preserve. `whoami` stays a top-level hot-path verb (its parse is unchanged, REQ-MSG-9); only the SELF pin's new description line is added behavior.

**endpoint list always merges local perches**:
<!-- [doc->REQ-ENDPOINT-LIST-MERGE-LOCAL] -->
`spt endpoint list` (and therefore `whoami`) **always** appends this node's **LOCAL perch roster** as a trailing section, in addition to the SELF pin and the subnet groups. The subnet groups are the WAN registry snapshot, which lags a just-bound perch by a pump cadence — so without the merge a freshly-online endpoint (or the caller's own, under `whoami`) could be **absent** from its own listing, which reads as lost. The earlier `--local` flag (a separate this-node-only view) is **removed**: the local view is no longer a mode, it is unconditionally part of the merged listing. `--subnet`/`--detail` still shape the subnet portion.

**local-link authentication**:
Cross-node traffic already rides Iroh (E2E-encrypted by node keypairs). The exposed surface is *same-node* channels where potentially-untrusted code touches SPT — a shell binary's HTTP/stdin/relay link, HTTP-to-harness-binary delivery. These require a **per-link handshake at `api bind` that establishes a link token + local-channel encryption**, required on every subsequent message — a local-link auth capability so other local processes can't inject into or read a link. Shell↔broker links specifically are encrypted + handshaked.

**binary-trust disclosure (accepted risk)**:
A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**

## Consent & security gates

Gates the high-risk cross-node capabilities. **Trust boundary:** everything is within *one user's own subnet* (their own TOTP-SPAKE2-paired, mutually-trusted nodes; cross-subnet is deferred). So consent is **not** stranger-authentication — it is guardrails against (1) an agent doing something surprising/destructive the user didn't want, (2) **runaway** (spawning instances / burning compute/billing across nodes), (3) **compromise containment** (limiting a compromised node/agent's cross-node blast radius).

**Consent model — hybrid (grants + interactive escalation):**
- **Grant store** — records `capability × subject (agent) × target (node)` (this granularity is sufficient; finer per-command-pattern scoping is a later refinement). **Enforced at the target node** (the node receiving a remote action checks its local grants), **settable subnet-wide** (a grant authored from any node propagates to the target), and **revocable**. Lives with subnet security material (near the trust store), not in the context git repo.
- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion "Other") for refinement.
- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).

**What is gated vs not:**
- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).
- **Pre-consented by flag:** shell owner-shutdown, shell wake-spawn-anywhere.
- **Ungated:** remote-drive of your *own running* instance (low risk — your own instance; a light "node X is driving `ling@desktop`" notification suffices), cross-node context/Psyche sync (your own data).

**endpoint access whitelist** (distinct from the grant store — the outer reach gate):
A per-endpoint allow-list controlling **who may remotely reach** an endpoint, **by origin node** (and, later, by user). The load-bearing semantic: it checks the **origin node of the inbound interaction** (where the operator is acting), **not** the sender endpoint's identity or home — *"the operator must be on a whitelisted machine; the sender endpoint's location is irrelevant."* It is a **stateful-firewall** model, not a blanket block:
- **Outbound** from the endpoint → any **visible** node (the whitelist never restricts who it talks *to*).
- **Inbound** → a **reply** correlated to the endpoint's own prior outbound (reply traffic, keyed on the inbound `from`) is allowed from any visible node ("established/related"); an **unsolicited/direct** control or message ("new inbound") is allowed only from a **whitelisted** node.
- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.

**Default empty = open** (any subnet-visible node, current behavior); setting a whitelist *restricts*. **Node-tier ships now (M4)** — whitelist Ed25519 node pubkeys. **User-tier is deferred** (the per-(subnet,user) model, ADR-0006 cross-user seam) — the schema reserves an inert `users` field until then. Enforced **at the target endpoint's node**, synced as **security material near the trust store** (not the context repo), subnet-settable, revocable — same plumbing as the grant store, **different table + polarity** (origin-node/default-open vs agent-subject/default-deny). **Composition — three orthogonal gates, nested:** subnet *visibility* (is it routable here at all?) → *access whitelist* (may your node reach it?) → *capability grants* (may this agent do this high-risk thing?). Discovery (*resource advertisement*) is gated by the first two.

**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.


Writing the fully-locked term now (the sigil grammar won't change); holding the "always-on endpoint" entry one more question since instantiation shapes its definition.


Edit

**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.

**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.

**`#` always-on sigil** (ratified 2026-06-21):
A reserved **leading address sigil** marking an [[always-on endpoint]], extending the `:`/`@` reserved-delimiter discipline (id charset stays narrow per `REQ-HAZARD-ID-CHARSET`; the sigil lives at the address-grammar layer, **never in the bare id** — `#general` addresses the clean stored id `general`). It is **mandatory and bijective**: `#name` ⟺ an always-on endpoint, bare `name` ⟺ an agent endpoint — so the router resolves the endpoint **class from the address alone**, before any registry lookup. Placement **hugs the id** within the qualified form: `[subnet:]#id[@node]` (e.g. `#general`, `#general@node`, `home:#general@node`). A mid-id `#` stays charset-rejected; only a single leading `#` on the id token is the sigil.


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)

**node label**:
A human display name for a node, defaulting to the machine's OS hostname (re-checked at daemon startup; a hostname change updates the label). Advertised through the existing registry gossip so subnet views render `HFENDULEAM (bcead52b…)` instead of bare key hex. The pubkey remains the identity; the label is **addressable**: an `@node` qualifier accepts a label or a key-prefix (`ling@hfenduleam`). Labels are not unique — an ambiguous label follows the resolution policy's refuse-and-qualify rule (refuse, list candidates with key prefixes), never a guess.

**`#` always-on sigil** (ratified 2026-06-21):
A reserved **leading address sigil** marking an [[always-on endpoint]], extending the `:`/`@` reserved-delimiter discipline (id charset stays narrow per `REQ-HAZARD-ID-CHARSET`; the sigil lives at the address-grammar layer, **never in the bare id** — `#general` addresses the clean stored id `general`). It is **mandatory and bijective**: `#name` ⟺ an always-on endpoint, bare `name` ⟺ an agent endpoint — so the router resolves the endpoint **class from the address alone**, before any registry lookup. Placement **hugs the id** within the qualified form: `[subnet:]#id[@node]` (e.g. `#general`, `#general@node`, `home:#general@node`). A mid-id `#` stays charset-rejected; only a single leading `#` on the id token is the sigil.

+A reserved **leading address sigil** marking an [[always-on endpoint]], extending the `:`/`@` reserved-delimiter discipline (id charset stays narrow per `REQ-HAZARD-ID-CHARSET`; the sigil lives at the address-grammar layer, **never in the bare id** — `#general` addresses the clean stored id `general`). It is **mandatory and bijective**: `#name` ⟺ an always-on endpoint, bare `name` ⟺ an agent endpoint — so the router resolves the endpoint **class from the address alone**, before any registry lookup. Placement **hugs the id** within the qualified form: `[subnet:]#id[@node]` (e.g. `#general`, `#general@node`, `home:#general@node`). A mid-id `#` stays charset-rejected; only a single leading `#` on the id token is the sigil.

The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.


`#` sigil captured in CONTEXT.md (linked to the pending `always-on endpoint` entry). Now the contour that decides how I define "always-on endpoint" — its instantiation/multiplicity model:


AskUserQuestion

Question: How are `#`-endpoints instantiated — what's the relationship between the supervised `[always-on]` binary and the addressable `#`-endpoints it serves?

Choices:
One binary, dynamic #-perches via api bind
`[always-on]` = ONE supervised binary per adapter (one Discord connection). It binds N #-endpoints at RUNTIME via existing 'api bind' (e.g. one per channel it discovers). Reuses bind; core just supervises the binary + recognizes #-class perches. Most flexible — bridge a new channel with no manifest edit.
[always-on] command = 'spt-discord-svc'
daemon: supervise 1 binary / adapter
binary -> api bind #general
        -> api bind #dev
1 connection, N #-endpoints
new channel = bind at runtime
`[always-on]` declares a FIXED named set; daemon supervises one binary per declared endpoint. Simplest core lifecycle (1:1 manifest->process), but rigid (new channel needs a manifest edit + re-register) and N processes/connections.
[always-on]
  endpoints = ['#general', '#dev']
daemon: 1 binary per declared id
N processes
new channel = edit manifest + re-register
One #-endpoint == one binary, spawned like shells
Each #-endpoint is spawned on demand (a command, like 'shell spawn') and is its own supervised binary. Flexible naming, but N processes / N Discord connections — wasteful for a substrate where one bot connection already serves all channels.
spt always-on spawn #general --adapter ...
each #id = its own supervised binary
N processes, N connections
flexible but heavy
Read how 'api bind' + perch creation work to confirm a single binary CAN bind multiple independent perches (and whether #-class needs new perch plumbing) before committing the model.
read: api bind (api/mod.rs / bind path)
  perch creation / info.json
can one binary bind N perches?
does #-class need new perch fields?


Your questions have been answered: "How are `#`-endpoints instantiated — what's the relationship between the supervised `[always-on]` binary and the addressable `#`-endpoints it serves?"=(no option selected) notes: api bind is the right idea. each adapter/profile would connect one bot, each endpoint created from an adapter/profile would represent another channel that bot is connected to.. You can now continue with these answers in mind.

Question: How are `#`-endpoints instantiated — what's the relationship between the supervised `[always-on]` binary and the addressable `#`-endpoints it serves?

Choices:
One binary, dynamic #-perches via api bind
`[always-on]` = ONE supervised binary per adapter (one Discord connection). It binds N #-endpoints at RUNTIME via existing 'api bind' (e.g. one per channel it discovers). Reuses bind; core just supervises the binary + recognizes #-class perches. Most flexible — bridge a new channel with no manifest edit.
[always-on] command = 'spt-discord-svc'
daemon: supervise 1 binary / adapter
binary -> api bind #general
        -> api bind #dev
1 connection, N #-endpoints
new channel = bind at runtime
`[always-on]` declares a FIXED named set; daemon supervises one binary per declared endpoint. Simplest core lifecycle (1:1 manifest->process), but rigid (new channel needs a manifest edit + re-register) and N processes/connections.
[always-on]
  endpoints = ['#general', '#dev']
daemon: 1 binary per declared id
N processes
new channel = edit manifest + re-register
One #-endpoint == one binary, spawned like shells
Each #-endpoint is spawned on demand (a command, like 'shell spawn') and is its own supervised binary. Flexible naming, but N processes / N Discord connections — wasteful for a substrate where one bot connection already serves all channels.
spt always-on spawn #general --adapter ...
each #id = its own supervised binary
N processes, N connections
flexible but heavy
Read how 'api bind' + perch creation work to confirm a single binary CAN bind multiple independent perches (and whether #-class needs new perch plumbing) before committing the model.
read: api bind (api/mod.rs / bind path)
  perch creation / info.json
can one binary bind N perches?
does #-class need new perch fields?


One bot per adapter-option, each `#`-endpoint = another channel that bot serves, bound via `api bind`. That fully resolves the concept. Writing the glossary entry + fixing the cross-references it creates (the "one class of boot-launched binary" claim at line 237 is now false — there are two).


Edit

A reserved **leading address sigil** marking an [[always-on endpoint]], extending the `:`/`@` reserved-delimiter discipline

A reserved **leading address sigil** marking an [[AlwaysOnEndpoint]], extending the `:`/`@` reserved-delimiter discipline


**`#` always-on sigil** (ratified 2026-06-21):

-A reserved **leading address sigil** marking an [[always-on endpoint]], extending the `:`/`@` reserved-delimiter discipline (id charset stays narrow per `REQ-HAZARD-ID-CHARSET`; the sigil lives at the address-grammar layer, **never in the bare id** — `#general` addresses the clean stored id `general`). It is **mandatory and bijective**: `#name` ⟺ an always-on endpoint, bare `name` ⟺ an agent endpoint — so the router resolves the endpoint **class from the address alone**, before any registry lookup. Placement **hugs the id** within the qualified form: `[subnet:]#id[@node]` (e.g. `#general`, `#general@node`, `home:#general@node`). A mid-id `#` stays charset-rejected; only a single leading `#` on the id token is the sigil.

+A reserved **leading address sigil** marking an [[AlwaysOnEndpoint]], extending the `:`/`@` reserved-delimiter discipline (id charset stays narrow per `REQ-HAZARD-ID-CHARSET`; the sigil lives at the address-grammar layer, **never in the bare id** — `#general` addresses the clean stored id `general`). It is **mandatory and bijective**: `#name` ⟺ an always-on endpoint, bare `name` ⟺ an agent endpoint — so the router resolves the endpoint **class from the address alone**, before any registry lookup. Placement **hugs the id** within the qualified form: `[subnet:]#id[@node]` (e.g. `#general`, `#general@node`, `home:#general@node`). A mid-id `#` stays charset-rejected; only a single leading `#` on the id token is the sigil.


that shell's offline wake-watcher (the one class of third-party binary spt-core boot-launches) holds the device-contact surface

that shell's offline wake-watcher (one of the two classes of third-party binary spt-core boot-launches — the other is the [[AlwaysOnEndpoint]] resident binary) holds the device-contact surface


_Implemented posture_: the **local** user-backed origins are honored end-to-end — a locally-hosted Gateway endpoint (info.json `state="gateway"`) and the local user's CLI (M9-T4/T5). The **cross-node WAN** path is being completed (trust posture **ratified 2026-06-13**): the **subnet membership boundary is the trust boundary**. A subnet is a collection of machines the user already trusts, so a `user-msg` arriving over the subnet from a **Gateway-typed** origin is honored as the user's authority; the daemon does **not** defend against a subnet member *forging* the Gateway type — an in-subnet compromise is out of scope by construction (if the subnet is breached at all, the trust model is already void). The origin's type is read from its advertised registry **`endpoint_type`**, resolved at the receive funnel against the **QUIC-handshake-proven origin node** (never wire bytes — that keying is correctness, not an extra trust layer). Until a node advertises `endpoint_type` (N-1 rollout grace), its WAN `user-msg` re-stamps to plain `msg` — a graceful degrade, not a trust gate. Mechanism: `Instance.endpoint_type` (additive, riding the existing epoch-leased registry replication) + advertise population + `receive_wan` resolve flipping the already-plumbed `origin_user_backed`. (An earlier per-node "user-surface trust set" proposal was vetoed as overkill — the subnet boundary already is that trust.)

-A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (the one class of third-party binary spt-core boot-launches) holds the device-contact surface and fires the standard **wake resolution** ("owner suspended → revive the owner"). No Gateway-manifest watcher, no autostart flag, no new mechanism.

+A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (one of the two classes of third-party binary spt-core boot-launches — the other is the [[AlwaysOnEndpoint]] resident binary) holds the device-contact surface and fires the standard **wake resolution** ("owner suspended → revive the owner"). No Gateway-manifest watcher, no autostart flag, no new mechanism.

_Avoid_: calling a Gateway a Shell or an agent; "console", "remote".

**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):


The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set.

**AlwaysOnEndpoint** (always-on endpoint; concept ratified 2026-06-21 — core kind, first instance downstream `spt-discord`):
A **resident, addressable endpoint that hosts no mind.** Its binary is daemon-**supervised continuously and runs independent of any agent's liveness** (up even when zero agents are online) — unlike an *agent endpoint* (a hosted mind with a Psyche + `tracked/` context) and unlike a **Shell** (single-owner, *driven*). It is **two-way addressable**: agents message it (to drive whatever external surface it fronts) and it messages out — notably it may call `endpoint wake <id>` to draw an offline agent online (wake authorization is **target-side**, so no special caller right is needed — see the wake-watcher/sleep-wake model). Declared by an adapter's **`[always-on]`** manifest section; the supervised binary is **one per adapter-option** (`<adapter>[:profile]`), and each always-on endpoint created from that option represents **another channel that one binary serves** — it binds via the existing `api bind`, so one connection fronts many `#`-endpoints. It is the **second class of third-party binary spt-core boot-launches** (the shell wake-watcher was the first); supervision **reuses the wake-watcher scaffolding** (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile) **minus the offline-only flip** — always online, never resting (no dormant/suspended states). Addressed with a mandatory leading [[`#` always-on sigil]].
_Avoid_: calling it a Shell (owner-less + not driven) or an agent (no mind); "service" alone (it is addressable, not faceless); a sleep/wake resting model (it does not rest).

The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set.