# CEM Legacy Observation Hook

The Phase 8D hook is a disabled-by-default, local-only observer attached to the
authoritative legacy AMI listener. It records facts the listener has already
decided; it does not participate in those decisions and does not replace the
listener.

Related architecture: MPBX-000, MPBX-004 and MPBX-006. Release: `cem-legacy-observation-hook-v0.8.2`.

## Safety and configuration

The listener initializes one `LegacyObserver` from
`config/legacy_observation_config.inc.php`. A missing, unreadable, malformed,
disabled, or incomplete file produces a disabled observer. Initialization,
sanitization, serialization, diagnostic, and write failures are caught. No
listener branch reads an observation result.

Copy `config/legacy_observation_config.inc.php.sample` locally, keep
`enabled => false` during review, and create a restrictive salt file. The same
salt file must be used when producing the later correlation bridge. Set an
append-only output under `var/runtime/`. The optional `observation_types`
allowlist can narrow output. Local config, salt, and output are ignored by Git.

When disabled, the observer returns before timestamp/default enrichment or
writer access. Enabling adds only a local JSONL append; there are no network,
database, CRM, archive, or WebSocket changes caused by observation.

## Listener touchpoints

- `ring_notified`: after the existing WebSocket bridge accepts the first ring
  line for an eligible extension.
- `answer_resolved`: after the existing Newstate or DialEnd branch commits the
  first eligible answering extension to listener memory.
- `popup_emitted`: after an existing ring/answered WebSocket line is written
  successfully; the payload and socket call are unchanged.
- `activity_created`: after the existing PBXManager transaction commits.
- `missed_call_finalized`: after that same successful commit in the existing
  missed finalization path.
- `call_ignored`: for reliably classified root outbound calls and immediately
  before existing role-ineligible and no-CRM-ring returns. Ambiguous secondary
  channels and ambiguous non-external events do not produce ignored-call evidence.
- `duplicate_suppressed`: immediately before existing ring, answer, or
  finalization duplicate returns.

The listener does not currently retain a reliable queue ID. The hook therefore
emits `queue_id: null` and `evidence_status: queue_unavailable`. It never infers
a queue from a caller number and does not hardcode a queue.

A bounded in-memory terminal-classification guard prevents a reliably observed
outbound root call from later producing a contradictory inbound
`missed_no_crm_rings` observation. The guard is keyed by raw Linkedid only in
process memory; raw keys are never written to observation output or persistent
state. The existing no-rings cleanup checks it without consuming it; the marker
is cleared only after the listener unsets the corresponding call state. Other
terminal cleanup follows the same ordering, and the registry evicts the oldest
entry at its fixed capacity. It does not alter listener call state or cleanup
decisions.

The guard is listener-lifetime, in-memory and bounded. A root outbound marker survives observer sequence cleanup, suppresses only a later contradictory `missed_no_crm_rings` observation for the same runtime Linkedid, and is removed after the corresponding listener call state is removed. It does not add outbound answer tracking or change missed-call business logic.

## Emitted and omitted data

Records use the existing `LegacyObservation` schema and contain the observation
type, timestamp, HMAC identity hashes, normalized extensions, action booleans,
safe reason codes, sequence number, tenant/environment, and bounded evidence
facts such as ring-extension count. Reason codes are explicitly allowlisted.

The hook deliberately omits raw caller numbers, clear Linkedid/Uniqueid,
channels, AMI packets, SIP headers, recording paths, credentials, CRM users,
and unrestricted reason text. Files and directories are created with modes
`0600` and `0700` respectively.

## Active observation file handling

The writer keeps the observation JSONL file open for the lifetime of the listener process. Do not remove the active file with `rm` while the listener is running.

On Unix, removing the pathname does not close the process file descriptor. The listener continues writing to the deleted inode. A new file created at the same pathname is a different inode and does not receive writes from the old descriptor. `lsof` may display the old file with `(deleted)`.

To clear an active test file, use controlled truncation while preserving restrictive permissions:

```bash
truncate -s 0 /path/to/observations.jsonl
chmod 600 /path/to/observations.jsonl
```

If the active file was removed, restart the listener so the writer opens the new pathname. This is normal Unix file-descriptor behavior, not a CEM business-logic failure.

## Controlled rollout and rollback

1. Review the focused `ami/listener.php` diff.
2. Install local configuration with `enabled => false` and confirm normal
   listener behavior.
3. Enable only in DEV/test for a bounded window.
4. Run ten controlled answered, missed, multi-agent-ring, and ignored
   internal/outbound calls.
5. Generate CRM shadow decisions and run the parity harness.
6. Set `enabled => false` immediately after the window.
7. Review sanitized parity results before considering broader observation.

Rollback is configuration-only: set `enabled => false` or remove the local
configuration file, then restart the listener through the existing operational
procedure. Observation output is runtime data and must not be committed.

The production legacy listener remains authoritative and active. Enabling this
hook does not authorize listener replacement or CRM side effects.

The minimum controlled DEV set must include an answered inbound call, a missed inbound call and a reliably classified outbound root call. Verify that outbound is observed once and is not followed by a false inbound `missed_no_crm_rings` observation.
