# UCM6301 Capture 01 Compatibility Analysis

Date: 2026-07-19  
Branch: `feature/ucm6301-integration`  
Listener: `ami/listener.php`  
Capture: `runtime/ami-capture/20260718T222522Z-1535501.ami-events.jsonl`

## Executive conclusion

The UCM6301 event correlation model is broadly compatible with the listener: all call legs share one `Linkedid`, the answered PJSIP channel emits `Newstate` with `ChannelStateDesc=Up`, and the losing PJSIP leg emits `Hangup` with cause 26 after the answer is known. The current listener's state and duplicate-prevention logic would handle those two PJSIP legs safely **if call state already existed**.

However, this capture exposes one blocking compatibility gap. The first inbound `Newchannel` on the DAHDI trunk contains no `CallerIDNum`, `ConnectedLineNum` or `DialString`. `numFrom()` therefore returns an empty value and the `Newchannel` handler returns before `initCall()`. The external number appears later, but the `DialBegin` handler requires an existing call entry. As written, the active listener would ignore this complete captured call and create no PBX record.

Caller names are omitted. Telephone-like values are masked except for their final three digits.

## Listener state model

Call state is keyed primarily by:

1. `Linkedid`, with `Uniqueid` only as a fallback (`ami/listener.php:397`, `413`, `465`, `509`, `556`);
2. extension number for the set of ringing destinations (`ringing_exts`, lines `287`, `447`);
3. the full answered channel string for completed-call finalization (`answered_chan`, lines `284`, `481`, `528`, `562`);
4. full PJSIP/SIP/Local channel strings are parsed to obtain extensions by `extFromChan()` (`187-195`).

This combination is appropriate for the observed UCM6301 fan-out: the shared `Linkedid` groups the trunk and both endpoint legs, the extension key prevents duplicate popup rings, and the full answered channel distinguishes the winning endpoint leg from the losing cause-26 leg.

## Event handling matrix

| AMI event | Active listener behaviour | Relevant lines | Capture result |
|---|---|---:|---|
| `Newchannel` | Extracts `Linkedid`/`Uniqueid`, requires an external number immediately, rejects internal context, initializes call and contact lookup. | `395-409`, helpers `178-198` | **Incompatible initialization:** first trunk event has no captured number field, so processing stops at line `400`. |
| `DialBegin` | Requires existing state; parses destination extension; checks CRM role; deduplicates by extension; may fill a late number; records ringing extension and sends `ring`. | `411-461` | Both PJSIP legs target the same extension. The first would add the extension and the second would be treated as a duplicate, but neither is reached without the initialization fix. |
| `Newstate` — `Ringing` | Ignored because the handler accepts only `ChannelStateDesc=Up`. | `463-465` | Expected: ringing is already driven by `DialBegin`. |
| `Newstate` — `Up` | For a known call and CRM extension, marks answered, stores full channel, clears missed timers, removes ringing extension and sends `answered`/`taken`. | `463-505` | Compatible: the winning second PJSIP leg emits `Up` before the losing leg's cause-26 hangup. |
| `DialEnd` | Used only when `DialStatus=ANSWER`; provides answer fallback using `DestChannel`. | `507-552` | `DialStatus` is absent from this capture projection, so this fallback cannot be verified from Capture 01. |
| `BridgeEnter` | No handler. | — | Two members enter one bridge: the DAHDI trunk and the answered PJSIP leg. Not required by current happy path. |
| `BridgeLeave` | No handler. | — | Both members leave the same bridge before normal hangups. |
| `HangupRequest` | No handler. | — | Requests appear for both the trunk and answered leg; intentionally ignored. |
| `Hangup` | If the full channel matches `answered_chan`, finalizes completed. If already answered but another channel hangs up, ignores it. Otherwise starts a 30-second missed candidate timer. | `554-574`, timeout `578-605` | Compatible for observed ordering: losing cause-26 leg is ignored after answer; winning leg finalizes; later trunk hangup finds no call state. |
| `Cdr` | No handler. | — | Three CDR events occur after hangup and have no effect. |

## Sanitized call trace

All relevant events share `Linkedid 1784414473.56`. Times are capture timestamps in UTC; ordering within the same second follows JSONL file order.

| Time | Event | Sanitized interpretation |
|---|---|---|
| 22:25:29 | `Newchannel` | Incoming trunk channel `DAHDI/o1-1`, state `Ring`; no usable external number field is present. |
| 22:25:30 | `Newstate Up` | Trunk leg is up; external caller ends in `108`; route destination/queue is `*500`. |
| 22:25:41 | `QueueCallerJoin` | Trunk enters the queue application. |
| 22:25:41 | two `Newchannel` | UCM6301 creates two PJSIP legs for extension `*000`: channels ending `014` and `015`. |
| 22:25:41 | two `DialBegin` | Both destination legs use `PJSIP/*000`; destination unique IDs end `.58` and `.59`. |
| 22:25:41 | `Newstate Ringing` | Leg ending `014`/`.58` reports ringing. |
| 22:25:50 | `Newstate Up` | Leg ending `015`/`.59` answers. This is the answered channel. |
| 22:25:50 | two `DialEnd` | Events are present for both destination legs, but the stored capture lacks `DialStatus`. |
| 22:25:50 | `Hangup` cause 26 | Ringing leg ending `014`/`.58` ends as `Answered elsewhere`. |
| 22:25:50 | `AgentConnect` | Queue confirms the answered destination leg ending `015`/`.59`. |
| 22:25:50 | two `BridgeEnter` | Answered PJSIP leg and `DAHDI/o1-1` join the same bridge. |
| 22:26:00 | two `HangupRequest` | Requests target the trunk and answered PJSIP leg. |
| 22:26:00 | two `BridgeLeave` | Trunk and answered PJSIP leg leave the bridge. |
| 22:26:00 | `Hangup` cause 16 | Answered PJSIP leg ending `015`/`.59` clears normally. |
| 22:26:00 | `Hangup` cause 16 | DAHDI trunk clears normally after endpoint finalization. |
| 22:26:00 | three `Cdr` | Post-call CDR events are ignored by the listener. |

### Captured call topology

- Incoming trunk: `DAHDI/o1-1`.
- Queue/routing destination: `*500`.
- Destination extension: `*000`.
- Ringing endpoint leg: `PJSIP/*000-*****014`, unique ID ending `.58`.
- Answered endpoint leg: `PJSIP/*000-*****015`, unique ID ending `.59`.
- Bridge membership: trunk plus answered endpoint leg; the cause-26 leg never joins.
- Final hangup order: answered endpoint leg, then trunk; CDR events follow.

## Two PJSIP legs and cause 26

With initialized call state, the current implementation correctly handles the two observed PJSIP legs:

- Both `DialBegin` events correlate to one call through `Linkedid` (`413`).
- `ringing_exts` is keyed by extension, so the second leg for the same extension is intentionally deduplicated (`425-427`, `447`). This produces one popup ring rather than two.
- `Newstate Up` from the winning leg stores its exact full channel as `answered_chan` (`479-486`).
- The losing leg's cause-26 `Hangup` has a different full channel. Since the call is already answered, line `567` ignores it; it cannot start a missed timer or finalize the call.
- The winning leg's later cause-16 `Hangup` matches `answered_chan` and calls `finalize(..., 'completed')` (`562-564`).
- `finalize()` uses a per-`Linkedid` latch and removes call state after success (`299`, `307-308`, `373`), preventing the later trunk hangup from creating a duplicate PBX record.

For the exact captured order, cause 26 cannot create a missed call, premature finalization or duplicate PBX record.

There is a residual ordering risk: if a cause-26 hangup arrives before answer detection, it starts the missed candidate timer (`569-572`). A later valid `Newstate Up` or `DialEnd ANSWER` clears the timer (`482-483`, `529-530`), so this is normally safe. A false missed record remains possible only if answer detection also fails—for example because the destination extension cannot be parsed, CRM role lookup rejects it, or the PBX supplies neither a usable `Newstate Up` nor `DialStatus=ANSWER`.

## Missing or unverifiable fields

### Blocking at initial `Newchannel`

The initial DAHDI `Newchannel` contains only channel/state/context/ID fields among those relevant to the listener. These `numFrom()` candidates are absent from the stored event:

- `CallerIDNum`;
- `ConnectedLineNum`;
- `DialString`.

The listener also checks legacy `CallerID`, but the capture tool does not store that field, so its presence in the original AMI packet cannot be determined from this file. A subsequent event provides `CallerIDNum`, but the current listener has no late initializer for it.

### Answer fallback

`DialStatus` is required by the `DialEnd` handler (`512-513`) but is not part of the current capture tool's selected field set. Therefore Capture 01 does not prove whether UCM6301 supplied `DialStatus=ANSWER`; it proves only that `Newstate Up` was available.

Other fields used by the successful path were present where needed: `Linkedid`, `Uniqueid`, `Context`, `DestChannel`, `Channel`, `ChannelStateDesc`, and the later `CallerIDNum`.

## Recommended minimal code changes

### Required: create provisional inbound state before caller number arrives

Reorder the `Newchannel` handler at `395-408` so that a non-internal event with a valid `Linkedid` initializes provisional state even when `numFrom()` is empty. Populate `num` and contact only when an external number is available. The existing late-number block in `DialBegin` (`430-436`) can then populate the caller from the captured `DialBegin` event.

Conceptually:

```php
$uuid = $e->getKey('Linkedid') ?: $e->getKey('Uniqueid');
if (!$uuid || isOutbound($e)) return;

initCall($uuid);
$num = numFrom($e);
if ($num && isExternalNumber($num) && !$calls[$uuid]['num']) {
    // Existing number/contact assignment.
}
```

This is the smallest change that makes the captured UCM6301 sequence enter the existing, already suitable `DialBegin`/`Newstate`/`Hangup` state machine. Provisional entries without a later external number are already dropped by `finalize()` at `302-305`; an additional stale-state cleanup may be considered separately.

### Recommended capture-only improvement

Add `DialStatus` and legacy `CallerID` to the passive capture field list. This does not change listener behaviour, but it makes the `DialEnd` fallback and initial-number evidence directly verifiable in the next capture.

No change is currently required for cause 26 or for the two PJSIP legs. Do not key `ringing_exts` by channel unless the UI is intended to receive multiple ring notifications for the same extension.

## Confirmed compatible behaviour

- All relevant UCM6301 call legs carry the same `Linkedid`.
- PJSIP channel names match `extFromChan()`'s `PJSIP/(digits)-...` pattern.
- The winning leg emits `Newstate Up`, which the listener supports.
- The exact winning channel remains stable through its final `Hangup`.
- Cause 26 is emitted on the non-winning channel after answer detection.
- The trunk and winning endpoint join and leave the same bridge.
- Endpoint hangup precedes trunk hangup, matching the listener's completed-finalization path.

## Concrete compatibility risks

1. **Blocking:** late caller identity on the initial DAHDI `Newchannel` prevents state initialization and causes the entire observed call to be ignored.
2. **Evidence gap:** capture omits `DialStatus`, so the listener's answer fallback cannot be validated.
3. **Role dependency:** `isInboundCrmAgentByExt()` performs a CRM database role lookup when roles are configured (`200-227`). Rejection or lookup failure suppresses both ringing and answer recognition.
4. **Exact-channel finalization:** completed finalization depends on equality with the full stored `answered_chan` (`562`). Transfers, masquerades or channel replacement could leave an answered call unfinalized; Capture 01 does not exercise this.
5. **Provisional state lifecycle:** the minimal initialization fix should eventually include timeout cleanup for calls that never acquire an external number or never dial a CRM extension.

## Additional captures required

- inbound missed call with no endpoint answering;
- caller hangup while endpoints are ringing;
- agent hangup and caller hangup as separate answered scenarios;
- attended and blind transfer, including any channel replacement;
- queue call with two different CRM extensions ringing;
- queue call where two device registrations exist for one extension but event order differs;
- outbound call, confirming that `from-internal` filtering remains correct;
- AMI reconnect during an active call;
- capture including `DialStatus` and legacy `CallerID`;
- final UCM6510 regression only after UCM6301 development behaviour is accepted.

## Scope and safety

This analysis used only the existing repository listener and the specified local capture. No service, PBX, database, TCP bridge, WebSocket or production directory was accessed. No caller name is included and telephone-like values are masked to their final three digits.
