diff --git a/.planning/phases/03-printer-configuration/03-RESEARCH.md b/.planning/phases/03-printer-configuration/03-RESEARCH.md
new file mode 100644
index 0000000..e5e29a8
--- /dev/null
+++ b/.planning/phases/03-printer-configuration/03-RESEARCH.md
@@ -0,0 +1,416 @@
+# Phase 3: Printer Configuration - Research
+
+**Researched:** 2026-04-10
+**Domain:** FastAPI + Peewee ORM + HTMX + Alpine.js — form CRUD, client grouping, saved config retrieval
+**Confidence:** HIGH
+
+## Summary
+
+Phase 3 builds on a fully functional Phase 2 stack: FastAPI 0.115, Peewee 3.17, Jinja2 3.1, HTMX, Alpine.js, Pico CSS. The Printer and Client ORM models are already created (from the Phase 1 schema spike) with every field the requirements specify: `name`, `ip_address`, `port_name`, `client` (FK), `driver` (FK), `duplex_mode`, `color_mode`, `paper_size`, `collate`. No schema changes are needed in Phase 3 — it is purely route + template + service work.
+
+The key interaction patterns are already proven in Phase 2: HTMX `hx-post` / `hx-get` with `outerHTML` swaps for partial re-renders, Jinja2 partials for list fragments, Peewee sync queries in sync FastAPI route handlers (`def`, not `async def`), and the `client` fixture + `monkeypatch` pattern for integration tests.
+
+The three planned sub-plans map cleanly to separable concerns: (1) printer CRUD with form validation, (2) Client CRUD and grouped display, (3) regeneration flow that re-uses the saved `driver` FK. No new libraries are required.
+
+**Primary recommendation:** Re-use every established Phase 2 pattern exactly — HTMX partial swaps, Peewee `get_or_create`/`save()`, `json.dumps` for multi-value fields, sync route handlers, and the conftest `client` fixture.
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| PRNT-01 | User can set printer display name | `Printer.name = CharField()` already in models.py. Route validates non-empty. |
+| PRNT-02 | User can set printer IP address or hostname | `Printer.ip_address = CharField()` already in models.py. Server-side regex validates format. |
+| PRNT-03 | System auto-suggests port name from IP (user can override) | Alpine.js `x-model` / `@input` on IP field drives port field in-browser; field remains editable. |
+| PRNT-04 | User can set duplex mode (one-sided, long-edge, short-edge) | `Printer.duplex_mode = CharField(default="OneSided")` already in models.py. `
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| FastAPI | 0.115.* | HTTP routing, request parsing, response | Already in use; same patterns as Phase 2 |
+| Peewee | 3.17.* | ORM for SQLite CRUD | Already in use; Printer/Client models pre-created |
+| Jinja2 | 3.1.* | Server-side HTML templating | Already in use; base.html + partials pattern established |
+| HTMX | baked-in static | Async partial HTML swaps without JS | Already in use; proven with driver upload flow |
+| Alpine.js | baked-in static | Reactive in-browser logic (auto-port derivation) | Already in use; needed for PRNT-03 live field derivation |
+| Pico CSS | baked-in static | Semantic HTML styling | Already in use |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| python-multipart | 0.0.9 | Form data parsing for FastAPI | Required for `Form(...)` parameters (already in requirements.txt) |
+
+### Alternatives Considered
+None — all decisions are locked by prior phases. Do not introduce new libraries.
+
+**Installation:** No new packages required.
+
+## Architecture Patterns
+
+### Project Structure (additions only)
+```
+imptune/
+├── api/
+│ ├── drivers.py # existing
+│ ├── pages.py # extend with /printers, /clients routes
+│ └── printers.py # NEW — POST /printers, PUT /printers/{id}, DELETE /printers/{id}
+│ └── clients.py # NEW — POST /clients (inline create for PRNT-08)
+├── templates/
+│ ├── printers.html # NEW — printer list page grouped by client
+│ ├── partials/
+│ │ ├── driver_list.html # existing
+│ │ ├── printer_form.html # NEW — create/edit form fragment
+│ │ ├── printer_list.html # NEW — grouped printer list fragment (HTMX target)
+│ │ └── printer_row.html # NEW — single printer row (optional, for inline edit)
+tests/
+└── test_printer_crud.py # NEW — integration tests for printer CRUD
+└── test_client_crud.py # NEW (or merged into test_printer_crud) — client creation tests
+```
+
+### Pattern 1: Sync Peewee Route Handler (established in Phase 2)
+**What:** All Peewee queries run in `def` (sync) route handlers, never `async def`. FastAPI runs sync handlers in a threadpool automatically.
+**When to use:** Every route that touches the DB.
+**Example:**
+```python
+# Source: imptune/api/drivers.py (Phase 2 established pattern)
+@router.post("/upload", response_class=HTMLResponse)
+def upload_driver(request: Request, file: UploadFile) -> HTMLResponse:
+ # Peewee calls here — all sync
+ Driver.get_or_create(sha256=sha256, defaults={...})
+```
+
+### Pattern 2: HTMX Partial Swap (established in Phase 2)
+**What:** Form submits to an API endpoint via `hx-post`; endpoint returns an HTML fragment that HTMX swaps into the target div using `outerHTML`.
+**When to use:** Printer form submission (PRNT-09), client assignment, list refresh.
+**Example:**
+```html
+
+
+```
+
+### Pattern 3: Error Fragment Response (established in Phase 2)
+**What:** Validation errors return an `HTMLResponse` whose content is a div with `id="[target-id]"` so HTMX replaces the target area with the error message.
+**When to use:** Any form validation failure in printer or client routes.
+**Example:**
+```python
+# Source: imptune/api/drivers.py (Phase 2 established)
+def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
+ return HTMLResponse(
+ content=f"
{message}
",
+ status_code=status_code,
+ )
+```
+
+### Pattern 4: Alpine.js Reactive Field (new in Phase 3, PRNT-03)
+**What:** Alpine.js `x-data` component on the printer form watches the IP address field and derives a default port name. The port field remains user-editable (not disabled).
+**When to use:** PRNT-03 auto-suggest port name from IP.
+**Example:**
+```html
+
+
+
+
+
+```
+Note: The exact derivation logic should be `IP_` + IP with dots replaced by underscores — this matches the Windows standard `pnputil` port name format used in Phase 4 scripts.
+
+### Pattern 5: Peewee FK Population for Dropdown (new in Phase 3)
+**What:** Pass a list of all Client records to the printer form template. Render as `` with an "Unassigned" option (value="").
+**When to use:** PRNT-08 client assignment dropdown.
+**Example:**
+```python
+# In GET /printers/new or GET /printers/{id}/edit route
+clients = list(Client.select().order_by(Client.name))
+drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
+return templates.TemplateResponse(
+ request=request,
+ name="partials/printer_form.html",
+ context={"clients": clients, "drivers": drivers, "printer": None},
+)
+```
+
+### Pattern 6: Grouping Printers by Client in Template (new in Phase 3, PRNT-08)
+**What:** Query all printers with their client FK resolved. In the route handler, group into a dict `{client_name: [printer, ...]}` before passing to template. Avoids N+1 queries.
+**When to use:** Printers page list view.
+**Example:**
+```python
+from collections import defaultdict
+printers = list(Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER).order_by(Client.name, Printer.name))
+grouped = defaultdict(list)
+for p in printers:
+ label = p.client.name if p.client_id else "Unassigned"
+ grouped[label].append(p)
+```
+
+### Anti-Patterns to Avoid
+- **`async def` route with Peewee calls:** Peewee is synchronous; calling it in an async context blocks the event loop. Use `def` handlers. (Established decision from Phase 2.)
+- **Schema changes in Phase 3:** The full 4-table schema was created in Phase 1. Do not call `db.create_tables()` for any new table — it does not exist in the schema.
+- **Free-text driver name entry:** The driver selection on the printer form must use the `Driver` FK dropdown (parsed from stored `driver_desc`), not a text input. This preserves the Phase 2 guarantee (DRV-03).
+- **Disabling the port name field:** PRNT-03 says "user can override" — keep the field editable. Alpine.js only pre-fills when the field is empty or when IP changes and port hasn't been manually set.
+- **N+1 queries for printer list:** Use `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER)` for the grouped list, not a loop with individual `.client` accesses.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Form field validation | Custom regex validator class | Peewee model constraints + FastAPI `Form(...)` validation | Peewee unique/null constraints enforce DB-level; FastAPI Form gives HTTP 422 on missing required fields |
+| Client auto-create on printer save | Complex upsert logic | Separate POST /clients endpoint or inline create in printer route | Simpler and testable in isolation |
+| Port name auto-derivation | Server-side computation on every request | Alpine.js `x-model` in-browser reactivity | PRNT-03 is a UX hint, not a business rule — belongs in the browser |
+| Driver file re-upload for regeneration | New upload flow | `DriverStore.get_path(driver.sha256)` with stored FK | The sha256 FK already points to the persisted file on disk |
+
+**Key insight:** Every data model needed for Phase 3 is already in SQLite. Phase 3 is purely UI wiring and route handlers — no new storage logic.
+
+## Common Pitfalls
+
+### Pitfall 1: Peewee ForeignKeyField Access Triggers N+1
+**What goes wrong:** Template or route iterates printers and accesses `p.client.name` — each access fires a separate SELECT query.
+**Why it happens:** Peewee lazy-loads FK relations by default.
+**How to avoid:** Use `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER)` to fetch all data in one query. Pre-build a grouping dict in Python before passing to template.
+**Warning signs:** Slow printer list page; SQL log shows many small SELECT queries per request.
+
+### Pitfall 2: Alpine.js Port Derivation Overwrites User Edits
+**What goes wrong:** User edits the port name field, then touches the IP field — Alpine.js replaces the edited port name.
+**Why it happens:** If `x-model` on port always derives from IP, any IP input event overwrites.
+**How to avoid:** Track whether the user has manually edited the port field. One approach: use a `portEdited` flag in `x-data`; set it on `@change` of the port input; only auto-derive when `!portEdited`.
+**Warning signs:** PRNT-03 success criterion says "user can edit it" — test this explicitly.
+
+### Pitfall 3: HTMX Target ID Mismatch on Error Response
+**What goes wrong:** Error HTML fragment has a different `id` than the HTMX `hx-target`, so HTMX cannot swap it.
+**Why it happens:** Error helper function uses a hardcoded ID that doesn't match the form's target.
+**How to avoid:** Either pass the target ID as a parameter to `_error_response()`, or use a consistent convention (`printer-list` is always the target for the printer page).
+**Warning signs:** Error messages not appearing after form submission; HTMX console warnings about missing targets.
+
+### Pitfall 4: Printer Form Missing `driver_id` Dropdown Population
+**What goes wrong:** Driver `` on printer form shows no options because drivers list was not passed to template context.
+**Why it happens:** GET /printers/new handler omits `drivers` from context.
+**How to avoid:** Always pass `drivers` list to both create and edit form contexts. Build `driver_data` the same way as in `drivers.py` (parse `driver_desc` JSON into a list per driver).
+**Warning signs:** Driver dropdown empty on new/edit printer form.
+
+### Pitfall 5: PRNT-10 Scope Creep into Phase 4
+**What goes wrong:** Implementing actual script/package regeneration (PowerShell generation) in Phase 3.
+**Why it happens:** PRNT-10 says "regenerate its package" — but Phase 4 is the script generation phase.
+**How to avoid:** Phase 3's scope for PRNT-10 is: (a) display saved printer config with all fields pre-populated, (b) show associated driver info, (c) provide a "Regenerate" button that will call the Phase 4 endpoint. The button can be disabled/placeholder in Phase 3. The plan 03-03 "Saved config retrieval and regeneration flow" is about navigation and form pre-population, not script generation.
+**Warning signs:** Trying to write PowerShell templates in Phase 3.
+
+### Pitfall 6: TestClient Lifespan Not Triggered
+**What goes wrong:** Tests fail with "table does not exist" errors.
+**Why it happens:** TestClient must be used as a context manager to trigger the FastAPI `lifespan` (which calls `init_db()`). Using `TestClient(app)` without `with` does not trigger lifespan.
+**How to avoid:** Always use `with TestClient(app) as c:` — established and enforced in conftest.py's `client` fixture. All new tests should use the `client` fixture, not create their own TestClient.
+**Warning signs:** `OperationalError: no such table: printer` in test output.
+
+## Code Examples
+
+### Create Printer (Peewee)
+```python
+# Pattern: use keyword args matching Printer model field names
+# Source: Peewee 3.17 docs + established Phase 2 Driver.get_or_create pattern
+from imptune.db.models import Printer, Client, Driver
+
+printer = Printer.create(
+ name=name,
+ ip_address=ip,
+ port_name=port,
+ duplex_mode=duplex, # "OneSided" | "LongEdge" | "ShortEdge"
+ color_mode=color_mode, # bool
+ paper_size=paper_size, # "A4" | "Letter" | "Legal"
+ collate=collate, # bool
+ client=client_obj_or_none,
+ driver=driver_obj_or_none,
+)
+```
+
+### Update Printer (Peewee)
+```python
+# Source: Peewee 3.17 — Model.save() with only_fields for efficiency
+printer = Printer.get_by_id(printer_id)
+printer.name = new_name
+printer.ip_address = new_ip
+# ... set other fields ...
+printer.updated_at = datetime.utcnow()
+printer.save()
+```
+
+### Query Printers Grouped by Client (Peewee)
+```python
+# Source: Peewee 3.17 JOIN pattern — avoids N+1
+from collections import defaultdict
+from peewee import JOIN
+
+printers = list(
+ Printer.select(Printer, Client)
+ .join(Client, JOIN.LEFT_OUTER)
+ .order_by(Client.name.nulls_last(), Printer.name)
+)
+grouped: dict[str, list] = defaultdict(list)
+for p in printers:
+ label = p.client.name if p.client_id else "Unassigned"
+ grouped[label].append(p)
+```
+
+### FastAPI Form Parsing (python-multipart)
+```python
+# Source: FastAPI docs — Form parameters
+from fastapi import Form
+
+@router.post("/printers", response_class=HTMLResponse)
+def create_printer(
+ request: Request,
+ name: str = Form(...),
+ ip_address: str = Form(...),
+ port_name: str = Form(...),
+ duplex_mode: str = Form("OneSided"),
+ color_mode: bool = Form(True),
+ paper_size: str = Form("A4"),
+ collate: bool = Form(True),
+ client_id: int | None = Form(None),
+ driver_id: int | None = Form(None),
+) -> HTMLResponse:
+ ...
+```
+
+### Alpine.js Port Auto-Derivation (PRNT-03)
+```html
+
+