Skip to content

Concurrent-Call Cap Architecture

Two layered caps gate outbound calls in this PBX. Knowing which one fires for a given call is a frequent operator question — this doc captures the layout and how it's surfaced in the UI + CDR.

Status: shipped 2026-05-20 via PR #260 (dialplan enforcement) + PR #262 (effective-cap UI) + PR #263 (CDR cap_rejected userfield + call-logs badge).

If you're debugging why a call shows "all circuits busy" or "Org cap" / "Trunk cap" in call-logs, also read Error 61.

The two caps

Cap Where it lives Scope Editor surface
Org cap organizations.limits.concurrent_calls (JSON column) Across all trunks for that org — total simultaneous outbound calls Admin → Organisations → <org> → "Concurrent Calls"
Trunk cap sip_trunks.max_channels (column) One specific trunk — simultaneous calls on that trunk only Editor → Trunks → <trunk> → "Channels"

The cap a caller actually experiences is min(org_cap, trunk_cap). A trunk with max_channels = 50 inside an org with limits.concurrent_calls = 10 will be capped at 10. The trunk knob is upper-bounded by the org knob; never the other way around.

Where it's enforced

api/src/services/asterisk/dialplanGenerator.jsgenerateOutboundContext().

For each outbound route, the generated ext_<org>.conf outbound context emits:

; Org cap (one counter per org)
exten => _X.,n,Set(GROUP(orgCap)=orgCap)
exten => _X.,n,GotoIf($[${GROUP_COUNT(orgCap@orgCap)} > ${ORG_MAX}]?org_limit_reached,1)

; Trunk cap (one counter per trunk)
exten => _X.,n,Set(GROUP(trunkCap_<trunkId>)=trunkCap_<trunkId>)
exten => _X.,n,GotoIf($[${GROUP_COUNT(trunkCap_<trunkId>@trunkCap)} > ${TRUNK_MAX}]?trunk_limit_reached,1)

; Then the actual Dial against the trunk
exten => _X.,n,Dial(PJSIP/${EXTEN}@<trunk_endpoint>,...)

[…]

exten => org_limit_reached,1,Set(CDR(userfield)=org_cap_rejected)
exten => org_limit_reached,n,Playback(all-circuits-busy)
exten => org_limit_reached,n,Hangup()

exten => trunk_limit_reached,1,Set(CDR(userfield)=trunk_cap_rejected)
exten => trunk_limit_reached,n,Playback(all-circuits-busy)
exten => trunk_limit_reached,n,Hangup()

Both caps fire before the Dial. Order matters — if org cap fires first, the call never tries any trunk and the trunk counter isn't incremented. So the labels accurately reflect which cap was the binding constraint, not "both caught it."

The trunkCap_<trunkId> category (not just trunkCap) gives each trunk its own independent counter. Without per-trunk categorization, GROUP_COUNT would mix all trunks together and the cap would behave as a system-wide knob instead of per-trunk.

CDR userfield ↔ UI badge

asterisk_cdr.userfield is stamped by the org_limit_reached / trunk_limit_reached branches above. The /api/v1/calls SQL pulls it into cap_rejected as a derived field:

CASE
  WHEN t.userfield = 'org_cap_rejected'   THEN 'org'
  WHEN t.userfield = 'trunk_cap_rejected' THEN 'trunk'
  ELSE NULL
END as cap_rejected,

In editor/app/dashboard/[orgId]/calls/page.tsx, the Status column short-circuits to a destructive-variant badge when cap_rejected is set:

cap_rejected value Badge label Variant
'org' Org cap destructive
'trunk' Trunk cap destructive
null (normal Completed / Missed / etc. via effectiveCallStatus)

Operators can also filter call-logs by direction + status to find these rejections. Hover shows "Rejected: org concurrent-call cap reached. Caller heard 'all circuits busy'." or the trunk variant.

Effective-cap UI

Trunks page (PR #262):

  • Channels column: shows <trunk_cap> / <org_cap> when they differ (e.g. 10 / 50), or just <n> when they match. A tooltip explains the effective cap is min(trunk, org).
  • Edit / Create dialog: helper text under the "Channels" input reads "Effective cap: min(trunk, org) = ". Updates live as the operator types.

Admin → Organisations → <org>: shows org Concurrent Calls alongside the highest-cap trunk's effective cap, so operators see what each trunk will actually accept.

Common semantic gotcha: what counts as a "channel"

Operators often ask: "If channels = 5 and there are 7 phones ringing, is that 5 channels in use or 7?"

A channel is occupied from Dial-attempt through Hangup, not just during talk time:

  • Ringing the callee phone → uses a channel
  • Talking to the callee → uses the same channel
  • On hold → still uses the channel
  • Bridged with another caller → uses 2 channels (yours + theirs)

For a queue ring-all strategy with 10 members and 1 inbound caller, the inbound caller uses 1 inbound channel. The queue Dial of those 10 agents creates ~10 outbound Local→PJSIP channels. So the org's total channel count (across inbound + outbound) jumps to ~11 momentarily. But this cap applies to outbound through the trunk specifically — internal ring-outs to extensions don't touch the trunk and don't count.

Tested empirically on staging 2026-05-20: trunk max_channels=1, placed 2 outbound calls. Second call sat in the queue waiting; the queue did not hang it up. (See Error 61 for the bug history.)

Gaps / known limitations

# Item State
1 qm-helper.js paths (queue members of type=phone that ring via a trunk) bypass the per-trunk cap — they ring directly through pjsip without hitting the outbound context's GROUP() block Tracked in PR #260 body; not blocking common org configs but should be closed before any org uses lots of phone-target queue members
2 Trunk tooltip text "Limits simultaneous calls on this trunk" doesn't clarify outbound-only — could mislead an operator into thinking it caps inbound too Deferred polish
3 No alerting when an org regularly hits its cap. Today operators learn from call-logs badges only. A dashboard counter (e.g. "12 cap rejections this week") would help right-size the cap Future work