---
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 — used only for public flows (provisioning setup, email approve/decline pages). Authenticated app is a Vue 3 SPA; Twig is not used there.

**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:** Vite (Vue SPA, outputs to `public/build/`). AssetMapper not used — no Stimulus, no Turbo, no `importmap.php`.

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

**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/` (public flows only) + `frontend/` (Vue SPA source)

**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)

**Post-scaffold cleanup:** After `symfony new pictureframe --webapp`, remove `symfony/stimulus-bundle`, `symfony/ux-turbo`, and AssetMapper. Initialize the Vue SPA in `frontend/` with `npm create vite@latest frontend -- --template vue-ts`.

**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 (authenticated app):** Symfony controllers return JSON responses for all authenticated app API calls under the `/api/` prefix. No Twig rendering for authenticated routes — Symfony serves the SPA shell only. Controllers use `JsonResponse` or Symfony Serializer.

**Web Controllers (public flows):** `/setup/{mac}`, `/token/{uuid}/approve`, `/token/{uuid}/decline` return Twig responses. These are the only controllers that render HTML directly.

**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

**Authenticated App — Vue 3 SPA:**
All authenticated routes are served by a Vue 3 SPA built with Vite + TypeScript strict mode. Vue Router handles client-side navigation; Pinia manages shared state (current user, device list, upload funnel state). SCSS modules scoped per SFC for component styles. Konva.js + Vue-Konva for the sticker canvas editor. No Stimulus, no Turbo, no AssetMapper.

Symfony serves the SPA shell (`public/build/index.html`) via a catch-all route for all authenticated paths. Vue Router takes over client-side navigation from that point.

**Public Flows — Symfony + Twig:**
Three routes remain as Symfony Twig pages with no Vue dependency:
- `/setup/{mac}` — device provisioning setup (post-QR scan)
- `/token/{uuid}/approve` — email approve page (no login required)
- `/token/{uuid}/decline` — email decline page (no login required)

These must work with images disabled, CSS disabled, and screen reader only.

**TypeScript:** Strict mode. `frontend/src/types/` holds interfaces mirroring every Symfony API response shape: `Device`, `Image`, `StickerLayer`, `RenderedAsset`, `Token`. The compiler surfaces API contract drift before it reaches deployed devices.

**SCSS:** Modules scoped per SFC (`<style scoped lang="scss">`). Global tokens in `frontend/src/styles/global.scss`. No Tailwind, no utility CSS framework, no pre-built component library.

**Build:** Vite outputs to `public/build/`. `vite.config.ts` sets `outDir: '../public/build'`. The Symfony catch-all controller renders `public/build/index.html` directly.

**Forms:** Symfony Form component is not used for authenticated app forms — Vue handles all form logic. Symfony Form is only used in Twig public flows if needed.

### 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`); remove Stimulus/Turbo/AssetMapper
2. Vue SPA scaffold in `frontend/` (`npm create vite@latest frontend -- --template vue-ts`); configure `vite.config.ts` to output to `public/build/`
3. Add Messenger, Scheduler, Imagick to Symfony
4. Domain + Nginx config on VPS
5. Core entities (User, Device, Image, RenderedAsset, Token)
6. Image processing worker
7. Symfony JSON API endpoints + SpaController catch-all
8. Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
9. 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/Controller/Api/UserApiController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`

**Device Management** → `src/Controller/Api/DeviceApiController.php`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `frontend/src/views/HomeView.vue`

**Image Library** → `src/Controller/Api/ImageApiController.php`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `frontend/src/views/LibraryView.vue`

**Image Approval & Sharing** → `src/Controller/Token/TokenActionController.php`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/` (email approve/decline pages), `frontend/src/components/ApproveCard.vue`

**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/`, `frontend/src/views/AdminView.vue` (if applicable)

**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 + vite build on push
├── frontend/                                ← Vue 3 SPA source (Vite + TypeScript)
│   ├── src/
│   │   ├── assets/
│   │   │   └── stickers/                    ← SVG sticker assets (Seasonal, Holidays, Fun, Family, Nature)
│   │   ├── components/
│   │   │   ├── base/
│   │   │   │   ├── BaseButton.vue
│   │   │   │   ├── BaseInput.vue
│   │   │   │   ├── BaseBottomSheet.vue
│   │   │   │   ├── BaseCard.vue
│   │   │   │   ├── BaseChip.vue
│   │   │   │   └── BaseToast.vue
│   │   │   ├── FrameCard.vue
│   │   │   ├── CropEditor.vue
│   │   │   ├── StickerCanvas.vue
│   │   │   ├── StickerTray.vue
│   │   │   ├── DevicePicker.vue
│   │   │   ├── PhotoThumb.vue
│   │   │   ├── ShareSheet.vue
│   │   │   ├── ApproveCard.vue
│   │   │   └── BottomNav.vue
│   │   ├── router/
│   │   │   └── index.ts                     ← Vue Router; catch-all handled by Symfony
│   │   ├── stores/
│   │   │   ├── auth.ts                      ← current user, login state
│   │   │   ├── devices.ts                   ← device list, current device
│   │   │   └── upload.ts                    ← upload funnel state, sticker composition
│   │   ├── styles/
│   │   │   ├── _breakpoints.scss            ← $bp-tablet: 640px, $bp-desktop: 960px
│   │   │   ├── _tokens.scss                 ← CSS custom properties for all 6 themes
│   │   │   └── global.scss                  ← reset, typography, base layout
│   │   ├── types/
│   │   │   ├── Device.ts
│   │   │   ├── Image.ts
│   │   │   ├── StickerLayer.ts              ← { id, type, x, y, scale, rotation }
│   │   │   ├── RenderedAsset.ts
│   │   │   └── Token.ts
│   │   ├── views/
│   │   │   ├── HomeView.vue                 ← FrameList — FrameCard per device
│   │   │   ├── LibraryView.vue              ← photo grid, search, tabs (All/Mine/Shared)
│   │   │   ├── UploadView.vue               ← funnel: crop → sticker → device picker
│   │   │   └── SettingsView.vue
│   │   ├── App.vue
│   │   └── main.ts
│   ├── index.html
│   ├── package.json
│   ├── tsconfig.json
│   └── vite.config.ts                       ← outDir: '../public/build'
├── 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                          ← catch-all route → SpaController for authenticated paths
│   ├── routes/
│   │   └── api.yaml                         ← /api/device/{mac}/image + /api/* app endpoints
│   └── services.yaml
├── migrations/
├── public/
│   ├── build/                               ← gitignored; Vite output (index.html + hashed assets)
│   └── index.php
├── src/
│   ├── Controller/
│   │   ├── Api/
│   │   │   ├── DeviceImageController.php    ← GET /api/device/{mac}/image → 200/204/404 (binary)
│   │   │   ├── DeviceApiController.php      ← JSON CRUD for devices (Vue SPA)
│   │   │   ├── ImageApiController.php       ← JSON CRUD for images, upload, share (Vue SPA)
│   │   │   └── UserApiController.php        ← current user, settings (Vue SPA)
│   │   ├── Admin/
│   │   │   ├── AdminDashboardController.php
│   │   │   └── AdminModerationController.php
│   │   ├── SpaController.php                ← catch-all; renders public/build/index.html
│   │   ├── SecurityController.php           ← login/logout (form POST, JSON response)
│   │   ├── Device/
│   │   │   └── DeviceProvisionController.php ← GET/POST /setup/{mac} → Twig
│   │   └── Token/
│   │       └── TokenActionController.php    ← /token/{uuid}/approve|decline → Twig
│   ├── 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
│   ├── 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/
│   ├── device/
│   │   └── provision.html.twig              ← /setup/{mac} public provisioning page
│   ├── token/
│   │   ├── approve.html.twig                ← email approve page (no login)
│   │   └── decline.html.twig                ← email decline page (no login)
│   └── base_public.html.twig                ← minimal base for public Twig pages only
├── tests/
│   ├── Functional/
│   │   ├── Api/
│   │   │   ├── DeviceImageControllerTest.php
│   │   │   ├── DeviceApiControllerTest.php
│   │   │   └── ImageApiControllerTest.php
│   │   └── Token/
│   │       └── TokenActionControllerTest.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
├── 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**
Authenticated routes are gated by Symfony form-login firewall. `SpaController` serves `public/build/index.html` as the catch-all — Vue Router handles client-side navigation from there. All authenticated data flows through `/api/*` JSON endpoints. `ROLE_SUPER_ADMIN` gates admin API controllers. Public flows (`/setup/{mac}`, `/token/{uuid}/approve|decline`) are outside the firewall and return Twig responses — no Vue involved.

**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. Vue 3 + Vite + TypeScript strict + SCSS modules + Konva.js is a standard, well-supported SPA stack. The Symfony/Vue boundary is clean: Symfony serves the SPA shell and JSON API; Vue owns all authenticated UI rendering. No build-step conflicts — Vite and Symfony operate independently.

**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
