Files
rutster/docs/ARCHITECTURE.md
Aaron D. Lee bdadfd9057
All checks were successful
CI / fmt (push) Successful in 1m36s
CI / clippy (push) Successful in 2m21s
CI / test (1.85) (push) Successful in 5m3s
CI / test (stable) (push) Successful in 4m23s
CI / deny (push) Successful in 1m35s
slice-5: scalability seams — addressing, admission, drain, events (review B1/M1-M7) (#14)
Co-authored-by: Aaron D. Lee <himself@adlee.work>
Co-committed-by: Aaron D. Lee <himself@adlee.work>
2026-07-05 04:35:38 +00:00

192 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rutster Architecture
## The reframe
Asterisk's power was: *one process, load any `.so`, wire anything to anything in the dialplan.*
rutster delivers the same "build anything" through a different substrate — but **the engine is
the goal, not the PBX**. Where Asterisk matched composability through a 1.2M-LOC monolith + in-process
modules, rutster matches it through:
- a **small hardened core** owning the per-call vertical end-to-end (media + signaling + call model
+ reflexes + spend gate) as one deterministic, auditable trust domain,
- a **clean audio tap** as the external brain's extension point (safely extensible by people you
don't fully trust, because the brain is out-of-process and the core authoritatively controls
playout),
- **declarative routing** as data for the common path,
- a **programmable API** (REST/gRPC + event stream) modeled on Asterisk's ARI — and that ARI lineage
is where the dialplan *goes*: external services reacting to call events.
More extensible than Asterisk, because extensions are safe to run from people you don't fully trust
— and they can be AI brains, not just `.so` files.
## The fused per-call vertical + composable horizontal platform
The core owns the **per-call vertical** end-to-end as one deterministic, auditable trust domain:
```
carrier SIP trunk ─► media termination (RTP/SRTP + local real-time reflexes)
├─► clean audio tap ──► external agent brain (STT/LLM/TTS)
└─► in-boundary spend / abuse gate
```
**Horizontal platform** concerns are services *around* the core, independently scaled: number
inventory, billing rollup, analytics, multi-region orchestration, call placement (the
session→node directory — which node owns which live call, the routing layer above N cores),
the management API, and the agent brain itself.
This **replaces the founding three-plane framing**:
- The **control↔media gRPC hop on the per-call hot path is removed.** Fusion where fusion buys
determinism + security + simplicity.
- The **spend gate** and the **agent tap** — which founding docs externalized — are pulled **into**
the boundary, because they are **constitutive** of the wedge (a runtime can't structurally enforce
spend/abuse control or barge-in if the media is elsewhere).
- Neither monolith nor pure microservices: **fused where fusion buys the wedge; composable where
independent scaling matters.** ([ADR-0002](adr/0002-north-star-and-fused-core.md))
## The FOB and the green zone — the build-vs-reuse doctrine
The rule that decides what lives *inside* that boundary: **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 — the antidote to
the not-invented-here death-march (a from-scratch SIP stack was exactly that). The full doctrine and the
mechanical test an agent applies live in [ADR-0008](adr/0008-fob-and-green-zone.md).
- **FOB** — memory-safe Rust, owned, fuzzed, inside the trust boundary; a capability is admitted *only*
if it is hot-path, security-constitutive, or genuinely differentiating: media termination, the
real-time reflexes, the call model, the agent tap, the spend/abuse gate, the control API + the
state/bus trait. (It may *link* mature OSS for internals — libopus, `str0m` — as trusted vendored
deps; the capability is still owned, the wire parser still Rust.)
- **Green zone** — trusted, *actively-maintained* OSS/services reused **at arm's length** (own process /
container / trust domain), admitted when they fail all three FOB tests: Valkey (state+bus), the trunk
transport (CPaaS / out-of-tree SBC), the agent brain, the reference GUI, object storage, KMS, the
supervisor.
This 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.
## Inside the boundary
- **Carrier trunk — rented transport, not first-party** ([ADR-0007](adr/0007-trunk-rented-transport.md)).
rutster owns no SIP stack: PSTN audio arrives as a media-leg ingress from a rented CPaaS raw-media
fork (primary), or from an out-of-tree SBC when media must stay on-prem (sovereignty graduation).
Inside the boundary it's clean RTP/SRTP like any other leg.
- **Media plane** — RTP/SRTP termination, mixing/bridging (softmix), transcoding, record/playback.
Built on the Rust WebRTC ecosystem (`str0m` sans-IO design, `webrtc-rs`). The media datapath
stays tight — do not over-decompose it across hops or into the bus; latency and failure modes
compound. Dedicated timing threads for the 20ms loop, **never the shared tokio pool**.
- **Local real-time reflexes** — VAD-driven barge-in / playout kill, half-duplex gating, jitter
buffer, pacing. These live in-core because the brain round-trip is too slow to enforce them. The
tap carries the *results* of reflexes to the brain, not the responsibility.
- **Call model** — the unifying `Channel`/leg object (signaling + media state); call-control
primitives (transfer/park/pickup, answer/originate); the ARI-modeled resource API surfaced as
REST/gRPC + a WebSocket/SSE event stream. "The dialplan" disappears — replaced by declarative
routing + external services reacting to call events (the ARI/Stasis model).
- **In-boundary spend / abuse gate** — spend caps, pacing caps, deny-by-default routing,
rate-limits, toll-fraud pattern detection. **Co-located with the tap + the provider
call-control client**: the brain never holds provider credentials, so every brain-initiated
action passes the gate; pacing/half-duplex/playout are enforced over media the core terminates
([ADR-0009](adr/0009-spend-gate-honest-rescope.md) — pair with provider-side caps for defense
in depth).
- **Audiohook primitive** — tap/inject/volume behind recording, ChanSpy/whisper/barge, and
human-agent escalation (rung 2: an agent takes over a call via WebRTC + audiohook handoff).
## Agent tap (the central interface)
The single most important interface in the system — where audio leaves the boundary to an external
brain. **Presumptive shape** (to harden against the thin-slice first proof, not a decided ADR yet):
- **Bidirectional WSS stream per call**, small versioned typed framing protocol. WSS (not gRPC)
because the consumer is a Python script / a browser / an OpenAI-Realtime-style speech-to-speech
API for which event-framed WSS is already the de-facto protocol — making the OpenAI adapter a thin
shim, not a gRPC-bridge project.
- **Core-as-client; brain-as-server. Always.** The audited boundary dials out to the brain (to
OpenAI, to a self-hosted brain process on localhost). **No inbound tap port on the core.** One
design choice deletes a whole attack class ("something connected to my tap port and read audio"),
simplifies firewall posture, and gives clean symmetry — every connection is initiated by the thing
you trust.
- **One canonical PCM format at the tap** — 16-bit, mono, a speech-model-friendly rate (24kHz
default, 16kHz fallback). The core terminates the codec soup on the PSTN side and exposes one clean
format. Brains never touch codecs.
- **Core-authoritative playout.** The brain *proposes* audio (`AudioOut` frames, advisory); the
core *disposes* — owning the playout buffer + the VAD that kills TTS on caller speech + the
half-duplex/pacing caps. A brain that can push audio straight to the PSTN wire is a brain that can
overlap, flood, or exfiltrate-via-timing. **Make `AudioOut` advisory / core-authoritative.**
> **Tap ≠ ingress — opposite security postures; never unified.** The tap is **egress**,
> core-as-**client**, no inbound port. Human ingress is **inbound**, core-as-**server**, with SSO +
> RBAC + per-tenant scoping — legitimately a server surface, fine because auth'd. The "core-as-client"
> move is specifically about egress, where the dangerous direction is. See
> [ADR-0006](adr/0006-ingress-posture.md).
For a brain that wants to be a **full media peer** (an existing SIP IVR, another media server, a
WebRTC client), the path is **media-leg ingress** (a real participant that speaks RTP/SRTP itself),
*not* the tap. Forcing a clean-audio brain into a media re-termination re-introduces a second media
termination, contradicting "terminate media once."
## Cross-cutting (outside the per-call boundary)
- **Event bus** — **Valkey** ([ADR-0005](adr/0005-event-bus.md)): streams + consumer groups for
CDR/CEL/analytics fanout; pub/sub for presence/MWI/BLF; KV for the state store (replacing `astdb`
+ sorcery). **The 20ms media loop never rides the bus.** The bus is **not** the source of truth
for billing- or call-loss-critical state — CDR/recordings emit durably to object storage; the bus
only *flows* events into that pipeline and lets services react. Pluggable to NATS later at a
config seam.
- **State store** — Valkey KV, replacing `astdb` + realtime/sorcery.
- **Observability** — OpenTelemetry traces that follow a single call across the boundary and out
to services.
- **Secrets / KMS** — Vault/KMS; no plaintext credentials in config.
- **Supply chain** — `cargo-deny`, SBOM generation, reproducible builds; continuous fuzzing of every
wire parser (SIP/SDP/RTP).
## DX spine — developer-first authoring
Headless and API-complete. The persona authors via **code + config-as-text**: git-versioned,
CLI-driven, IDE-native, AI-assistant-friendly (typed, schema'd, LSP-friendly). **Terraform/Rails
for call centers, not Squarespace.**
- **The AEL lesson** (DCAP author's): *better isn't enough.* AEL was superior to `extensions.conf`
and still lost — it arrived after the muscle memory had set. An authoring layer must win **on
contact** and meet people where their muscle memory is. In 2026 that's **code + an AI pair**, not
a config syntax or a visual canvas.
- **"Boom" + swiss-army-knife, reconciled:** an opinionated **batteries-included reference distro**
(`compose up`, point a trunk at it, start taking calls) on top of a **composable framework** (build
anything in code). Home Assistant model, not raw dialplan.
- **Call-flow authoring is a first-class design surface.** The AI-era twist: part of the old
dialplan **dissolves into the agent** (the model improvises the conversation). The authoring layer
becomes the **routing, escalation, and business scaffolding *around* an AI that writes half the
flow at runtime** — genuinely new design space, not "dialplan 2.0."
## GUI & extension architecture
**The GUI is a pure API client, never an insider** — FreePBX↔Asterisk, modernized.
- **Not a plugin in the core.** A web GUI must not share an address space / lifecycle / attack
surface with the latency-pinned media engine inside the one secure boundary.
- **A separate application.** The **official reference GUI** ships in the batteries-included distro
(so "boom" includes a usable UI) but holds **no privilege a third party couldn't get.** The
ecosystem can build rival GUIs.
- **Discipline:** the official GUI is built **only** on the public API — no backdoors. That
guarantees API completeness and prevents FreePBX's leaky "don't hand-edit config or it'll clobber
you" coupling.
- **Config-as-code vs. GUI-mutation tension → the Kubernetes model.** Declarative desired-state
(config-as-code, git, reconciled by the engine) *plus* an API/CLI/GUI for live operational state
and ad-hoc actions, all through **one API**, single source of truth. The GUI is the *dashboard*,
not a side-channel. *Manifests + kubectl + dashboard, for call centers.* Bonus scope guard: the GUI
can only surface what the API already does, so it can't drag the engine toward Five9-parity.
## Biggest technical risk
**No longer the SIP stack — because rutster no longer builds one**
([ADR-0007](adr/0007-trunk-rented-transport.md)). The trunk is rented transport (a CPaaS raw-media
fork) or an out-of-tree SBC; rutster parses no SIP. That retires what ADR-0003 called the single
biggest risk item and redirects the solo-years to the white space (the contact-center domain + the
reflex/eval loop + the tap-as-open-protocol).
The risk that remains is **the reflex loop itself** — turn-taking, VAD-driven barge-in, jitter and
pacing in a no-GC real-time loop (spearhead steps 14). That is also the most-differentiating work,
which is the point. Everything else builds on the existing Rust media ecosystem (`str0m`); the agent
**brain** is external by necessity and reached via the tap.