# DCP-Cine Player operational audit: implementation and Catcher handoff

Date: 9 October 2026. First distribution version: **4.2.15 beta**. Release status: packaged for signed distribution; on-site qualification is a separate step. Installations still running 4.2.14 do not have this API.

The [requested addition](DCP_CINE_PLAYER_AUDIT_API_ADDITION.md) is the design contract. This handoff describes the implementation, operational recovery and integration work. These are **unsigned operational records**, not a certified DCI/SMPTE security log. SHA-256 verifies downloaded bytes; it does not authenticate a secure device or establish TPN/MPAA certification. The native API exports NDJSON. The 10 October TMS additions include a separately commissioned unsigned SOAP/FTP XML bridge; see the [complete TMS handoff](DCP_CINE_PLAYER_TMS_PROGRAMMING_HANDOFF.md). Actual legacy Catcher qualification remains outstanding, so `audit.barco_compatible_exports` stays false.

## Native API and permissions

Use the existing configured scheme, host and port, usually `http://IP:8080/api/player/v1`. Keep the native player type: `dcp-cine-player`, `dcp-cine` or `catcher-player`. Authentication uses the existing bearer/API token or username/password login session. Never put a token in a download URL. Administrators and managers have `audit.read` and `audit.export`; other roles are denied. Jobs and files are scoped to the creating authenticated principal, so poll/download with that same principal.

| Method | Relative path | Purpose |
| --- | --- | --- |
| GET | `/capabilities` | Existing fields plus the `audit` capability |
| POST | `/audit/exports` | Durable asynchronous export; requires `Idempotency-Key` and `audit.export` |
| GET | `/audit/exports/{export_id}` | State, snapshot, coverage and immutable document manifest; requires `audit.read` |
| GET | `/audit/exports/{export_id}/documents/{document_id}` | Authenticated UTF-8 NDJSON download |
| GET | `/audit/journal` | Persistent identity, high water mark, coverage, health and retention |
| PUT | `/audit/retention` | `{ "retention_days": 90 }`; also requires `config.write` |
| GET | `/audit/documentation` | Download this handoff |
| GET | `/audit/schemas/{name}` | Download `audit-event.schema.json`, `audit-export.schema.json` or `audit-api.openapi.json` |

The existing `/documentation` download includes this handoff. The schemas are bundled into the service binary and also installed in `/usr/share/doc/dcp-cine-player/player-api/`. Canonical source files are [event schema](audit-event.schema.json), [export schema](audit-export.schema.json) and [OpenAPI](audit-api.openapi.json).

Discovery advertises support only with authenticated audit permissions and the native engine's audit-notification handshake. A process-mode or older native engine cannot establish complete observed playback coverage and therefore reports unsupported. A journal fault is separately visible in `/audit/journal`; a supported API can refuse new exports with `503 audit_unavailable` while operators investigate.

Defaults: NDJSON only; maximum historical range **86,400 elapsed seconds**; incremental limit and document limit **10,000 records**; files also split at approximately **8 MiB**. At most 16 queued/running exports are accepted. Journal retention defaults to **90 days**, configurable from 1–3650. Files remain available for at least **24 hours after completion**; idempotency tombstones remain for seven further days. No record cap silently truncates a historical range.

## Collection workflow

Persist the exact request and idempotency key before submitting. HTTP 202 immediately includes `player_id`, `log_store_id`, `snapshot_sequence`, `coverage` and `status_url`, including queued/running jobs. Repeating a key with normalized equivalent arguments recovers that job; changed arguments receive `409 idempotency_conflict`. Expired jobs receive `410 export_expired`, requiring a new key.

Historical example:

```json
{"mode":"range","format":"ndjson","from_utc":"2026-10-08T00:00:00Z","through_utc":"2026-10-09T00:00:00Z"}
```

Ranges select `from_utc <= occurred_at_utc < through_utc`. Supply explicit UTC/offset timestamps; naive times, empty/reversed ranges and future end times fail. For a multi-day bootstrap, reuse the first job's `log_store_id` and `snapshot_sequence` in **every subsequent range**. Finish every intended interval before committing that fence as the global incremental cursor. If the recording coverage starts later than the requested history, `409 retention_gap` provides a valid boundary; an operator must explicitly acknowledge the shorter window.

Subsequent example:

```json
{"mode":"incremental","format":"ndjson","log_store_id":"22222222-2222-4222-8222-222222222222","after_sequence":"1042","limit":10000}
```

Sequences are unsigned 64-bit **decimal strings**. Use `(target, player_id, log_store_id, sequence)` as the import identity, retaining full UUID `event_id`. Keep one snapshot fence for each incremental batch run and honour `has_more`. The next scheduled run uses a fresh fence. Occurrence timestamps are never cursors: same-timestamp events, backdated events and backward clock changes are collected by sequence.

Poll at one-to-two second intervals. A 180-second client budget limits each invocation, not the server's export lifetime. Resume the same persisted job after timeout. For completed jobs, download every document, verify SHA-256, byte count, line count, identity and sequence order, then store all records and progress in one local transaction. An empty successful job has zero records, no documents and null first/last sequence. Empty incremental exports retain the requested cursor. Permission failures, unsupported discovery, incomplete history and completed-empty are distinct outcomes.

Additional status fields are `retained_after_sequence` (the pruned prefix at acceptance), `created_at_utc` and `status_url`; range status also contains `has_more: false`. Clients should tolerate documented additive fields. Error objects include `error`, stable `code` and `details`. Retry queue pressure/unavailability with `Retry-After`; never advance a cursor after errors, corrupt documents or failed jobs. Unknown jobs/documents are 404; expired ones are 410 while metadata remains. Failed jobs publish no partial manifest.

## Recorded operations and execution evidence

Playback records come from serialized native engine callbacks: first presented frame, actual item return, applied transport change and terminal playlist result. `SPLStart` and item/CPL starts share a playback-run UUID; repeated CPL occurrences retain distinct show-item IDs. Only a full, naturally completed CPL emits `PlayoutComplete`. Seeked partial playback ends as unverified; skipped/stopped items abort; interrupted or repositioned shows cannot become successful merely because a duration elapsed. Non-CPL black/event/SDI items use supplementary `ItemStart`, `ItemEnd` or `ItemAbort` subtypes. Live SDI does not start an audit run until a frame is presented.

Automation attempts have `MacroRequested` and terminal `MacroComplete`/`MacroFailed`, distinct attempt IDs and a shared command ID for retries/steps. Explicit empty cues are `no_action`. Successful transport dispatch to a device with no effect acknowledgement is `unverified`, not verified device execution. Failed dispatch is `failed`. Macro completion describes its executed software steps; device effect remains unverified where feedback is absent. Action results contain safe outcomes and public IDs, excluding transport credentials, payloads and argument values. Pending attempts survive crashes and recover as interrupted failures.

Ingest job start/completion/failure and atomic content inventory indexing/deletion are journalled, as are KDM import/rejection/deletion, applied scheduler/session changes and validation refusals. Supplementary `ContentIndexed`, `SessionExecutionState`, `AuditRetentionChanged`, `AuditRetentionPruned` and `AuditReset` records provide inventory, execution and policy evidence. Original titles/show/cue names are saved when records are written; historical exports do not resolve them against current catalogues or make per-record network requests. Authenticated request principals and saved scheduler origins are propagated to background work. Clock adjustments retain existing timestamps and record the adjustment separately.

## Persistence, migration and recovery

The normal SQLite migration advances the application schema from **9 to 11** (audit tables introduced in 10; TMS programming/recovery metadata in 11), adding the journal, event, export and active-operation tables plus captured job origin. Existing users, content and shows are retained. New installations begin recording at `coverage_start_utc`; no earlier coverage is invented. The journal lives beside the existing application data:

- `DCP_PLAYER_DB_PATH`, normally `/opt/dcp-player-app/db/player.sqlite`, including its WAL during live use.
- `player.sqlite.audit-identity.json`: separately fsynced high water and installation/journal identity.
- `player.sqlite.audit-fault.json`: persistent fault/gap marker when present.
- `audit-exports/`: isolated immutable export-job directories.

Business transitions share SQLite transactions with their audit events where applicable. Journal writes use FULL synchronous durability. Export workers use short pages and do not hold the playback or schedule lock while querying/building/transmitting files. Queued/running jobs recover as failed `export_interrupted` after restart, preserving their key; clients deliberately request a new job/key after seeing the failure. Retention pruning waits while accepted snapshots are active and removes only an old contiguous sequence prefix, advancing recording coverage explicitly.

Ordinary clean restarts preserve identities, events and next sequence. Active playback/automation markers are durable. After an **unclean restart**, recovery records interruptions and `UncleanRestart`/`AuditGap`; it conservatively latches a journal health fault because events between the last durable notification and the crash cannot be proven complete. It never invents completion. Exports accepted before the fault keep their frozen manifest; new exports are unavailable until the operator has resolved and acknowledged the gap.

An older database restored while the separate high-water file is current is detected: the installation `player_id` survives, a **new `log_store_id`** is assigned and recording coverage restarts. Restoring **both** the database and high-water file to the same old backup cannot be detected automatically by a local store. A restore/rebuild must therefore use the explicit reset procedure before collection resumes. Do not restore old identity sidecars and assume monotonic sequences remain provable.

For planned upgrades/restarts, stop the service cleanly and use the deployment's usual configuration. Back up SQLite using a consistent SQLite backup or while stopped; copy the sidecars too. To deliberately acknowledge lost history/reset the journal, stop the service and run `dcp-player-service --reset-audit-journal` as its normal service user with the same `DCP_PLAYER_DB_PATH` environment. This **deletes the old operational journal**, changes the journal UUID and starts a visible new coverage boundary; archive available history and get operator acknowledgement first. The maintenance lock prevents resetting a running player. Restart normally, inspect `/audit/journal`, and make Catcher explicitly re-bootstrap/record lost history. Never delete the database to repair audit alone. Disk/append/checkpoint failures latch a health fault; fixing disk space alone does not silently erase evidence of loss.

System → **Operational Audit** displays identities, sequences, health/gaps and retention. The **Test audit export** action creates/downloads a small read-only incremental export and verifies the manifest; it does not play content, fire cues, delete history or reset the journal. Browsers on insecure HTTP may lack Web Crypto; that UI then displays the supplied hash instead of claiming a local SHA-256 check. The Catcher collector always checks hashes.

## Catcher integration deliverable and remaining wiring

**The Cinema Catcher repository was not available in this workspace. Its production dispatcher, Django migrations, background task and audit UI have not been changed here.** A dependency-free native collector and tests are delivered under `integrations/catcher/`. This is executable integration code, but it must be wired into Catcher and qualified there before claiming Player Audit works end to end.

`native_audit.Collector` accepts Catcher's existing authenticated native request helper through `(method, relative_path, json_body, extra_headers) -> (http_status, headers, bytes)`. Bind a helper separately per target using its existing host/scheme/port/authentication and bounded reauthentication policy. Never route these aliases through vendor FTP. `collect_players(players, collector_factory)` isolates failures per screen; each factory creates a collector with the correct target transport. Serialize collection per target in the existing background task.

The reference collector persists jobs, keys, bootstrap progress, journal/fence/cursor and full raw event metadata in SQLite with WAL/FULL durability. To integrate with the existing Django store, migrate its persistence seam (`state`, `save`, event import) into durable models while preserving atomic import-plus-progress semantics. Required fields:

| Model | Fields and constraints |
| --- | --- |
| Audit collection state | target FK unique; player/log-store UUID; sequence **text or exact decimal(20,0)**; bootstrap bounds/next interval/fence; pending key, exact JSON arguments and job/status; visible outcome/error; operator lost-history acknowledgement |
| Audit native event | target FK; full player/log-store/event UUIDs; sequence text/decimal; outcome; occurred/recorded UTC; saved title/kind/cue/message; complete raw JSON |
| Uniqueness | `(target_id, player_id, log_store_id, sequence)` and full event UUID identity; reject changed bytes under an existing identity |

Do **not** put UUIDs in the existing 20-character `pa_entry.recId`, truncate them or use display timestamps as import identity. Link a native event row to any legacy display row using a new FK/identity field. Map `event_type`, `event_subtype`, `occurred_at_utc`, `cpl_id`, saved `content_title_text`/`content_kind`, `cue_name`, `outcome`, `error_code` and full `message` into report fields; truncate summaries only for display. A historical title must remain usable after content deletion. Cache any optional enrichment per collection run.

The collector implements fixed-fence 24-hour bootstrap, idempotent document import, hash/count checks, sequence pagination, durable timeout resume, expired-job renewal, shorter-range retries and explicit retention/reset acknowledgements. `acknowledge_history` requires an operator reason, retains old events and records the previous state; no automatic silent cursor reset is provided. The reference transport intentionally delegates credential handling/reauthentication to Catcher's existing authentication layer.

## Fixtures and qualification

`docs/audit-fixtures/` includes successful, failed, completed-empty, incremental and retention-gap examples plus immutable `audit-0000.ndjson` with its actual hash/counts. These are deterministic **test records**, not production evidence. A harmless empty cue is provided as `harmless-cue.xml`. The included `test-dcp.zip` contains a complete playable five-second unencrypted black/silent SMPTE DCP, with CPL, picture/sound MXFs, PKL and AssetMap. Extract it to a dedicated staging directory and ingest normally. `tests/audit-fixtures/make_test_dcp.cpp` and `scripts/create-audit-test-dcp.sh` generate a short, unencrypted black/silent SMPTE test DCP using the repository's libdcp and FFmpeg toolchain. Generated IDs are reported by its CPL XML; import it through the usual ingest path into a test player.

Run native transport regression with `tests/extended_playback_control_test.py --binary <native-binary> --dcp <test-dcp-directory>`, Go tests with `go -C player-service test ./...`, race qualification for store/service/automation, and Catcher collector tests with `python3 -m unittest discover -s integrations/catcher -p 'test_*.py' -v`. See [qualification results](audit-qualification.md) for what was exercised and what remains on-site. On the test player, create a show with the same test CPL twice and the empty cue, play naturally, repeat with pause/resume/seek/skip/stop, export and inspect correlation/outcomes. Rename/delete the show/content and export again. Perform restart/retention/reset tests on isolated data and retain the loss acknowledgement.

Release handoff must still include a published build and on-site native DCP/audio/SDI runs, power-loss/storage-pressure testing on the target filesystem, and real Catcher import/report qualification after integration. The 10 October changes implement a separately commissioned unsigned Barco parser bridge; see [the TMS handoff](DCP_CINE_PLAYER_TMS_PROGRAMMING_HANDOFF.md). Its SOAP/TLS/login/passive-FTP loopback tests do not establish actual legacy Catcher-client or hardware qualification; `audit.barco_compatible_exports` remains false.
