# Call-end taxonomy — `end_reason` → `ended_by` / `outcome`

The CANONICAL mapping for how a finished call is described. Code:
`server/app/postcall.py` (`ended_by()`, `classify_outcome()`, `is_retryable()`).
Keep this file and those sets in sync — **every string here has a real producer
in the code; the sets contain nothing else.** (Merged 2026-07-06: dead aliases
that nothing produced — `bridge_lost`, `server_draining`, `unsupported`,
`unsupported_codec`, `max_duration_s`, `max_duration`, `policy_hangup`, `busy`,
`line_busy`, `user_busy` — were deleted; the two real trial reasons were mapped.
2026-08-12: `user_own_call` deleted with the auto_answer single-key model — the
server no longer force-ends phone-initiated calls, so nothing produces it.
2026-08-24: the outbound watchdog added the concrete `time_limit` producer.)

## The three fields, in one line each

| Field | 白话 | Where it comes from |
|---|---|---|
| `end_reason` | 传输层的机器原因串 — WHAT mechanically ended the call | Stamped once by the observed CallWorld edge (for a real phone hangup this remains `hfp_idle`) |
| `ended_by` | 人话结论 — WHO/what closed the call | `ended_by(end_reason, tool_call events)`, stamped on the calls row alongside `outcome` by `record_outcome` at call end (boot sweep stamps its reaped rows `system`); top-level on `/result` + `/replay`, NULL while live. Snapshot semantics: a mapping change needs a backfill to rewrite history |
| `outcome` | 这通电话办成了什么 — the disposition | `classify_outcome()`: signal-first (did the callee speak?), reasons as tie-breakers; stamped in the same write |

Jasper-initiated physical hangups also write `state` events named
`termination_requested` and `termination_confirmed`. They preserve the policy
intent (`provider_error`, `time_limit`, `stopped`, or `agent_hangup`) and source
without replacing the mechanical `hfp_idle` fact. Firmware `cmd_result=ok` is
only command acceptance; `termination_confirmed` is emitted only after the
exact HFP call identity disappears.

## `end_reason` — every value the code can produce

| `end_reason` | Producer (the ONE place it's set) | Meaning |
|---|---|---|
| `hfp_idle` | `call_manager` (firmware reports the phone-level call gone) | The call ended at the phone level — someone hung up (us via `hangup_call` → AT+CHUP, or the far end / handset) |
| `hfp_session_changed` | `call_manager` | A DIFFERENT call's identity surfaced on the device (transfer / call-waiting swap / back-to-back dial) — this call was superseded |
| `link_down` | engine call world (`call_world.rs`) | The BT link was DOWN at the instant the firmware let the call identity go — the ordinary BT-drop end. This is the reason a mid-call Bluetooth loss lands on, **not** `identity_grace_expired` (see the note under it) |
| `identity_grace_expired` | `call_manager._grace_expire` | OUR 25 s grace (`HFP_ID_GRACE_S`) ran out. **Narrower than it reads:** it needs the firmware to still be reporting the held id when our timer fires, and the firmware's own grace (~10 s) is shorter — so on a plain BT drop the id is already gone and the end is `link_down`. What is left for this reason is the case where snapshots stop arriving entirely mid-hold (a dead bridge) |
| `device_lost` | `routing.py` (device WS session dropped) | The device/bridge connection died. (A dead bridge IS this — its WS session is the device session; there is no separate `bridge_lost`.) |
| `stopped` | `call_service.cancel_call` / `force_end_call` default | An explicit Stop from the API/console |
| `provider_error` | `call_handler` (terminal AI-provider failure) | The AI provider couldn't connect/stay up; the call was ended because the "brain" was gone |
| `time_limit` | `call_service._watchdog` | The server's per-call duration policy ended an outbound call |
| `server_restart` | `app.py` boot sweep (`db.end_stale_calls`) | Rows orphaned by a dead server process, closed at next startup |
| `trial_ended` | `call_manager.end_trial_call` (browser trial teardown) | The user's browser voice-trial session closed |
| `trial_superseded` | `call_manager.create_trial_call` | A new trial call replaced the user's still-open one (one trial per user) |

Anything else → `ended_by: unknown` and no outcome hint. That's ON PURPOSE
(fail loud): a new end reason must be added here + to the mapping sets + tests,
not silently absorbed.

## `ended_by` — the postcall verdict

Priority order as implemented (`postcall.ended_by()`):

| `ended_by` | Condition | 白话 |
|---|---|---|
| `agent` | `hfp_idle` **AND** a successful agent-source `hangup_call` result | AI 自己挂的(固件确认生效才算 — 被拒/超时/死链上的 hangup spam 不算) |
| `admin` | `stopped`, or `hfp_idle` after a successful admin-source `hangup_call` | 人从 API/console 取消的 |
| `link_lost` | `link_down` / `device_lost` / `identity_grace_expired` | 信号/链路断了,没人挂(`link_down` 是普通蓝牙掉线走的那条) |
| `call_switched` | `hfp_session_changed` / `trial_superseded` | 电话被另一通顶掉了 |
| `system` | `time_limit`, `server_restart`, `provider_error`, or `hfp_idle` after a successful system-source hangup | 服务器策略/故障结束的 |
| `peer` | `hfp_idle` without an effective hangup tool / `trial_ended` | 对方(或本人在 trial 里)挂的 |
| `unknown` | anything unmapped | 认不出来 — 加映射前先加生产者 |
| `null` | call still live | 还没定论(和 `retryable` 一样) |

## `outcome` — the five dispositions

Signal-first: a terminal provider failure forces `error`; an escalating
`report_to_admin` forces `needs_human`; a callee that spoke forces `answered` —
reasons only break ties for calls where nobody engaged.

| `outcome` | How | `retryable` |
|---|---|---|
| `error` | terminal provider event, `end_reason` ∈ {`device_lost`, `server_restart`, `provider_error`, `time_limit`}, or a confirmed `time_limit` termination intent paired with mechanical `hfp_idle` | true iff `end_reason` ∈ {`device_lost`, `server_restart`} (transient); `provider_error` defers to the provider event's own `retryable` flag; `time_limit` is not retryable |
| `needs_human` | a successfully acknowledged agent `report_to_admin` with urgency `need_input`/`alert` | false |
| `answered` | the callee spoke (two-way conversation) | false |
| `agent_hangup` | nobody engaged and firmware acknowledged the AI's `hangup_call` | false |
| `no_answer` | nobody engaged, no other signal | **true** |

## Known gaps (real behavior the taxonomy can't express yet)

These are NOT missing mappings — they're missing **producers**. Don't re-add
speculative strings to the sets; wire a producer first.

1. **Inbound policy cut does not end the phone call.** The outbound
   `call_service._watchdog` now records a `time_limit` termination intent before
   sending its targeted system hangup, and waits for exact HFP confirmation.
   The independent inbound/global
   `call_billing._duration_watchdog` only disconnects Jasper's SCO/provider and
   intentionally leaves the cellular call to the handset; if that call later
   ends normally, the phone-level close still reads `peer`.
2. **Busy lines aren't detected.** The firmware reports no busy-distinct state,
   so no `busy` end_reason or outcome exists. If CIEV callsetup analysis ever
   lands in the firmware/bridge, add the producer, then the mapping.
3. **A trial the AI hangs up reads `peer`.** `end_trial_call` always stamps
   `trial_ended`, so an in-trial `hangup_call` isn't distinguished. Cosmetic;
   revisit only if trials grow real hangup semantics.

## Related but DIFFERENT strings (don't confuse with end_reason)

* `server_draining` — a `place_call` REJECTION code (503) and a Prometheus gauge;
  a drain never stamps it on a call (the device WS closing lands `device_lost`).
* `unsupported_codec` — a per-call-metrics **issue code** + `calls` column
  (`timing.unsupported_codec`); the call it ruins still ends with a normal
  reason. See `docs/per_call_metrics.md`.
* `max_duration_s` — the place_call/assistant PARAMETER (the cap itself), never
  an end reason (see gap #1).
* `device_busy`, `dial_timeout`, `bt_not_connected`, … — `place_call` preflight
  rejection codes; the call never existed.
