diff --git a/docs/adr/0003-sip-rust-native-trunk.md b/docs/adr/0003-sip-rust-native-trunk.md index cffa296..63e1974 100644 --- a/docs/adr/0003-sip-rust-native-trunk.md +++ b/docs/adr/0003-sip-rust-native-trunk.md @@ -1,10 +1,17 @@ # ADR-0003 — SIP strategy: Rust-native trunk SIP, no SBC shield -- **Status:** Accepted +- **Status:** ~~Accepted~~ **Superseded** (2026-06) - **Date:** 2026-06 - **Supersedes:** [ADR-0001](0001-sip-strategy.md) +- **Superseded by:** [ADR-0007](0007-trunk-rented-transport.md) — rent the trunk transport; no first-party SIP stack - **Origin:** [vision-revision](../superpowers/specs/2026-06-26-vision-revision-design.md) §5 +> **Superseded.** Kept as the historical record of the "own trunk SIP + media termination in Rust, +> no SBC shield" plan. Reversed under the 2026-06 strategic-relevance review: a from-scratch Rust +> trunk-SIP stack is the highest-cost / lowest-differentiation part of a solo build, and there is no +> production-hardened pure-Rust SIP stack to stand on. The trunk is **rented, not built** — see +> [ADR-0007](0007-trunk-rented-transport.md). + ## Context ADR-0001 layered the SIP strategy: own the Rust parser, but front the public edge with a diff --git a/docs/adr/0007-trunk-rented-transport.md b/docs/adr/0007-trunk-rented-transport.md new file mode 100644 index 0000000..60dae6b --- /dev/null +++ b/docs/adr/0007-trunk-rented-transport.md @@ -0,0 +1,139 @@ +# ADR-0007 — Trunk/PSTN strategy: rent the transport, no first-party SIP stack + +- **Status:** Accepted +- **Date:** 2026-06 +- **Supersedes:** [ADR-0003](0003-sip-rust-native-trunk.md) — Rust-native trunk SIP, no SBC shield +- **Origin:** 2026-06 strategic-relevance review (market + code sanity-check). Re-aims the solo build + at the AI-telephony frontier — the reflex/tap/eval loop and the contact-center domain — and off the + trunk-SIP rewrite. +- **Amends:** `README.md`, `docs/ARCHITECTURE.md`, `docs/PORT_PLAN.md` +- **Related:** [ADR-0002](0002-north-star-and-fused-core.md) (fused vertical), [ADR-0006](0006-ingress-posture.md) + (ingress posture), [ADR-0001](0001-sip-strategy.md) (the "rent the edge" instinct this re-converges with) + +## Context + +[ADR-0003](0003-sip-rust-native-trunk.md) decided to own trunk SIP **and** media termination directly +in the Rust core (`rsip` + `ezk`), dropping the Kamailio + rtpengine shield so the memory-safety thesis +would be "literally true at the wire." The argument was internally coherent: under the contact-center +scope the device-interop tail collapses to a few documented carriers, so a bounded Rust trunk client +looked tractable. + +A strategic-relevance review — the goal is *staying at the AI-telephony frontier*, not shipping a +from-scratch protocol stack — stress-tested that bet against the 2026 market and the realities of a +solo build. It does not survive: + +1. **A trunk SIP/media core is the highest-cost, lowest-differentiation square on the board.** + End-to-end voice-agent latency is ~1,100 ms mouth-to-ear; the media/transport layer is ~80–100 ms + of it, and PSTN transport is ~$0.02/min — a rounding error. The perceived-quality battle + (turn-taking, barge-in, endpointing) and the buying criteria (integration, reliability, compliance, + outcomes) all live **above** the transport. No buyer in this market has ever purchased on + implementation language. +2. **There is no production-hardened pure-Rust SIP stack to stand on.** `ezk-sip` is the only credible + one and it is single-maintainer / pre-1.0 (bus-factor-1); `str0m` self-describes as "not for + production" for this purpose. Every serious player stands on mature C/Go and puts its own language + only at the orchestration edge — LiveKit on Go/Pion, Daily wrapping libwebrtc, jambonz on + FreeSWITCH + rtpengine. A solo founder re-fighting 25 years of carrier/NAT/DTMF hardening in a young + Rust stack spends the one resource it cannot spare (years) on the layer that pays back least. +3. **It steals those years from the actual white space.** The differentiation versus LiveKit / Vapi / + Pipecat is the **contact-center domain** (ACD, queues, escalation, CDR, recording, supervisor) plus + the **reflex loop** plus the **data-owned eval/containment loop** — none of which a trunk-SIP rewrite + gets you closer to. + +ADR-0003's own sequencing already conceded the point: it put the reflex loop (spearhead steps 1–4) +*before* the trunk (step 5) precisely because the reflexes are "the hard, most-differentiating part." +Followed one step further, that logic says: don't make the trunk a first-party build at all. + +## Decision + +rutster **owns no SIP stack.** Carrier/PSTN reach is **rented transport**, in three layers — and +rutster owns only the top one: + +1. **Primary (demo, frontier, most users) — rented CPaaS raw-media ingress. Zero SIP in rutster.** + A CPaaS raw-audio fork (Twilio **Media Streams**, Telnyx media streaming, or equivalent) delivers + the PSTN call's audio (µ-law/PCM) over a WebSocket; call control (answer/originate/hangup) is the + provider's REST/Call-Control API. This lands as a **media-leg *ingress*** — core-as-**server**, + exactly parallel to the WebRTC ingress in [ADR-0006](0006-ingress-posture.md) — resampled to the + canonical tap format and fed straight into the reflex loop. Use the **raw-audio** fork, **not** + managed Voice-AI products (Twilio ConversationRelay et al.) that perform STT/TTS for you and would + consume the reflex loop that is rutster's differentiation. +2. **Graduation (sovereignty / on-prem customers) — out-of-tree SBC adapter.** + When PSTN media must stay on the operator's infra, a mature C/Go SBC (Kamailio / FreeSWITCH / + drachtio + rtpengine) sits **outside the trust boundary** and B2BUAs carrier SIP into rutster as + clean RTP / tap-format media. The operator *operates* C; rutster *parses* none. This is + [ADR-0006](0006-ingress-posture.md)'s out-of-tree edge-adapter pattern, applied to trunks. +3. **Never — a first-party Rust trunk SIP stack.** `rsip` / `ezk` stay off the critical path. If a thin + Rust SIP UA is ever wanted, it is a discretionary, out-of-tree experiment — never a core dependency + or a blocker. + +**The only things rutster owns in Rust are the call model, the reflex loop, and the agent tap.** +Everything that touches a carrier is rented (layer 1) or out-of-tree (layer 2). + +## Topology + +``` + carrier / PSTN rented or out-of-tree rutster trust boundary + ──────────────► ┌──────────────────────────┐ ┌──────────────────────────────┐ + SIP trunks, │ layer 1: CPaaS raw-media │ │ media (RTP/SRTP, str0m) │ + far-end UAs │ fork — NO SIP in rutster│──►│ + reflexes (VAD/barge-in, │ + │ provider API = control │ │ jitter, pacing) │ + │ layer 2: out-of-tree SBC │ │ + in-boundary spend/abuse │ + │ (C/Go) → clean RTP/tap │ │ + clean audio tap ──► brain │ + └──────────────────────────┘ └──────────────────────────────┘ + SIP lives here — outside rutster, never first-party no SIP parser, ever +``` + +WebRTC ingress ([ADR-0006](0006-ingress-posture.md)) is unchanged and remains the primary +human-participant path; layer 1 adds a phone number without adding SIP. + +## What this costs (stated so we don't kid ourselves) + +- **Data-ownership / "terminate media once" dilutes for PSTN in layer 1.** A CPaaS-forked PSTN call's + audio traverses the provider's infra — the same property rutster criticizes in Vapi/Retell. What + survives even in layer 1: WebRTC legs stay on your infra; the **brain is self-hostable**, so + *training data and model* remain owned even when transport is rented; and layer 2 brings PSTN media + back on-prem for buyers who require it. The wedge becomes a **graduation**, not a day-one guarantee — + an honest narrowing the pitch must reflect (README competitor table + wedge bullet 2). +- **A CPaaS dependency** (billing, availability) for layer-1 PSTN. Mitigation: it is a config seam + (provider-agnostic media-leg ingress), and the frontier work (reflexes, tap, brain) is built and + demoed entirely on WebRTC, so nothing on the critical path blocks on any provider. + +## Memory-safety, restated honestly + +ADR-0003's headline was "own a fuzzed Rust SIP parser → safe at the wire." ADR-0007's is stronger in +one respect and weaker in another, stated plainly: **rutster parses no SIP at all.** Its entire +first-party wire surface is WebRTC/RTP/SRTP (`str0m`) + the WebSocket tap/ingress protocol — all +memory-safe Rust, fuzzed. The carrier-SIP interop tail lives **outside the trust boundary** (the +provider's, or an out-of-tree SBC). The concession vs. 0003: layer 2 reintroduces C — but it is *not +first-party*, *not on the per-call hot path inside the boundary*, and is widely-deployed, battle-tested +infra the operator runs at the edge. rutster removes SIP from its own attack surface entirely rather +than rewriting it safely. + +## Consequences + +- **Positive:** the solo-years redirect from a trunk-SIP rewrite to the white space (contact-center + domain + reflex/eval loop + the tap-as-open-protocol); no first-party SIP attack surface; no + bus-factor-1 dependency on a young Rust SIP stack; a real phone number becomes a small rented + integration, not the riskiest step; first-call still never blocks on SIP. +- **Negative:** PSTN data-ownership becomes a layer-2 graduation rather than a day-one property (the + pitch must be re-scoped honestly); a CPaaS dependency for layer-1 PSTN; the "memory-safe SIP parser" + talking point is retired. +- **Mitigation:** provider-agnostic media-leg ingress at a config seam; the sovereignty wedge is + preserved as the out-of-tree SBC path; the frontier is demoed end-to-end on WebRTC regardless of + trunk choice. + +## Relationship to ADR-0001 and ADR-0003 + +This re-converges with [ADR-0001](0001-sip-strategy.md)'s correct instinct — *don't own the interop +tail; rent the hardened edge* — while keeping [ADR-0003](0003-sip-rust-native-trunk.md)'s correct +instinct — *don't front your own media plane with a double-terminating shield.* The synthesis: the +rented transport delivers **clean media** straight to the tap/ingress, so there is neither a +first-party SIP tail (0001's worry) nor a second in-boundary media termination (0003's worry). 0001 +rented an SBC *in front of* rutster's own media plane; 0007 rents transport that *replaces* the need +for a first-party trunk entirely, with WebRTC + the tap as the media spine. + +## References + +- [ADR-0003](0003-sip-rust-native-trunk.md) — superseded (Rust-native trunk SIP) +- [ADR-0001](0001-sip-strategy.md) — the original "rent the edge" instinct this re-converges with +- [ADR-0002](0002-north-star-and-fused-core.md) — fused per-call vertical (the boundary this re-scopes) +- [ADR-0006](0006-ingress-posture.md) — WebRTC-first ingress; out-of-tree edge-adapter pattern (applied here to trunks) diff --git a/docs/adr/0008-fob-and-green-zone.md b/docs/adr/0008-fob-and-green-zone.md new file mode 100644 index 0000000..74909a2 --- /dev/null +++ b/docs/adr/0008-fob-and-green-zone.md @@ -0,0 +1,122 @@ +# ADR-0008 — The FOB and the green zone: the build-vs-reuse doctrine + +- **Status:** Accepted +- **Date:** 2026-06 +- **Origin:** 2026-06 strategic-relevance review — names the boundary doctrine implicit across ADRs + 0002–0007 and makes it a mechanical rule every contributor (human or agent) applies the same way. +- **Amends:** `docs/ARCHITECTURE.md` (adds the organizing metaphor), `AGENTS.md` (adds it to "key decisions") +- **Related:** [ADR-0002](0002-north-star-and-fused-core.md) (the boundary this rule governs), + [ADR-0007](0007-trunk-rented-transport.md) (a green-zone call), [ADR-0005](0005-event-bus.md) (a + green-zone call), [ADR-0006](0006-ingress-posture.md) (arm's-length out-of-tree adapters) + +## Context + +Across ADRs 0002–0007 one criterion kept deciding calls without ever being named: **what does rutster +build and own, and what does it reuse?** Fused vertical (0002), rent the trunk (0007), bundle Valkey +(0005), the out-of-tree SBC (0006) — all the same judgment, applied case by case. A solo build cannot +re-derive that judgment per decision, and multiple coding agents now work this repo and need one +explicit, mechanical rule so they classify consistently. + +The failure mode this prevents is **not-invented-here**: rebuilding mature, actively-maintained OSS +inside the core and drowning the scarce solo-years in undifferentiated plumbing. The from-scratch Rust +trunk-SIP stack (superseded by [ADR-0007](0007-trunk-rented-transport.md)) was exactly that mistake. + +Stated as one image: **stop building a fortress; build a secure base of operations.** Harden and own +the core — the **FOB** (forward operating base). Operate through a trusted, actively-maintained OSS +perimeter — the **green zone**. Leverage, not autarky. + +## Decision + +Every capability is classified **FOB** or **green zone**. + +### FOB — build it, own it, in Rust + +Memory-safe Rust, owned, fuzzed, **inside the trust boundary** — the surface you would certify +(PCI / HIPAA / TCPA). A capability is admitted to the FOB **only if it passes at least one** of three +tests: + +1. **Hot path** — it runs on the per-call real-time loop, where a GC pause / jitter / an extra network + hop would degrade call quality. *(media termination, jitter buffer, pacing, the reflexes.)* +2. **Security-constitutive** — it *is* the auditable boundary: the spend/abuse gate, the trust-domain + edge, the audit surface. A 3-vendor chain structurally cannot enforce it. *(spend gate, the boundary + itself.)* +3. **Differentiating** — it is the thing no competitor ships and a reason rutster exists. *(the reflex + loop, the agent tap, the contact-center domain logic.)* + +The FOB **may link mature OSS for internals** — libopus (codec), `str0m` (sans-IO WebRTC), speexdsp +(DSP) — as **trusted vendored dependencies**. Linking a maintained library to *implement* an FOB +capability is not a violation: you don't rewrite a codec. What makes the capability FOB is that rutster +owns it, it passes an FOB test, and the wire-facing parser is memory-safe Rust. Any vendored dep that +parses hostile bytes is fuzzed. + +**Current FOB:** media termination (`rutster-media`, on `str0m`) · real-time reflexes (VAD/barge-in, +jitter, pacing) · call model (`rutster-call-model`) · the agent tap (`rutster-tap`) · the spend/abuse +gate (`rutster-spend`) · the control API + the state/bus **trait** (`rutster`). + +### Green zone — reuse it, at arm's length + +Trusted, **actively-maintained** OSS or an external managed service, **reused at arm's length** — its +own process, container, or trust domain, **never in the FOB's address space**, so its failures cannot +corrupt the core. A capability belongs here if it **fails all three FOB tests** and a trusted, +maintained project already does it well. + +**Current green zone:** Valkey (state store + bus, [ADR-0005](0005-event-bus.md) — bundled in the +all-in-one image, external in the modular layout) · the carrier trunk (CPaaS raw-media fork / +out-of-tree SBC, [ADR-0007](0007-trunk-rented-transport.md)) · the agent brain (OpenAI Realtime or a +self-hosted STT/LLM/TTS) · the reference GUI (admin portal + call-flow builder — a pure API client) · +object storage (recording/CDR durability) · KMS/Vault (secrets) · the OTel collector · the container +supervisor (tini/s6). + +### The "actively maintained" gate is load-bearing + +A green-zone dependency the FOB relies on **must** be healthy: maintained, released, production-grade, +not bus-factor-1. This single gate is what: + +- **excludes** a first-party Rust SIP stack (`ezk`, bus-factor-1 / pre-1.0 — [ADR-0007](0007-trunk-rented-transport.md)) + and `webrtc-rs` (perpetual re-architecture); +- **admits** Valkey (healthy), and `str0m` **for WebRTC** (its maintained sweet spot) — while rejecting + `str0m` for SIP (out of scope there, "not for production"). + +Re-audit periodically — a project can go cold (e.g. FreeSWITCH's OSS line). A green-zone dep that stops +being maintained is a defect to resolve, not a fixture. + +## How to apply (every contributor, human or agent) + +Before building a subsystem or adding a dependency, run the test: + +1. **Does it pass an FOB test?** (hot-path **or** security-constitutive **or** differentiating) + - **No** → green zone. Find the trusted, actively-maintained OSS/service and integrate it **at arm's + length** (separate process / container / trust domain). **Do not rebuild it in-core.** + - **Yes** → build it in the FOB, in Rust. For *internals* a mature library already solves (codecs, + DSP), link that library as a trusted vendored dep — don't rewrite it. Fuzz any vendored parser that + touches hostile bytes. +2. **Is the OSS you're about to depend on actively maintained / production-grade?** If not, it is not + eligible for anything the FOB depends on — find another, or scope the need away. + +When in doubt, default to **green zone**. The FOB earns its members; it does not collect them. + +## Consequences + +- **Positive:** one mechanical rule, applied identically by every human and agent; the solo-years + concentrate on the FOB (the only part that differentiates); the memory-safety claim becomes *precise* + rather than an over-claim; retroactively consistent with ADRs 0002–0007. +- **Negative:** the green zone is a dependency-trust surface that must be re-audited ("actively + maintained" can decay); arm's-length integration costs some glue versus pulling things in-process. +- **Restated pillar:** **memory-safe = the FOB** (100% Rust at the boundary that holds the call), with + mature OSS trusted at arm's length around it — *not* a claim that every byte in the deployment is Rust + (libopus, Valkey, the SBC are not). + +## Worked example — deployment packaging + +The all-in-one container is the doctrine made physical: the **FOB binary** + bundled `valkey-server` +(green zone) + the reference GUI (green zone), supervised in one container; point it at a brain + CPaaS, +`docker run`, taking calls. The modular/compose layout splits the same green-zone services out to scale. +Same FOB binary underneath; only the state/bus backend seam differs (bundled vs external). *(The +deployment topology itself may get its own ADR once that design closes.)* + +## References + +- [ADR-0002](0002-north-star-and-fused-core.md) — fused per-call vertical + composable platform (the boundary this rule governs) +- [ADR-0007](0007-trunk-rented-transport.md) — rent the trunk (a green-zone classification) +- [ADR-0005](0005-event-bus.md) — Valkey (a green-zone classification) +- [ADR-0006](0006-ingress-posture.md) — out-of-tree adapters (arm's-length integration)