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_rejecteduserfield + 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.js → generateOutboundContext().
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 ismin(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 |
Related¶
- Dashboard — Call Pickup Time — sister dashboard work shipped same week. Different metric (agent ring-to-pickup, not cap rejections), but lives in the same overview page and uses the same time-window selector.
- Error 61: Trunk max_channels stored but not enforced — the bug history
- Error 62: Call-logs softphone→PSTN misclassification — adjacent CDR-direction work shipped same week
dialplanGenerator.generateOutboundContext— code that emits the dialplan blocks aboveapi/tests/trunk-max-channels.test.js— 13 unit tests covering trunk-cap emission (including regression-trap:trunk_cap=0emits NOTHING) + CDR userfield assertions