--- 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 (`