# Persistent CEM call aggregates

Phase 4 stores active call aggregates under `var/runtime/cem-aggregates/` by default. State is isolated by a hashed capture-session directory and one hashed correlation file per aggregate. Files use schema version 1 and mode `0600`; directories use mode `0700`.

Related architecture: MPBX-001, MPBX-002 and MPBX-006. Releases: `cem-shadow-v0.1`, `cem-lifecycle-v0.2` and `cem-aggregate-persistence-v0.4`.

MPBX-001 owns normative call identity, correlation, causation and lifecycle semantics, while MPBX-002 owns logical runtime-state and recovery responsibilities. The directory layout, JSON state schema and crash-ordering steps below describe this PHP implementation only; they do not redefine the canonical call aggregate or require other conforming implementations to use files.

Persistent shadow state does not make this path authoritative for production CRM effects. The legacy listener remains authoritative except for an explicitly interlocked action scope during a reviewed controlled rollout.

The aggregate stores one `agent_extension`. For ringing, the first structurally valid destination extension is retained across repeated ring legs. A later reliably resolved answered-agent extension may replace that ring candidate. Multiple simultaneous ring candidates are represented in call legs/provenance, but not yet as a canonical extension set.

The aggregate also stores the first reliable explicit AMI `Queue` value as canonical `queue_id`. Repeated identical evidence is stable. A different later explicit value cannot overwrite the first identity and sets a persisted conflict indicator for safe diagnosis. Generic dialplan and channel fields never establish or replace queue identity. Queue evidence received before ringing enriches that transition; late evidence applies only to subsequent canonical facts.

For inbound calls, the aggregate stores the first normalized `CallerIDNum` accepted from the canonical root leg. A secondary agent leg, `ConnectedLineNum`, queue identity, agent extension or channel text cannot replace it. This value is personal data required for CRM lookup: it is persisted in active aggregate state and canonical ringing payload, and therefore in the Event Journal. It is not an event, correlation, trace or idempotency identity and remains excluded from action/authority audits and consumer idempotency state.

The durability order for each complete sanitized AMI line is:

1. derive aggregate mutations and canonical transition intents;
2. append, flush and `fsync` each new CEM event where supported;
3. record or reconcile the emitted event identity in the aggregate;
4. atomically write and `fsync` aggregate state;
5. atomically commit the follower byte offset;
6. after an ended transition, remove the terminal aggregate from active state.

If a crash occurs after CEM output but before aggregate state, replay uses the existing output idempotency-to-event map to reconcile the transition and causation identity. If a crash occurs after aggregate state but before the follower offset, the restored processed-source identity suppresses repeated state mutation while the offset advances. Corrupt, invalid or schema-mismatched aggregate files are moved to the quarantine directory and counted.

Atomic rename durability still depends on the host filesystem and mount semantics; parent directories are not explicitly `fsync`ed by this PHP implementation. CEM JSONL and aggregate state are separate files rather than one transactional journal, so abrupt storage failure can still require operator inspection.

## Manual restart validation

Use a sanitized synthetic or controlled DEV capture and temporary runtime paths:

```bash
php tools/cem_shadow_follow.php \
  --input /tmp/cem-phase4/capture.ami-events.jsonl \
  --output /tmp/cem-phase4/events.jsonl \
  --state /tmp/cem-phase4/follower-state.json \
  --aggregate-state-dir /tmp/cem-phase4/aggregates
```

Append sanitized created, ringing and answered source records, then stop the follower with `Ctrl+C`. Restart the same command and append the agent-leg and root-leg hangup records. Verify that the output sequence is exactly:

```text
communication.call.created
communication.call.ringing
communication.call.answered
communication.call.ended
```

All four events must share one `correlation_id` and call `subject.id`; every `causation_id` after the first must reference the preceding event. Confirm that the active aggregate file exists before termination and is absent after the ended event and follower offset have been committed.
