docs(serverless): add design report (architecture, decisions, verification)

This commit is contained in:
Michael Neale
2026-06-03 16:36:41 +10:00
parent 8846ef3c12
commit b601a92861

306
REPORT.md Normal file
View File

@@ -0,0 +1,306 @@
# Serverless Mode — Design Report
**Branch:** `micn/serverless-mode`
**Status:** feature-complete, verified end-to-end against live public relays.
This report explains what serverless mode is, the design decisions behind it,
how each piece works, and the evidence that it works end-to-end without
affecting the existing server-bound ("sprout relay") behaviour.
---
## 1. What it is
Serverless mode (a.k.a. "sprout-lite") lets the Sprout desktop app and the agent
harness talk **directly to generic public Nostr relays** with **no Sprout
infrastructure** — no `sprout-relay`, no Postgres, no Redis, no Typesense, no
auth/membership server. It is a **degraded subset** of Sprout: channels, DMs,
private channels, messages, threading, and agents — all expressed as plain Nostr
events over standard relays.
A workspace is either:
- **`sprout` mode** — talks to a Sprout relay via the HTTP bridge (`/query`,
`/events`) + NIP-98 auth + NIP-42 AUTH WebSocket. (Unchanged, the default.)
- **`serverless` mode** — talks to a comma-separated **list** of public relays
over plain WebSocket (NIP-01 `REQ`/`EVENT`/`EOSE`/`OK`). No AUTH required.
Default relays when you enable serverless:
`wss://relay.damus.io, wss://nos.lol, wss://relay.nostr.band`.
---
## 2. Guiding design decisions
### 2.1 "It's still relays at the end of the day"
The core insight that shaped everything: **serverless is the same product with a
different transport.** The agent's brain (respond gate, threading, prompt
rendering, reply mechanism) and the desktop's feature surface are shared code.
Only the bottom transport layer swaps:
| Concern | `sprout` mode | `serverless` mode |
|---|---|---|
| Respond gate (owner-only default, allowlist, siblings) | shared | **shared** |
| Threading decision (NIP-10) | shared | **shared** |
| Prompt rendering / reply via `sprout` CLI | shared | **shared** |
| **Transport** | HTTP bridge + NIP-98 + NIP-42 | plain WS, multi-relay |
| Channel discovery | one relay (HTTP) | multi-relay union |
| Reply / message publish | one relay | multi-relay, first-accepts |
| Membership push (kind 44100) | relay side-effect | periodic re-discovery |
Everything new is **gated** behind `is_serverless()` / `if serverless`. Server
mode runs the exact same code paths it always has.
### 2.2 The standard Nostr client model (no exotic tricks)
Relays **do not gossip** — an event stored on relay B is invisible to a
subscriber on relay A. Every production Nostr client therefore:
1. **publishes to many** relays (succeed if any accepts), and
2. **subscribes to many** relays and **dedups by event id**.
This is exactly what damus (`RelayPool.swift`), nostr-tools (`SimplePool`), and
0xchat do. Serverless mode follows the same model. There is nothing bespoke.
### 2.3 Model the operations as Nostr events, not new endpoints
Per the project's "prefer Nostr events over new REST endpoints" rule, serverless
membership/channel operations are done by **writing the same addressable events**
the relay would have produced (kinds 39000 / 39002), rather than inventing new
flows. The server's command kinds (9007 create-channel, 9021 join, 44100
member-added, …) are **server side-effects** that a dumb relay never executes, so
serverless writes the resulting state events directly.
### 2.4 `nostr-relay-pool` for the agent, hand-rolled pool for the desktop
The **agent** (`sprout-acp`, a standalone binary) uses the official
`nostr-relay-pool` crate (rust-nostr SDK) for its relay layer — free
auto-reconnect, auto-resubscribe, and dedup. The **desktop** deliberately keeps
its own hand-rolled connection pool (`ws_pool.rs`) to avoid pulling a *second*
TLS/crypto stack into the Tauri process (which already runs aws-lc-rs rustls for
reqwest/media). Two rustls providers in one process risk a `CryptoProvider`
install panic. The desktop pool is serverless-only and already self-heals, so the
agent's old missed-message bug does not apply there. This asymmetry is documented
in `ws_pool.rs`.
---
## 3. How it works, piece by piece
### 3.1 Event kinds used
All standard Sprout kinds (defined in `sprout-core/src/kind.rs`):
- **39000** — channel metadata (addressable; `d` = channel id, `name`, `t`,
`public`/`private`).
- **39002** — channel members (addressable; `p` tags, role as the 4th tag
element: `["p", <pubkey>, "", "owner"|"member"]`).
- **9** (`KIND_STREAM_MESSAGE`) — a channel message, scoped by the `h` tag
(NIP-29 group tag).
- **1059** — NIP-59 gift wrap (encrypted DMs / private channels).
- **20002** — typing indicator (ephemeral).
- **5** — NIP-09 deletion (used for channel delete, since the dumb relay won't
process a delete command).
### 3.2 Channels & membership (desktop)
- **Create** (`commands/channels.rs`): publish 39000 (metadata) + 39002 (members,
creator recorded as `owner`) directly to all relays. The `ChannelInfo` is built
**locally** and returned immediately — no re-query race (this fixed the early
"nothing happened" bug).
- **Join / leave / add / remove**: read the current 39002, modify the `p` list
(preserving roles), and re-publish (read-modify-write). NIP-29 command kinds are
no-ops on a dumb relay, so direct state writes are the only thing that works.
- **`get_channels`**: in serverless, skips the server's `limit:5000` network
discovery (which pulled hundreds of unrelated public channels) and scopes to the
user's own membership.
- **Critical fix:** nostr 0.44 strips self-`p` tags unless
`.allow_self_tagging()` is set. Without it, the creator's own membership tag was
dropped, so `get_channels` (`#p:[me]`) found nothing. All serverless membership
builders set `.allow_self_tagging()`.
### 3.3 DMs & private channels (encryption)
- **DMs**: `open_dm` derives a deterministic UUIDv5 channel id from the two
pubkeys (so both sides converge on the same id), then publishes 39000 + 39002.
- **Encryption (NIP-17 / NIP-59)**: a DM or private channel message is built as a
normal kind-9 **rumor** (unsigned, carries the `h` tag), then **gift-wrapped
(kind 1059) once per recipient** — every channel member plus self. Only the
gift wraps hit the relay; the plaintext kind-9 never does. The `h` tag lives
*inside* the encrypted rumor, so a relay query by `#h` returns nothing — the
channel itself is hidden.
- **Threaded encrypted replies** (the privacy fix in this branch): replies in a
private channel are **also** gift-wrapped, with the NIP-10 thread `e` tags
placed *inside* the rumor. Previously replies fell through to the plaintext
path and leaked the reply content to public relays. Now threading is preserved
*and* encrypted. The thread root is resolved locally from already-decrypted
messages (the parent rumor isn't queryable in plaintext on the relay).
### 3.4 Multi-relay transport
- **Desktop** (`ws_pool.rs` + `ws_relay.rs`): one long-lived WebSocket per relay,
multiplexing all queries/publishes. Reads fan out and **merge + dedup by event
id**; writes fan out and **succeed if any relay accepts** (with a retry if all
rate-limit). Dropped connections are detected and reconnected on next use. This
fixed the rate-limit storm (the original code opened a fresh socket per op) and
the "message shows then disappears" split-brain (publish landed on relay B,
live-read was on relay A).
- **Agent** (`serverless_relay.rs` wrapping `nostr-relay-pool`): a persistent
multi-relay subscription with built-in auto-reconnect + auto-resubscribe. On
reconnect the SDK re-sends the `REQ`, and the relay replays its stored matching
events — so a message that arrived during a blip is recovered. Events are merged
and deduped across relays automatically.
### 3.5 Agents (the headline capability)
Agents work the **same** as in server mode — the permission model is identical;
only the transport differs.
- **Launch**: managed agents launch with the **current workspace** relay list and
`SPROUT_SERVERLESS=true`. A startup gate (`workspace_applied`) makes agent
restore wait (up to 15s) for the frontend to apply the workspace, so agents
never launch with stale relays or in the wrong mode.
- **Serverless detection**: the agent infers serverless from a comma-list relay
URL (`SPROUT_RELAY_URL=wss://a,wss://b`) in both `sprout-acp` and the `sprout`
CLI, so even the agent's reply (`sprout messages send`) takes the serverless
path.
- **Discovery**: the agent finds channels it belongs to by querying 39002
(`#p` = agent) across all relays. Because a dumb relay never emits the
membership-added push (kind 44100), the agent **re-runs discovery every 20s** and
subscribes to any newly-joined channels.
- **Receiving messages**:
- *Public channels*: live `REQ` for kind 9 scoped by `#h` = channel.
- *Private channels / DMs*: a **gift-wrap inbox** subscription (kind 1059,
`#p` = agent). On each wrap the agent decrypts to the inner rumor, reads its
`h` tag, and routes it to the right channel.
- **The respond gate** (unchanged from server mode): default **`owner-only`** —
the agent only acts on messages from its **owner** (the npub that added it,
resolved from the `SPROUT_AUTH_TAG` NIP-OA attestation), plus "siblings" (other
agents launched by the same owner). Other modes: `allowlist`, `anyone`,
`nobody`. The gate runs in the harness **before** the LLM sees the event.
- **Replying**: the agent posts its reply via the `sprout messages send` CLI
(multi-relay, first-accepts). It addresses the asker by `@name` in the channel —
it does **not** DM the owner. Threading mirrors the trigger: a reply to a
top-level message is top-level; a reply inside a thread uses `--reply-to` and
stays threaded.
- **Working cues**: while a turn is in flight the agent publishes a typing
indicator (kind 20002, every 3s) and a `💬` reaction, so the UI shows "working…"
during the (30–90s) LLM latency.
---
## 4. Bugs found and fixed on this branch
In rough order, each reproduced and then fixed (and most now covered by a test):
1. **"Join did nothing"** — membership via server command kinds is a no-op on a
dumb relay → switched to direct 39002 read-modify-write; then `.allow_self_tagging()`.
2. **Rate-limit storm** — fresh socket per op → persistent connection pool.
3. **"Message shows then disappears"** — split-brain reads → multi-relay
read/write/subscribe.
4. **Query flood** — `limit:5000` discovery pulled the whole public network → skip
in serverless.
5. **Channel delete no-op** → NIP-09 kind-5 deletion.
6. **Agent DNS crash** — comma-list fed to a single connect → multi-relay connect.
7. **Agent didn't see new channels** (no 44100 push) → 20s re-discovery.
8. **Agent reconnect storm** — a paid relay's `auth-required` → drop that sub,
don't reconnect-loop; removed paid relays from defaults.
9. **Agent launched in server mode / stale relays** → `workspace_applied` gate +
serverless inference from comma-list.
10. **Agent reply lost to rate-limit** → multi-relay fan-out + backoff retry.
11. **Agent relay layer fragile** (missed messages on relay drop) → replaced with
`nostr-relay-pool` (auto-reconnect/resubscribe/dedup).
12. **`💬` working-cue never landed** (500ms timeout too tight for a real WS
round-trip) → 4s.
13. **Encrypted replies leaked plaintext** → gift-wrap replies too, NIP-10 tags
inside the rumor.
---
## 5. Verification — evidence it works end-to-end
All run on `micn/serverless-mode` at the time of this report.
### Automated
- **Unit tests:** `sprout-acp` 272 ✅, `sprout-cli` 114 ✅, `sprout-sdk` 138 ✅,
desktop backend 427 ✅ (all pass).
- **Clippy:** clean on every touched crate (acp, cli, sdk, desktop backend).
- **End-to-end against live public relays** (`crates/sprout-acp/tests/e2e_agent_responds.rs`,
runs the **real** `sprout-acp` + **real** `sprout` CLI, no LLM stub):
- `agent_responds_in_channel_e2e` — proves: receive @mention → reply #1 →
**typing indicator (kind 20002)** during the turn → second message → reply #2
(**cancel/redispatch** path). ✅
- `agent_responds_in_private_channel_e2e` — proves: a **gift-wrapped (kind
1059)** message is received, **decrypted**, routed by the inner `h` tag,
gated, and replied to. ✅
- Both pass reliably (run repeatedly) **even while damus returns 503** — the
multi-relay fan-out fails over to another relay.
- **Encryption unit test** (`desktop/src-tauri/src/encrypted.rs`): a threaded
reply in a private channel is gift-wrapped (kind 1059), the plaintext never
appears on the wire, and the NIP-10 root/reply tags survive decryption. ✅
### Manual (live)
- Confirmed on the relays directly that a user's **private** channels show **zero
plaintext kind-9** and only un-`h`-tagged gift wraps.
- Confirmed goose replies in **public** channels live (addresses the user by
display name).
---
## 6. Will it break `main` (server mode)?
**No.** Risk is contained:
- **Every serverless behaviour is gated** behind `is_serverless()` (desktop) or
`if serverless` / `Option<ServerlessRelay>` being `None` (agent). The
`ServerlessRelay`/`nostr-relay-pool` path is only constructed inside
`if serverless`. Server mode runs the original code paths unchanged.
- **New modules** (`ws_pool.rs`, `ws_relay.rs`, `encrypted.rs`,
`serverless_relay.rs`, `ServerlessContext.tsx`, `defaultRelays.ts`) are reachable
only from serverless branches.
- **The single shared, non-gated change** is `REACTION_TIMEOUT` (500ms → 4s). It
only makes a best-effort reaction *more* tolerant; server mode's HTTP bridge
responds well under 4s, so it cannot regress server behaviour.
- **Dependency additions** (`nostr-relay-pool`, rustls feature on the CLI) resolve
to the same `nostr 0.44.3` already in the tree — no version conflict.
- The one failing workspace unit test (`git-sign-nostr::test_parse_envelope_rejects_invalid_oa_pubkey`)
**also fails on `main`** — it is pre-existing and unrelated to this branch.
---
## 7. Known limitations / follow-ups
- **Public relays are unreliable stores.** They rate-limit, 503, and may not
retain low-traffic events indefinitely. The multi-relay fan-out + auto-reconnect
mitigates this, but serverless is inherently best-effort — appropriate for a
"lite" mode, not a system of record.
- **Profile/display names** depend on the relays carrying kind-0 metadata; absent
that, the agent may address users by raw npub.
- **Desktop `ws_pool.rs` is hand-rolled** (by deliberate choice, §2.4). If the
desktop ever shows a concrete live-subscription recovery bug, that targeted path
can be hardened without adopting a second TLS stack.
- **Server-mode integration suite** (`just test`, needs Postgres + Redis) was not
run for this report; the serverless changes are gated so server paths are
unaffected, but running it is the recommended final gate before merge.
---
## 8. Key files
| Area | File |
|---|---|
| Serverless detection / mode | `desktop/src-tauri/src/app_state.rs`, `commands/workspace.rs` |
| Desktop relay transport | `desktop/src-tauri/src/relay.rs`, `ws_relay.rs`, `ws_pool.rs` |
| Channels / DMs / messages | `desktop/src-tauri/src/commands/{channels,dms,messages}.rs` |
| Encryption (gift wrap) | `desktop/src-tauri/src/encrypted.rs`, `commands/encrypted.rs` |
| Agent relay (SDK pool) | `crates/sprout-acp/src/serverless_relay.rs`, `relay.rs` |
| Agent main loop / re-discovery / gate | `crates/sprout-acp/src/lib.rs` |
| Agent + CLI serverless detection | `crates/sprout-acp/src/config.rs`, `crates/sprout-cli/src/lib.rs` |
| CLI multi-relay client | `crates/sprout-cli/src/client.rs` |
| Shared event builders | `crates/sprout-sdk/src/builders.rs`, `desktop/src-tauri/src/events.rs` |
| Frontend serverless UI | `desktop/src/features/workspaces/*` |
| E2E proof | `crates/sprout-acp/tests/e2e_agent_responds.rs` |
| Deeper implementation map | `docs/SPROUT_LITE_MODE.md` |