diff --git a/.planning/phases/01-foundation/01-RESEARCH.md b/.planning/phases/01-foundation/01-RESEARCH.md
new file mode 100644
index 0000000..7290da1
--- /dev/null
+++ b/.planning/phases/01-foundation/01-RESEARCH.md
@@ -0,0 +1,646 @@
+# Phase 1: Foundation - Research
+
+**Researched:** 2026-04-10
+**Domain:** Docker container scaffold, SQLite schema with Peewee ORM, .intunewin format spike (Python-native AES-256-CBC)
+**Confidence:** HIGH (Docker/Peewee patterns), MEDIUM (.intunewin byte-level format — must be validated against real Intune tenant)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **App shell & navigation:** Persistent left sidebar with flat, equal-weight sections: Dashboard, Drivers, Printers, Clients, Packages. Dashboard is the landing page: quick action buttons at top ("New Printer", "Upload Driver", "Export Package") plus recent printers/packages list below. System/auto theme — follow OS dark/light preference.
+- **CSS & offline access:** Air-gapped deployment — no CDN access from the server; all assets must be bundled in the Docker image. Use a lightweight pre-built CSS framework (e.g., Pico CSS) instead of Tailwind — no build step, just a static CSS file. HTMX and Alpine.js downloaded during Docker image build (ADD/curl), baked into the image as static files. All JS/CSS served from the container's static files directory — zero external requests at runtime.
+- **Database schema:** Full schema created upfront in Phase 1 — all tables for phases 2-5 (drivers, printers, clients, icons). Peewee ORM for all database operations. Schema auto-created on first run via Peewee's `create_tables()`.
+- **Driver storage:** SHA256 content-addressed storage for driver files on the Docker volume. Deduplication: same file uploaded twice results in one copy on disk. SQLite stores hash reference + original filename + metadata; filesystem stores actual files.
+
+### Claude's Discretion
+
+- Specific lightweight CSS framework selection (Pico CSS, Simple.css, or similar)
+- Dashboard layout details and empty state design
+- Exact color scheme for light and dark themes
+- Project directory structure (guided by ARCHITECTURE.md research)
+- .intunewin spike implementation details
+
+### Deferred Ideas (OUT OF SCOPE)
+
+None — discussion stayed within phase scope.
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| INFRA-01 | Application runs as a single Docker container | Docker scaffold plan (Dockerfile + docker-compose.yml); python:3.12-slim-bookworm base; no sidecar services |
+| INFRA-02 | Application has minimal runtime dependencies (no Node.js, no external DB) | Pico CSS + HTMX + Alpine.js baked into image at build time; SQLite via Peewee (stdlib + one pip package); no Node build pipeline |
+
+
+---
+
+## Summary
+
+Phase 1 delivers three things: a running Docker container with the app scaffold, the complete SQLite schema initialized via Peewee, and a validated Python-native .intunewin generator. These are independent workstreams that can be built in parallel but must converge before Phase 2 starts.
+
+The Docker scaffold is low-risk and well-understood. The base image is `python:3.12-slim-bookworm` (never Alpine — C-extension wheels fail on musl libc). All frontend assets (Pico CSS, HTMX, Alpine.js) are downloaded with `curl` during the Docker build and served as static files. There are zero external HTTP requests at container runtime — a hard requirement for air-gapped MSP networks.
+
+The SQLite schema via Peewee is also straightforward, but the Phase 1 decision to create the full schema upfront (all tables for phases 2-5) means the models file must define every table now. The `.intunewin` format spike is the highest-risk item: the format is reverse-engineered (MEDIUM confidence), AES-256-CBC with HMAC-SHA256, and the Python implementation must be validated against a real Intune tenant before Phase 5 export work begins. A known documentation error exists: STACK.md states "32-byte IV" but the actual AES-CBC standard IV is 16 bytes — use 16 bytes in the implementation.
+
+**Primary recommendation:** Build the Docker scaffold and schema in parallel. Treat the .intunewin spike as a time-boxed investigation (max 2 days) that ends in a real Intune upload test — not just local file creation.
+
+---
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| python:3.12-slim-bookworm | 3.12 (Debian 12) | Docker base image | LTS Python, Debian glibc (not musl), slim keeps image under 200 MB, pre-built C-extension wheels always work |
+| FastAPI | 0.115.x | HTTP framework | Async-capable, Pydantic v2 validation, `TemplateResponse`, `FileResponse`, `StreamingResponse` built in |
+| Uvicorn | 0.30.x | ASGI server | FastAPI's recommended server; `uvicorn[standard]` pulls in uvloop + httptools |
+| Jinja2 | 3.1.x | HTML templating | Ships with FastAPI's template support; used for both HTML pages and PS script generation |
+| Peewee | 3.17.x | ORM for SQLite | Sync-only ORM perfectly matched to SQLite single-writer model; `create_tables()` for schema auto-init |
+| pycryptodome | 3.20.x | AES-256-CBC + HMAC-SHA256 | Required for .intunewin inner package encryption; import as `from Crypto.Cipher import AES` |
+| python-dotenv | 1.0.x | Env-var config | Docker-level overrides without rebuilding (data dir, port, base URL) |
+| python-multipart | 0.0.9 | Multipart file uploads | Required by FastAPI's `UploadFile`; always install alongside FastAPI for file upload routes |
+
+### Supporting (Phase 1 specific)
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| Pico CSS | 2.x | Lightweight CSS framework | Downloaded at image build time via curl; ~14 KB minified; supports OS dark/light via `data-theme="auto"` |
+| HTMX | 2.0.x | Dynamic UI without SPA | Downloaded at image build time; served as static file; handles partial page updates |
+| Alpine.js | 3.x | Client-side UI state | Downloaded at image build time; dropdowns, toggles, modals; no build step |
+
+### Installation
+
+```bash
+# In Dockerfile (not requirements.txt — these are baked in at image build time)
+# Frontend assets downloaded via curl during build:
+# RUN curl -sLo /app/static/pico.min.css https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css
+# RUN curl -sLo /app/static/htmx.min.js https://unpkg.com/htmx.org@2/dist/htmx.min.js
+# RUN curl -sLo /app/static/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js
+
+# requirements.txt (installed via pip in Dockerfile)
+fastapi==0.115.*
+uvicorn[standard]==0.30.*
+jinja2==3.1.*
+python-multipart==0.0.9
+pycryptodome==3.20.*
+python-dotenv==1.0.*
+peewee==3.17.*
+```
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+
+```
+imptune/
+├── api/ # HTTP route handlers (thin — delegate to services)
+│ ├── __init__.py
+│ ├── pages.py # HTML page routes (SSR with Jinja2)
+│ └── health.py # GET /health — Docker healthcheck endpoint
+├── services/ # Domain logic (testable without HTTP context)
+│ └── __init__.py
+├── generators/ # Format-specific builders
+│ ├── __init__.py
+│ └── intunewin_builder.py # Phase 1 spike: Python .intunewin assembler
+├── templates/ # Jinja2 HTML templates
+│ ├── base.html # Layout with sidebar, static asset includes
+│ └── dashboard.html # Landing page (quick actions + recent activity)
+├── db/
+│ ├── __init__.py
+│ ├── database.py # Peewee database init, create_tables()
+│ └── models.py # ALL tables for phases 1-5 (full schema upfront)
+├── storage/
+│ └── driver_store.py # Abstraction over /data/drivers volume path
+├── static/ # Served as /static/ — contains baked-in assets
+│ ├── pico.min.css # Downloaded at Docker build time
+│ ├── htmx.min.js # Downloaded at Docker build time
+│ └── alpine.min.js # Downloaded at Docker build time
+├── config.py # Env-var driven configuration (DATA_DIR, PORT)
+├── main.py # App entrypoint: create FastAPI, mount routes, StaticFiles
+├── Dockerfile
+├── docker-compose.yml
+└── requirements.txt
+```
+
+### Pattern 1: Docker Offline Asset Baking
+
+**What:** Download CSS/JS assets with `curl` during `docker build` so they are baked into the image. No CDN access at container runtime.
+
+**When to use:** Always — this is a hard requirement for air-gapped MSP networks.
+
+**Example Dockerfile snippet:**
+```dockerfile
+FROM python:3.12-slim-bookworm
+
+WORKDIR /app
+
+# Install system deps and download frontend assets in one layer
+RUN apt-get update && apt-get install -y --no-install-recommends curl \
+ && mkdir -p /app/static \
+ && curl -sLo /app/static/pico.min.css \
+ "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \
+ && curl -sLo /app/static/htmx.min.js \
+ "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
+ && curl -sLo /app/static/alpine.min.js \
+ "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \
+ && apt-get purge -y curl && apt-get autoremove -y \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+VOLUME ["/data"]
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+```
+
+**docker-compose.yml:**
+```yaml
+services:
+ imptune:
+ build: .
+ ports:
+ - "8000:8000"
+ volumes:
+ - imptune_data:/data
+ restart: unless-stopped
+ environment:
+ - DATA_DIR=/data
+
+volumes:
+ imptune_data:
+```
+
+### Pattern 2: Peewee Schema Auto-Init
+
+**What:** Define all tables (all phases) in `models.py`, auto-create on startup via `create_tables(safe=True)`.
+
+**When to use:** On every container start — `safe=True` is idempotent (no-op if tables already exist).
+
+**Example:**
+```python
+# db/database.py
+from peewee import SqliteDatabase
+import os
+
+DB_PATH = os.environ.get("DATA_DIR", "/data") + "/imptune.db"
+db = SqliteDatabase(DB_PATH, pragmas={"journal_mode": "wal", "foreign_keys": 1})
+
+def init_db():
+ from db.models import Driver, Printer, Client, Icon
+ db.connect(reuse_if_open=True)
+ db.create_tables([Driver, Printer, Client, Icon], safe=True)
+```
+
+```python
+# db/models.py
+from peewee import *
+from db.database import db
+import datetime
+
+class BaseModel(Model):
+ class Meta:
+ database = db
+
+class Client(BaseModel):
+ name = CharField(unique=True)
+ created_at = DateTimeField(default=datetime.datetime.utcnow)
+
+class Driver(BaseModel):
+ sha256 = CharField(unique=True, index=True) # content-addressed key
+ original_filename = CharField()
+ size_bytes = IntegerField()
+ uploaded_at = DateTimeField(default=datetime.datetime.utcnow)
+ # Phase 2 fields (populated during INF parsing):
+ driver_desc = CharField(null=True) # parsed DriverDesc from INF
+ inf_filename = CharField(null=True) # which INF file inside the ZIP
+ architecture = CharField(null=True) # x64, x86, arm64
+ has_cat_file = BooleanField(default=False)
+
+class Printer(BaseModel):
+ name = CharField()
+ ip_address = CharField()
+ port_name = CharField()
+ client = ForeignKeyField(Client, backref="printers", null=True)
+ driver = ForeignKeyField(Driver, backref="printers", null=True)
+ duplex_mode = CharField(default="OneSided") # OneSided|TwoSidedLongEdge|TwoSidedShortEdge
+ color_mode = BooleanField(default=True)
+ paper_size = CharField(default="A4")
+ collate = BooleanField(default=True)
+ created_at = DateTimeField(default=datetime.datetime.utcnow)
+ updated_at = DateTimeField(default=datetime.datetime.utcnow)
+
+class Icon(BaseModel):
+ printer = ForeignKeyField(Printer, backref="icons", unique=True)
+ sha256 = CharField()
+ original_filename = CharField()
+ size_bytes = IntegerField()
+ uploaded_at = DateTimeField(default=datetime.datetime.utcnow)
+```
+
+### Pattern 3: .intunewin File Assembly (Python-Native)
+
+**What:** Assemble a valid .intunewin file in Python without IntuneWinAppUtil.exe.
+
+**Verified byte layout (from svrooij.io decryption article):**
+```
+Encrypted blob layout:
+ [0:32] — HMAC-SHA256 of the ciphertext (32 bytes)
+ [32:48] — AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
+ [48:] — AES-256-CBC ciphertext (padded to 16-byte boundary)
+
+IMPORTANT: The IV is 16 bytes, not 32. STACK.md has a documentation error on this point.
+```
+
+**Detection.xml schema:**
+```xml
+
+ install.ps1
+ 12345
+ IntunePackage.intunewin
+ install.ps1
+
+ base64(32-byte AES key)
+ base64(32-byte HMAC key)
+ base64(16-byte IV)
+ base64(32-byte HMAC-SHA256)
+ SHA256
+ ProfileVersion1
+ base64(SHA256 of plaintext ZIP)
+ SHA256
+
+
+```
+
+**Outer ZIP structure:**
+```
+IntuneWinPackage/
+├── Contents/
+│ └── IntunePackage.intunewin ← the encrypted blob
+└── Metadata/
+ └── Detection.xml ← encryption metadata
+```
+
+**Python assembly skeleton:**
+```python
+# generators/intunewin_builder.py
+import os, io, base64, hashlib, hmac, zipfile
+from Crypto.Cipher import AES
+from Crypto.Util.Padding import pad
+from xml.etree.ElementTree import Element, SubElement, tostring
+import xml.dom.minidom
+
+def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
+ """Build a .intunewin file from source_dir, with setup_file as entry point."""
+
+ # Step 1: Create inner ZIP (DEFLATE-compressed content)
+ inner_zip_buf = io.BytesIO()
+ with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for root, dirs, files in os.walk(source_dir):
+ for file in files:
+ abs_path = os.path.join(root, file)
+ arc_name = os.path.relpath(abs_path, source_dir)
+ zf.write(abs_path, arc_name)
+ plaintext = inner_zip_buf.getvalue()
+
+ # Step 2: Encrypt with AES-256-CBC
+ aes_key = os.urandom(32) # 32-byte AES key
+ mac_key = os.urandom(32) # 32-byte HMAC key
+ iv = os.urandom(16) # 16-byte IV (standard AES block size)
+ cipher = AES.new(aes_key, AES.MODE_CBC, iv)
+ ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
+
+ # Step 3: Compute HMAC-SHA256 over ciphertext
+ mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()
+
+ # Step 4: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext]
+ encrypted_blob = mac + iv + ciphertext
+
+ # Step 5: Compute plaintext digest for Detection.xml
+ file_digest = hashlib.sha256(plaintext).digest()
+
+ # Step 6: Build Detection.xml
+ app_info = Element("ApplicationInfo",
+ xmlns="http://schemas.microsoft.com/IntuneWin")
+ SubElement(app_info, "Name").text = setup_file
+ SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
+ SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
+ SubElement(app_info, "SetupFile").text = setup_file
+ enc = SubElement(app_info, "EncryptionInfo")
+ SubElement(enc, "EncryptionKey").text = base64.b64encode(aes_key).decode()
+ SubElement(enc, "MacKey").text = base64.b64encode(mac_key).decode()
+ SubElement(enc, "InitializationVector").text = base64.b64encode(iv).decode()
+ SubElement(enc, "Mac").text = base64.b64encode(mac).decode()
+ SubElement(enc, "MacAlgorithm").text = "SHA256"
+ SubElement(enc, "ProfileIdentifier").text = "ProfileVersion1"
+ SubElement(enc, "FileDigest").text = base64.b64encode(file_digest).decode()
+ SubElement(enc, "FileDigestAlgorithm").text = "SHA256"
+ detection_xml = xml.dom.minidom.parseString(tostring(app_info)).toprettyxml()
+
+ # Step 7: Build outer ZIP (STORED — no extra compression on encrypted content)
+ with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
+ outer.writestr("IntuneWinPackage/Contents/IntunePackage.intunewin",
+ encrypted_blob)
+ outer.writestr("IntuneWinPackage/Metadata/Detection.xml",
+ detection_xml)
+```
+
+**Source:** svrooij.io decryption article (verified format), volodymyrsmirnov/IntuneWin C# reference (structure verified)
+
+### Anti-Patterns to Avoid
+
+- **Alpine Linux base image:** musl libc breaks pycryptodome and other C-extension wheels; use `python:3.12-slim-bookworm` only.
+- **Downloading assets at container runtime:** Never use CDN links in HTML templates; all assets must be served from `/app/static/` which is baked into the image.
+- **Tailwind CDN Play script in templates:** Per Tailwind docs, Play CDN is development-only. The locked decision already chooses Pico CSS — a pre-built static file that needs no CDN at runtime.
+- **Storing the SQLite file inside the container filesystem:** Always mount `/data` as a named volume; SQLite must persist across container restarts.
+- **`peewee.database.connect()` without WAL mode:** SQLite default journal mode is DELETE; enable WAL (`"journal_mode": "wal"`) so reads don't block writes during generation.
+- **32-byte IV in .intunewin:** Standard AES-CBC IV is 16 bytes (AES block size). Using 32 bytes will produce a non-compliant file that Intune will reject. The STACK.md documentation has this wrong — use 16 bytes.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| AES-256-CBC encryption | Custom AES implementation | `pycryptodome` (`from Crypto.Cipher import AES`) | Padding edge cases, IV handling, block alignment — stdlib `hashlib` does not provide AES |
+| HMAC-SHA256 | Custom HMAC | Python stdlib `hmac.new(key, data, hashlib.sha256)` | Already in stdlib, correct constant-time comparison built in |
+| SQLite schema management | Raw `CREATE TABLE IF NOT EXISTS` strings | Peewee `create_tables(safe=True)` | Migration safety, model-to-SQL mapping, foreign key management |
+| Serving static files in FastAPI | Custom file-serving route | `app.mount("/static", StaticFiles(directory="static"))` | FastAPI's built-in `StaticFiles` handles ETags, range requests, content-type detection |
+| Docker healthcheck HTTP request | curl (which may not be in final image) | Python one-liner: `python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"` | Uses stdlib; no curl dependency in the slim image |
+
+**Key insight:** pycryptodome handles all the crypto complexity. The hard part of the .intunewin spike is not the encryption itself — it's assembling the exact byte layout Intune expects and validating the output against a real tenant.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Wrong IV Size in .intunewin (CRITICAL)
+
+**What goes wrong:** Using a 32-byte IV instead of the correct 16-byte AES block size. The encrypted blob format is `[HMAC-SHA256 (32 bytes)] + [IV (16 bytes)] + [ciphertext]`. The total overhead is 48 bytes, not 64. Files built with a 32-byte IV will fail to decrypt on the Intune side.
+
+**Why it happens:** STACK.md states "32-byte IV" — this is a documentation error. The decryption article confirms 16 bytes via `.NET's aes.IV.Length` (which is always 16 for AES).
+
+**How to avoid:** Always use `iv = os.urandom(16)` and `AES.new(key, AES.MODE_CBC, iv)` where `len(iv) == 16`.
+
+**Warning signs:** `ValueError: IV must be 16 bytes long` from pycryptodome if you use 32.
+
+### Pitfall 2: Storing State in Container Filesystem
+
+**What goes wrong:** Writing the SQLite file or driver ZIPs to `/app/` or `/tmp/`. Data disappears on container restart.
+
+**Why it happens:** Default working directory in Docker is the app folder; developers forget to configure the volume.
+
+**How to avoid:** Set `DATA_DIR=/data` env var. `docker-compose.yml` mounts `imptune_data:/data`. SQLite path must derive from `DATA_DIR`. Driver files go to `DATA_DIR/drivers/`. Never write persistent data outside the volume mount.
+
+**Warning signs:** Fresh database on every `docker compose restart`.
+
+### Pitfall 3: Alpine Base Image Breaking pycryptodome
+
+**What goes wrong:** Using `python:3.12-alpine` as the Docker base. pycryptodome requires C extensions; the pre-built wheels target glibc, not Alpine's musl libc. pip will try to compile from source (requiring gcc/musl-dev) and often fails silently or produces a broken install.
+
+**Why it happens:** Alpine is smaller, so it seems attractive for Docker images.
+
+**How to avoid:** Use `python:3.12-slim-bookworm` (Debian 12). The final image will be slightly larger (~150-200 MB vs ~80 MB for Alpine) but will reliably install all C-extension packages.
+
+### Pitfall 4: CDN Assets Requested at Runtime
+
+**What goes wrong:** HTML templates reference ``. The container starts but the browser gets no CSS/JS when running on an air-gapped network.
+
+**Why it happens:** Developers test on internet-connected machines where CDN works; the failure only manifests on offline deployments.
+
+**How to avoid:** All `` and `
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+