# Phase 4 Remediation Report

Date: 2026-07-15

Scope: Phase 4 remediation only. Phase 5, history exports, AirBridge integration, and unrelated features were not started. Governing contract files such as `MASTER_SPEC.md`, `DATABASE_SCHEMA.sql`, `OPENAPI.yaml`, and `CODEX_PROMPTS.md` were not modified.

## Summary

Implemented remediation for every original `FAIL` item in `docs/PHASE4_AUDIT.md`, with JavaScript behavior verified where the current environment permits and PHP/MySQL/Redis execution explicitly marked blocked.

The core security fixes are in place at the source level:

- Spin completion is now bound to stored `sessionId` and `guestId`.
- Entitlement issuance is limited to persisted, unclaimed `NORMAL_WIN` spins.
- One entitlement per spin is enforced through `replacementEntitlementBySpin` and `replacementEntitlementId` on the stored spin.
- Entitlements now include `expectedStateVersion` and commit rejects stale state with `REPLACEMENT_STATE_CONFLICT`.
- Raw client device claims are no longer final authorization inputs.
- Image processing no longer copies original uploads when WebP/GD support is unavailable.
- Capture cleanup is idempotent and behaviorally tested.

## Files Changed

- `apps/web/src/main.js`
- `packages/core/src/bootstrap-state.js`
- `packages/core/src/index.js`
- `packages/core/src/replacement-client.js`
- `packages/core/src/replacement-events.js`
- `packages/ui/src/camera-view.js`
- `server/src/Controllers/AssetController.php`
- `server/src/Controllers/ReplacementController.php`
- `server/src/Controllers/SpinController.php`
- `server/src/Services/AssetService.php`
- `server/src/Services/ImageMutationAuthorizationService.php`
- `server/src/Services/ReplacementEntitlementService.php`
- `server/src/Services/ReplacementWorkflowService.php`
- `server/src/Services/SpinAuthorizationService.php`
- `server/tests/Unit/ImageMutationAuthorizationTest.php`
- `server/tests/Unit/ReplacementWorkflowTest.php`
- `tests/phase4/run-phase4-tests.mjs`
- `docs/PHASE4_AUDIT.md`
- `docs/PHASE4_REMEDIATION_REPORT.md`

## Original FAIL Remediation

| Original FAIL | Remediation | Verification Status |
| --- | --- | --- |
| Spin ownership failure | Stored `sessionId`/`guestId` on authorization and checked them during completion and entitlement creation. See `server/src/Services/SpinAuthorizationService.php:50`, `server/src/Services/SpinAuthorizationService.php:84`, `server/src/Services/ReplacementEntitlementService.php:21`. | Statically verified; PHP runtime blocked. |
| Non-normal/expired/forged/already-claimed spins could still complete/claim | Added explicit rejections for wrong session, expired, forged, already claimed, and non-`NORMAL_WIN` spins. See `server/src/Services/SpinAuthorizationService.php:87`, `server/src/Services/SpinAuthorizationService.php:88`, `server/src/Services/SpinAuthorizationService.php:89`, `server/src/Services/SpinAuthorizationService.php:92`. | Statically verified; PHP runtime blocked. |
| Missing one-entitlement-per-spin guard | Added `replacementEntitlementBySpin` and stored `replacementEntitlementId` on the spin. See `server/src/Services/ReplacementEntitlementService.php:27`, `server/src/Services/ReplacementEntitlementService.php:55`, `server/src/Services/ReplacementEntitlementService.php:60`. | Statically verified; PHP runtime blocked. |
| Missing expected state version | Added `expectedStateVersion` to entitlement and commit-time conflict rejection. See `server/src/Services/ReplacementEntitlementService.php:46`, `server/src/Services/ReplacementWorkflowService.php:115`. | Statically verified; PHP runtime blocked. |
| Raw device/capability trust | Replacement client sends `X-WR-Session-Capability`, not raw device capability; server resolves trusted capability from state. See `packages/core/src/replacement-client.js:3`, `server/src/Controllers/ReplacementController.php:74`, `server/src/Services/ReplacementWorkflowService.php:232`, `server/src/Services/ImageMutationAuthorizationService.php:24`. | JS client behavior verified; PHP runtime blocked. |
| Private encouragement leaked publicly/always enabled | Public payload no longer contains private encouragement; private event is separate and gated by `settings.notifyRemovedGuest` plus active removed-guest session. See `server/src/Services/ReplacementWorkflowService.php:140`, `server/src/Services/ReplacementWorkflowService.php:257`, `server/src/Services/ReplacementWorkflowService.php:267`, `server/src/Services/ReplacementWorkflowService.php:276`. | JS helper behavior verified; PHP runtime blocked. |
| Camera object URLs/tracks not cleaned | Added URL revoke, stream stopping, destroy lifecycle, beforeunload cleanup, and duplicate submission prevention. See `packages/ui/src/camera-view.js:53`, `packages/ui/src/camera-view.js:73`, `packages/ui/src/camera-view.js:80`, `packages/ui/src/camera-view.js:104`, `packages/ui/src/camera-view.js:193`. | Behaviorally verified in Node tests. |
| Original upload fallback when image processor unavailable | Removed original-to-reel copy fallback. Missing required functions now throw `IMAGE_PROCESSOR_UNAVAILABLE`; partial variant directories are removed. See `server/src/Services/AssetService.php:137`, `server/src/Services/AssetService.php:139`, `server/src/Services/AssetService.php:142`, `server/src/Services/AssetService.php:177`. | Statically verified; PHP runtime blocked. |
| Moderation rejection not represented | Added explicit `MODERATION_REJECTED` commit rejection before occupant mutation. See `server/src/Services/ReplacementWorkflowService.php:93`. | Statically verified; PHP runtime blocked. |
| Tests mostly source-text matching | Added executable JS behavior tests for capture limit, cleanup, consent, duplicate submit, desktop UI, client capability header, and event public/private separation. See `tests/phase4/run-phase4-tests.mjs:87`, `tests/phase4/run-phase4-tests.mjs:101`, `tests/phase4/run-phase4-tests.mjs:110`, `tests/phase4/run-phase4-tests.mjs:127`, `tests/phase4/run-phase4-tests.mjs:137`, `tests/phase4/run-phase4-tests.mjs:153`, `tests/phase4/run-phase4-tests.mjs:171`. | Behaviorally verified for JS; PHP runtime blocked. |

## New/Updated Exports

- `loadBootstrap()` in `packages/core/src/bootstrap-state.js:37`
- `publicReplacementEvent()` in `packages/core/src/replacement-events.js:1`
- `privateReplacementEventForSession()` in `packages/core/src/replacement-events.js:9`
- Capture cleanup/submission helpers in `packages/ui/src/camera-view.js:53`, `packages/ui/src/camera-view.js:67`, `packages/ui/src/camera-view.js:73`, `packages/ui/src/camera-view.js:80`, `packages/ui/src/camera-view.js:100`, `packages/ui/src/camera-view.js:104`

## Behaviorally Verified

Executed by `node tests/phase4/run-phase4-tests.mjs`:

- One-to-three image cap.
- Remove/reorder helpers.
- Object URL cleanup.
- Consent gating.
- Duplicate submission prevention.
- Idempotent camera track cleanup.
- Desktop capture UI disabled.
- Replacement client uses server session capability token and does not send raw device capability.
- Public/private replacement event helper behavior.

## Statically Verified

- Spin ownership checks.
- Result type rejection before entitlement issuance.
- One entitlement per spin.
- Expected state-version commit rejection.
- Trusted server-side session capability lookup.
- Protected-symbol revalidation.
- Moderation-rejected upload guard.
- Image processor unavailable rejection.
- Public/private event separation in PHP workflow.

## Blocked Pending PHP/MySQL/Redis Runtime

The following were not executed because `php`, `composer`, `docker`, `mysql`, and `redis-server` are unavailable in this environment:

- PHP syntax checks.
- Composer validate/install.
- PHPUnit execution.
- MySQL migrations.
- Apache/PHP endpoint testing.
- Redis/distributed lock verification.
- Two simultaneous valid replacement commits against the same wedding/symbol.
- Actual GD/WebP image fixture processing.

The PHPUnit file now contains the required blocked cases in `server/tests/Unit/ReplacementWorkflowTest.php:16`, `server/tests/Unit/ReplacementWorkflowTest.php:21`, `server/tests/Unit/ReplacementWorkflowTest.php:26`, `server/tests/Unit/ReplacementWorkflowTest.php:31`, `server/tests/Unit/ReplacementWorkflowTest.php:36`, `server/tests/Unit/ReplacementWorkflowTest.php:41`, `server/tests/Unit/ReplacementWorkflowTest.php:46`, `server/tests/Unit/ReplacementWorkflowTest.php:51`, `server/tests/Unit/ReplacementWorkflowTest.php:56`, and `server/tests/Unit/ReplacementWorkflowTest.php:61`.

## Commands Run

```bash
node --check apps/web/src/main.js
node --check packages/ui/src/camera-view.js
node --check packages/core/src/replacement-client.js
node --check packages/core/src/replacement-events.js
node --check packages/core/src/bootstrap-state.js
node --check tests/phase4/run-phase4-tests.mjs
node tests/phase4/run-phase4-tests.mjs
npm run ci:local
rg -n "HTTP_X_WR_CLIENT_CAPABILITY|clientCapability" server packages apps tests/phase4 -g '!node_modules'
php -v
composer --version
docker --version
mysql --version
redis-server --version
```

## Test Results

`node tests/phase4/run-phase4-tests.mjs`:

```text
ok - mobile capture state supports one-to-three images, remove, reorder, and URL cleanup
ok - addCaptureFiles enforces one-to-three image limit behaviorally
ok - capture consent and duplicate submission prevention are executable behavior
ok - camera track cleanup is idempotent
ok - capture component disables ordinary desktop UI and destroys cleanly
ok - replacement client sends entitlement and server capability token, not raw device capability
ok - replacement event public/private helpers keep private payload separate
ok - server code binds replacement entitlement to spin ownership and expected state version
ok - server image pipeline rejects unavailable processors instead of copying originals
ok - server authorization no longer trusts raw client capability headers
ok - replacement public event excludes private encouragement and private outbox is separate
ok - PHP replacement runtime test specifications are present for Docker/CI execution
ok - Phase 4 routes and migration are present without Phase 5 scope creep
phase 4 tests passed
```

`npm run ci:local` passed through Phase 0, Phase 1, Phase 2, Phase 3, and Phase 4. The Phase 3 500-client WebSocket smoke metric from the final run was:

```text
metrics - ws500 delivered=500 p50=23.54ms p95=30.93ms rssDelta=17264640
```

Runtime availability checks:

```text
php: command not found
composer: command not found
docker: command not found
mysql: command not found
redis-server: command not found
```

## Not Started

- Phase 5.
- History exports.
- AirBridge integration.
- New moderation UI/workflows.
- Real trusted mobile join issuance flow.

## Remaining Release Blockers

- Execute PHP 8.3/Composer/PHPUnit/MySQL/Redis/Docker suite.
- Replace file-backed lock/transaction scaffold with real MySQL transaction plus Redis/distributed lock.
- Prove two simultaneous valid commits cannot create multiple current occupants.
- Run real browser/mobile camera tests and real server image fixture tests.
