# CEM Consumer SDK

Phase 6 provides a local, framework-free SDK for deterministic consumers of the
Phase 5 append-only Event Journal. It contains no CRM, WebSocket, archive, AI,
AMI, database, network, or tenant-specific business logic. The legacy listener
remains authoritative for production CRM behavior. Its optional observation
hook is additive, disabled by default and does not change business behavior.

Related architecture: MPBX-002, MPBX-004 and MPBX-006. Release: `cem-consumer-sdk-v0.6`.

## Handler contract

Implement `JournalConsumerHandler`:

```php
final class ExampleHandler implements JournalConsumerHandler
{
    public function handle(JournalRecord $record, ConsumerContext $context): JournalProcessingResult
    {
        return JournalProcessingResult::ack();
    }
}
```

`JournalRecord` is readonly. `ConsumerContext` supplies the consumer name,
version, stable logical instance ID, attempt number, replay mode, and configured
tenant/environment scope.

- `ACK`: processing succeeded; advance the checkpoint.
- `SKIP`: valid but not applicable; advance the checkpoint.
- `RETRY`: transient failure; retry according to policy and do not advance while
  unresolved.
- `DEAD_LETTER`: permanent failure; append a safe dead-letter record and advance
  only after the append is flushed and synchronized.

The handler owns its processing outcome: the SDK interprets ACK, SKIP, RETRY and DEAD_LETTER for checkpoint movement but does not manufacture business success. A handler that creates an external business effect must implement effect-level idempotency using `event_id`, `journal_record_id` or a documented deterministic business key; a checkpoint alone cannot make an external side effect exactly once.

## Validation and filtering

The reader processes only newline-terminated records and validates required
journal fields, timestamps, supported CEM version, top-level/embedded event IDs,
event types and correlation IDs, and the SHA-256 canonical event checksum. It
never prints malformed input. Unknown embedded event extensions remain intact.

Filtering occurs after validation and supports exact event-type allowlists,
tenant, environment, inclusive occurrence-time bounds, and journal byte-offset
bounds. Filtered records use `SKIP` semantics and advance a live checkpoint.

## Consumer identity and checkpoints

Every live consumer supplies a name, version, and stable logical instance ID.
The checkpoint stores schema version `1`, all three identities, journal path and
device/inode, committed offset and line boundary, last record/event IDs, counts,
processed journal identities, a boundary checksum, and update time. Identity or
journal mismatch is rejected.

Checkpoint replacement is temporary-file, flush, optional `fsync`, and atomic
rename with mode `0600`. It advances only after ACK, SKIP, successful dead-letter
append, duplicate suppression, or safe rejection of an invalid complete line.
The latter prevents malformed poison lines from causing an endless replay loop.

## Replay modes

- Resume: an existing matching live checkpoint resumes at its committed byte.
- Initial: without a checkpoint, live consumption begins at byte zero.
- Bounded replay: any explicit offset or time bound uses an isolated replay and
  never reads or modifies the normal checkpoint.
- Dry-run: validates and invokes the inspection handler without checkpoint or
  dead-letter writes.

`--from-offset` therefore operates only as explicit historical replay, never as
an implicit reset of a live checkpoint. A missing journal is tolerated while
following. Truncation, inode replacement, or committed-boundary mismatch fails
safe and requires an explicit replay/reset decision.

## Retry and dead-letter behavior

`ConsumerRetryPolicy` defaults to three attempts, 50 ms base delay, 1000 ms cap,
and the `transient` retryable category. Backoff is deterministic and bounded.
An unresolved retry exits the one-shot CLI non-zero. Exhausted retryable records
are dead-lettered only when a writer is configured; otherwise they remain
uncommitted.

Dead letters are append-only, mode `0600`, contain identifiers and a restricted
safe failure description but no event payload, and are deduplicated by consumer
name plus journal record ID. A torn dead-letter tail fails safe.

## Locking

Live mode takes a nonblocking exclusive lock derived from its checkpoint. Replay
and dry-run use a separate replay lock. Two consumers can read one journal using
different checkpoints; two processes sharing a logical checkpoint cannot.

## Inspection CLI

```sh
php tools/cem_journal_replay.php \
  --journal /tmp/cem-journal/journal.jsonl \
  --consumer-name inspection-v1 \
  --checkpoint /tmp/cem-consumer/inspection.checkpoint.json \
  --event-types communication.call.created,communication.call.ended \
  --once
```

The built-in handler has no side effects and prints only `event_id`, `event_type`,
`correlation_id`, `tenant_id`, `environment`, and `occurred_at`. `--once` and
`--follow` are mutually exclusive; the default without either is one pass.

## Limitations

Checkpoint identity sets grow with processed records. There is no automatic
checkpoint compaction, journal rotation, retention, parallel partitioning, or
transaction spanning an external handler side effect and checkpoint write.
Handlers requiring stronger delivery semantics must implement idempotency using
`event_id` or `journal_record_id`.
