Files
rutster/docs/adr/0007-trunk-rented-transport.md
opencode controller 272aa07acd adr: +0007 rent trunk transport +0008 FOB/green-zone doctrine; supersede 0003
ADR-0007 — Trunk/PSTN strategy: rent the transport, no first-party SIP stack.
Supersedes ADR-0003 (Rust-native trunk SIP). The 2026-06 strategic-relevance
review stress-tested ADR-0003 against the 2026 market + solo-build realities
and it did not survive: a trunk SIP/media core is the highest-cost,
lowest-differentiation square on the board (the perceived-quality battle and
the buying criteria both live above the transport), and no production-hardened
pure-Rust SIP stack exists to stand on (ezk is bus-factor-1 / pre-1.0;
str0m self-describes as 'not for production' for SIP).

rutster owns no SIP stack. PSTN reach is rented transport in three layers:
  1. Primary (demo, most users) — CPaaS raw-media fork (Twilio Media Streams,
     Telnyx). Media-leg ingress, core-as-server (parallel to WebRTC ingress
     per ADR-0006). Use the raw-audio fork, *not* managed Voice-AI products
     that would consume the reflex loop.
  2. Graduation (on-prem) — out-of-tree SBC (Kamailio/FreeSWITCH/drachtio +
     rtpengine) B2BUAs carrier SIP into rutster as clean RTP/tap media,
     outside the trust boundary.
  3. Never — a first-party Rust trunk SIP stack. rsip/ezk stay off the
     critical path.

The only things rutster owns in Rust: the call model, the reflex loop, the
agent tap. Everything that touches a carrier is rented (layer 1) or
out-of-tree (layer 2).

ADR-0008 — The FOB and the green zone: the build-vs-reuse doctrine. Names
the boundary criterion implicit across ADRs 0002-0007 as one mechanical rule
every contributor (human or agent) applies the same way.

  - FOB (build in Rust) — admitted only if it passes one of: hot path,
    security-constitutive, differentiating. Current FOB: media termination
    (rutster-media on str0m) · real-time reflexes (VAD/barge-in/jitter/pacing)
    · call model (rutster-call-model) · agent tap (rutster-tap) · spend/abuse
    gate (rutster-spend) · control API + state/bus trait (rutster).
  - Green zone (reuse at arm's length) — its own process/container/trust
    domain, never in the FOB's address space. Admitted when it fails all
    three FOB tests AND a trusted, actively-maintained project already does
    it well. Current green zone: Valkey (ADR-0005) · carrier trunk (ADR-0007)
    · agent brain · reference GUI · object storage · KMS · OTel collector ·
    container supervisor.
  - 'Actively maintained' is the load-bearing gate: excludes a first-party
    Rust SIP stack and webrtc-rs; admits Valkey and str0m-for-WebRTC.
  - When in doubt, default to green zone. The FOB earns its members.

Restates the memory-safety pillar precisely: the FOB is 100% memory-safe
Rust; the green zone is trusted battle-tested OSS kept outside the boundary
— not an over-claim that every byte is Rust.

ADR-0003's status is flipped to Superseded with a forward-pointer to ADR-0007.
Kept as the historical record of the 'own trunk SIP + media termination in
Rust, no SBC shield' plan; reversed under the strategic-relevance review.
2026-06-29 20:26:24 -04:00

9.7 KiB
Raw Blame History

ADR-0007 — Trunk/PSTN strategy: rent the transport, no first-party SIP stack

  • Status: Accepted
  • Date: 2026-06
  • Supersedes: ADR-0003 — 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 (fused vertical), ADR-0006 (ingress posture), ADR-0001 (the "rent the edge" instinct this re-converges with)

Context

ADR-0003 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 ~80100 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 14) 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 — 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'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) 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's correct instinct — don't own the interop tail; rent the hardened edge — while keeping ADR-0003'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 — superseded (Rust-native trunk SIP)
  • ADR-0001 — the original "rent the edge" instinct this re-converges with
  • ADR-0002 — fused per-call vertical (the boundary this re-scopes)
  • ADR-0006 — WebRTC-first ingress; out-of-tree edge-adapter pattern (applied here to trunks)