ADR-0005 — Video Consultation Platform
Status: Proposed — conditional on OQ-08 and a legal ruling · Date: 2026-07-20 · Deciders: Amir Haroun (Tech Lead)
Related: SEAM-02, QAS-AVL-01, QAS-SCL-02, QAS-MOD-02, R-10, ADR-0001
Context#
SRC-MOM FR-1 / REQ-03 requires in-app video consultations, "stable start to finish". The MoM (§11) proposed self-hosted Jitsi on open-source cost grounds. CON-13 requires KSA residency; scale target is hundreds of concurrent sessions within 12–18 months, from near zero at launch.
Research overturned three widely-held assumptions, including two the architect held going in. They are recorded prominently because acting on the original assumptions would have produced a materially worse system.
⚠ Three corrected assumptions#
Correction 1 — LiveKit does not solve the hostNetwork problem#
Assumed: LiveKit is "Kubernetes-native" where Jitsi is not, so it enables pod-level autoscaling via UDP mux.
Actual: LiveKit's own Kubernetes documentation states it requires host networking, and that this "limits deployments to one LiveKit pod per node." The official Helm chart defaults to podHostNetwork: true with the comment "you can run only one instance of LiveKit per physical node." Media address advertisement (rtc.use_external_ip) is architecturally identical to Jitsi's NAT_HARVESTER_PUBLIC_ADDRESS.
UDP mux is real (rtc.udp_port, conventionally 7882, multiplexing all media on one port demultiplexed by SSRC) — but enabling it does not by itself remove hostNetwork. LiveKit's docs and chart do not ship that configuration. Building it means going off the supported path.
Consequence: "LiveKit scales better on Kubernetes" is false as commonly stated. Both products have the same scaling unit — the node, not the pod.
Correction 2 — the real solution to hostNetwork is STUNner, and it is vendor-neutral#
STUNner (MIT, Kubernetes Gateway API based) exposes a single ingress port and lets media servers run in "ordinary unprivileged Kubernetes pods". It ships documented integrations for LiveKit, Jitsi, Janus, mediasoup, and others.
Consequence: STUNner is not an argument for LiveKit over Jitsi — it solves the problem for both equally. If avoiding
hostNetworkis a hard requirement, STUNner is the lever, and the media-server choice becomes independent of it.⚠️ Unverified: production maturity, performance overhead, and adoption scale. Commit authorship is near-single-maintainer. Promising lead, not a proven answer.
Correction 3 — Jitsi's P2P mode is a larger cost lever than assumed, and recording destroys it#
Jitsi ships p2p: { enabled: true } by default: with exactly two participants it attempts a direct connection, and on success media stops flowing through the bridge entirely. It keeps the bridge connection alive as a non-lossy fallback, and returns to the bridge when a third participant joins.
LiveKit has no P2P mode at all — it is a pure SFU. Every byte of every 1-to-1 call crosses your infrastructure.
For a platform whose core product is 1-to-1 consultations, this is the dominant cost term:
| Jitsi (P2P succeeds) | Jitsi (P2P fails) | LiveKit (always) | |
|---|---|---|---|
| Server media bandwidth | 0 | full relay | full relay |
| Server cost per 1:1 call | ~0 | full | full |
But — and this is the finding that decides the recording question — Jibri records by joining the conference as a third participant. A conference with a third participant is no longer 1-to-1, so it falls back to the bridge. Universal recording therefore eliminates the P2P saving entirely and adds the most expensive tier. The two effects compound, and both flow from a single product decision.
OQ-08(are consultations recorded?) is therefore not a minor product question. It is the single largest determinant of video infrastructure cost, plausibly swinging it by ~80 %. It must be answered before any video work begins.
Recording economics — where LiveKit decisively wins#
If recording is required, the comparison inverts sharply:
| Jibri (Jitsi) | LiveKit Egress | |
|---|---|---|
| Concurrency | "Only one recording at a time … on a single jibri" | Multiple jobs per instance |
| Runtime | Always full Chrome + X + ffmpeg, on a dedicated machine | 2–6 CPUs for composited; Track egress needs no Chrome and no transcode |
| Cheap path | None | "hundreds of simultaneous TrackEgress jobs … on a single instance" |
| Autoscaling signal | External orchestration | livekit_egress_available Prometheus metric |
At 300 concurrent recorded sessions, Jibri implies ~300 dedicated instances. This is the most commonly under-budgeted line item in self-hosted Jitsi, and it is the strongest single argument for LiveKit.
Other verified differentiators#
| Factor | Winner | Detail |
|---|---|---|
| Component count | LiveKit | No XMPP stack. Jitsi needs Prosody + Jicofo + JVB + Jibri + coturn. |
| Embedded TURN | LiveKit | TURN is built into the server binary — deletes coturn entirely from the architecture |
| Graceful drain | LiveKit | Built into the server; chart sets terminationGracePeriodSeconds: 18000. Jitsi requires external orchestration. |
| Large rooms | Jitsi | Octo (bridge cascading) is open source. LiveKit's distributed mesh is Cloud-only; self-hosted caps at ~3 000/room. Irrelevant for 1-to-1. |
| 1:1 bypass | Jitsi | P2P. LiveKit has none. |
| Signalling HA | Neither | Jitsi's Prosody/Jicofo are effectively singletons — the availability risk everyone overlooks while focusing on JVB |
| Helm chart | Neither | LiveKit's is first-party but ~3 months and 2 minor versions behind the server, with three meaningful commits in 18 months. Expect to fork and own it. |
| Licence | Tie | LiveKit is Apache 2.0, verified — no BSL/SSPL relicense. Egress, Ingress, and SIP are not feature-gated. |
LiveKit Cloud runs the same open-source SFU — verified twice from LiveKit's own sources. What is proprietary is the orchestration layer around it (global mesh, edge routing, managed agents), not the media server.
TURN#
TURN is mandatory in production, not optional. Without TURN over TLS on 443, users on carrier-grade NAT and UDP-blocking corporate networks simply cannot connect. For a paid consultation, an unconnectable call is a refund plus a churned customer.
On relay rates — the honest position#
The commonly cited "8–20 % of sessions need TURN" figure is folklore with no traceable primary source. The one trustworthy measurement is Philipp Hancke's ~10M-call Chrome dataset: 17.7 % relayed for peer-to-peer, but only ~4 % against an SFU — and of those, 78 % were TURN/TCP, i.e. UDP-blocked networks rather than NAT symmetry.
⚠️ That data is from 2017 and desktop-Chrome-dominated. No credible published relay-rate data exists for mobile carrier networks, and none at all for Saudi Arabia or the GCC. Numbers attributed to Cloudflare in circulating articles are not on Cloudflare's pages — do not use them.
Two structural facts that cut in opposite directions: Saudi mobile networks are heavily CGNAT'd, which pushes relay rates up; but an SFU has a public IP, which makes client→server traversal far easier than client→client. Expect the binding constraint to be UDP-blocking networks, not NAT symmetry — so TURN/TLS on 443 is the highest-value connectivity investment.
Action: instrument RTCIceCandidatePair on the selected pair from day one, segmented by carrier (STC / Mobily / Zain) and access type. Within a week of real traffic you will have better data than anything published.
coturn — reputation is out of date#
coturn is actively maintained: monthly-or-better releases through 2026, current 4.14.0. The "coturn is abandoned" narrative is false as of 2026.
⚠️ But run ≥ 4.13.1. Recent high-severity CVEs include a single-packet unauthenticated remote DoS on ARM64 (CVE-2026-40613, fixed 4.10.0) and predictable nonces from LCG-based randomness (CVE-2025-69217, fixed 4.8.0). Two of the last four highs are IPv4-mapped-IPv6 ACL bypasses — if deploying dual-stack, explicitly deny ::ffff: ranges.
coturn has the same hostNetwork problem, and worse — its maintainers recommend host networking because of the 16 384-port default relay range (restrictable via min-port/max-port). Allocations are per-process; Redis shares credentials and telemetry only, not allocation state — so losing an instance drops every session on it.
This is a strong argument for LiveKit's embedded TURN: it removes an entire stateful, CVE-exposed, hostNetwork-afflicted service from the architecture.
Cost#
Egress dominates. Compute is nearly irrelevant.
Per relayed 1:1 call-hour at 1 Mbps (~0.9 GB billable egress):
| Provider | $/call-hr |
|---|---|
| Flat-traffic bare metal (e.g. €1/TB) | ~$0.001 |
| Cloudflare Realtime TURN ($0.05/GB) | ~$0.045 |
| Hyperscaler egress (~$0.09/GB) | ~$0.081 |
| Twilio ($0.40/GB US/EU) | ~$0.36 |
Do not run TURN on a hyperscaler — you pay premium egress and operate the server. Worst quadrant.
Cloudflare's TURN free tier is 1 000 GB/month (~1 100 relayed call-hours), and free entirely when paired with their SFU. At launch scale this likely covers the platform outright — which sidesteps the whole coturn operational and CVE burden.
Self-host vs managed crossover: infrastructure alone is roughly break-even at ~300 concurrent. Adding a WebRTC-capable SRE — a scarce specialisation, scarcer in the KSA market — pushes the true crossover well above 300. At launch scale, self-hosting means paying the full fixed cost of a media-ops capability to serve almost no traffic.
Decision#
Abstract video behind a provider-agnostic port (SEAM-02) and defer the provider decision until OQ-08 and the residency ruling are answered.
Decision tree — do not skip step 1#
- Obtain a legal ruling: does transient foreign SFU relay constitute a PDPL cross-border transfer? Everything depends on this. Recordings and session metadata stay in-Kingdom regardless — they are unambiguously stored personal data and may be sensitive health data (
R-04). - Answer
OQ-08(recording). It swings cost ~80 % and inverts the Jitsi/LiveKit comparison. - If foreign relay is acceptable → managed CPaaS at the nearest GCC point of presence, recordings written to your own in-Kingdom bucket. Fastest launch, lowest fixed cost.
- If foreign relay is NOT acceptable, or recording is universal → self-host LiveKit on the in-Kingdom cluster (ADR-0001), with STUNner if
hostNetworkmust be avoided. - Jitsi is the last choice — unless recording is ruled out. If consultations are not recorded, Jitsi's P2P mode makes it dramatically cheaper for a 1-to-1 product and it becomes genuinely competitive. This is the one scenario where the MoM's original proposal is correct, and it should be re-evaluated on that basis rather than dismissed.
Managed CPaaS — screening gate applied before price#
⚠️ No major CPaaS has a confirmed media presence inside Saudi Arabia. Evaluate residency before looking at pricing:
- Media points of presence in KSA / UAE / Bahrain?
- Contractual residency or geo-fencing guarantee — not merely "a nearby PoP"?
- Where do recordings land, and can that be pinned in-Kingdom?
Notes: Twilio Video is EOL — exclude it, and note that a major vendor exiting this exact market is the strongest available justification for the port. Amazon Chime SDK is not deprecated (the Chime application ended 2026-02-20; the SDK is actively shipping) — its server-side control plane is genuinely strong, but its React Native story is weak (no official library; only a sample you fork and own) and an OpenSSL/libvpx vulnerability issue has been open since Nov 2024. For a mobile-first product that is a material risk.
The port interface#
Provider-neutral domain concepts only. The port owns identity — sessionId is a booking-domain identifier; vendor room IDs live in an adapter-owned mapping table.
createSession(bookingId, {maxParticipants, recordingPolicy, maxDurationMinutes, region?})
getSession(sessionId) / endSession(sessionId, reason) / extendSession(...)
issueAccessToken(sessionId, {userId, displayName, role, permissions, ttlSeconds})
-> {token, connectionUrl, iceHints?}
revokeAccess(sessionId, userId)
startRecording(sessionId, {layout, audioOnly?, destination: StorageRef})
stopRecording / getRecording / listRecordings / deleteRecording
Events (normalised): ParticipantJoined/Left · SessionStarted/Ended
RecordingStarted/Ready/Failed
QualityDegraded · ConnectionFailed
Five requirements that actually determine swappability:
destination: StorageRefis a compliance control, not a convenience. Recordings must land in the in-Kingdom bucket. Any vendor that cannot write to your bucket in your region fails the gate.connectionUrlreturned with the token — hardcoding an endpoint client-side is the single most common thing that blocks a vendor swap. Ship this from day one even with one vendor.- Reconciliation, because webhooks are lossy. Billing is on session duration; a missed
SessionEndedis a billing error or an indefinitely-open paid session. Non-negotiable. ConnectionFailed/QualityDegradedare business events, not telemetry — they feed refund policy (OQ-05) and consultant-rating fairness. Most teams bolt these on after the first dispute.- The mobile SDK is the leaky part. A server-side port is ~70 % of a swap; the client SDK is the other 30 % and the port cannot abstract it. Wrap the vendor SDK behind a thin mobile interface too, and budget for it explicitly.
Switch triggers — define now, while nobody is under pressure#
| Trigger | Action |
|---|---|
| Managed bill > ~$8–10k/mo sustained 3 months | Begin self-hosted spike |
| Sustained concurrency > 200 | Migration planning in earnest |
| Counsel hardens on media relay | Migrate in-Kingdom immediately — keep the self-hosted path warm for this reason alone |
| Vendor announces EOL or repricing | Exercise the port (already happened once in this market) |
| Measured TURN relay > ~20 % | Re-run the whole cost model |
| Recording cost exceeds media cost | Investigate track-level recording before scaling either path |
Consequences#
Positive: the decision is deferred without blocking work; residency and recording rulings can arrive late; vendor exit is survivable; the port is cheap to build now.
Negative / accepted: the port is upfront work with no immediate feature value; capability negotiation is needed to degrade gracefully; the mobile SDK wrapper is an ongoing tax.
⚠ Verification gaps#
| Gap | Consequence |
|---|---|
| Whether any CPaaS offers KSA/GCC media presence with contractual residency | Gates the entire managed option |
| Legal ruling on transient relay as a PDPL transfer | Gates the whole decision tree — counsel, not research |
| STUNner production maturity and overhead | Determines whether hostNetwork is avoidable |
| LiveKit node-failure behaviour — undocumented | Rooms are pinned to one node with no replication; loss likely drops all rooms on it. Test before committing. |
| Real TURN relay % for KSA mobile carriers | No published data exists — instrument and measure |
| Operational headcount for either platform | No reliable data; search results were AI-generated SEO content, deliberately not cited |
| LiveKit embedded TURN's 1024–30000 relay range vs non-hostNetwork K8s | Unresolved — reintroduces the port-range problem UDP mux was meant to solve |