# Handling more volume, end to end

Four limits bind, and rarely the one people expect. Worker capacity refuses calls at a load of 0.85 per core; the plan's daily allowance holds sends; the model quota opens a breaker on a 429; and query cost grows with the business rather than with traffic. Measure statements before adding anything, because the fourth limit is the one that looks like all the others.

- **Status:** Available
- **Audience:** both
- **Channels:** phone, email
- **In the app:** #/billing, #/calls, #/home
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/scaling-up/

## The four limits, in the order they usually bind

| Limit | What you see | Where it is set | What it does when reached |
|---|---|---|---|
| Worker capacity | Calls refused before they ring | `WORKER_BUSY_LOAD`, 0.85 one-minute load per core | `ring` answers `not_ready`, so the follow-up drain retries rather than losing the call |
| Daily allowance | Approved replies sit and do not go | The workspace's plan | The send waits; the approval is recorded and the item stays in the queue until it goes |
| Model quota or budget | Scheduled work quietly skipped | `ai_quota`, `budget_refusal` | A breaker opens on a 429 or a revoked key: scheduled work is skipped, a caller already on the line is still attempted |
| Query cost | Every screen slows at once, in proportion to the business | Nowhere — it is a property of the query | Nothing refuses. It just gets slower, which is why it is found last |

> **Note** Only the first three announce themselves. The fourth is the one that has actually bitten hardest here, and it is invisible until someone counts statements.

## The chain more volume runs through

1. TRIGGER — traffic rises: more inbound calls, more mail per hour, more contacts in the workspace.
2. USER / EXTERNAL EVENT — customers arrive faster than before; nothing in the product is switched on to cause it.
3. AUTH / WORKSPACE RESOLUTION — unchanged per request, but every request still resolves a workspace and is filtered three times: the allowlist, the ORM kernel and row-level security.
4. INGEST / REQUEST — mail is fetched per mailbox and calls arrive per line. Burst handling and the cursor decide whether a spike is absorbed or dropped.
5. CANONICAL RECORD — more rows. This is where query cost begins to bind, because a query that reads a slice of rows changes its answer as the slice stops covering the set.
6. CLASSIFICATION / RESEARCH / REASONING — one loop thinks. Adding processes does not add thinkers; the engine tick is not the place to scale.
7. KNOWLEDGE + MEMORY + RULES — unchanged by volume. The per-call budgets are per call, not per workspace.
8. AUTONOMY / APPROVAL — a rising queue in Needs You is the first human-visible symptom of a limit, and it is usually the allowance rather than the engine.
9. ACTION / PROVIDER — capacity is checked here. A worker over the busy load refuses new calls; a worker reporting no load reads as free, because refusing calls on an old worker's silence would be worse than the saturation it guards.
10. RESULT — refusals are outcomes, not errors: `not_ready` retries, a budget refusal plays the spoken closed-line message instead of leaving a caller in silence.
11. RELATIONSHIP / TIMELINE / MEMORY — unchanged, but timeline and 360 views are exactly the screens that N+1 queries make slow first.
12. AUDIT / USAGE / NEEDS YOU — usage and cost are per workspace and per call, so the ledger tells you which line or mailbox grew.
13. NEXT — change one limit, re-measure, and only then look at the next. Two changes at once make the improvement unattributable.

## Measure statements, not seconds

Wall-clock timing on a small workspace hides the failure mode that matters, which is a query count that grows with the number of rows rather than with the number of users. Counting statements makes it visible at any size. These are the measured reductions on this codebase:

- The Owner's customer list: 5,574 statements to 3.
- The triage strip: 83 to 11, and flat with page size.
- The workspace console: 89 to 17, also flat.
- Home: 133 to 15, flat.

Flat with page size is the property to insist on. A screen that costs eleven statements at twenty rows and eleven at two hundred will not become an incident later; one that costs eleven at twenty and four hundred at two hundred already is one.

## The counting bug that arrives with growth

A headline number computed from a page of rows is correct until the business outgrows the page, and then it is quietly wrong. Both of these were real.

- The conversation list read 400 rows and did counting, filtering, searching and paging in Python over that slice. On a 430-conversation workspace the Closed tab read 0 and page five was empty.
- `prospect_summary` derived every headline number from the newest 500 rows. On a 540-contact workspace it reported 500 organisations when there were 530, and 0 already contacted when there were 40.

Neither raised an error, and neither was visible on a small workspace. When volume rises, the first thing to distrust is any number that could have been counted in the application rather than by the database.

## What to change, in what order

1. Count statements on the slowest screen at two workspace sizes.
   - Result: You learn immediately whether the cost is per request or per row. Only the second kind gets worse on its own.
2. Fix anything that grows with row count before adding capacity.
   - Result: Capacity added underneath a query that grows is capacity spent to stand still.
3. Then look at worker capacity, using the load the workers report.
   - Result: The busy threshold and the number the worker hands the media server as its own admission control are the same 0.85, so the gate and admission control cannot disagree.
4. Then the allowance and the budget.
   - Result: These are commercial limits, not engineering ones. A customer's use is bounded by their plan; the Owner has no plan and no gates.
5. Deploy with drain time in mind.
   - Result: Draining allows 180 seconds for calls in progress, and the service unit allows 210 before the process is killed. A heartbeat file forgets a process not seen for an hour, so a stopped worker stops counting as capacity.

## Questions

### Will more workers make replies faster?

No. Workers decide how many calls can run at once, not how quickly any one of them answers. Reply time is the model's first token plus end-of-turn detection, with a measured floor of 3.3 seconds median on the best call — adding capacity does not move it.

### What happens to a caller when there is no capacity?

The call is refused before a row exists, as `not_ready`, so a follow-up drain retries it rather than recording a failure. A caller refused for budget hears the spoken closed-line message in the business's own name instead of silence.

### Does volume change what Connect is allowed to do?

Only through the allowance. Autonomy, suppression and consent are unaffected by load; the plan decides how much may be sent in a day, and reaching it holds work rather than widening any permission.

## Related

- [Capacity and admission control](https://connectbyjbrh.com/docs/phone/worker-capacity/)
- [The budget that stops a call](https://connectbyjbrh.com/docs/phone/voice-budget/)
- [The query cost that grows with the business](https://connectbyjbrh.com/research/n-plus-one-that-grows/)
- [Headline numbers that stop at the page size](https://connectbyjbrh.com/research/counting-past-the-limit/)
- [Every line is busy](https://connectbyjbrh.com/docs/troubleshooting/capacity-full/)
- [A screen is slow to load](https://connectbyjbrh.com/docs/troubleshooting/slow-screen/)
- [Reviewing an incident, end to end](https://connectbyjbrh.com/docs/workflows/incident-review/)

## What this page is based on

- `docs-source/sources/PHONE.md` §5 — capacity, drain and heartbeat
- `docs-source/sources/GENERAL.md` §11 — the measured query costs
- `backend/app/metering.py`, `ai_quota.py` — allowances and the breaker
- Connect capability registry (docs-source/facts.py)
