---
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8]
lastStep: 8
status: 'complete'
completedAt: '2026-04-27'
inputDocuments: ['prd.md']
workflowType: 'architecture'
project_name: 'pictureFrame'
user_name: 'Matt.edholm'
date: '2026-04-27'
---

# Architecture Decision Document

_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._

## Project Context Analysis

### Requirements Overview

**Functional Requirements:** 44 FRs across 8 domains — User & Account Management, Device Management, Image Library, Image Approval & Sharing, Device Provisioning, Image Rotation & Cycle Engine, Display & Status, Admin & Moderation.

**Non-Functional Requirements:**
- Image pre-rendering completes within 10 seconds of upload/approval trigger (async job target)
- Device image pull endpoint ≤10s on home broadband
- Web pages load ≤3s on standard broadband
- Image rotation ±5 minutes of configured interval
- HTTPS all device-server communication
- MAC-authenticated device endpoint
- Single-use authorization links with configurable TTL
- Scheduled cleanup without manual intervention
- Zero blank screens; last image persists through outages

**Scale & Complexity:**
- Primary domain: Full-stack web app with IoT device client
- Complexity level: Medium
- Deployment target: Single VPS, solo developer, personal-scale traffic
- Estimated architectural components: Web app (Symfony), Async image processing worker, Device API endpoint, Email/token service, Scheduler/cron runner, ESP32 firmware (separate codebase)

### Technical Constraints & Dependencies

- Stack: Symfony (PHP), PostgreSQL, Nginx-FPM, DDEV local dev — mirrors aqua-iq
- APP_BASE_URL is a firmware build constant — domain must be established before firmware development
- API contract is stable by design; breaking changes require physical reflash of all deployed devices
- No OTA firmware updates in V1
- Infrastructure patterns from ~/src/aqua-iq (DDEV, server, SSH)

### Cross-Cutting Concerns Identified

- **Async image processing pipeline:** Upload/approval dispatches a Symfony Messenger message → worker processes (resize, 4bpp palette quantization) → sets image rendering_status to `ready`. Images only enter device pull pool when `ready`. Doctrine transport (consistent with aqua-iq).
- **Image rendering status:** Each image asset tracks `pending → processing → ready | failed` per device model/orientation. Rotation engine and device pull endpoint filter exclusively on `ready`.
- **Device identity:** MAC address as device identifier throughout — provisioning, image pull auth, ownership transfer.
- **Authorization tokens:** Single-use links with TTL for email approve/decline and hard-delete confirmation.
- **Soft-delete with retention rules:** Images retained until last approval removed; scheduled cleanup handles hard-delete.
- **Rotation engine:** Per-device schedule tracking, uniqueness window enforcement, next-image selection from `ready` pool only.

## Starter Template Evaluation

### Primary Technology Domain

Symfony full-stack web application with IoT device client. Stack pre-determined by user.

### Selected Starter: Symfony 8.0 `--webapp`

**Initialization Command:**

```bash
symfony new pictureframe --webapp
```

**Architectural Decisions Provided by Starter:**

**Language & Runtime:** PHP 8.4+, Symfony 8.0

**Templating:** Twig with Stimulus and UX Turbo (Hotwire) — lightweight interactivity without a JS build step

**ORM & Database:** Doctrine ORM, PostgreSQL 16 (DDEV local), migrations via `doctrine/migrations`

**Security:** Symfony Security component — firewalls, voters, role hierarchy

**Email:** Symfony Mailer — handles sharing flows and hard-delete confirmations

**Build Tooling:** AssetMapper (no Webpack/Node build step required)

**Testing:** PHPUnit via `symfony/test-pack`

**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/`

**Additional packages required:**
- `symfony/messenger` + Doctrine transport — async image processing worker
- `symfony/scheduler` — rotation engine and scheduled cleanup
- Image processing library (TBD step 4: GD vs Imagick)

**Local Dev:** DDEV configured to mirror aqua-iq (PHP 8.4, Nginx-FPM, PostgreSQL 16)

**Note:** Project initialization using this command is the first implementation story.

## Core Architectural Decisions

### Decision Priority Analysis

**Critical Decisions (Block Implementation):**
- APP_BASE_URL established: `https://pictureframe.edholm.me` — bake into firmware as build constant
- Image rendering status lifecycle required before rotation engine can select images
- Imagick required on VPS before image processing worker can run

**Important Decisions (Shape Architecture):**
- Local filesystem image storage (not object storage)
- Symfony Messenger async processing with `ready` status gate
- Token entity pattern for all single-use authorization links

**Deferred Decisions (Post-MVP):**
- Caching layer (Redis) — add if query performance becomes an issue
- Additional display models beyond Waveshare 7.3" 800×480

### Data Architecture

**Image Storage:** Local filesystem on VPS. Pre-rendered assets stored at `storage/images/{image_id}/{device_model}_{orientation}.bin`. Originals stored separately at `storage/images/{image_id}/original.{ext}`. Paths stored in DB are relative to `STORAGE_PATH` env var. No object storage — personal scale doesn't warrant the complexity.

**Image Processing Library:** Imagick (PHP extension). Chosen for Floyd-Steinberg dithering quality on the 6-color e-ink palette. GD's palette quantization produces inferior results for this use case. Imagick must be installed on VPS and in DDEV container.

**Image Rendering Status:** Each pre-rendered asset (per device model + orientation) tracks status independently: `pending → processing → ready | failed`. Rotation engine and device pull endpoint filter exclusively on `ready`. Stored as an enum column on a `RenderedAsset` entity.

**Caching:** None in V1. PostgreSQL at personal scale is sufficient. Add Redis later if needed.

**Migrations:** Doctrine Migrations (`doctrine/migrations`). Standard Symfony approach.

### Authentication & Security

**Web Authentication:** Symfony form login — email + password, `remember_me` cookie. Standard Symfony Security firewall.

**Roles:** Two roles only — `ROLE_USER` and `ROLE_SUPER_ADMIN`. Configured in `security.yaml` with role hierarchy. Super admin is a single designated account.

**Authorization Token Pattern:** A `Token` entity — UUID primary key, `type` enum (`share_approve`, `share_decline`, `hard_delete_confirm`), `expires_at` datetime, `used_at` nullable datetime. Single-use enforced: token is invalid if `used_at IS NOT NULL` or `expires_at < NOW()`. TTL is configurable per token type.

**Device MAC Authentication:** Symfony controller guard on the image pull endpoint. Device sends MAC address via URL segment; server validates it exists in the `Device` entity and is linked to an active account before serving the binary asset.

**HTTPS:** Enforced at Nginx level on VPS. DDEV provides HTTPS locally.

### API & Communication Patterns

**Device API:** Single endpoint — `GET /api/device/{mac}/image` — returns raw binary (`application/octet-stream`). This is the only machine-to-machine API surface. No versioning scheme beyond URL stability guarantee (no breaking changes in V1).

**Web Controllers:** Standard Symfony controllers returning Twig responses. No JSON API for the web application.

**Email:** Symfony Mailer. Transactional emails: image share notification (with approve link), hard-delete confirmation. Authorization links embedded as tokenized URLs pointing to Symfony routes.

**Async Messaging:** Symfony Messenger with Doctrine transport. Messages dispatched on image upload and image approval. Worker consumes queue and processes images via Imagick. Sets `RenderedAsset` status to `ready` on completion, `failed` on exception.

**Scheduled Tasks:** Symfony Scheduler. Two recurring tasks:
- Rotation engine: per-device, fires on each device's configured interval to advance the current image pointer
- Cleanup job: periodic hard-delete of soft-deleted images with no remaining approvals

### Frontend Architecture

**Templating:** Twig. Identical pattern to aqua-iq.

**Interactivity:** Stimulus controllers + Turbo Drive (Hotwire). No SPA, no build step required.

**Forms:** Symfony Form component.

**Assets:** AssetMapper (no Webpack/Node).

### Infrastructure & Deployment

**Domain:** `pictureframe.edholm.me` — APP_BASE_URL and API base URL. Must be configured on VPS before firmware build constants are set.

**VPS:** Single server. Nginx reverse proxy → PHP-FPM → Symfony. PostgreSQL on same host. Mirrors aqua-iq production setup.

**Local Dev:** DDEV — PHP 8.4, Nginx-FPM, PostgreSQL 16. Config mirrors aqua-iq `.ddev/config.yaml`.

**Git Hosting:** `git.edholm.me` (self-hosted Gitea/Forgejo). Credentials from existing projects.

**CI/CD:** Gitea Actions (if enabled on git.edholm.me) or SSH-based deploy script. Mirror aqua-iq deployment pattern.

**SSH/Server Access:** Documented in aqua-iq project. Same VPS, same access pattern.

### Decision Impact Analysis

**Implementation Sequence:**
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`)
2. Add Messenger, Scheduler, Imagick
3. Domain + Nginx config on VPS
4. Core entities (User, Device, Image, RenderedAsset, Token)
5. Image processing worker
6. Device pull endpoint
7. Web application features (library, approval, sharing, admin)
8. Firmware (after domain + API contract confirmed)

**Cross-Component Dependencies:**
- Firmware cannot be finalized until `pictureframe.edholm.me` is live and API endpoint format is confirmed
- Device pull endpoint depends on `RenderedAsset.status = ready`
- Rotation engine depends on uniqueness tracking and `ready` asset pool
- Email sharing depends on Token entity and Mailer configuration

## Implementation Patterns & Consistency Rules

### Entity & Enum Patterns

**PHP Backed Enums:** Use PHP 8.1 backed enums for all finite value sets.

```php
enum RenderStatus: string {
    case Pending = 'pending';
    case Processing = 'processing';
    case Ready = 'ready';
    case Failed = 'failed';
}

enum TokenType: string {
    case ShareApprove = 'share_approve';
    case ShareDecline = 'share_decline';
    case HardDeleteConfirm = 'hard_delete_confirm';
}

enum Orientation: string {
    case Landscape = 'landscape';
    case Portrait = 'portrait';
}
```

Doctrine maps backed enums to VARCHAR columns via built-in PHP 8.1 enum type support. No string constants elsewhere.

### Repository Naming Convention

**Soft-Delete Awareness:** All repository methods that exclude soft-deleted records use the `findActive*` prefix.

- `findActiveByDevice(Device $device)` — images where `deleted_at IS NULL`
- `findActiveReadyByDevice(Device $device)` — images with `deleted_at IS NULL` AND `status = RenderStatus::Ready`
- `findBy*` — raw Doctrine finders, may include soft-deleted records

This makes soft-delete awareness visible at the call site rather than hidden in query logic.

### Image Storage Pattern

**Storage Location:** `storage/images/{image_id}/{device_model}_{orientation}.bin` for pre-rendered assets. Originals at `storage/images/{image_id}/original.{ext}`. Never `var/images/`.

**Database References:** Store paths relative to `STORAGE_PATH` root, not absolute paths. The storage root can move without a data migration.

**Environment Variable:** `STORAGE_PATH` — configured per environment. In DDEV, mapped to a local volume. On VPS, absolute path under the project root.

### Async Image Processing Pattern

**Exclusive Dispatch Points:** `ProcessImageMessage` is dispatched in exactly two places:
- `ImageService::upload()` — after persisting a new uploaded image
- `ImageService::approve()` — after a share-approval token is consumed

No other code dispatches `ProcessImageMessage`.

**Messenger Transport Configuration:**

```yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            image_processing:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: image_processing
                retry_strategy:
                    max_retries: 1
                    delay: 1000
                    multiplier: 2
```

`max_retries: 1` — a single retry on transient failures. Failed messages set `RenderedAsset.status` to `RenderStatus::Failed` so the error surfaces in the admin UI.

### Device Pull Endpoint — Critical Response Codes

> **CRITICAL RULE:** `GET /api/device/{mac}/image` must return:
> - **`204 No Content`** — MAC is valid, device exists, but no `ready` image is currently available
> - **`404 Not Found`** — MAC is not registered in the system
> - **`200 OK`** — returns raw binary (`application/octet-stream`)
>
> **Never return 404 when the device is known but has no ready image.** The ESP32 firmware treats 204 as "wait and retry" and 404 as "this device is not configured." Confusing them causes the device to enter a permanent error state.

### Testing Strategy

**Unit Tests (`tests/Unit/`):** Pure logic, no database or I/O. Covers: enum behavior, token TTL calculation, image processing pipeline steps, rotation selection algorithm.

**Integration Tests (`tests/Integration/`):** Hit the real test database via Doctrine, using Symfony's `KernelTestCase`. Covers: repository `findActive*` methods, Messenger handler with real entity persistence, token single-use enforcement.

**Functional Tests (`tests/Functional/`):** Full HTTP stack via `WebTestCase`. Covers: device pull endpoint (204/404/200 response codes), form submissions, auth flows, email dispatch.

No database mocks. Integration tests use a real test database — mirrors the pattern from aqua-iq.

### Enforcement Guidelines

Mandatory rules for all code in this project:

1. **`findActive*` prefix** on all repository methods filtering out soft-deleted records
2. **`ImageService::upload()` and `ImageService::approve()`** are the only dispatch points for `ProcessImageMessage`
3. **`204` not `404`** when device is known but has no ready image
4. **`storage/images/`** for all file storage — never `var/images/`
5. **Relative paths** in the database — never absolute filesystem paths
6. **`max_retries: 1`** on the `image_processing` Messenger transport
7. **PHP backed enums** for `RenderStatus`, `TokenType`, `Orientation` — no raw string constants
8. **Rotation engine and device pull endpoint** filter exclusively on `RenderStatus::Ready`

## Project Structure & Boundaries

### Requirements to Structure Mapping

**User & Account Management** → `src/Controller/SecurityController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`, `templates/security/`

**Device Management** → `src/Controller/Device/`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `templates/device/`

**Image Library** → `src/Controller/Image/`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `templates/image/`

**Image Approval & Sharing** → `src/Controller/Token/`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/`

**Device Provisioning** → `src/Controller/Device/DeviceProvisionController.php`, `templates/device/provision.html.twig`

**Image Rotation & Cycle Engine** → `src/Schedule/RotationSchedule.php`, `src/Service/RotationService.php`

**Display & Status (device pull endpoint)** → `src/Controller/Api/DeviceImageController.php`

**Admin & Moderation** → `src/Controller/Admin/`, `templates/admin/`

**Cross-Cutting: Async Processing** → `src/Message/ProcessImageMessage.php`, `src/MessageHandler/ProcessImageMessageHandler.php`, `config/packages/messenger.yaml`

**Cross-Cutting: Scheduled Tasks** → `src/Schedule/RotationSchedule.php`, `src/Schedule/ImageCleanupSchedule.php`, `config/packages/scheduler.yaml`

**Cross-Cutting: Enums** → `src/Enum/RenderStatus.php`, `src/Enum/TokenType.php`, `src/Enum/Orientation.php`

### Complete Project Directory Structure

```
pictureframe/
├── .ddev/
│   ├── config.yaml                          ← PHP 8.4, nginx-fpm, pgsql 16 — mirror aqua-iq
│   └── docker-compose.imagick.yaml          ← adds Imagick to web container
├── .gitea/
│   └── workflows/
│       └── ci.yml                           ← Gitea Actions: lint + test on push
├── assets/
│   ├── app.js                               ← AssetMapper entry, imports Stimulus + Turbo
│   ├── controllers/
│   │   ├── image_upload_controller.js
│   │   └── device_status_controller.js
│   └── styles/
│       └── app.css
├── bin/
│   └── console
├── config/
│   ├── packages/
│   │   ├── doctrine.yaml
│   │   ├── doctrine_migrations.yaml
│   │   ├── mailer.yaml
│   │   ├── messenger.yaml                   ← image_processing transport, max_retries: 1
│   │   ├── scheduler.yaml
│   │   ├── security.yaml                    ← form_login, remember_me, ROLE_SUPER_ADMIN hierarchy
│   │   └── twig.yaml
│   ├── routes.yaml
│   ├── routes/
│   │   └── api.yaml                         ← /api/device/{mac}/image route
│   └── services.yaml
├── migrations/
├── public/
│   └── index.php
├── src/
│   ├── Controller/
│   │   ├── Api/
│   │   │   └── DeviceImageController.php    ← GET /api/device/{mac}/image → 200/204/404
│   │   ├── Admin/
│   │   │   ├── AdminDashboardController.php
│   │   │   └── AdminModerationController.php
│   │   ├── Device/
│   │   │   ├── DeviceController.php
│   │   │   └── DeviceProvisionController.php
│   │   ├── Image/
│   │   │   ├── ImageLibraryController.php
│   │   │   ├── ImageUploadController.php
│   │   │   └── ImageShareController.php
│   │   ├── SecurityController.php
│   │   └── Token/
│   │       └── TokenActionController.php    ← consume approve/decline/hard-delete tokens
│   ├── Entity/
│   │   ├── Device.php
│   │   ├── Image.php
│   │   ├── RenderedAsset.php                ← per device-model+orientation, RenderStatus
│   │   ├── Token.php                        ← UUID PK, TokenType, expires_at, used_at
│   │   └── User.php
│   ├── Enum/
│   │   ├── Orientation.php
│   │   ├── RenderStatus.php
│   │   └── TokenType.php
│   ├── Form/
│   │   ├── DeviceType.php
│   │   ├── ImageUploadType.php
│   │   └── RegistrationType.php
│   ├── Message/
│   │   └── ProcessImageMessage.php          ← DTO: imageId, deviceModel, orientation
│   ├── MessageHandler/
│   │   └── ProcessImageMessageHandler.php   ← Imagick → .bin → sets RenderStatus::Ready
│   ├── Repository/
│   │   ├── DeviceRepository.php
│   │   ├── ImageRepository.php              ← findActiveByDevice(), findActiveReadyByDevice()
│   │   ├── RenderedAssetRepository.php      ← findReadyForDevice()
│   │   ├── TokenRepository.php              ← findValidToken() (unused + unexpired)
│   │   └── UserRepository.php
│   ├── Schedule/
│   │   ├── ImageCleanupSchedule.php         ← hard-delete orphaned soft-deleted images
│   │   └── RotationSchedule.php             ← advance current_image pointer per device
│   ├── Service/
│   │   ├── DeviceService.php
│   │   ├── ImageProcessingService.php       ← Imagick: resize → 4bpp dither → .bin
│   │   ├── ImageService.php                 ← upload() and approve() — exclusive dispatch points
│   │   ├── RotationService.php              ← next-image selection, uniqueness window
│   │   └── TokenService.php                 ← issue and consume single-use tokens
│   └── Kernel.php
├── storage/
│   └── images/                              ← gitignored; STORAGE_PATH points here
├── templates/
│   ├── admin/
│   │   ├── dashboard.html.twig
│   │   └── moderation.html.twig
│   ├── device/
│   │   ├── index.html.twig
│   │   ├── provision.html.twig
│   │   └── show.html.twig
│   ├── image/
│   │   ├── library.html.twig
│   │   ├── share.html.twig
│   │   └── upload.html.twig
│   ├── security/
│   │   └── login.html.twig
│   ├── token/
│   │   ├── approve.html.twig
│   │   └── decline.html.twig
│   └── base.html.twig
├── tests/
│   ├── Functional/
│   │   ├── Api/
│   │   │   └── DeviceImageControllerTest.php
│   │   ├── Device/
│   │   │   └── DeviceControllerTest.php
│   │   └── Image/
│   │       └── ImageLibraryControllerTest.php
│   ├── Integration/
│   │   ├── MessageHandler/
│   │   │   └── ProcessImageMessageHandlerTest.php
│   │   ├── Repository/
│   │   │   ├── ImageRepositoryTest.php
│   │   │   └── RenderedAssetRepositoryTest.php
│   │   └── Service/
│   │       └── TokenServiceTest.php
│   └── Unit/
│       ├── Enum/
│       │   └── RenderStatusTest.php
│       └── Service/
│           ├── ImageProcessingServiceTest.php
│           └── RotationServiceTest.php
├── var/
│   ├── cache/
│   └── log/
├── .env
├── .env.local                               ← gitignored; STORAGE_PATH, DATABASE_URL, MAILER_DSN
├── .env.test
├── .gitignore
├── composer.json
├── composer.lock
├── importmap.php
├── phpunit.xml.dist
└── symfony.lock
```

### Architectural Boundaries

**API Boundary — Device Pull Endpoint**
`GET /api/device/{mac}/image` is the only machine-to-machine surface. MAC address validated against `Device` entity before serving. Returns `application/octet-stream`, 204, or 404. Isolated in `config/routes/api.yaml`.

**Web Application Boundary**
All other routes behind Symfony form-login firewall. Twig responses only. `ROLE_SUPER_ADMIN` gates admin controllers.

**Async Processing Boundary**
`ImageService` → Messenger bus → `ProcessImageMessageHandler`. Handler is the only component writing to `storage/images/`. `RenderedAsset.status` is the only signal crossing this boundary.

**Scheduled Task Boundary**
`RotationSchedule` and `ImageCleanupSchedule` run in the worker process — no HTTP context, no session access. Database reads and writes only.

**Storage Boundary**
`storage/images/` written by `ProcessImageMessageHandler`, read by `DeviceImageController`. All other code uses `STORAGE_PATH` env var. Paths in DB are always relative.

### Integration Points & Data Flow

**Image Upload → Display**
```
User uploads → ImageService::upload() persists Image → dispatches ProcessImageMessage
  → Worker: Imagick resize + dither → storage/images/{id}/waveshare73_landscape.bin
  → RenderedAsset.status = Ready
  → RotationSchedule advances device pointer
  → ESP32: GET /api/device/{mac}/image → 200 + binary stream
```

**Image Sharing Flow**
```
User shares image with owner → ImageService creates Token (ShareApprove/ShareDecline)
  → Mailer sends tokenized URL
  → Owner clicks link → TokenActionController::approve()
  → TokenService consumes token → ImageService::approve() dispatches ProcessImageMessage
  → Image enters ready pool for recipient device
```

**Soft-Delete & Cleanup Flow**
```
User soft-deletes image → Image.deleted_at set (excluded by findActive*)
  → If last approval removed → ImageCleanupSchedule hard-deletes image + storage files
```

## Architecture Validation Results

### Coherence Validation ✅

**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. AssetMapper + Stimulus + Turbo has first-party Symfony UX support and requires no build tooling.

**Pattern Consistency:** `findActive*` naming, `storage/images/` path convention, exclusive Messenger dispatch points, and backed enum usage are consistently applied. One contradiction (`var/images/` in Data Architecture section) found during validation and corrected — all references now point to `storage/images/`.

**Structure Alignment:** Every controller, service, entity, and test file has a defined home. All five boundaries (API, web, async, storage, scheduled) are respected in the project structure.

### Requirements Coverage Validation ✅

All 8 FR domains covered:

| Domain | Architectural Support |
|---|---|
| User & Account Management | `User` entity, form login, `SecurityController` |
| Device Management | `Device` entity, `DeviceController`, `DeviceService` |
| Image Library | `Image` entity, `ImageLibraryController`, `ImageService` |
| Image Approval & Sharing | `Token` entity, `TokenService`, `TokenActionController` |
| Device Provisioning | `DeviceProvisionController` |
| Image Rotation & Cycle Engine | `RotationSchedule`, `RotationService` |
| Display & Status | `DeviceImageController`, `RenderedAsset` |
| Admin & Moderation | `AdminDashboardController`, `AdminModerationController` |

All 9 NFRs covered: pre-rendering ≤10s (async Messenger), device pull ≤10s (direct binary stream), web pages ≤3s (standard Symfony), rotation ±5min (Scheduler), HTTPS (Nginx), MAC auth (controller guard), single-use links (Token entity), scheduled cleanup (ImageCleanupSchedule), zero blank screens (204 not 404 + last image persistence).

### Implementation Readiness Validation ✅

All critical decisions documented. Enforcement guidelines provide 8 explicit, checkable rules. Project tree is specific with no generic placeholders. The 204/404 distinction is called out as a critical rule with the reason documented.

### Gap Analysis

**Critical Gaps:** None.

**Minor (non-blocking):**
- `.env.example` not in project tree — add alongside `.env` on scaffold
- `device_model` field type on `RenderedAsset` not fully specified — for V1 with a single display (Waveshare 7.3" 800×480), a string constant or single-value enum is sufficient

### Architecture Completeness Checklist

**✅ Requirements Analysis** — context, scale, constraints, cross-cutting concerns

**✅ Architectural Decisions** — stack, data architecture, auth, API patterns, infrastructure

**✅ Implementation Patterns** — enums, `findActive*`, storage, Messenger, 204/404, testing, enforcement rules

**✅ Project Structure** — complete directory tree, boundaries, FR mapping, data flow diagrams

**✅ Validation** — coherence, coverage, readiness, gaps

### Architecture Readiness Assessment

**Overall Status: READY FOR IMPLEMENTATION**

**Confidence: High.** Coherent, fully covers the PRD, no critical gaps, sufficient specificity for consistent implementation.

**First Implementation Step:**
```bash
symfony new pictureframe --webapp
```

### Implementation Handoff

**AI Agent Guidelines:**
- Follow all architectural decisions exactly as documented
- Apply the 8 enforcement rules in every code generation task
- Respect the `storage/images/` boundary — never write image assets to `var/`
- Confirm `204 vs 404` on every implementation of the device pull endpoint
- Reference this document for all architectural questions; do not invent decisions not documented here
