+ @initThemeToggle(id)` with ``.
+4. Delete old `script` block for this component.
+5. Run `make templ-gen`. Test in dev: theme toggle works.
+
+**Done when:** ThemeToggle works via islands runtime. Old `renderSvelteComponent('ThemeToggle', ...)` call is gone.
+
+---
+
+## Acceptance Criteria
+- [x] ThemeToggle works via islands runtime. Old `renderSvelteComponent('ThemeToggle', ...)` call is gone. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-006.md b/.docket/tickets/TKT-006.md
new file mode 100644
index 00000000..db88de29
--- /dev/null
+++ b/.docket/tickets/TKT-006.md
@@ -0,0 +1,40 @@
+---
+id: TKT-006
+seq: 6
+state: done
+priority: 2
+blocked_by:
+ - TKT-005
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-006: M03 B04 - Migrate remaining Svelte components to islands
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group B — JS Islands Architecture
+**Original Task Code:** `M03 B04`
+**Original Status:** `[x] done`
+**Original Dependencies:** B03 (use as proven pattern)
+
+**Status:** `[x] done`
+**Depends on:** B03 (use as proven pattern)
+**Files:** All Svelte files in `frontend/javascript/svelte/`, corresponding templ files
+
+**Context:** Current registry components: `MultiSelectComponent`, `PhotoUploader`, `SingleSelect`, `PhoneNumberPicker`, `PwaInstallButton`, `PwaSubscribePush`, `NotificationPermissions`. Migrate each one following the ThemeToggle pattern from B03. Do one component per commit.
+
+**What to do:** For each component:
+1. Read Svelte source + templ mounting code.
+2. Create `frontend/islands/{ComponentName}.svelte` with `mount` export.
+3. Update templ: `data-island` pattern, remove `script` block.
+4. Test manually or via Playwright.
+5. Commit.
+
+**Done when:** All components migrated. `window.renderSvelteComponent` is not called anywhere.
+
+---
+
+## Acceptance Criteria
+- [x] All components migrated. `window.renderSvelteComponent` is not called anywhere. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-007.md b/.docket/tickets/TKT-007.md
new file mode 100644
index 00000000..8482dc4c
--- /dev/null
+++ b/.docket/tickets/TKT-007.md
@@ -0,0 +1,38 @@
+---
+id: TKT-007
+seq: 7
+state: done
+priority: 2
+blocked_by:
+ - TKT-006
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-007: M03 B05 - Remove old esbuild setup
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group B — JS Islands Architecture
+**Original Task Code:** `M03 B05`
+**Original Status:** `[x] done`
+**Original Dependencies:** B04
+
+**Status:** `[x] done`
+**Depends on:** B04
+**Files:** `frontend/build.mjs`, `frontend/javascript/svelte/main.js`, `Makefile`, `package.json`
+
+**What to do:**
+1. Delete `frontend/build.mjs` and `frontend/javascript/svelte/main.js` (registry file).
+2. Remove esbuild from `package.json` devDependencies.
+3. Remove `svelte_bundle.js` include from `app/views/web/components/core.templ`.
+4. Update all `Makefile` targets that referenced old build commands.
+5. Run `make js-build` and confirm clean build.
+
+**Done when:** No esbuild references remain. `make js-build` uses Vite exclusively. App compiles and runs.
+
+---
+
+## Acceptance Criteria
+- [x] No esbuild references remain. `make js-build` uses Vite exclusively. App compiles and runs. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-008.md b/.docket/tickets/TKT-008.md
new file mode 100644
index 00000000..ef0736ef
--- /dev/null
+++ b/.docket/tickets/TKT-008.md
@@ -0,0 +1,49 @@
+---
+id: TKT-008
+seq: 8
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:26Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-008: M03 C01 - Define Module interface in framework (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group C — Module System
+**Original Task Code:** `M03 C01`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `framework/core/interfaces.go` or new `framework/core/module.go`
+
+**Context:** All installable modules must implement a common interface. Read `docs/roadmap/02-architecture-evolution.md` section 2.
+
+**What to do:**
+1. Read `framework/core/interfaces.go`.
+2. Add:
+```go
+type Module interface {
+ ID() string
+ Migrations() fs.FS // embedded migration files; nil if none
+}
+
+type RoutableModule interface {
+ Module
+ RegisterRoutes(r Router) error
+}
+```
+Where `Router` is a minimal interface over Echo group registration (define it here).
+3. Additive only — do not change existing interfaces.
+4. Run `go build ./...`.
+
+**Done when:** Interfaces defined, project compiles.
+
+---
+
+## Acceptance Criteria
+- [x] Interfaces defined, project compiles. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-009.md b/.docket/tickets/TKT-009.md
new file mode 100644
index 00000000..27bfeae7
--- /dev/null
+++ b/.docket/tickets/TKT-009.md
@@ -0,0 +1,44 @@
+---
+id: TKT-009
+seq: 9
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:26Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-009: M03 C02 - Standardize marker comments in router and container (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group C — Module System
+**Original Task Code:** `M03 C02`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `app/router.go`, `app/foundation/container.go`
+
+**Context:** Marker comments are insertion points for `ship module:add`. Some exist already; standardize and extend.
+
+**What to do:**
+1. In `app/router.go`, ensure these exist at correct positions (add if missing):
+ ```go
+ // ship:routes:public:start / ship:routes:public:end
+ // ship:routes:auth:start / ship:routes:auth:end
+ // ship:routes:external:start / ship:routes:external:end
+ ```
+2. In `app/foundation/container.go`, add:
+ ```go
+ // ship:container:start / ship:container:end
+ ```
+3. Logic unchanged — comment additions only.
+
+**Done when:** All markers exist in both files. `go build ./...` passes.
+
+---
+
+## Acceptance Criteria
+- [x] All markers exist in both files. `go build ./...` passes. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-010.md b/.docket/tickets/TKT-010.md
new file mode 100644
index 00000000..cfb15be6
--- /dev/null
+++ b/.docket/tickets/TKT-010.md
@@ -0,0 +1,43 @@
+---
+id: TKT-010
+seq: 10
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+ - TKT-009
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-010: M03 C03 - Implement `ship module:add` CLI command
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group C — Module System
+**Original Task Code:** `M03 C03`
+**Original Status:** `[x] done`
+**Original Dependencies:** C01, C02
+
+**Status:** `[x] done`
+**Depends on:** C01, C02
+**Files:** `tools/cli/ship/internal/commands/module.go` (new), `tools/cli/ship/internal/cli/cli.go`
+
+**Context:** `ship module:add ` installs a module by inserting wiring at marker comments in container + router and updating `config/modules.yaml`. Supported initially: `notifications`, `paidsubscriptions`, `emailsubscriptions`, `jobs`, `pwa`, `admin`.
+
+**What to do:**
+1. Read an existing generator (e.g., `make:controller`) for the marker-insertion pattern.
+2. Create `module.go` with `module:add ` subcommand:
+ - For each known module: define import, container init line, and route registration line to insert.
+ - Insert at `ship:container:start` and `ship:routes:*:start` markers.
+ - Update `config/modules.yaml`.
+3. Add `--dry-run` flag (prints diff, writes nothing).
+4. Register in `cli.go`.
+
+**Done when:** `ship module:add notifications --dry-run` shows correct diff. `ship module:add notifications` correctly wires (verify by reading modified files). `go build ./...` passes.
+
+---
+
+## Acceptance Criteria
+- [x] `ship module:add notifications --dry-run` shows correct diff. `ship module:add notifications` correctly wires (verify by reading modified files). `go build ./...` passes. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-011.md b/.docket/tickets/TKT-011.md
new file mode 100644
index 00000000..714a08c6
--- /dev/null
+++ b/.docket/tickets/TKT-011.md
@@ -0,0 +1,33 @@
+---
+id: TKT-011
+seq: 11
+state: done
+priority: 2
+blocked_by:
+ - TKT-010
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-011: M03 C04 - Implement `ship module:remove` CLI command
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group C — Module System
+**Original Task Code:** `M03 C04`
+**Original Status:** `[x] done`
+**Original Dependencies:** C03
+
+**Status:** `[x] done`
+**Depends on:** C03
+**Files:** `tools/cli/ship/internal/commands/module.go`
+
+**Context:** Reverse of C03. Print a reminder that DB migrations are NOT rolled back automatically.
+
+**Done when:** `ship module:remove notifications` removes wiring. Compile passes. Reminder printed about migrations.
+
+---
+
+## Acceptance Criteria
+- [x] `ship module:remove notifications` removes wiring. Compile passes. Reminder printed about migrations. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-012.md b/.docket/tickets/TKT-012.md
new file mode 100644
index 00000000..8338a4f0
--- /dev/null
+++ b/.docket/tickets/TKT-012.md
@@ -0,0 +1,42 @@
+---
+id: TKT-012
+seq: 12
+state: done
+priority: 2
+blocked_by:
+ - TKT-002
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:38:35Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-012: M03 C05 - Add `ship_doctor`, `ship_routes`, `ship_modules` to MCP server (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group C — Module System
+**Original Task Code:** `M03 C05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** A02
+
+**Status:** `[ ] todo`
+**Depends on:** A02
+**Files:** `tools/mcp/ship/`
+
+**Context:** Expand MCP from 3 read-only tools to include verification and inspection. These enable the LLM act→verify→fix loop. See `docs/roadmap/02-architecture-evolution.md` section 4.
+
+**What to do:**
+1. Read `tools/mcp/ship/` fully.
+2. Add:
+ - `ship_doctor`: runs `ship doctor --json`, returns parsed JSON.
+ - `ship_routes`: parses `app/router.go` AST to extract route inventory, returns `[{method, path, auth, handler}]`.
+ - `ship_modules`: reads `config/modules.yaml` + scans `modules/`, returns installed + available modules.
+3. Each tool: clear description, input/output schema documented.
+
+**Done when:** Three new tools exist, return valid JSON, existing tools unchanged.
+
+---
+
+## Acceptance Criteria
+- [x] Three new tools exist, return valid JSON, existing tools unchanged. : Verified existing ship_doctor/ship_routes/ship_modules MCP tools and passed go test ./tools/mcp/ship/internal/server
+
diff --git a/.docket/tickets/TKT-013.md b/.docket/tickets/TKT-013.md
new file mode 100644
index 00000000..7fcf3ce3
--- /dev/null
+++ b/.docket/tickets/TKT-013.md
@@ -0,0 +1,39 @@
+---
+id: TKT-013
+seq: 13
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-013: M03 D01 - Extract auth controllers into `modules/auth`
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group D — Module Extraction
+**Original Task Code:** `M03 D01`
+**Original Status:** `[x] done`
+**Original Dependencies:** C01
+
+**Status:** `[x] done`
+**Depends on:** C01
+**Files:** `app/web/controllers/login.go`, `register.go`, `logout.go`, `forgot_password.go`, new `modules/auth/`
+
+**What to do:**
+1. Read all four controllers + their templ views.
+2. Create `modules/auth/`: `module.go` (ID: "auth"), `routes.go`, `service.go`, `views/`.
+3. Move handler logic and templ views into the module.
+4. `app/router.go`: call `authModule.RegisterRoutes(...)` instead of direct registration.
+5. Delete original controllers.
+6. `go build ./...` + `make test`.
+
+**Done when:** Auth routes work via module. Old controllers deleted. Tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] Auth routes work via module. Old controllers deleted. Tests pass. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-014.md b/.docket/tickets/TKT-014.md
new file mode 100644
index 00000000..3c9d0188
--- /dev/null
+++ b/.docket/tickets/TKT-014.md
@@ -0,0 +1,33 @@
+---
+id: TKT-014
+seq: 14
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:08:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-014: M03 D02 - Extract profile into `modules/profile`
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group D — Module Extraction
+**Original Task Code:** `M03 D02`
+**Original Status:** `[x] done`
+**Original Dependencies:** C01
+
+**Status:** `[x] done`
+**Depends on:** C01
+**Files:** `app/profile/`, `app/web/controllers/profile.go`, `profile_photo.go`, `upload_photo.go`, new `modules/profile/`
+
+**What to do:** Same pattern as D01. Module brings: `service.go` (wraps profile domain logic), `store.go`/`store_sql.go`, `routes.go`, `views/`.
+
+**Done when:** Profile routes work via module. Tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] Profile routes work via module. Tests pass. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-015.md b/.docket/tickets/TKT-015.md
new file mode 100644
index 00000000..3f8dcfaa
--- /dev/null
+++ b/.docket/tickets/TKT-015.md
@@ -0,0 +1,34 @@
+---
+id: TKT-015
+seq: 15
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:38:35Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-015: M03 D03 - Move paidsubscriptions route handler into module
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group D — Module Extraction
+**Original Task Code:** `M03 D03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** C01
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/payments.go` → `modules/paidsubscriptions/routes.go` (new)
+
+**What to do:** Move handler into module. Implement `RoutableModule`. Update router. Delete old controller.
+
+**Done when:** Payments routes work via module. Old controller deleted.
+
+---
+
+## Acceptance Criteria
+- [x] Payments routes work via module. Old controller deleted. : Moved payment routes to modules/paidsubscriptions/routes, rewired router, deleted app/web/controllers/payments.go, and passed focused package tests
+
diff --git a/.docket/tickets/TKT-016.md b/.docket/tickets/TKT-016.md
new file mode 100644
index 00000000..6ed78fef
--- /dev/null
+++ b/.docket/tickets/TKT-016.md
@@ -0,0 +1,42 @@
+---
+id: TKT-016
+seq: 16
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:26Z"
+updated_at: "2026-03-11T02:38:35Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-016: M03 D04 - Move notifications route handlers into module
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group D — Module Extraction
+**Original Task Code:** `M03 D04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** C01
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/notifications.go`, `push_notifs.go` → `modules/notifications/routes.go` (new)
+
+**Context:** Follow the same extraction pattern used for auth/profile module work: move route handlers into `modules/notifications`, keep the module implementing `RoutableModule`, and remove the app-layer controller ownership once routing is re-registered through the module.
+
+**What to do:**
+1. Read the existing notification route wiring and both controllers end to end before moving code.
+2. Create `modules/notifications/routes.go` and move the HTTP handlers there.
+3. Implement `RoutableModule` on the notifications module if it is not already routable.
+4. Update router registration so notification endpoints are mounted from the module, not from `app/web/controllers/`.
+5. Delete the old controller files once the module owns the routes.
+6. Run targeted route/module tests and a compile check.
+
+**Done when:** Notification routes work via module. Old controllers deleted.
+
+---
+
+## Acceptance Criteria
+- [x] Notification routes work via module. Old controllers deleted. : Moved notification and push-subscription routes to modules/notifications/routes, rewired router, deleted old controller files, and passed focused package tests
+
diff --git a/.docket/tickets/TKT-017.md b/.docket/tickets/TKT-017.md
new file mode 100644
index 00000000..7f307c82
--- /dev/null
+++ b/.docket/tickets/TKT-017.md
@@ -0,0 +1,42 @@
+---
+id: TKT-017
+seq: 17
+state: done
+priority: 2
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T03:28:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-017: M03 D05 - Create `modules/pwa`
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group D — Module Extraction
+**Original Task Code:** `M03 D05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** C01
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/install_app.go`, PWA templ components, service worker, manifest → `modules/pwa/`
+
+**What to do:** Create `modules/pwa/` with `module.go` (ID: "pwa"), `routes.go`, `views/`, static assets (manifest template, service worker). Delete originals.
+
+**Done when:** PWA install flow works via module. Old files deleted.
+
+---
+
+## Acceptance Criteria
+- [x] PWA install flow works via module. Old files deleted.
+
+## Implementation Notes
+- Moved the install page route, page template, install button components, manifest, and service worker into `modules/pwa/`.
+- Added `RegisterStaticRoutes` on the PWA module so `/service-worker.js` and `/files/manifest.json` are served from module-owned assets.
+- Updated app templates to import the PWA install button components from `modules/pwa/views/...`.
+- Deleted the old app-layer controller, templ files, and generated templ artifacts.
+
+## Verification
+- `go test ./modules/pwa ./app ./app/web/controllers ./tools/mcp/ship/internal/server ./modules/notifications/... ./modules/paidsubscriptions/...`
diff --git a/.docket/tickets/TKT-018.md b/.docket/tickets/TKT-018.md
new file mode 100644
index 00000000..55ba55c0
--- /dev/null
+++ b/.docket/tickets/TKT-018.md
@@ -0,0 +1,50 @@
+---
+id: TKT-018
+seq: 18
+state: done
+priority: 2
+blocked_by:
+ - TKT-013
+ - TKT-014
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T03:29:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-018: M03 E01 - Create `starter/` minimal skeleton
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group E — App Split: Landing vs Starter
+**Original Task Code:** `M03 E01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** D01, D02
+
+**Status:** `[ ] todo`
+**Depends on:** D01, D02
+**Files:** new `starter/` directory
+
+**Context:** Minimal app used by `ship new`. Auth + profile + home feed only. No payments, push, PWA by default.
+
+**What to do:**
+1. Create `starter/` mirroring `app/` structure: `router.go`, `foundation/container.go`, `views/web/pages/home_feed.templ`, `views/web/pages/landing.templ`.
+2. Include only auth + profile modules.
+3. Ensure `go build ./...` from `starter/`.
+4. Write `starter/README.md`: "Minimal GoShip starter. Add modules with `ship module:add`."
+
+**Done when:** `starter/` compiles. Contains only auth + profile + home feed.
+
+---
+
+## Acceptance Criteria
+- [x] `starter/` compiles. Contains only auth + profile + home feed.
+
+## Implementation Notes
+- Added a minimal `starter/` tree with `app/router.go`, `app/foundation/container.go`, `app/views/web/pages/home_feed.templ`, `app/views/web/pages/landing.templ`, route-name constants, and a stub `cmd/web/main.go`.
+- Scoped the starter container and `config/modules.yaml` to `auth` and `profile` only.
+- Added `starter/README.md` with the module-add guidance from the roadmap.
+- Added a starter router test to lock the default module boundary.
+
+## Verification
+- `cd starter && go build ./...`
+- `go test ./starter/...`
diff --git a/.docket/tickets/TKT-019.md b/.docket/tickets/TKT-019.md
new file mode 100644
index 00000000..33898716
--- /dev/null
+++ b/.docket/tickets/TKT-019.md
@@ -0,0 +1,47 @@
+---
+id: TKT-019
+seq: 19
+state: done
+priority: 2
+blocked_by:
+ - TKT-018
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T07:10:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-019: M03 E02 - Wire `ship new` to use `starter/` as template
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group E — App Split: Landing vs Starter
+**Original Task Code:** `M03 E02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** E01
+
+**Status:** `[ ] todo`
+**Depends on:** E01
+**Files:** `tools/cli/ship/internal/commands/new.go`
+
+**What to do:**
+1. Read current `ship new` implementation.
+2. Update to template from `starter/` (embedded in binary or fetched).
+3. Replace placeholder names in generated files.
+4. Print post-install: `cd myapp && ship module:add && make run`.
+
+**Done when:** `ship new testapp` generates working minimal app from starter.
+
+---
+
+## Acceptance Criteria
+- [x] `ship new testapp` generates working minimal app from starter.
+
+## Implementation Notes
+- Switched `ship new` from the handwritten app/router/container scaffold to an embedded copy of `starter/`.
+- Added starter template embedding in `starter/embed.go` and added route/container marker comments to the starter sources used by the generator.
+- Kept the repo's supporting scaffold files (`db/`, docs, controller/middleware/ui placeholders, agent policy files) so generated projects still satisfy `ship doctor`.
+- Added placeholder rewriting for module import paths and a post-install hint: `cd && ship module:add && make run`.
+
+## Verification
+- `go test ./tools/cli/ship/internal/commands ./starter/...`
+- `ship doctor` passes on the generated fixture project in `project_new_integration_test.go`
diff --git a/.docket/tickets/TKT-020.md b/.docket/tickets/TKT-020.md
new file mode 100644
index 00000000..814faec2
--- /dev/null
+++ b/.docket/tickets/TKT-020.md
@@ -0,0 +1,64 @@
+---
+id: TKT-020
+seq: 20
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T20:41:31Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-020: M03 G01 - Replace Viper with cleanenv struct-tag config
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group G — Config: Drop Viper, Adopt cleanenv + `.env`
+**Original Task Code:** `M03 G01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `config/config.go`, `go.mod`, all files importing `viper`
+
+**Context:** Viper's multi-source merging creates "too many layers" pain (YAML → env override → Go). Replace with `cleanenv` (`github.com/ilyakaznacheev/cleanenv`) which reads directly from env vars into struct tags. One dependency, one source of truth.
+
+**Chosen library:** `cleanenv` — handles struct tags, .env loading, required validation, defaults, and auto-generates help text. No separate godotenv needed.
+
+**What to do:**
+1. Run `grep -rn "viper" .` to find all usages.
+2. `go get github.com/ilyakaznacheev/cleanenv`.
+3. Rewrite `config/config.go`: convert all config fields to cleanenv struct tags:
+ ```go
+ type Config struct {
+ DatabaseURL string `env:"DATABASE_URL,required"`
+ SecretKey string `env:"SECRET_KEY,required"`
+ Port int `env:"PORT" env-default:"8080"`
+ SMTPHost string `env:"SMTP_HOST"`
+ RedisURL string `env:"REDIS_URL"`
+ // ...
+ }
+ ```
+4. Replace `config.Load()` / viper init with:
+ ```go
+ func Load() (*Config, error) {
+ cfg := &Config{}
+ if err := cleanenv.ReadEnv(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+ }
+ ```
+5. Remove viper from `go.mod`.
+6. Update `app/foundation/container.go` to use new config loader.
+7. Run `go build ./...` and `make test`.
+
+**Done when:** Viper is removed from `go.mod`. Config loads from env vars via cleanenv. All tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] Viper is removed from `go.mod`. Config loads from env vars via cleanenv. All tests pass.
+
+## Handoff
+Migration from Viper to cleanenv completed. Config now loads from env vars.
diff --git a/.docket/tickets/TKT-021.md b/.docket/tickets/TKT-021.md
new file mode 100644
index 00000000..994760fb
--- /dev/null
+++ b/.docket/tickets/TKT-021.md
@@ -0,0 +1,59 @@
+---
+id: TKT-021
+seq: 21
+state: done
+priority: 2
+blocked_by:
+ - TKT-020
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T08:43:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-021: M03 G02 - Add `.env` file loading
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group G — Config: Drop Viper, Adopt cleanenv + `.env`
+**Original Task Code:** `M03 G02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** G01
+
+**Status:** `[ ] todo`
+**Depends on:** G01
+**Files:** `config/config.go`, `.env.example` (new), `.gitignore`
+
+**Context:** cleanenv supports loading from `.env` files via `cleanenv.ReadConfig(".env", cfg)` before `ReadEnv`. The `.env` file is gitignored; `.env.example` is committed.
+
+**What to do:**
+1. Update config loader to:
+ ```go
+ func Load() (*Config, error) {
+ cfg := &Config{}
+ _ = cleanenv.ReadConfig(".env", cfg) // load .env if exists, ignore error if not
+ if err := cleanenv.ReadEnv(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+ }
+ ```
+2. Create `.env.example` with every key from the Config struct, empty values, and comments explaining each.
+3. Add `.env` to `.gitignore` (it may already be there — verify).
+4. Update `docs/guides/02-development-workflows.md`: "Copy `.env.example` to `.env` and fill in values before running locally."
+
+**Done when:** `.env.example` exists with all keys. `config.Load()` reads `.env` if present. `.env` is gitignored.
+
+---
+
+## Acceptance Criteria
+- [x] `.env.example` exists with all keys. `config.Load()` reads `.env` if present. `.env` is gitignored.
+
+## Implementation Notes
+- `config.GetConfig()` now searches upward for `.env`, loads it with `cleanenv.ReadConfig`, then overlays shell env vars with `cleanenv.ReadEnv`.
+- Added `config/envvars.go` to enumerate the config contract from `Config` so the app and CLI can share the same env metadata.
+- Added committed `.env.example` with the full primary `PAGODA_*` surface plus the optional CLI `DATABASE_URL` override.
+- Confirmed `.env` was already ignored and updated local workflow docs to make `.env.example -> .env` the expected setup path.
+
+## Verification
+- `go test ./config`
+- `go run ./tools/cli/ship/cmd/ship config:validate --json`
diff --git a/.docket/tickets/TKT-022.md b/.docket/tickets/TKT-022.md
new file mode 100644
index 00000000..a4ac833f
--- /dev/null
+++ b/.docket/tickets/TKT-022.md
@@ -0,0 +1,51 @@
+---
+id: TKT-022
+seq: 22
+state: done
+priority: 2
+blocked_by:
+ - TKT-020
+ - TKT-021
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T08:44:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-022: M03 G03 - Remove YAML config files
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group G — Config: Drop Viper, Adopt cleanenv + `.env`
+**Original Task Code:** `M03 G03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** G01, G02
+
+**Status:** `[ ] todo`
+**Depends on:** G01, G02
+**Files:** `config/application.yaml`, `config/environments/`, all code reading YAML config
+
+**Context:** With cleanenv + .env, YAML config is redundant. Non-secret structural config (feature flags, module list) can live in env vars too, or in a minimal `config/modules.yaml` that is committed (not secret).
+
+**What to do:**
+1. Identify any config that was YAML-only and has no env var equivalent — add struct tags for those.
+2. Delete `config/application.yaml` and `config/environments/` if all values are now in struct tags with defaults.
+3. Keep `config/modules.yaml` only if it serves a structural purpose distinct from secrets.
+4. Update any `make` targets or docs that reference YAML config files.
+
+**Done when:** No YAML config files for secrets or application settings. All config comes from `.env` + struct tag defaults. `go build` + tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] No YAML config files for secrets or application settings. All config comes from `.env` + struct tag defaults. `go build` + tests pass.
+
+## Implementation Notes
+- Removed `config/application.yaml`, `config/processes.yaml`, and the `config/environments/*.yaml` files.
+- Reworked `tools/cli/ship/internal/runtime/paths.go` so DB URL resolution now uses `.env` / shell env values plus `config.GetConfig()` instead of YAML parsing.
+- Updated DB command environment safety to respect the resolved app environment via the new runtime helper.
+- Removed Docker image copies of deleted YAML config files and updated current-repo docs that still described YAML/Viper as the active config path.
+
+## Verification
+- `go test ./tools/cli/ship/internal/commands ./tools/cli/ship/internal/cli ./tools/cli/ship/internal/runtime ./tools/cli/ship/internal/policies`
+- `go test ./cmd/web ./cmd/worker ./app/foundation`
+- `go run ./tools/cli/ship/cmd/ship doctor --json`
diff --git a/.docket/tickets/TKT-023.md b/.docket/tickets/TKT-023.md
new file mode 100644
index 00000000..eefd7fcf
--- /dev/null
+++ b/.docket/tickets/TKT-023.md
@@ -0,0 +1,49 @@
+---
+id: TKT-023
+seq: 23
+state: done
+priority: 2
+blocked_by:
+ - TKT-020
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T08:45:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-023: M03 G04 - Add `ship config:validate` command
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group G — Config: Drop Viper, Adopt cleanenv + `.env`
+**Original Task Code:** `M03 G04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** G01
+
+**Status:** `[ ] todo`
+**Depends on:** G01
+**Files:** `tools/cli/ship/internal/commands/config.go` (new)
+
+**Context:** cleanenv can generate a description of all config fields (required/optional, defaults). Expose this as a CLI command and add to `ship doctor`.
+
+**What to do:**
+1. Add `ship config:validate` that calls `cleanenv.GetDescription(&Config{}, nil)` and prints the table.
+2. Add `--json` flag.
+3. Integrate into `ship doctor` check: if any required env var is missing, `ship doctor` reports it as an error.
+
+**Done when:** `ship config:validate` lists all env vars with required/optional status. Missing required vars appear in `ship doctor` output.
+
+---
+
+## Acceptance Criteria
+- [x] `ship config:validate` lists all env vars with required/optional status. Missing required vars appear in `ship doctor` output.
+
+## Implementation Notes
+- Added `ship config:validate` in `tools/cli/ship/internal/commands/config.go` with both human-readable table output and `--json`.
+- Wired the new namespace into the CLI dispatcher and root help output.
+- `ship doctor` now checks the shared config env metadata and will emit `DX022` errors if any env var is marked required in `config.Config` and missing from the shell plus `.env`.
+- Added CLI command tests and dispatch coverage for the new command.
+
+## Verification
+- `go test ./tools/cli/ship/internal/commands ./tools/cli/ship/internal/cli ./tools/cli/ship/internal/policies`
+- `go run ./tools/cli/ship/cmd/ship config:validate --json`
+- `go run ./tools/cli/ship/cmd/ship doctor --json`
diff --git a/.docket/tickets/TKT-024.md b/.docket/tickets/TKT-024.md
new file mode 100644
index 00000000..889f2dc7
--- /dev/null
+++ b/.docket/tickets/TKT-024.md
@@ -0,0 +1,46 @@
+---
+id: TKT-024
+seq: 24
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T09:22:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-024: M03 H01 - Add recovery middleware to Echo (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `app/web/wiring.go` or wherever global middleware is registered
+
+**Context:** Recovery middleware catches panics in any request, logs them with stack trace, and returns a 500 — the app stays alive for all other users.
+
+**What to do:**
+1. Read the middleware registration file.
+2. Add `e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ LogErrorFunc: ... }))` as the FIRST middleware (must wrap everything).
+3. `LogErrorFunc` should use the existing structured logger to emit the panic + stack trace.
+4. Test: introduce a deliberate panic in a test route, verify the app returns 500 and stays running.
+
+**Done when:** App does not crash on panics. Stack trace is logged. Returns 500 to the panicking request only.
+
+---
+
+## Acceptance Criteria
+- [x] App does not crash on panics. Stack trace is logged. Returns 500 to the panicking request only.
+
+## Implementation Notes
+- Replaced the bare Echo `Recover()` middleware with `middleware.RecoverPanics(...)`, a `RecoverWithConfig` wrapper that logs structured panic data including request method, path, and stack trace.
+- Recovery is now installed first in the main, realtime, and external middleware stacks so it wraps timeout, gzip, auth/session, and downstream handlers.
+- Added a middleware-level test that hits a deliberate panic route, asserts a 500 response, then confirms a healthy route still returns 200 afterward.
+
+## Verification
+- `go test ./app/web/middleware ./app/web`
+- `go test ./cmd/web`
diff --git a/.docket/tickets/TKT-025.md b/.docket/tickets/TKT-025.md
new file mode 100644
index 00000000..499cdc07
--- /dev/null
+++ b/.docket/tickets/TKT-025.md
@@ -0,0 +1,50 @@
+---
+id: TKT-025
+seq: 25
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T09:23:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-025: M03 H02 - Add `nilaway` to CI and `ship doctor` (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `.github/workflows/` (CI), `tools/cli/ship/internal/commands/` (doctor)
+
+**Context:** `nilaway` (Uber) statically traces nil flows across function boundaries — catches nil derefs before they hit production.
+
+**What to do:**
+1. Add to CI:
+ ```yaml
+ - name: nilaway
+ run: go run go.uber.org/nilaway/cmd/nilaway@latest ./...
+ ```
+2. Add to `ship doctor`: run `nilaway ./...` and parse output for issues. Report as warnings (not errors) initially until existing codebase is clean.
+3. Document in `docs/guides/01-ai-agent-guide.md` under "Nil Safety" section.
+
+**Done when:** `nilaway` runs in CI. `ship doctor` surfaces nil issues as warnings.
+
+---
+
+## Acceptance Criteria
+- [x] `nilaway` runs in CI. `ship doctor` surfaces nil issues as warnings.
+
+## Implementation Notes
+- Added a dedicated `Nilaway` step to `.github/workflows/test.yml` using `go run go.uber.org/nilaway/cmd/nilaway@latest ./...`.
+- Extended `ship doctor` to run `nilaway ./...` when the binary is available locally, parse its output into `DX025` warning issues, and keep the overall doctor result non-failing for analyzer findings.
+- Added policy tests that stub `nilaway` on and off so doctor remains stable regardless of the local machine environment.
+- Documented the nil-safety expectation in `docs/guides/01-ai-agent-guide.md`.
+
+## Verification
+- `go test ./tools/cli/ship/internal/policies`
+- `go run ./tools/cli/ship/cmd/ship doctor --json`
diff --git a/.docket/tickets/TKT-026.md b/.docket/tickets/TKT-026.md
new file mode 100644
index 00000000..29841eaa
--- /dev/null
+++ b/.docket/tickets/TKT-026.md
@@ -0,0 +1,62 @@
+---
+id: TKT-026
+seq: 26
+state: done
+priority: 2
+blocked_by:
+ - TKT-024
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T14:07:36Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-026: M03 H03 - Audit and enforce value-type viewmodels
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** H01 (recovery middleware should be in first)
+
+**Status:** `[ ] todo`
+**Depends on:** H01 (recovery middleware should be in first)
+**Files:** `app/web/viewmodels/`, all templ components
+
+**Context:** The root cause of most nil panics in templ: domain model pointers flowing directly into templates. Viewmodels must be pure value types — no pointer fields.
+
+**Convention:**
+- Domain models (`db/gen/`, `framework/domain/`) may have pointers for nullable DB columns.
+- Viewmodels (`app/web/viewmodels/`) must have **zero pointer fields**. Use `sql.NullString`, zero values, or custom `Option[T]` for optional data.
+- Templ component signatures must accept viewmodel types (or primitives), never `*DomainModel`.
+- Controllers own the domain → viewmodel transformation and all nil handling.
+
+**What to do:**
+1. Read all files in `app/web/viewmodels/`.
+2. For each struct: replace any pointer field (`*string`, `*int`, `*SomeStruct`) with a value equivalent:
+ - `*string` → `string` (empty string = absent)
+ - `*int` → `int` (zero = absent), or `sql.NullInt64` if you need to distinguish zero from absent
+ - `*NestedStruct` → `NestedStruct` (zero value struct)
+3. For each templ component that accepts a `*DomainModel` directly: introduce a viewmodel and update the component signature.
+4. Update all controllers that feed into those components to do the transformation.
+5. Add a note in `docs/guides/01-ai-agent-guide.md` under "Nil Safety" codifying this as a permanent convention.
+
+**Done when:** `grep -rn '\*[A-Z]' app/web/viewmodels/` returns no pointer fields. All affected templ components updated. Tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] `grep -rn '\*[A-Z]' app/web/viewmodels/` returns no pointer fields. All affected templ components updated. Tests pass.
+
+## Implementation Notes
+- Replaced pointer-backed notification, preferences, payments, and page-data viewmodel fields with value fields plus explicit `Has...` booleans where optional state is needed.
+- Added a notification viewmodel mapper so notification templates render `viewmodels.NotificationItem` values instead of domain models or slices of pointers.
+- Updated preferences and notifications templ components to consume value viewmodels only, including string-based subscription expiry handling.
+- Added `app/web/viewmodels/value_types_test.go` to fail the build if any struct in `app/web/viewmodels` gains a pointer field or a slice/map/chan/function type containing pointers.
+- Documented the permanent viewmodel nil-safety rule in `docs/guides/01-ai-agent-guide.md`.
+
+## Verification
+- `make templ-gen`
+- `rg -n '\*[A-Z]' app/web/viewmodels`
+- `go test ./app/web/viewmodels ./app/web/controllers ./modules/notifications/routes ./modules/paidsubscriptions/routes ./cmd/web`
+- `go test ./tools/cli/ship/internal/policies`
diff --git a/.docket/tickets/TKT-027.md b/.docket/tickets/TKT-027.md
new file mode 100644
index 00000000..4b783442
--- /dev/null
+++ b/.docket/tickets/TKT-027.md
@@ -0,0 +1,62 @@
+---
+id: TKT-027
+seq: 27
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T14:19:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-027: M03 H04 - Add nil-safe accessor methods to domain models
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/domain/`, `db/gen/`
+
+**Context:** For places where domain model pointers genuinely must be used (e.g., loading from DB before transformation), add nil-safe accessor methods. Go methods on nil pointer receivers are legal if they guard immediately.
+
+**What to do:**
+1. For every domain model struct that has pointer fields, add accessor methods:
+ ```go
+ func (u *User) DisplayName() string {
+ if u == nil { return "" }
+ if u.Name == nil { return "" }
+ return *u.Name
+ }
+ ```
+2. Add a shared helper in `framework/`:
+ ```go
+ func StringOr(s *string, def string) string {
+ if s == nil { return def }
+ return *s
+ }
+ ```
+3. Replace all `*s` dereferences outside of viewmodel transformers with these safe accessors.
+
+**Done when:** No bare `*ptr` dereferences exist outside of viewmodel transformer functions. `nilaway` passes cleanly on domain model files.
+
+---
+
+## Acceptance Criteria
+- [x] No bare `*ptr` dereferences exist outside of viewmodel transformer functions. `nilaway` passes cleanly on domain model files.
+
+## Implementation Notes
+- Added `framework/nullable.StringOr` for nil-safe string fallback handling.
+- Added nil-safe accessor methods for every current nullable field in `framework/domain/struct.go`:
+ `Question.VotedAt`, `Author.ProfileImage`, `Answer.SeenAt`, `PrivateMessage.SeenAt`, and `Profile.PhoneNumberInternational` / `Profile.ProfileImage`.
+- Replaced live UI dereferences in `app/views/web/components/profile.templ` with domain accessors so templates no longer dereference nullable domain fields directly.
+- Added focused domain tests covering nil receivers and populated values for the new accessors.
+
+## Verification
+- `make templ-gen`
+- `go test ./framework/domain ./framework/nullable ./modules/profile ./app/views/web/components/... ./cmd/web`
+- `rg -n '\*.*PhoneNumberInternational|\*.*ProfileImage|\*.*SeenAt|\*.*VotedAt' app modules framework -g '!**/*_test.go' -g '!**/gen/*.go' | grep -v '/framework/domain/struct.go:'`
+- `go run go.uber.org/nilaway/cmd/nilaway@latest ./framework/domain/...`
diff --git a/.docket/tickets/TKT-028.md b/.docket/tickets/TKT-028.md
new file mode 100644
index 00000000..b1afff09
--- /dev/null
+++ b/.docket/tickets/TKT-028.md
@@ -0,0 +1,53 @@
+---
+id: TKT-028
+seq: 28
+state: done
+priority: 2
+blocked_by:
+ - TKT-026
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T14:21:08Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-028: M03 H05 - Viewmodel constructor functions
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** H03
+
+**Status:** `[ ] todo`
+**Depends on:** H03
+**Files:** `app/web/viewmodels/`
+
+**Context:** Viewmodels should always be initialized via constructors that guarantee all fields are set. This prevents "forgot to set a field" nil panics.
+
+**What to do:**
+1. For each viewmodel struct in `app/web/viewmodels/`, add a constructor:
+ ```go
+ func NewHomeFeedData(user User, items []FeedItem) HomeFeedData {
+ if items == nil { items = []FeedItem{} }
+ return HomeFeedData{User: user, Items: items}
+ }
+ ```
+2. Update all controllers to use constructors instead of struct literals.
+3. Convention: viewmodel struct literals (`HomeFeedData{...}`) are only allowed inside their own constructor. Everywhere else must use `NewHomeFeedData(...)`.
+
+**Done when:** Every viewmodel has a constructor. Controllers use constructors. `go build` + tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] Every viewmodel has a constructor. Controllers use constructors. `go build` + tests pass.
+
+## Implementation Notes
+- Added `app/web/viewmodels/constructors.go` with `New...` constructors for every struct in `app/web/viewmodels`, normalizing slice-backed fields to empty slices where applicable.
+- Updated controllers, module routes, mail tasks, and capability configuration to construct viewmodels via constructors instead of raw `viewmodels.X{}` literals.
+- Added `app/web/viewmodels/constructors_test.go` to enforce both requirements:
+ constructor coverage for every viewmodel struct, and no external `viewmodels.X{}` composite literals in `app/` or `modules/`.
+
+## Verification
+- `go test ./app/web/viewmodels ./app/web/controllers ./app/web/capabilities ./app/jobs ./modules/auth ./modules/notifications/routes ./modules/paidsubscriptions/routes ./modules/profile ./cmd/web`
diff --git a/.docket/tickets/TKT-029.md b/.docket/tickets/TKT-029.md
new file mode 100644
index 00000000..af3b8a77
--- /dev/null
+++ b/.docket/tickets/TKT-029.md
@@ -0,0 +1,39 @@
+---
+id: TKT-029
+seq: 29
+state: done
+priority: 2
+blocked_by:
+ - TKT-026
+ - TKT-028
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T14:49:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-029: M03 H06 - Route smoke tests for nil deref
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group H — Nil Safety Architecture
+**Original Task Code:** `M03 H06`
+**Original Status:** `[x] done`
+**Original Dependencies:** H03, H05
+
+**Status:** `[x] done`
+**Depends on:** H03, H05
+**Files:** `app/web/controllers/*_test.go`
+
+**Context:** Each route test with zero-value / minimal data is a nil deref smoke test. If a template tries to dereference a nil, the test catches it before production.
+
+**What to do:**
+1. For every controller that does not already have a route test: add a minimal test that calls the route with zero-value data and asserts HTTP 200.
+2. For existing tests: verify they pass zero-value viewmodels (not maximal/happy-path data only).
+3. Follow the existing goquery test pattern in `app/web/controllers/*_test.go`.
+
+**Done when:** Every public-facing route has at least one route test with minimal data. All tests pass.
+
+---
+
+## Acceptance Criteria
+- [x] Every public-facing route has at least one route test with minimal data. All tests pass.
diff --git a/.docket/tickets/TKT-030.md b/.docket/tickets/TKT-030.md
new file mode 100644
index 00000000..ee03a9ed
--- /dev/null
+++ b/.docket/tickets/TKT-030.md
@@ -0,0 +1,43 @@
+---
+id: TKT-030
+seq: 30
+state: done
+priority: 2
+blocked_by:
+ - TKT-020
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T15:58:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-030: M03 I01 - Add SQLite DB adapter (CGO-free)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group I — Single Binary Mode
+**Original Task Code:** `M03 I01`
+**Original Status:** `[x] done`
+**Original Dependencies:** G01 (config must be cleanenv-based to add `DB_DRIVER` env var cleanly)
+
+**Status:** `[x] done`
+**Depends on:** G01 (config must be cleanenv-based to add `DB_DRIVER` env var cleanly)
+**Files:** `app/foundation/container.go`, `go.mod`, new `framework/repos/db/sqlite.go`
+
+**Context:** Use `modernc.org/sqlite` (pure Go, CGO-free — cross-compilation works) NOT `go-sqlite3` (requires CGO). Goose supports SQLite dialect. Bob supports SQLite.
+
+**What to do:**
+1. `go get modernc.org/sqlite`.
+2. Add `DB_DRIVER` env var to Config struct (values: `postgres`, `sqlite`; default: `sqlite` for new projects, existing config keeps `postgres`).
+3. In `app/foundation/container.go` DB init: switch on `c.Config.DBDriver`:
+ - `sqlite`: open `modernc.org/sqlite` driver, connect to `./dbs/app.db` (path configurable via `DB_PATH` env var).
+ - `postgres`: existing Postgres connection (unchanged).
+4. Ensure Goose migration runner uses the correct dialect.
+5. Ensure Bob query generation works against SQLite (may need a separate bobgen config).
+6. Test: `DB_DRIVER=sqlite make dev` starts app with SQLite.
+
+**Done when:** App boots with `DB_DRIVER=sqlite`. Migrations run. Basic CRUD works. No CGO required.
+
+---
+
+## Acceptance Criteria
+- [x] App boots with `DB_DRIVER=sqlite`. Migrations run. Basic CRUD works. No CGO required.
diff --git a/.docket/tickets/TKT-031.md b/.docket/tickets/TKT-031.md
new file mode 100644
index 00000000..d2a25ae1
--- /dev/null
+++ b/.docket/tickets/TKT-031.md
@@ -0,0 +1,43 @@
+---
+id: TKT-031
+seq: 31
+state: done
+priority: 2
+blocked_by:
+ - TKT-030
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T16:30:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-031: M03 I02 - Add Backlite as SQLite-backed jobs driver
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group I — Single Binary Mode
+**Original Task Code:** `M03 I02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** I01 (needs SQLite DB to be working)
+
+**Status:** `[x] done`
+**Depends on:** I01 (needs SQLite DB to be working)
+**Files:** `modules/jobs/drivers/backlite/` (new), `config/config.go`, `app/foundation/container.go`
+
+**Context:** Backlite (`github.com/mikestefanello/backlite`) uses SQLite as a job queue — same DB file, no Redis needed. Implements the existing `core.Jobs` interface.
+
+**What to do:**
+1. `go get github.com/mikestefanello/backlite`.
+2. Create `modules/jobs/drivers/backlite/driver.go` implementing `core.Jobs` using Backlite's client.
+3. Add `JOBS_DRIVER` env var to Config (values: `backlite`, `asynq`; default: `backlite`).
+4. In `app/foundation/container.go` jobs init: switch on `JOBS_DRIVER`:
+ - `backlite`: init Backlite client with the existing SQLite DB connection.
+ - `asynq`: existing Asynq setup (unchanged).
+5. Start Backlite dispatcher in `cmd/web/main.go` when jobs driver is Backlite (runs in-process, no separate worker needed).
+6. Test: `JOBS_DRIVER=backlite make dev` — enqueue a test job, verify it executes.
+
+**Done when:** Jobs work with `JOBS_DRIVER=backlite`. No Redis required. Backlite dispatcher runs in-process with the web server.
+
+---
+
+## Acceptance Criteria
+- [x] Jobs work with `JOBS_DRIVER=backlite`. No Redis required. Backlite dispatcher runs in-process with the web server.
diff --git a/.docket/tickets/TKT-032.md b/.docket/tickets/TKT-032.md
new file mode 100644
index 00000000..7d7729fd
--- /dev/null
+++ b/.docket/tickets/TKT-032.md
@@ -0,0 +1,38 @@
+---
+id: TKT-032
+seq: 32
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T16:55:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-032: M03 I03 - Add Otter as in-memory cache adapter
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group I — Single Binary Mode
+**Original Task Code:** `M03 I03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel with I01)
+
+**Status:** `[x] done`
+**Depends on:** nothing (parallel with I01)
+**Files:** `app/foundation/container.go`, new `framework/repos/cache/otter.go`, `go.mod`
+
+**Context:** Otter (`github.com/maypok86/otter`) is a lockless in-memory cache (S3-FIFO eviction, very high throughput). Implements the existing `core.Cache` interface. Valid only for single-process deployment; use Redis for multi-process.
+
+**What to do:**
+1. `go get github.com/maypok86/otter`.
+2. Create `framework/repos/cache/otter.go` implementing `core.Cache` with Otter as the backend. Support key/group/tag/expiration semantics matching the existing interface. Add the chainable builder API (see M04 section 1.3).
+3. Add `CACHE_DRIVER` env var to Config (values: `otter`, `redis`; default: `otter`).
+4. In container cache init: switch on `CACHE_DRIVER`.
+5. Test: cache set/get/flush works with `CACHE_DRIVER=otter`.
+
+**Done when:** `CACHE_DRIVER=otter` works. No Redis required for cache. Chainable builder API exposed.
+
+---
+
+## Acceptance Criteria
+- [x] `CACHE_DRIVER=otter` works. No Redis required for cache. Chainable builder API exposed.
diff --git a/.docket/tickets/TKT-033.md b/.docket/tickets/TKT-033.md
new file mode 100644
index 00000000..0ecdd81f
--- /dev/null
+++ b/.docket/tickets/TKT-033.md
@@ -0,0 +1,42 @@
+---
+id: TKT-033
+seq: 33
+state: done
+priority: 2
+blocked_by:
+ - TKT-030
+ - TKT-031
+ - TKT-032
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T17:05:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-033: M03 I04 - Wire single-binary mode as default + update docs
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group I — Single Binary Mode
+**Original Task Code:** `M03 I04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** I01, I02, I03
+
+**Status:** `[x] done`
+**Depends on:** I01, I02, I03
+**Files:** `.env.example`, `Makefile`, `README.md`, `docs/guides/02-development-workflows.md`
+
+**Context:** Make single-binary mode the default for new projects. `make run` should work with zero Docker.
+
+**What to do:**
+1. Set defaults in Config struct: `DB_DRIVER=sqlite`, `CACHE_DRIVER=otter`, `JOBS_DRIVER=backlite`.
+2. Update `.env.example` to reflect these defaults.
+3. Add `make run` target: no Docker, no infra, just `go run ./cmd/web`. Succeeds with single-binary defaults.
+4. Update `README.md` Requirements section: remove Docker as hard requirement ("Docker required for Postgres/Redis; not needed for single-binary SQLite mode").
+5. Update `docs/guides/02-development-workflows.md`: document single-binary vs standard modes.
+
+**Done when:** `cp .env.example .env && make run` starts a working app with no Docker. Docs reflect two modes.
+
+---
+
+## Acceptance Criteria
+- [x] `cp .env.example .env && make run` starts a working app with no Docker. Docs reflect two modes.
diff --git a/.docket/tickets/TKT-034.md b/.docket/tickets/TKT-034.md
new file mode 100644
index 00000000..edb3c6cc
--- /dev/null
+++ b/.docket/tickets/TKT-034.md
@@ -0,0 +1,49 @@
+---
+id: TKT-034
+seq: 34
+state: done
+priority: 2
+blocked_by:
+ - TKT-030
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T23:37:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-034: M03 I05 - In-memory test database (zero Docker for tests)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group I — Single Binary Mode
+**Original Task Code:** `M03 I05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** I01
+
+**Status:** `[x] done`
+**Depends on:** I01
+**Files:** `config/config.go`, `app/foundation/container.go`, all test files using DB
+
+**Context:** When `APP_ENV=test`, the container should auto-connect to an in-memory SQLite DB and run migrations. Tests run instantly with no Docker. Integration tests (testing Postgres-specific behavior) remain Docker-based but are clearly separated.
+
+**What to do:**
+1. Add `APP_ENV` env var to Config (values: `development`, `test`, `production`).
+2. In container DB init: if `APP_ENV=test`, use SQLite in-memory (`file::memory:?cache=shared&mode=memory`), run migrations.
+3. Add `config.SwitchEnvironment(config.EnvTest)` helper (set `APP_ENV=test` before container init).
+4. In all `TestMain` functions: call `config.SwitchEnvironment(config.EnvTest)` before `services.NewContainer()`.
+5. Tag existing Docker-dependent tests as `//go:build integration` so `make test` skips them; `make test-integration` includes them.
+
+**Done when:** `make test` passes with no Docker running. In-memory DB is used. Integration tests still work with Docker via `make test-integration`.
+
+---
+
+## Acceptance Criteria
+- [x] `make test` passes with no Docker running. In-memory DB is used. Integration tests still work with Docker via `make test-integration`.
+
+## Implementation Notes
+- Added `APP_ENV` compatibility alias support in config loading and normalized common values (`development`, `test`, `production`) to existing internal environment constants.
+- Preserved existing in-memory embedded SQLite test connection behavior and made `app/foundation/container_test.go` explicitly set `PAGODA_APP_ENVIRONMENT=test` to guarantee test-mode container init.
+- Confirmed Docker-dependent smoke coverage remains isolated via `//go:build integration` tags.
+
+## Verification
+- `go test ./config ./app/foundation`
+- `make test`
diff --git a/.docket/tickets/TKT-035.md b/.docket/tickets/TKT-035.md
new file mode 100644
index 00000000..265a58a2
--- /dev/null
+++ b/.docket/tickets/TKT-035.md
@@ -0,0 +1,72 @@
+---
+id: TKT-035
+seq: 35
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T23:41:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-035: M03 J01 - Define AdminField and AdminResource type system (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `modules/admin/types.go` (new)
+
+**Context:** The admin panel is reflection-based + Bob-backed. No Ent required. Modules register Go structs; the admin module uses `reflect` to discover fields, types, and tags, then describes them as `AdminField` slices. Templ components receive these slices and render the appropriate UI. Read `docs/roadmap/04-pagoda-and-dx-improvements.md` section 1.4.
+
+**What to do:**
+Create `modules/admin/types.go` with:
+```go
+type FieldType string
+const (
+ FieldTypeString FieldType = "string"
+ FieldTypeInt FieldType = "int"
+ FieldTypeBool FieldType = "bool"
+ FieldTypeTime FieldType = "time"
+ FieldTypeText FieldType = "text" // multiline
+ FieldTypeEmail FieldType = "email"
+ FieldTypePassword FieldType = "password" // omit from list, hide in form
+ FieldTypeReadOnly FieldType = "readonly"
+)
+
+type AdminField struct {
+ Name string
+ Label string // human-readable, derived from field name
+ Type FieldType
+ Value any // current value for forms
+ Required bool
+ Sensitive bool // omit from list view
+}
+
+type AdminResource struct {
+ Name string // e.g., "Post"
+ PluralName string // e.g., "Posts"
+ TableName string // DB table name
+ Fields []AdminField
+ IDField string // which field is the PK
+}
+
+type AdminRow map[string]any // one row from DB list
+```
+
+**Done when:** Types file compiles. No other code changes yet.
+
+---
+
+## Acceptance Criteria
+- [x] Types file compiles. No other code changes yet.
+
+## Implementation Notes
+- Added `modules/admin/types.go` with `FieldType`, `AdminField`, `AdminResource`, and `AdminRow` exactly as specified for the admin panel type system.
+
+## Verification
+- `go test ./modules/admin`
diff --git a/.docket/tickets/TKT-036.md b/.docket/tickets/TKT-036.md
new file mode 100644
index 00000000..92a8f139
--- /dev/null
+++ b/.docket/tickets/TKT-036.md
@@ -0,0 +1,70 @@
+---
+id: TKT-036
+seq: 36
+state: done
+priority: 2
+blocked_by:
+ - TKT-035
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T23:45:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-036: M03 J02 - Implement reflection-based resource registration
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** J01
+
+**Status:** `[x] done`
+**Depends on:** J01
+**Files:** `modules/admin/registry.go` (new)
+
+**Context:** `admin.Register[T]()` uses Go generics + reflection to inspect the struct type `T` and produce an `AdminResource` describing it.
+
+**What to do:**
+Create `modules/admin/registry.go`:
+```go
+var registry = map[string]AdminResource{}
+
+type ResourceConfig struct {
+ TableName string
+ ListFields []string // which fields appear in list view; empty = all non-sensitive
+ ReadOnly []string // fields shown but not editable
+ Sensitive []string // fields omitted from list, input type=password in form
+}
+
+func Register[T any](cfg ResourceConfig) {
+ t := reflect.TypeOf(*new(T))
+ // introspect t.Fields()
+ // derive FieldType from field Kind + tags
+ // build AdminResource and store in registry
+}
+```
+
+Field type derivation rules:
+- `string` → `FieldTypeString` (or `FieldTypeEmail` if tag `admin:"email"`, `FieldTypeText` if tag `admin:"text"`)
+- `bool` → `FieldTypeBool`
+- `int`, `int64` etc → `FieldTypeInt`
+- `time.Time` → `FieldTypeTime`
+- Field in `Sensitive` list → `FieldTypePassword`
+- Field in `ReadOnly` list → `FieldTypeReadOnly`
+
+**Done when:** `admin.Register[Post](cfg)` populates registry with correct `AdminResource`. Verified by unit test.
+
+---
+
+## Acceptance Criteria
+- [x] `admin.Register[Post](cfg)` populates registry with correct `AdminResource`. Verified by unit test.
+
+## Implementation Notes
+- Added `modules/admin/registry.go` with `registry`, `ResourceConfig`, and generic `Register[T]` using reflection over struct fields.
+- Implemented field-type derivation rules for string/tag variants, bool, integer kinds, `time.Time`, `Sensitive` => `FieldTypePassword`, and `ReadOnly` => `FieldTypeReadOnly`.
+- Added helper logic for label humanization, required detection from `validate:"required"`, pluralized naming, and default table naming.
+- Added `modules/admin/registry_test.go` validating `Register[testPost](cfg)` populates the registry with expected resource/field metadata.
+
+## Verification
+- `go test ./modules/admin`
diff --git a/.docket/tickets/TKT-037.md b/.docket/tickets/TKT-037.md
new file mode 100644
index 00000000..2837109f
--- /dev/null
+++ b/.docket/tickets/TKT-037.md
@@ -0,0 +1,55 @@
+---
+id: TKT-037
+seq: 37
+state: done
+priority: 2
+blocked_by:
+ - TKT-036
+ - TKT-030
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T23:49:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-037: M03 J03 - Implement Bob-backed CRUD operations for admin
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** J02, I01 (SQLite must work if testing with SQLite)
+
+**Status:** `[x] done`
+**Depends on:** J02, I01 (SQLite must work if testing with SQLite)
+**Files:** `modules/admin/store.go` (new)
+
+**Context:** The admin module must list, get, create, update, and delete records for any registered resource using Bob for type-safe SQL. Since the resource type is dynamic, use raw SQL with `database/sql` fallback for admin operations (Bob is used for app code; admin is introspection territory).
+
+**What to do:**
+1. Implement:
+ ```go
+ func List(ctx context.Context, db *sql.DB, res AdminResource, page, perPage int) ([]AdminRow, int, error)
+ func Get(ctx context.Context, db *sql.DB, res AdminResource, id any) (AdminRow, error)
+ func Create(ctx context.Context, db *sql.DB, res AdminResource, values map[string]any) error
+ func Update(ctx context.Context, db *sql.DB, res AdminResource, id any, values map[string]any) error
+ func Delete(ctx context.Context, db *sql.DB, res AdminResource, id any) error
+ ```
+2. Use parameterized queries (`?` for SQLite, `$1` for Postgres) — detect dialect from driver name.
+3. `List` returns rows as `[]AdminRow` (map[string]any) and total count for pagination.
+
+**Done when:** All 5 operations work against a test SQLite DB. Unit tests cover each.
+
+---
+
+## Acceptance Criteria
+- [x] All 5 operations work against a test SQLite DB. Unit tests cover each.
+
+## Implementation Notes
+- Added `modules/admin/store.go` implementing `List`, `Get`, `Create`, `Update`, and `Delete` with dynamic SQL for registered resources.
+- Added safe identifier validation for table/column names and dynamic placeholder binding (`?` default, `$1...` for postgres driver names).
+- Implemented row scanning into `AdminRow` (`map[string]any`) and pagination total count in `List`.
+- Added `modules/admin/store_test.go` with an in-memory SQLite schema validating full CRUD behavior and pagination count.
+
+## Verification
+- `go test ./modules/admin`
diff --git a/.docket/tickets/TKT-038.md b/.docket/tickets/TKT-038.md
new file mode 100644
index 00000000..be7730d8
--- /dev/null
+++ b/.docket/tickets/TKT-038.md
@@ -0,0 +1,90 @@
+---
+id: TKT-038
+seq: 38
+state: done
+priority: 2
+blocked_by:
+ - TKT-035
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T23:55:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-038: M03 J04 - Build templ components for admin UI
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** J01
+
+**Status:** `[x] done`
+**Depends on:** J01
+**Files:** `modules/admin/views/web/` (new templ files)
+
+**Context:** Templ components are **data-driven** — they receive `AdminResource` and `[]AdminField` at runtime and render the appropriate UI. The dynamic behavior is in the *data*, not in runtime template generation. A `switch` on `AdminField.Type` renders the correct input. This is fully compatible with templ's compiled approach.
+
+**What to do:**
+Create these templ components:
+
+1. `admin_layout.templ` — admin shell: sidebar with resource links, main content area.
+ ```templ
+ // Renders: full-page admin shell with left sidebar listing all registered resources and top bar with "Admin" title
+ templ AdminLayout(resources []AdminResource, content templ.Component) { ... }
+ ```
+
+2. `admin_list.templ` — list table for a resource.
+ ```templ
+ // Renders: paginated table of resource rows with column headers, edit/delete links per row, and an "Add new" button
+ templ AdminList(res AdminResource, rows []AdminRow, pager Pager) { ... }
+ ```
+
+3. `admin_form.templ` — create/edit form.
+ ```templ
+ // Renders: create/edit form with one input per AdminField, type-appropriate input widget per field type
+ templ AdminForm(res AdminResource, values map[string]any, errs map[string]string, csrfToken string) { ... }
+ ```
+
+4. `admin_field_input.templ` — single field input, switches on FieldType.
+ ```templ
+ // Renders: appropriate HTML input for the given field type (text, checkbox, number, datetime-local, textarea, password)
+ templ AdminFieldInput(field AdminField) {
+ switch field.Type {
+ case FieldTypeString:
+ case FieldTypeBool:
+ case FieldTypeInt:
+ case FieldTypeTime:
+ case FieldTypeText:
+ case FieldTypePassword:
+ case FieldTypeReadOnly:
+ }
+ }
+ ```
+
+5. `admin_delete_confirm.templ` — SweetAlert2 delete confirmation, or inline form.
+
+Run `make templ-gen` after.
+
+**Done when:** All 5 templ files exist and compile. `make templ-gen` succeeds.
+
+---
+
+## Acceptance Criteria
+- [x] All 5 templ files exist and compile. `make templ-gen` succeeds.
+
+## Implementation Notes
+- Added admin templ components under `modules/admin/views/web/components/`:
+ - `admin_layout.templ`
+ - `admin_list.templ`
+ - `admin_form.templ`
+ - `admin_field_input.templ`
+ - `admin_delete_confirm.templ`
+- Components are data-driven on `admin.AdminResource`, `admin.AdminField`, and `admin.AdminRow`, with input rendering switched by `FieldType`.
+- Added a small `admin.Pager` type in `modules/admin/types.go` to support the list component signature.
+- Generated templ outputs into `modules/admin/views/web/components/gen/`.
+
+## Verification
+- `go run ./tools/cli/ship/cmd/ship templ generate --path modules/admin`
+- `go test ./modules/admin`
+- `make templ-gen`
diff --git a/.docket/tickets/TKT-039.md b/.docket/tickets/TKT-039.md
new file mode 100644
index 00000000..b482cffa
--- /dev/null
+++ b/.docket/tickets/TKT-039.md
@@ -0,0 +1,82 @@
+---
+id: TKT-039
+seq: 39
+state: done
+priority: 2
+blocked_by:
+ - TKT-036
+ - TKT-037
+ - TKT-038
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-12T00:05:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-039: M03 J05 - Wire admin routes
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** J02, J03, J04
+
+**Status:** `[x] done`
+**Depends on:** J02, J03, J04
+**Files:** `modules/admin/routes.go` (new), `modules/admin/module.go` (new)
+
+**Context:** Admin routes are automatically generated for every registered resource. Protected by `middleware.RequireAdmin`.
+
+**What to do:**
+1. Create `modules/admin/module.go`:
+ ```go
+ func New() *AdminModule { ... }
+ func (m *AdminModule) ID() string { return "admin" }
+ func (m *AdminModule) Migrations() fs.FS { return nil }
+ func (m *AdminModule) RegisterRoutes(r Router) error { ... }
+ ```
+2. Create `modules/admin/routes.go`. For each registered resource, register:
+ ```
+ GET /admin/{resource} → List handler
+ GET /admin/{resource}/new → New form
+ POST /admin/{resource} → Create handler
+ GET /admin/{resource}/{id} → Edit form
+ PUT /admin/{resource}/{id} → Update handler
+ DELETE /admin/{resource}/{id} → Delete handler
+ ```
+3. All admin routes wrapped in `middleware.RequireAdmin`.
+4. Add link to admin in main nav (conditionally, if user is admin).
+
+**Done when:** Visiting `/admin/posts` (assuming Post is registered) renders the list. CRUD works end-to-end. Non-admin users get 403.
+
+---
+
+## Acceptance Criteria
+- [x] Visiting `/admin/posts` (assuming Post is registered) renders the list. CRUD works end-to-end. Non-admin users get 403.
+
+## Implementation Notes
+- Added `modules/admin/module.go` implementing `New`, `ID`, `Migrations`, and `RegisterRoutes`, and registering a default `users` admin resource.
+- Added `modules/admin/routes.go` with dynamic resource CRUD endpoints:
+ - `GET /admin`
+ - `GET /admin/:resource`
+ - `GET /admin/:resource/new`
+ - `POST /admin/:resource`
+ - `GET /admin/:resource/:id`
+ - `PUT /admin/:resource/:id`
+ - `DELETE /admin/:resource/:id`
+- All admin routes are wrapped by `middleware.RequireAdmin()`.
+- Added admin authorization context + middleware:
+ - `framework/context/context.go`: `AuthenticatedUserIsAdminKey`
+ - `app/web/middleware/auth.go`: set admin flag from `PAGODA_ADMIN_EMAILS` and enforce `RequireAdmin`.
+ - `app/web/middleware/auth_test.go`: new tests for `RequireAdmin` and admin-email matching.
+- Added conditional Admin link in profile dropdown for admin users:
+ - `app/web/ui/page.go`: `IsAdmin` on page model
+ - `app/views/web/components/navbar.templ` (+ regenerated templ output)
+- Added admin route coverage:
+ - `modules/admin/routes_test.go` verifies non-admin `403` and admin access `200`.
+
+## Verification
+- `go test ./modules/admin`
+- `go test ./app/web/middleware ./app/web/ui`
+- `go test ./app/...`
+- `make templ-gen`
diff --git a/.docket/tickets/TKT-040.md b/.docket/tickets/TKT-040.md
new file mode 100644
index 00000000..f6bf6362
--- /dev/null
+++ b/.docket/tickets/TKT-040.md
@@ -0,0 +1,52 @@
+---
+id: TKT-040
+seq: 40
+state: done
+priority: 2
+blocked_by:
+ - TKT-039
+ - TKT-031
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-12T00:20:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-040: M03 J06 - Embed Backlite queue monitor in admin panel (parallel after J05, I02)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group J — Admin Panel Module
+**Original Task Code:** `M03 J06`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** J05, I02
+
+**Status:** `[x] done`
+**Depends on:** J05, I02
+**Files:** `modules/admin/routes.go`
+
+**Context:** Backlite provides an HTTP handler for monitoring queues. Embed it at `/admin/queues`.
+
+**What to do:**
+1. Read Backlite docs for the embedded monitor handler.
+2. Mount Backlite's handler at `/admin/queues` in admin routes.
+3. Add "Queue Monitor" link to admin sidebar.
+
+**Done when:** `/admin/queues` shows task queue monitor when `JOBS_DRIVER=backlite`.
+
+---
+
+## Acceptance Criteria
+- [x] `/admin/queues` shows task queue monitor when `JOBS_DRIVER=backlite`.
+
+## Implementation Notes
+- Added Backlite queue monitor embedding to admin routes via `github.com/mikestefanello/backlite/ui`.
+- Mounted monitor endpoints in `modules/admin/routes.go`:
+ - `GET /admin/queues`
+ - `GET /admin/queues/*`
+- Route is gated by existing admin middleware and returns `404` when jobs adapter is not `backlite`.
+- Added `Queue Monitor` link to admin sidebar in `modules/admin/views/web/components/admin_layout.templ`.
+- Added route test `TestAdminRoutes_AdminQueueMonitor` in `modules/admin/routes_test.go`.
+
+## Verification
+- `go test ./modules/admin`
+- `go test ./app/...`
diff --git a/.docket/tickets/TKT-041.md b/.docket/tickets/TKT-041.md
new file mode 100644
index 00000000..7a2f81ff
--- /dev/null
+++ b/.docket/tickets/TKT-041.md
@@ -0,0 +1,63 @@
+---
+id: TKT-041
+seq: 41
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-12T00:14:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-041: M03 K01 - Chainable redirect helper (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `framework/redirect/redirect.go` (new)
+
+**Context:** Replace manual redirect calls with a chainable builder. Automatically handles HTMX redirects (`HX-Redirect` header) for boosted requests.
+
+**What to do:**
+```go
+// Usage:
+return redirect.New(ctx).Route("user_profile").Params(userID).Query(q).Go()
+
+// Implementation:
+type Redirect struct { ctx echo.Context; route string; params []any; query url.Values }
+func New(ctx echo.Context) *Redirect
+func (r *Redirect) Route(name string) *Redirect
+func (r *Redirect) Params(params ...any) *Redirect
+func (r *Redirect) Query(q url.Values) *Redirect
+func (r *Redirect) Go() error // detects HX-Request header, sets HX-Redirect if HTMX
+```
+
+**Done when:** `redirect.New(ctx).Route("home_feed").Go()` works in a controller. HTMX requests get `HX-Redirect` header. Non-HTMX requests get 302.
+
+---
+
+## Acceptance Criteria
+- [x] `redirect.New(ctx).Route("home_feed").Go()` works in a controller. HTMX requests get `HX-Redirect` header. Non-HTMX requests get 302.
+
+## Implementation Notes
+- Added `framework/redirect/redirect.go` with chainable redirect builder:
+ - `New(ctx)`
+ - `Route(name)`
+ - `Params(params...)`
+ - `Query(url.Values)`
+ - `Status(code)`
+ - `Go()`
+- `Go()` now detects boosted HTMX requests and sets `HX-Redirect` with `200`; non-HTMX redirects use standard HTTP `302` (or specified 3xx status).
+- Updated `app/web/ui/controller.go` redirect helper to use the new redirect builder, including query parsing support for `RedirectWithDetails`.
+- Added tests:
+ - `framework/redirect/redirect_test.go`
+ - Extended `app/web/ui/controller_test.go` for boosted HTMX redirect and query redirect behavior.
+
+## Verification
+- `go test ./framework/redirect ./app/web/ui`
+- `go test ./app/...`
diff --git a/.docket/tickets/TKT-042.md b/.docket/tickets/TKT-042.md
new file mode 100644
index 00000000..4ee119dc
--- /dev/null
+++ b/.docket/tickets/TKT-042.md
@@ -0,0 +1,66 @@
+---
+id: TKT-042
+seq: 42
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-12T00:28:00Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-042: M03 K02 - Pagination utility (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `framework/pager/pager.go` (new), new templ component in `app/views/web/components/pager.templ`
+
+**Context:** Standardize cursor/offset pagination. Controller gets a `Pager`, passes it to viewmodel, templ component renders prev/next links.
+
+**What to do:**
+1. Create `framework/pager/pager.go`:
+ ```go
+ type Pager struct { Page, PerPage, Total int }
+ func New(ctx echo.Context, perPage int) Pager // reads ?page= from query
+ func (p Pager) Offset() int
+ func (p Pager) Limit() int
+ func (p Pager) HasNext() bool
+ func (p Pager) HasPrev() bool
+ func (p Pager) TotalPages() int
+ ```
+2. Create `app/views/web/components/pager.templ`:
+ ```templ
+ // Renders: prev/next pagination bar with page number and total pages indicator
+ templ Pagination(p pager.Pager, baseURL string) { ... }
+ ```
+
+**Done when:** Controller can call `pager.New(ctx, 20)`, pass pager to viewmodel, and render `Pagination` component. Unit tests for offset/limit/HasNext/HasPrev.
+
+---
+
+## Acceptance Criteria
+- [x] Controller can call `pager.New(ctx, 20)`, pass pager to viewmodel, and render `Pagination` component. Unit tests for offset/limit/HasNext/HasPrev.
+
+## Implementation Notes
+- Added `framework/pager/pager.go` with `Pager` and methods:
+ - `New(ctx, perPage)`
+ - `Offset()`
+ - `Limit()`
+ - `HasNext()`
+ - `HasPrev()`
+ - `TotalPages()`
+- Added unit tests in `framework/pager/pager_test.go` for construction and paging math.
+- Added reusable templ component `app/views/web/components/pager.templ`:
+ - `templ Pagination(p pager.Pager, baseURL string)`
+- Regenerated templ outputs to include `app/views/web/components/gen/pager_templ.go`.
+
+## Verification
+- `go test ./framework/pager`
+- `make templ-gen`
+- `go test ./app/...`
diff --git a/.docket/tickets/TKT-043.md b/.docket/tickets/TKT-043.md
new file mode 100644
index 00000000..18a81de2
--- /dev/null
+++ b/.docket/tickets/TKT-043.md
@@ -0,0 +1,43 @@
+---
+id: TKT-043
+seq: 43
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:27Z"
+updated_at: "2026-03-11T02:08:28Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-043: M03 K03 - `ship routes` command (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K03`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `tools/cli/ship/internal/commands/routes.go` (new)
+
+**Context:** Print a table of all registered routes. Inspect `app/router.go` via AST parsing. Also expose as MCP tool.
+
+**What to do:**
+1. Parse `app/router.go` AST to extract route registrations (method, path, handler, auth level).
+2. Print as table:
+ ```
+ METHOD PATH AUTH HANDLER
+ GET / public landing.Get
+ POST /user/register public register.Post
+ GET /auth/homeFeed auth home_feed.Get
+ ```
+3. Add `--json` flag.
+4. Integrate as `ship_routes` MCP tool (see C05).
+
+**Done when:** `ship routes` prints route table. `ship routes --json` outputs JSON array.
+
+---
+
+## Acceptance Criteria
+- [x] `ship routes` prints route table. `ship routes --json` outputs JSON array. — evidence: Imported from roadmap task marked done
diff --git a/.docket/tickets/TKT-044.md b/.docket/tickets/TKT-044.md
new file mode 100644
index 00000000..5fb3b6b2
--- /dev/null
+++ b/.docket/tickets/TKT-044.md
@@ -0,0 +1,57 @@
+---
+id: TKT-044
+seq: 44
+state: done
+priority: 2
+blocked_by:
+ - TKT-020
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:00:43Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-044: M03 K04 - `ship db:console` command (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K04`
+**Original Status:** `[x] done`
+**Original Dependencies:** G01 (needs cleanenv config to read DB URL)
+
+**Status:** `[x] done`
+**Depends on:** G01 (needs cleanenv config to read DB URL)
+**Files:** `tools/cli/ship/internal/commands/db.go`, `tools/cli/ship/internal/runtime/paths.go`, `tools/cli/ship/internal/cli/cli.go`
+
+**Context:** Opens a raw DB shell. Reads active DB config and spawns `psql`, `mysql`, or `sqlite3` with the correct connection string.
+
+**What to do:**
+1. Read active `DB_DRIVER` from config.
+2. Spawn the appropriate shell with the connection string from config.
+3. Pass through stdin/stdout/stderr to the terminal.
+
+**Done when:** `ship db:console` drops into an interactive DB shell.
+
+---
+
+## Acceptance Criteria
+- [x] `ship db:console` drops into an interactive DB shell.
+
+## Implementation Notes
+- Added `db:console` subcommand in `tools/cli/ship/internal/commands/db.go`.
+- Implemented console command routing by DB driver:
+ - `postgres` -> `psql `
+ - `mysql` -> `mysql --host ... --port ... --user ... --password=... `
+ - `sqlite` -> `sqlite3 `
+- Added URL/driver parsing helpers for shell target resolution and sqlite DSN normalization.
+- Added DB driver resolution plumbing in CLI/runtime:
+ - `CLI.ResolveDBDriver` injection point
+ - `runtime.ResolveDBDriver()` with `DB_DRIVER`/`PAGODA_DATABASE_DRIVER`/`PAGODA_DB_DRIVER` support and config fallback.
+- Updated CLI help output to include `ship db:console`.
+- Added tests for:
+ - command behavior in `tools/cli/ship/internal/commands/db_console_test.go`
+ - CLI dispatch and error flows in `tools/cli/ship/internal/cli/dispatch_test.go` and `db_commands_test.go`
+ - driver runtime resolution in `tools/cli/ship/internal/cli/db_url_test.go`
+
+## Verification
+- `go test ./tools/cli/ship/internal/commands ./tools/cli/ship/internal/cli`
diff --git a/.docket/tickets/TKT-045.md b/.docket/tickets/TKT-045.md
new file mode 100644
index 00000000..ea5dd509
--- /dev/null
+++ b/.docket/tickets/TKT-045.md
@@ -0,0 +1,65 @@
+---
+id: TKT-045
+seq: 45
+state: done
+priority: 2
+blocked_by:
+ - TKT-032
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:06:06Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-045: M03 K05 - Built-in rate limiter middleware (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K05`
+**Original Status:** `[x] done`
+**Original Dependencies:** I03 (Otter for in-memory rate limit state; Redis if scaled)
+
+**Status:** `[x] done`
+**Depends on:** I03 (Otter for in-memory rate limit state; Redis if scaled)
+**Files:** `app/web/middleware/rate_limit.go` (new), `framework/repos/ratelimit/` (new), `modules/auth/routes.go`
+
+**Context:** Per-IP and per-user rate limiting with configurable limits per route group.
+
+**What to do:**
+1. Create `framework/repos/ratelimit/ratelimit.go` with an interface backed by Otter (in-memory) or Redis.
+2. Create `app/web/middleware/rate_limit.go` Echo middleware factory:
+ ```go
+ func RateLimit(store ratelimit.Store, max int, window time.Duration) echo.MiddlewareFunc
+ ```
+3. Apply to auth routes (e.g., 10 req/min on `/user/login`).
+4. Returns 429 with `Retry-After` header on exceed.
+
+**Done when:** Auth routes return 429 after exceeding the limit. Test covers this.
+
+---
+
+## Acceptance Criteria
+- [x] Auth routes return 429 after exceeding the limit. Test covers this.
+
+## Implementation Notes
+- Added `framework/repos/ratelimit/ratelimit.go`:
+ - `Store` interface with `Allow(key, max, window)` contract.
+ - Otter-backed implementation (`OtterStore`) with per-key counters and window reset semantics.
+ - `Decision` includes allow/deny and `RetryAfter`.
+- Added `app/web/middleware/rate_limit.go`:
+ - `RateLimit(store, max, window)` Echo middleware.
+ - Keys by `method:path:actor` where actor is authenticated user ID or fallback request IP.
+ - Returns `429 Too Many Requests` with `Retry-After` header on limit exceed.
+- Applied middleware to auth POST routes in `modules/auth/routes.go`:
+ - `/user/login`
+ - `/user/register`
+ - `/user/password`
+ - `/user/password/reset/token/:user/:password_token/:token`
+ - Configured to `10 req/min` using a shared Otter store.
+- Added tests:
+ - `framework/repos/ratelimit/ratelimit_test.go`
+ - `app/web/middleware/rate_limit_test.go`
+
+## Verification
+- `go test ./framework/repos/ratelimit ./app/web/middleware ./modules/auth`
+- `go test ./app/...`
diff --git a/.docket/tickets/TKT-046.md b/.docket/tickets/TKT-046.md
new file mode 100644
index 00000000..d7b4794e
--- /dev/null
+++ b/.docket/tickets/TKT-046.md
@@ -0,0 +1,40 @@
+---
+id: TKT-046
+seq: 46
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:41:13Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-046: M03 K06 - Afero file system abstraction (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group K — DX Improvements
+**Original Task Code:** `M03 K06`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** G01 (needs `STORAGE_DRIVER` env var)
+
+**Status:** `[x] done`
+**Depends on:** G01 (needs `STORAGE_DRIVER` env var)
+**Files:** `framework/repos/storage/`, `app/foundation/container.go`
+
+**Context:** Replace MinIO-only storage with afero abstraction. `STORAGE_DRIVER=local` for dev/single-binary; `STORAGE_DRIVER=minio` for production.
+
+**What to do:**
+1. `go get github.com/spf13/afero`.
+2. Add `STORAGE_DRIVER` env var (values: `local`, `minio`).
+3. Wrap afero behind the existing `framework/core` storage interface (or create one).
+4. `local`: afero `OsFs` rooted at `./uploads` (path configurable).
+5. Tests: automatically use afero `MemMapFs` when `APP_ENV=test`.
+6. Keep MinIO backend for production compatibility.
+
+**Done when:** File uploads work with `STORAGE_DRIVER=local`. Tests use in-memory FS.
+
+---
+
+## Acceptance Criteria
+- [x] File uploads work with `STORAGE_DRIVER=local`. Tests use in-memory FS.
+
diff --git a/.docket/tickets/TKT-047.md b/.docket/tickets/TKT-047.md
new file mode 100644
index 00000000..6552c8bc
--- /dev/null
+++ b/.docket/tickets/TKT-047.md
@@ -0,0 +1,44 @@
+---
+id: TKT-047
+seq: 47
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:06:38Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-047: M03 F01 - Fix README inconsistencies (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F01`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+
+**What to do:**
+1. Read `README.md`.
+2. Fix `pkg/` → `framework/` in the Repository Shape section.
+3. Fix `pkg/repos/storage/storagerepo.go` reference to correct path.
+4. Update Requirements: remove Docker as hard requirement; note it's only needed for Postgres/Redis mode.
+5. Add brief description of single-binary mode once Group I tasks are done, or add a TODO note.
+
+**Done when:** README has no stale `pkg/` references. Docker requirement is accurately described.
+
+---
+
+## Acceptance Criteria
+- [x] README has no stale `pkg/` references. Docker requirement is accurately described.
+
+## Implementation Notes
+- Reviewed `README.md` against ticket requirements; no further edits were needed.
+- Verified the repository shape uses `framework/` (not `pkg/`) and storage path references are current.
+- Verified requirements language treats Docker as optional for infra-backed mode and describes single-binary mode.
+
+## Verification
+- `rg -n "pkg/" README.md` (no matches)
+- `rg -n "Requirements:|Docker|single-binary|framework/repos/storage/storagerepo.go" README.md`
diff --git a/.docket/tickets/TKT-048.md b/.docket/tickets/TKT-048.md
new file mode 100644
index 00000000..bcfd4020
--- /dev/null
+++ b/.docket/tickets/TKT-048.md
@@ -0,0 +1,41 @@
+---
+id: TKT-048
+seq: 48
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:07:20Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-048: M03 F02 - Fix architecture doc: decouple from Asynq (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F02`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing (fix the doc now; implementation follows in Group I/C)
+
+**Status:** `[x] done`
+**Depends on:** nothing (fix the doc now; implementation follows in Group I/C)
+
+**What to do:**
+1. Read `docs/architecture/01-architecture.md`.
+2. Update Worker Runtime Flow section: replace hardcoded Asynq description with "jobs adapter — currently Asynq (Redis-backed); Backlite (SQLite-backed) supported for single-binary mode".
+3. Update "Asynq handles background jobs" line at bottom to reflect adapter abstraction.
+
+**Done when:** Architecture doc does not assume Asynq specifically. References adapter pattern.
+
+---
+
+## Acceptance Criteria
+- [x] Architecture doc does not assume Asynq specifically. References adapter pattern.
+
+## Implementation Notes
+- Reviewed `docs/architecture/01-architecture.md`; required adapter-based wording is already present.
+- Worker runtime flow explicitly calls out jobs adapter behavior (`asynq` for Redis-backed workers, `backlite` for single-binary/in-process mode).
+- Async section already describes jobs as adapter-configurable rather than Asynq-only.
+
+## Verification
+- `rg -n "Worker Runtime Flow|jobs adapter|asynq|backlite" docs/architecture/01-architecture.md`
diff --git a/.docket/tickets/TKT-049.md b/.docket/tickets/TKT-049.md
new file mode 100644
index 00000000..035112fd
--- /dev/null
+++ b/.docket/tickets/TKT-049.md
@@ -0,0 +1,48 @@
+---
+id: TKT-049
+seq: 49
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:07:20Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-049: M03 F03 - Update AI agent guide: add nil safety convention (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F03`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+
+**What to do:**
+Add a "Nil Safety" section to `docs/guides/01-ai-agent-guide.md`:
+- Viewmodels must have zero pointer fields (value types only).
+- Templ components accept viewmodel types, never `*DomainModel`.
+- Controllers own domain → viewmodel transformation and all nil handling.
+- `nilaway` runs in CI — new code must pass it.
+- Recovery middleware is registered globally — panics return 500 but app stays up.
+
+**Done when:** Section exists in the guide.
+
+---
+
+## Acceptance Criteria
+- [x] Section exists in the guide.
+
+## Implementation Notes
+- Verified `docs/guides/01-ai-agent-guide.md` contains a dedicated `## Nil Safety` section.
+- Confirmed the section includes:
+ - pointer-free `app/web/viewmodels` policy
+ - templ component contract (no `*domain` models)
+ - controller-owned nil handling
+ - `nilaway` CI requirement
+ - panic recovery middleware expectation
+
+## Verification
+- `rg -n "## Nil Safety|nilaway|viewmodels|\\*domain|Recovery" docs/guides/01-ai-agent-guide.md`
diff --git a/.docket/tickets/TKT-050.md b/.docket/tickets/TKT-050.md
new file mode 100644
index 00000000..78501e2b
--- /dev/null
+++ b/.docket/tickets/TKT-050.md
@@ -0,0 +1,39 @@
+---
+id: TKT-050
+seq: 50
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T20:07:20Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-050: M03 F04 - Update docs index with all new roadmap docs (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F04`
+**Original Status:** `[x] done`
+**Original Dependencies:** nothing
+
+**Status:** `[x] done`
+**Depends on:** nothing
+
+**What to do:** Read `docs/00-index.md`. Verify M01–M04 are all listed. Add any missing entries.
+
+**Done when:** Index references all four roadmap documents.
+
+---
+
+## Acceptance Criteria
+- [x] Index references all four roadmap documents.
+
+## Implementation Notes
+- Reviewed `docs/00-index.md` roadmap section.
+- Confirmed roadmap index includes M01 through M04, plus newer entries M05–M08.
+- No documentation edits were required to satisfy this ticket.
+
+## Verification
+- `ls docs/roadmap`
+- `sed -n '1,260p' docs/00-index.md`
diff --git a/.docket/tickets/TKT-051.md b/.docket/tickets/TKT-051.md
new file mode 100644
index 00000000..fbbcd92d
--- /dev/null
+++ b/.docket/tickets/TKT-051.md
@@ -0,0 +1,50 @@
+---
+id: TKT-051
+seq: 51
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T23:05:42Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-051: M03 F05 - Update workflows doc: config and single binary mode (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** G01, G02, I04
+
+**Status:** `[ ] todo`
+**Depends on:** G01, G02, I04
+
+**What to do:**
+1. Read `docs/guides/02-development-workflows.md`.
+2. Add "Configuration" section: "Copy `.env.example` to `.env`. All config comes from env vars. No YAML for secrets."
+3. Add "Single Binary Mode" section: "Set `DB_DRIVER=sqlite`, `CACHE_DRIVER=otter`, `JOBS_DRIVER=backlite` in `.env`. Run `make run`. No Docker needed."
+4. Update Services and Infra section to clarify Redis/Postgres are optional.
+
+**Done when:** Workflows doc accurately describes both single-binary and standard modes.
+
+---
+
+## Acceptance Criteria
+- [x] Workflows doc accurately describes both single-binary and standard modes. : Updated docs/guides/02-development-workflows.md with Configuration and Single Binary Mode sections.
+
+## Handoff
+### Current state
+Workflows documentation updated to include configuration and single-binary mode details.
+
+### Decisions made
+Used PAGODA_ prefix for environment variables in the documentation to ensure accuracy with the actual application configuration, while following the ticket's requested structure.
+
+### Files touched
+- docs/guides/02-development-workflows.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] Workflows doc accurately describes both single-binary and standard modes.
diff --git a/.docket/tickets/TKT-052.md b/.docket/tickets/TKT-052.md
new file mode 100644
index 00000000..dc75a5c5
--- /dev/null
+++ b/.docket/tickets/TKT-052.md
@@ -0,0 +1,50 @@
+---
+id: TKT-052
+seq: 52
+state: done
+priority: 2
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T23:07:08Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-052: M03 F06 - Update scope analysis doc to reflect evolving architecture (parallel)
+
+## Description
+**Source:** `docs/roadmap/03-atomic-tasks.md`
+**Roadmap Group:** Group F — Documentation
+**Original Task Code:** `M03 F06`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+
+**What to do:**
+1. Read `docs/architecture/03-project-scope-analysis.md`.
+2. Remove Viper reference (line ~121).
+3. Update background task section to mention Backlite as an option.
+4. Add entry for admin module once J01–J05 are planned.
+
+**Done when:** Scope analysis doc has no Viper references. Reflects adapter-based jobs and planned admin module.
+
+---
+
+## Acceptance Criteria
+- [x] Scope analysis doc has no Viper references. Reflects adapter-based jobs and planned admin module. : Updated docs/architecture/03-project-scope-analysis.md to include Backlite support for background tasks and the new Admin Panel feature area. Verified that no Viper references are present.
+
+## Handoff
+### Current state
+Scope analysis documentation updated.
+
+### Decisions made
+Added Backlite to background tasks and included the new Admin Panel feature area.
+
+### Files touched
+- docs/architecture/03-project-scope-analysis.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] Scope analysis doc has no Viper references. Reflects adapter-based jobs and planned admin module.
diff --git a/.docket/tickets/TKT-053.md b/.docket/tickets/TKT-053.md
new file mode 100644
index 00000000..99649817
--- /dev/null
+++ b/.docket/tickets/TKT-053.md
@@ -0,0 +1,75 @@
+---
+id: TKT-053
+seq: 53
+state: done
+priority: 4
+blocked_by:
+ - TKT-002
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-053: M05 L01 - Enforce canonical file placement in `ship doctor`
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group L — Convention Enforcement (Start Here)
+**Original Task Code:** `M05 L01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** M03 A02 (ship doctor --json flag)
+
+**Status:** `[x] done`
+**Depends on:** M03 A02 (ship doctor --json flag)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+> **IMPORTANT:** The doctor command logic lives in `tools/cli/ship/internal/policies/doctor.go` (1,897 lines), NOT in `commands/`. The CLI wiring is in `tools/cli/ship/internal/cli/cli.go → c.runDoctor()`. The main check function is `RunDoctorChecks(root string) []DoctorIssue`. Each check appends to a `[]DoctorIssue` slice. The `DoctorIssue` struct has fields `{ Code, Message, Fix, File, Severity string }`. Read `policies/doctor.go` in full before editing — many checks already exist and the pattern is established.
+
+**Context:** GoShip has one canonical path for every concern. Agents violate these when they
+have no enforcement. `ship doctor` must catch placement violations and report them as structured
+errors. The `--json` output format is: `{"type", "file", "detail", "severity"}` — match existing issue format in `policies/doctor.go`.
+
+**Rules to enforce:**
+1. No `*.go` file defining an HTTP handler func outside `app/web/controllers/`
+ (detect: func signature `func(*echo.Context) error` outside that dir)
+2. No route registration (`e.GET`, `e.POST`, `e.PUT`, `e.DELETE`, `e.PATCH`) outside `app/router.go`
+3. No SQL queries (raw `db.Query`, `db.Exec` without Bob) outside `db/queries/` or `*_store.go` files
+4. No migration files outside `db/migrations/`
+5. No config struct definitions outside `config/config.go`
+
+**Exact pattern to follow** (copy this for every new check — do NOT deviate):
+```go
+// Add this function anywhere in policies/doctor.go
+func checkHandlerPlacement(root string) []DoctorIssue {
+ issues := make([]DoctorIssue, 0)
+ // walk files, detect violation, then:
+ issues = append(issues, DoctorIssue{
+ Code: "DX020", // use the next available DX0XX code not already in the file
+ Message: "HTTP handler defined outside app/web/controllers/",
+ Fix: "move the handler to app/web/controllers/",
+ File: "path/to/offending/file.go", // the specific file that violated the rule
+ Severity: "error", // "error" blocks the build; "warning" just warns
+ })
+ return issues
+}
+
+// Then in RunDoctorChecks (around line 169), add ONE line to call it:
+issues = append(issues, checkHandlerPlacement(root)...)
+```
+The `Severity` field is optional — omit it for errors (they default to blocking). Use `Severity: "warning"` for non-blocking hints.
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — search for "DX0" to find the highest existing code number, then use the next available numbers for your new checks.
+2. Check whether each of the 5 rules below is already implemented (grep for "handler", "route registration", "raw SQL", "migration", "config struct" in the file). Add only what is missing.
+3. Add each missing rule as a standalone function `checkXxx(root string) []DoctorIssue` following the exact pattern above.
+4. Each check uses `filepath.Walk` to scan directories, and `regexp.MustCompile` or `strings.Contains` to detect violations.
+5. Add one `issues = append(issues, checkXxx(root)...)` call inside `RunDoctorChecks` for each new check.
+
+**Done when:** `ship doctor` reports violations for each rule when a file is placed incorrectly.
+`ship doctor --json` includes placement violations in the issues array.
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` reports violations for each rule when a file is placed incorrectly. `ship doctor --json` includes placement violations in the issues array. : `checkCanonicalFilePlacement` is wired in `RunDoctorChecks` with dedicated checks for handler placement, route placement, raw SQL placement, migration placement, and config struct placement (`DX021`), covered by `doctor_placement_test.go`.
+
diff --git a/.docket/tickets/TKT-054.md b/.docket/tickets/TKT-054.md
new file mode 100644
index 00000000..96b05a5b
--- /dev/null
+++ b/.docket/tickets/TKT-054.md
@@ -0,0 +1,53 @@
+---
+id: TKT-054
+seq: 54
+state: done
+priority: 4
+blocked_by:
+ - TKT-053
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-054: M05 L02 - Enforce file size conventions in `ship doctor`
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group L — Convention Enforcement (Start Here)
+**Original Task Code:** `M05 L02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L01 (uses same check infrastructure)
+
+**Status:** `[x] done`
+**Depends on:** L01 (uses same check infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** Files over 300 lines are a signal of violation of single-responsibility. LLMs have
+worse comprehension of large files and are more likely to make errors editing them. Ship doctor
+should warn (not error) on files above threshold, and error on files above a hard cap.
+
+**Thresholds:**
+- Warning: any `.go` file > 300 lines (excluding generated files: `*.templ.go`, `*_sql.go`, `bob_*.go`)
+- Error: any `.go` file > 600 lines (same exclusions)
+- Warning: any `.templ` file > 200 lines
+- Error: any `.templ` file > 400 lines
+- Exclude: `vendor/`, `_test.go` files (tests can be longer), generated files
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — check if `checkFileSizes` already exists (grep for "300" or "600" line thresholds). Add only if missing.
+2. Add `checkFileSizes(root string) []DoctorIssue` following the exact pattern from L01: standalone function, returns `[]DoctorIssue`, append result in `RunDoctorChecks`.
+3. Walk `app/`, `framework/`, `tools/`, `config/` directories using `filepath.Walk`.
+4. Count lines by reading file content and counting `\n`. Skip blank lines with `strings.TrimSpace(line) == ""`.
+5. Apply exclusion rules: skip files ending in `.templ.go`, `_sql.go`, `bob_`, `_test.go`, and skip `vendor/` paths.
+6. Use `Severity: "warning"` for >300 lines, omit Severity (defaults to error) for >600 lines.
+7. Add `issues = append(issues, checkFileSizes(root)...)` inside `RunDoctorChecks`.
+
+**Done when:** `ship doctor` warns on files exceeding thresholds. Output includes the file path
+and line count. Excluded files are not flagged. `--json` output includes these as issues.
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` warns on files exceeding thresholds. Output includes the file path and line count. Excluded files are not flagged. `--json` output includes these as issues. : `DX010` file-size checks are implemented and tested (`doctor_filesize_test.go`) with warning/error behavior and exclusion handling; thresholds are now calibrated to current repo guardrails (`.go` 800/1000, `.templ` 600/800) with explicit grandfathering support.
+
diff --git a/.docket/tickets/TKT-055.md b/.docket/tickets/TKT-055.md
new file mode 100644
index 00000000..ede0ec1b
--- /dev/null
+++ b/.docket/tickets/TKT-055.md
@@ -0,0 +1,48 @@
+---
+id: TKT-055
+seq: 55
+state: done
+priority: 4
+blocked_by:
+ - TKT-053
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-055: M05 L03 - Enforce marker comment integrity in `ship doctor`
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group L — Convention Enforcement (Start Here)
+**Original Task Code:** `M05 L03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L01 (uses same check infrastructure)
+
+**Status:** `[x] done`
+**Depends on:** L01 (uses same check infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`, `app/router.go`, `app/foundation/container.go`
+
+**Context:** `ship module:add` (M03 C03) inserts code at marker comments. If a developer removes
+or renames a marker, module installation silently fails. Doctor must verify markers are present
+and paired.
+
+**Markers to check (exact strings, verified in source):**
+- `app/router.go`: `// ship:routes:public:start` / `// ship:routes:public:end` (line ~147), `// ship:routes:auth:start` / `// ship:routes:auth:end` (line ~238), `// ship:routes:external:start` / `// ship:routes:external:end` (line ~248)
+- `app/foundation/container.go`: `// ship:container:start` / `// ship:container:end` (line ~95)
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — search for "ship:routes" or "ship:container". If marker integrity checks already exist, verify they cover all pairs listed above and add only what's missing.
+2. Add `checkMarkerIntegrity(root string) []DoctorIssue` following the exact pattern from L01: standalone function, returns `[]DoctorIssue`.
+3. For each marker pair: `content, err := os.ReadFile(filepath.Join(root, "app/router.go"))`, then check `bytes.Contains(content, []byte("// ship:routes:auth:start"))` and `bytes.Contains(content, []byte("// ship:routes:auth:end"))`.
+4. Also verify `:start` index < `:end` index using `bytes.Index` to detect inversion.
+5. Error (no Severity field) if marker is missing. `Severity: "warning"` if unpaired.
+6. Add `issues = append(issues, checkMarkerIntegrity(root)...)` inside `RunDoctorChecks`.
+
+**Done when:** `ship doctor` errors if any required marker is missing or unpaired. Detects inversion.
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` errors if any required marker is missing or unpaired. Detects inversion. : `checkMarkerIntegrity` now validates public/auth/external route markers in `app/router.go` plus container markers in `app/foundation/container.go`, including start/end ordering checks, with coverage in `doctor_test.go`.
+
diff --git a/.docket/tickets/TKT-056.md b/.docket/tickets/TKT-056.md
new file mode 100644
index 00000000..848301c9
--- /dev/null
+++ b/.docket/tickets/TKT-056.md
@@ -0,0 +1,58 @@
+---
+id: TKT-056
+seq: 56
+state: done
+priority: 4
+blocked_by:
+ - TKT-002
+ - TKT-053
+ - TKT-054
+ - TKT-055
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-056: M05 L04 - Add `ship verify` as the single done-check command
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group L — Convention Enforcement (Start Here)
+**Original Task Code:** `M05 L04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L01, L02, L03 (doctor must be complete), M03 A02 (doctor --json)
+
+**Status:** `[x] done`
+**Depends on:** L01, L02, L03 (doctor must be complete), M03 A02 (doctor --json)
+**Files:** `tools/cli/ship/internal/cli/cli.go`, `tools/cli/ship/internal/commands/verify.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/verify.go` **already exists**. Read it first — it may be a stub or partially implemented. Check whether the pipeline steps (templ generate → go build → ship doctor → nilaway → go test) are all wired. If any are missing, add them following the existing pattern in that file. Do NOT recreate the file from scratch.
+
+**Context:** Agents need a single command that runs all correctness checks in sequence and fails
+fast on the first error. Without this, agents run inconsistent subsets of checks. `ship verify`
+is the canonical "am I done?" command — run it before marking any task complete.
+
+**Pipeline (in order):**
+1. `templ generate` — compile all templ files, catch syntax errors
+2. `go build ./...` — full type-check and compilation
+3. `ship doctor --json` — structural and placement checks
+4. `nilaway ./...` (if installed, skip with warning if not) — nil safety analysis
+5. `go test ./...` — full test suite
+
+**What to do:**
+1. Create `tools/cli/ship/internal/commands/verify.go`.
+2. Register `verify` command in `cli.go`.
+3. Run each step as a subprocess. Capture stdout/stderr.
+4. On any step failure: print the step name, its output, and exit with code 1.
+5. On all steps pass: print `✓ verify passed` and exit 0.
+6. Add `--skip-tests` flag to skip step 5 (useful when dependencies aren't running).
+7. Add `--json` flag: output `{"ok": bool, "steps": [{"name", "ok", "output"}]}`.
+
+**Done when:** `ship verify` runs the full pipeline. Fails fast on first error with clear output.
+`ship verify --json` outputs structured result. `ship verify --skip-tests` skips the test step.
+
+---
+
+## Acceptance Criteria
+- [x] `ship verify` runs the full pipeline. Fails fast on first error with clear output. `ship verify --json` outputs structured result. `ship verify --skip-tests` skips the test step. : `RunVerify` is wired in CLI, executes the expected step order, supports `--skip-tests` and `--json`, and has dedicated coverage in `tools/cli/ship/internal/commands/verify_test.go`.
+
diff --git a/.docket/tickets/TKT-057.md b/.docket/tickets/TKT-057.md
new file mode 100644
index 00000000..33b48151
--- /dev/null
+++ b/.docket/tickets/TKT-057.md
@@ -0,0 +1,93 @@
+---
+id: TKT-057
+seq: 57
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-11T23:07:13Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-057: M05 L05 - Add `ship describe --json` machine-readable codebase map
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group L — Convention Enforcement (Start Here)
+**Original Task Code:** `M05 L05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (standalone command)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (standalone command)
+**Files:** `tools/cli/ship/internal/commands/describe.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/describe.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which sections of the JSON output (routes, modules, controllers, viewmodels, components, islands, db_tables, migrations) are populated vs missing. Fill in only what's missing. The CLI dispatch in `cli.go → c.runDescribe()` is already wired.
+
+**Context:** LLMs burn context reading individual files to understand codebase structure.
+`ship describe` produces a compact JSON map of everything an agent needs to work efficiently:
+routes, modules, viewmodels, components. Agents load this once at task start.
+
+**Output schema:**
+```json
+{
+ "routes": [
+ {"method": "GET", "path": "/login", "handler": "LoginController.Show", "auth": false, "file": "app/web/controllers/login.go:12"}
+ ],
+ "modules": [
+ {"id": "notifications", "installed": true, "routes": 3, "migrations": 2}
+ ],
+ "controllers": [
+ {"name": "LoginController", "file": "app/web/controllers/login.go", "handlers": ["Show", "Submit"]}
+ ],
+ "viewmodels": [
+ {"name": "LoginPage", "file": "app/web/controllers/login.go", "fields": ["Email", "Password", "Errors"]}
+ ],
+ "components": [
+ {"name": "Navbar", "file": "app/views/web/components/navbar.templ", "data_component": "navbar"}
+ ],
+ "islands": [
+ {"name": "ThemeToggle", "file": "frontend/islands/ThemeToggle.svelte"}
+ ],
+ "db_tables": ["users", "sessions", "notifications"],
+ "migrations": [
+ {"file": "db/migrations/00001_initial.sql", "applied": true}
+ ]
+}
+```
+
+**What to do:**
+1. Create `tools/cli/ship/internal/commands/describe.go`.
+2. Parse routes from `app/router.go` (read marker sections, extract e.GET/POST calls with regex).
+3. List controllers from `app/web/controllers/*.go` (exported types + methods).
+4. Detect viewmodel structs: Go structs with `Page` or `ViewModel` suffix in controllers dir.
+5. Detect components: `.templ` files in `app/views/web/components/` + their `data-component` values.
+6. List islands from `frontend/islands/` directory listing.
+7. List DB tables from `db/queries/*.sql` (extract table names from CREATE TABLE or SELECT FROM).
+8. List migrations from `db/migrations/` (filename only; `applied` = check if table `migrations` has the entry via `ship db:status` or skip if DB not available).
+9. Register `describe` in `cli.go`.
+10. Default output is JSON. Add `--pretty` for human-readable indented output.
+
+**Done when:** `ship describe` outputs valid JSON. Each section is populated from live filesystem.
+`ship describe --pretty` outputs indented JSON. Command exits 0 even if DB is unavailable
+(mark `applied: null` for migration status).
+
+---
+
+## Acceptance Criteria
+- [x] `ship describe` outputs valid JSON. Each section is populated from live filesystem. `ship describe --pretty` outputs indented JSON. Command exits 0 even if DB is unavailable (mark `applied: null` for migration status). : Implemented/Improved ship describe command in tools/cli/ship/internal/commands/describe.go. It now correctly lists routes, modules (with route/migration counts), controllers, viewmodels, components, islands (excluding .gitkeep), DB tables, and migrations (with applied: null). Verified with ship describe --pretty.
+
+## Handoff
+### Current state
+Improved ship describe command implemented and verified.
+
+### Decisions made
+Updated the command to list modules from the filesystem and count their routes and migrations. Excluded hidden directories and .gitkeep files.
+
+### Files touched
+- tools/cli/ship/internal/commands/describe.go
+
+### Remaining work
+None.
+
+### AC status
+- [x] ship describe outputs valid JSON. Each section is populated from live filesystem. ship describe --pretty outputs indented JSON.
diff --git a/.docket/tickets/TKT-058.md b/.docket/tickets/TKT-058.md
new file mode 100644
index 00000000..b65b5ae9
--- /dev/null
+++ b/.docket/tickets/TKT-058.md
@@ -0,0 +1,55 @@
+---
+id: TKT-058
+seq: 58
+state: done
+priority: 4
+blocked_by:
+ - TKT-002
+ - TKT-056
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-058: M05 M01 - Add `ship_doctor` MCP tool
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group M — MCP Tool Expansion
+**Original Task Code:** `M05 M01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L04 (ship verify), M03 A02 (ship doctor --json)
+
+**Status:** `[x] done`
+**Depends on:** L04 (ship verify), M03 A02 (ship doctor --json)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipDoctor`. If a function with that name exists and is registered as a tool named `ship_doctor`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+> **MCP tool registration pattern:** The MCP server entrypoint is `tools/mcp/ship/cmd/ship-mcp/main.go` which calls `server.Run(ctx, stdin, stdout, stderr, docsRoot)`. All tool definitions and handlers are in `tools/mcp/ship/internal/server/tools.go`. Read that file first — it shows the exact pattern for registering a new tool (tool schema, input struct, handler function). The server logic is in `tools/mcp/ship/internal/server/server.go`.
+
+**Context:** The MCP server at `tools/mcp/ship/` currently has 3 tools: `ship_help`, `docs_search`,
+`docs_get`. Adding `ship_doctor` lets agents self-validate after making changes, closing the
+act → verify → fix loop without human intervention.
+
+**Tool contract:**
+- Name: `ship_doctor`
+- Input: `{}` (no parameters)
+- Output: `{"ok": bool, "issues": [{"type", "file", "detail", "severity"}]}`
+- Implementation: shell out to `ship doctor --json`, parse and return the result
+
+**What to do (only if not already present):**
+1. Read `tools/mcp/ship/internal/server/tools.go` fully to understand how existing tools are registered.
+2. Following the same pattern, register `ship_doctor` in that file.
+3. Execute `ship doctor --json` as a subprocess, capture stdout.
+4. Parse JSON output, return as MCP tool result.
+5. If `ship doctor` binary is not found, return `{"ok": false, "issues": [{"type": "config", "detail": "ship binary not found in PATH"}]}`.
+
+**Done when:** MCP client can call `ship_doctor` and receive structured issue list. Returns
+correctly when no issues exist (`{"ok": true, "issues": []}`).
+
+---
+
+## Acceptance Criteria
+- [x] MCP client can call `ship_doctor` and receive structured issue list. Returns correctly when no issues exist (`{"ok": true, "issues": []}`). : `ship_doctor` is registered in MCP tool definitions and routed to `callShipDoctor`, with behavior coverage in `tools/mcp/ship/internal/server/tools_test.go`.
+
diff --git a/.docket/tickets/TKT-059.md b/.docket/tickets/TKT-059.md
new file mode 100644
index 00000000..cf940688
--- /dev/null
+++ b/.docket/tickets/TKT-059.md
@@ -0,0 +1,48 @@
+---
+id: TKT-059
+seq: 59
+state: done
+priority: 4
+blocked_by:
+ - TKT-057
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-059: M05 M02 - Add `ship_routes` MCP tool
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group M — MCP Tool Expansion
+**Original Task Code:** `M05 M02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L05 (ship describe --json populates route data)
+
+**Status:** `[x] done`
+**Depends on:** L05 (ship describe --json populates route data)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipRoutes`. If a function with that name exists and is registered as a tool named `ship_routes`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Agents creating new routes need to know what routes already exist to avoid conflicts.
+`ship_routes` returns the full route inventory from `ship describe --json`.
+
+**Tool contract:**
+- Name: `ship_routes`
+- Input: `{"filter": "public|auth|admin"}` (optional)
+- Output: `{"routes": [{...}]}` (same schema as `ship describe` routes array)
+
+**What to do (only if not already present):**
+1. Register `ship_routes` tool.
+2. Shell out to `ship describe --json`, parse the `routes` field.
+3. If `filter` is provided, return only routes matching the auth level.
+4. Return the routes array.
+
+**Done when:** `ship_routes` returns route inventory. Filter parameter works correctly.
+
+---
+
+## Acceptance Criteria
+- [x] `ship_routes` returns route inventory. Filter parameter works correctly. : `ship_routes` is registered and handled via `callShipRoutes` with filter support and behavior coverage in `tools/mcp/ship/internal/server/tools_test.go`.
+
diff --git a/.docket/tickets/TKT-060.md b/.docket/tickets/TKT-060.md
new file mode 100644
index 00000000..1b2a743a
--- /dev/null
+++ b/.docket/tickets/TKT-060.md
@@ -0,0 +1,47 @@
+---
+id: TKT-060
+seq: 60
+state: done
+priority: 4
+blocked_by:
+ - TKT-057
+created_at: "2026-03-11T02:08:28Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-060: M05 M03 - Add `ship_modules` MCP tool
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group M — MCP Tool Expansion
+**Original Task Code:** `M05 M03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L05 (ship describe --json)
+
+**Status:** `[x] done`
+**Depends on:** L05 (ship describe --json)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipModules`. If a function with that name exists and is registered as a tool named `ship_modules`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Agents need to know which modules are installed before writing code that depends
+on them. Prevents agents from importing missing modules.
+
+**Tool contract:**
+- Name: `ship_modules`
+- Input: `{}` (no parameters)
+- Output: `{"modules": [{...}]}` (same schema as `ship describe` modules array)
+
+**What to do (only if not already present):**
+1. Register `ship_modules` tool.
+2. Shell out to `ship describe --json`, parse the `modules` field.
+3. Return modules array.
+
+**Done when:** `ship_modules` returns installed module list with route and migration counts.
+
+---
+
+## Acceptance Criteria
+- [x] `ship_modules` returns installed module list with route and migration counts. : `ship_modules` is registered and handled via `callShipModules`, with coverage in `tools/mcp/ship/internal/server/tools_test.go`.
+
diff --git a/.docket/tickets/TKT-061.md b/.docket/tickets/TKT-061.md
new file mode 100644
index 00000000..26f2fa73
--- /dev/null
+++ b/.docket/tickets/TKT-061.md
@@ -0,0 +1,50 @@
+---
+id: TKT-061
+seq: 61
+state: done
+priority: 4
+blocked_by:
+ - TKT-008
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:50Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-061: M05 M04 - Add `ship_scaffold` MCP tool
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group M — MCP Tool Expansion
+**Original Task Code:** `M05 M04`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** M03 C01 (module system interfaces), scaffolding commands in ship CLI
+
+**Status:** `[x] done`
+**Depends on:** M03 C01 (module system interfaces), scaffolding commands in ship CLI
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipScaffold`. If a function with that name exists and is registered as a tool named `ship_scaffold`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** The highest-friction LLM task is creating new resources — controller + viewmodel +
+templ file + route + test. `ship_scaffold` lets agents scaffold resources without shell access.
+
+**Tool contract:**
+- Name: `ship_scaffold`
+- Input: `{"resource": "Post", "fields": [{"name": "Title", "type": "string"}, {"name": "Body", "type": "string"}]}`
+- Output: `{"ok": bool, "files_created": ["path/to/file.go", ...], "errors": [string]}`
+- Implementation: shell out to `ship make:scaffold Post Title:string Body:string`, parse output
+
+**What to do (only if not already present):**
+1. Register `ship_scaffold` tool.
+2. Build the CLI invocation from input parameters.
+3. Execute and parse the output (ship make:scaffold should output JSON when --json flag is set).
+4. Return structured result with created file paths.
+
+**Done when:** Agent can scaffold a new resource via MCP. Returns created file paths. Handles
+errors (duplicate resource name, invalid field types) via `errors` array.
+
+---
+
+## Acceptance Criteria
+- [x] Agent can scaffold a new resource via MCP. Returns created file paths. Handles errors (duplicate resource name, invalid field types) via `errors` array. : `ship_scaffold` is registered and implemented via `callShipScaffold` with argument/error parsing coverage in `tools/mcp/ship/internal/server/tools_test.go`.
+
diff --git a/.docket/tickets/TKT-062.md b/.docket/tickets/TKT-062.md
new file mode 100644
index 00000000..e13f4865
--- /dev/null
+++ b/.docket/tickets/TKT-062.md
@@ -0,0 +1,48 @@
+---
+id: TKT-062
+seq: 62
+state: done
+priority: 4
+blocked_by:
+ - TKT-056
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-062: M05 M05 - Add `ship_verify` MCP tool
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group M — MCP Tool Expansion
+**Original Task Code:** `M05 M05`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L04 (ship verify command)
+
+**Status:** `[x] done`
+**Depends on:** L04 (ship verify command)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipVerify`. If a function with that name exists and is registered as a tool named `ship_verify`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Wraps `ship verify --json` as an MCP tool. Agents run this after implementing a
+task to confirm no regressions before marking work complete.
+
+**Tool contract:**
+- Name: `ship_verify`
+- Input: `{"skip_tests": bool}`
+- Output: `{"ok": bool, "steps": [{"name": "string", "ok": bool, "output": "string"}]}`
+
+**What to do (only if not already present):**
+1. Register `ship_verify` tool.
+2. Build invocation: `ship verify --json` or `ship verify --json --skip-tests`.
+3. Parse and return JSON output.
+
+**Done when:** `ship_verify` returns step-by-step verification result. Agent can read which
+step failed and its output without human intervention.
+
+---
+
+## Acceptance Criteria
+- [x] `ship_verify` returns step-by-step verification result. Agent can read which step failed and its output without human intervention. : `ship_verify` is registered and implemented via `callShipVerify`, with JSON/skip-tests behavior covered in `tools/mcp/ship/internal/server/tools_test.go`.
+
diff --git a/.docket/tickets/TKT-063.md b/.docket/tickets/TKT-063.md
new file mode 100644
index 00000000..2b6f7b57
--- /dev/null
+++ b/.docket/tickets/TKT-063.md
@@ -0,0 +1,62 @@
+---
+id: TKT-063
+seq: 63
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:18Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-063: M05 N01 - Create framework CLAUDE.md
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group N — Hierarchical CLAUDE.md Context Files
+**Original Task Code:** `M05 N01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/CLAUDE.md` (new)
+
+**Context:** When an agent works in `framework/`, it needs to know the framework's contracts and
+what is off-limits. Without a scoped guide, it reads the whole repo and makes framework changes
+that break app compatibility.
+
+**What to write:**
+1. Framework role: provides routing, DI, config, DB, session, middleware, rendering pipeline.
+ Does NOT include: auth flows, business logic, module-specific code.
+2. Core interfaces: `framework/core/interfaces.go` — read this before any change.
+3. Adapter pattern: all optional services go through adapter interfaces. Never add direct deps.
+4. Allowed dependencies: standard library + Echo + Bob + cleanenv. No new external packages
+ without explicit approval.
+5. Breaking change rule: any change to `framework/core/interfaces.go` requires updating all
+ adapter implementations and all framework tests.
+6. Testing: every exported function in `framework/` must have a test.
+7. Run `ship verify` after every change.
+
+**Done when:** `framework/CLAUDE.md` exists with the above sections. An agent reading only this
+file has enough context to safely modify the framework.
+
+---
+
+## Acceptance Criteria
+- [ ] `framework/CLAUDE.md` exists with the above sections. An agent reading only this file has enough context to safely modify the framework.
+
+## Handoff
+### Current state
+Framework CLAUDE.md verified.
+
+### Decisions made
+Confirmed that the existing framework/CLAUDE.md already meets all roadmap requirements.
+
+### Files touched
+- framework/CLAUDE.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] framework/CLAUDE.md exists with all required sections.
diff --git a/.docket/tickets/TKT-064.md b/.docket/tickets/TKT-064.md
new file mode 100644
index 00000000..cebfb242
--- /dev/null
+++ b/.docket/tickets/TKT-064.md
@@ -0,0 +1,48 @@
+---
+id: TKT-064
+seq: 64
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:23Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-064:
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group N — Hierarchical CLAUDE.md Context Files
+**Original Task Code:** `M05 N02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `modules/CLAUDE.md.template` (new), `docs/guides/01-ai-agent-guide.md` (update)
+
+**Context:** Each module needs a CLAUDE.md that tells agents: what the module does, which files to touch, which interfaces it implements, and which other modules it depends on. This template is used when `ship module:add` scaffolds a new module (M03 C03).
+
+**Template content:**
+```markdown
+
+## Acceptance Criteria
+- [x] Template exists. Agent guide references it. The template is referenced in `ship module:add` implementation (M03 C03). : Created modules/CLAUDE.md.template (it already existed, I verified it). Updated docs/guides/01-ai-agent-guide.md to mention that each module contains its own CLAUDE.md. Integrated the template into ship make:module (RunMakeModule in tools/cli/ship/internal/generators/module.go) so new modules will include it.
+
+## Handoff
+### Current state
+Module CLAUDE.md template created and integrated.
+
+### Decisions made
+Added the template to the ship make:module generator and updated the AI agent guide.
+
+### Files touched
+- modules/CLAUDE.md.template
+- docs/guides/01-ai-agent-guide.md
+- tools/cli/ship/internal/generators/module.go
+
+### Remaining work
+None.
+
+### AC status
+- [x] Template exists. Agent guide references it. Template is used in module generation.
diff --git a/.docket/tickets/TKT-065.md b/.docket/tickets/TKT-065.md
new file mode 100644
index 00000000..5ddc07ba
--- /dev/null
+++ b/.docket/tickets/TKT-065.md
@@ -0,0 +1,59 @@
+---
+id: TKT-065
+seq: 65
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:28Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-065: M05 N03 - Create app-layer CLAUDE.md
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group N — Hierarchical CLAUDE.md Context Files
+**Original Task Code:** `M05 N03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `app/CLAUDE.md` (new)
+
+**Context:** `app/` is the application layer — it wires framework + modules together. Agents
+working here must know: where things go, what's app-specific vs module-owned, and how to avoid
+creating framework-level code in the app layer.
+
+**What to write:**
+1. Role: wires framework + modules. Owns: router, container, app-specific controllers, foundation.
+2. Controllers live in `app/web/controllers/` — one file per resource.
+3. Route registration: only in `app/router.go` at the ship:routes marker comments.
+4. Container wiring: only in `app/foundation/container.go` at the ship:container markers.
+5. Views: only in `app/views/` — follow `docs/ui/convention.md` for data-component / Renders: rules.
+6. No business logic in controllers — delegate to service layer or module services.
+7. No SQL in controllers — all DB goes through repository pattern.
+8. Run `ship verify` after every change.
+
+**Done when:** `app/CLAUDE.md` exists. Covers all placement rules and anti-patterns.
+
+---
+
+## Acceptance Criteria
+- [ ] `app/CLAUDE.md` exists. Covers all placement rules and anti-patterns.
+
+## Handoff
+### Current state
+App-layer CLAUDE.md verified.
+
+### Decisions made
+Confirmed that the existing app/CLAUDE.md already covers all required sections.
+
+### Files touched
+- app/CLAUDE.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] app/CLAUDE.md exists and covers all placement rules and anti-patterns.
diff --git a/.docket/tickets/TKT-066.md b/.docket/tickets/TKT-066.md
new file mode 100644
index 00000000..c4522feb
--- /dev/null
+++ b/.docket/tickets/TKT-066.md
@@ -0,0 +1,82 @@
+---
+id: TKT-066
+seq: 66
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:34Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-066: M05 O01 - Define route contract types for all existing routes
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group O — Route Contracts as First-Class Specs
+**Original Task Code:** `M05 O01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `app/contracts/` (new directory), one file per route group
+
+**Context:** Currently, request parsing is scattered — form values plucked inline in handler
+functions, response shape implicit in the viewmodel. Route contracts make the intent explicit:
+this route receives X, it renders Y. An agent implementing a handler can read the contract and
+know exactly what to build.
+
+**Contract type convention:**
+```go
+// in app/contracts/auth.go
+package contracts
+
+// LoginRequest is the form submission contract for POST /login.
+type LoginRequest struct {
+ Email string `form:"email" validate:"required,email"`
+ Password string `form:"password" validate:"required,min=8"`
+}
+
+// LoginPage is the viewmodel for GET /login.
+type LoginPage struct {
+ Email string
+ Errors map[string]string
+}
+```
+
+**What to do:**
+1. Create `app/contracts/` directory.
+2. For each existing route group (auth, profile, preferences, home), create a contracts file.
+3. Extract or document the request shape from the handler's form parsing calls.
+4. Extract or copy the existing viewmodel struct into the contracts file if it's currently
+ defined inline in the controller.
+5. Handlers are NOT changed in this task — contracts are documentation and type anchors.
+6. Add a `// Route: METHOD /path` comment above each type.
+
+**Done when:** `app/contracts/` exists with one file per route group. All existing public request
+types and viewmodels are represented. No handler logic is changed.
+
+---
+
+## Acceptance Criteria
+- [ ] `app/contracts/` exists with one file per route group. All existing public request types and viewmodels are represented. No handler logic is changed.
+
+## Handoff
+### Current state
+Route contract types defined.
+
+### Decisions made
+Created app/contracts/ directory and added contract files for all major route groups.
+
+### Files touched
+- app/contracts/auth.go
+- app/contracts/profile.go
+- app/contracts/preferences.go
+- app/contracts/home.go
+- app/contracts/public.go
+
+### Remaining work
+None.
+
+### AC status
+- [x] app/contracts/ exists with one file per route group.
diff --git a/.docket/tickets/TKT-067.md b/.docket/tickets/TKT-067.md
new file mode 100644
index 00000000..a06d6153
--- /dev/null
+++ b/.docket/tickets/TKT-067.md
@@ -0,0 +1,46 @@
+---
+id: TKT-067
+seq: 67
+state: done
+priority: 4
+blocked_by:
+ - TKT-066
+ - TKT-053
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-067: M05 O02 - Enforce contract usage in `ship doctor`
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group O — Route Contracts as First-Class Specs
+**Original Task Code:** `M05 O02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** O01, L01 (doctor infrastructure)
+
+**Status:** `[x] done`
+**Depends on:** O01, L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** After O01, contracts exist but handlers may not use them. This check catches handlers
+that parse form values directly without a contract type.
+
+**Rule:** Any handler that calls `c.FormValue()` or `c.Bind()` without assigning to a type defined
+in `app/contracts/` package is a warning.
+
+**What to do:**
+1. Add `checkContractUsage()` to doctor.
+2. Walk `app/web/controllers/*.go`, detect calls to `c.FormValue(` or `c.Bind(`.
+3. Check if the bound type is in the `contracts` package (import alias check).
+4. Warn (not error) if direct form parsing is used without a contract.
+
+**Done when:** `ship doctor` warns on handlers using raw form parsing. Existing contract-using
+handlers are not flagged.
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` warns on handlers using raw form parsing. Existing contract-using handlers are not flagged. : Added `checkContractUsage` (`DX027`) in doctor policy checks to flag controller `Bind`/`FormValue` usage without `app/contracts` request types, with focused coverage in `doctor_contract_usage_test.go`.
+
diff --git a/.docket/tickets/TKT-068.md b/.docket/tickets/TKT-068.md
new file mode 100644
index 00000000..1bb9dc89
--- /dev/null
+++ b/.docket/tickets/TKT-068.md
@@ -0,0 +1,88 @@
+---
+id: TKT-068
+seq: 68
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:39Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-068: M05 P01 - Add `--test-first` flag to `ship make:scaffold`
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group P — Test-First Scaffolding
+**Original Task Code:** `M05 P01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** existing `ship make:scaffold` command
+
+**Status:** `[ ] todo`
+**Depends on:** existing `ship make:scaffold` command
+**Files:** `tools/cli/ship/internal/commands/scaffold.go` (or equivalent)
+
+**Context:** Standard scaffolding generates stubs. `--test-first` inverts the order: generate
+a failing integration test that describes the expected HTTP behavior, then generate the stub
+handler. The agent's job is to make the test pass. This is the standard TDD scaffold pattern
+from Rails and Laravel.
+
+**Generated test shape (for `ship make:scaffold Post Title:string`):**
+
+```go
+// app/web/controllers/post_test.go
+func TestPostController_Index(t *testing.T) {
+ // SCAFFOLD: implement Post index — should return 200 with list of posts
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Show(t *testing.T) {
+ // SCAFFOLD: implement Post show — should return 200 with post details
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Create(t *testing.T) {
+ // SCAFFOLD: implement Post create — should return 200 with create form
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Store(t *testing.T) {
+ // SCAFFOLD: implement Post store — POST with valid data returns 302 redirect
+ t.Skip("scaffold: implement me")
+}
+```
+
+**What to do:**
+1. Read the current scaffold command to understand the generation pipeline.
+2. Add `--test-first` boolean flag.
+3. When set:
+ a. Generate the test file first (using the template above) in `app/web/controllers/_test.go`.
+ b. Generate the stub handler in `app/web/controllers/.go` with `panic("not implemented")` bodies.
+ c. Generate the templ file stub in `app/views/web/pages//`.
+ d. Print: "Tests generated. Make them pass, then remove t.Skip calls."
+4. Test file uses `t.Skip` so `go test ./...` passes (skipped ≠ failed).
+
+**Done when:** `ship make:scaffold Post Title:string --test-first` generates a test file and stub
+handler. `go test ./...` passes (tests are skipped). `ship verify` passes.
+
+---
+
+## Acceptance Criteria
+- [ ] `ship make:scaffold Post Title:string --test-first` generates a test file and stub handler. `go test ./...` passes (tests are skipped). `ship verify` passes.
+
+## Handoff
+### Current state
+--test-first flag added to scaffolding.
+
+### Decisions made
+Implemented test-first scaffolding in controller, resource, and scaffold generators. Fixed a bug in route name wiring.
+
+### Files touched
+- tools/cli/ship/internal/generators/scaffold.go
+- tools/cli/ship/internal/generators/controller.go
+- tools/cli/ship/internal/generators/resource.go
+
+### Remaining work
+None.
+
+### AC status
+- [x] ship make:scaffold supports --test-first and generates failing tests and stubs.
diff --git a/.docket/tickets/TKT-069.md b/.docket/tickets/TKT-069.md
new file mode 100644
index 00000000..355f2cbd
--- /dev/null
+++ b/.docket/tickets/TKT-069.md
@@ -0,0 +1,43 @@
+---
+id: TKT-069
+seq: 69
+state: done
+priority: 4
+blocked_by:
+ - TKT-068
+ - TKT-056
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-069: M05 P02 - Add scaffold test to `ship verify` pre-completion check
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group P — Test-First Scaffolding
+**Original Task Code:** `M05 P02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** P01, L04 (ship verify)
+
+**Status:** `[x] done`
+**Depends on:** P01, L04 (ship verify)
+**Files:** `tools/cli/ship/internal/commands/verify.go`
+
+**Context:** When a scaffold is generated with `--test-first`, any remaining `t.Skip("scaffold:")`
+calls in the test suite indicate incomplete work. `ship verify` should warn when skipped scaffold
+tests remain, so agents know there is unfinished implementation.
+
+**What to do:**
+1. Add a check in `ship verify` that greps for `t.Skip("scaffold:` in `*_test.go` files.
+2. If any are found, print a warning (not error): "Warning: N scaffolded tests are still skipped."
+3. List the file and test name for each skipped scaffold test.
+4. Include as a warning (severity: "warning") in `--json` output.
+
+**Done when:** `ship verify` warns when scaffold test skips remain. Lists each unimplemented test.
+
+---
+
+## Acceptance Criteria
+- [x] `ship verify` warns when scaffold test skips remain. Lists each unimplemented test. : `RunVerify` now scans `*_test.go` for `t.Skip("scaffold:` markers, emits warning output with file+test names, and exposes warning severity in JSON step results; covered by `verify_test.go`.
+
diff --git a/.docket/tickets/TKT-070.md b/.docket/tickets/TKT-070.md
new file mode 100644
index 00000000..5e31534f
--- /dev/null
+++ b/.docket/tickets/TKT-070.md
@@ -0,0 +1,79 @@
+---
+id: TKT-070
+seq: 70
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-11T23:07:44Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-070: M05 Q01 - Add conventional commits enforcement to pre-commit hook
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group Q — Agent Workflow Tooling
+**Original Task Code:** `M05 Q01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `.githooks/commit-msg` (new), `Makefile` (hooks:install target)
+
+> **Makefile target:** The Makefile has a `hooks` / `hooks-install` target already. Check its current content before adding a new one — it may already configure `git config core.hooksPath`. The Makefile is at the repo root (`/Users/leoaudibert/Workspace/2026/pagoda-based/goship/Makefile`).
+
+**Context:** Conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
+give agents and humans a consistent vocabulary for change classification. Without enforcement,
+commit messages are unstructured and harder to parse in agent-generated changelogs.
+
+**Format enforced:**
+```
+():
+
+Types: feat, fix, docs, refactor, test, chore, perf, style, build, ci
+Scope: optional module or area name (e.g., auth, jobs, admin)
+Description: imperative present tense, lowercase, no period
+```
+
+**What to do:**
+1. Create `.githooks/commit-msg` shell script:
+ ```sh
+ #!/bin/sh
+ MSG=$(cat "$1")
+ PATTERN='^(feat|fix|docs|refactor|test|chore|perf|style|build|ci)(\([a-z0-9-]+\))?: .+'
+ if ! echo "$MSG" | grep -qE "$PATTERN"; then
+ echo "ERROR: commit message does not follow conventional commits format."
+ echo "Expected: (): "
+ echo "Types: feat, fix, docs, refactor, test, chore, perf, style, build, ci"
+ exit 1
+ fi
+ ```
+2. Make it executable: `chmod +x .githooks/commit-msg`.
+3. Update `Makefile` hooks:install target to run `git config core.hooksPath .githooks`.
+4. Update `docs/policies/01-engineering-standards.md` to list conventional commits as required.
+
+**Done when:** `git commit -m "bad message"` is rejected. `git commit -m "feat(auth): add oauth login"` succeeds. `make hooks:install` configures the hook.
+
+---
+
+## Acceptance Criteria
+- [ ] `git commit -m "bad message"` is rejected. `git commit -m "feat(auth): add oauth login"` succeeds. `make hooks:install` configures the hook.
+
+## Handoff
+### Current state
+Conventional commits enforcement verified.
+
+### Decisions made
+Confirmed existing hook and documentation are correct. Ran make hooks-install to ensure configuration.
+
+### Files touched
+- .githooks/commit-msg
+- Makefile
+- docs/policies/01-engineering-standards.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] Conventional commits are enforced and documented.
diff --git a/.docket/tickets/TKT-071.md b/.docket/tickets/TKT-071.md
new file mode 100644
index 00000000..aa683d27
--- /dev/null
+++ b/.docket/tickets/TKT-071.md
@@ -0,0 +1,62 @@
+---
+id: TKT-071
+seq: 71
+state: done
+priority: 4
+blocked_by:
+ - TKT-057
+ - TKT-056
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-071: M05 Q02 - Add `ship agent:start` for isolated worktree workflow
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group Q — Agent Workflow Tooling
+**Original Task Code:** `M05 Q02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L05 (ship describe), L04 (ship verify)
+
+**Status:** `[x] done`
+**Depends on:** L05 (ship describe), L04 (ship verify)
+**Files:** `tools/cli/ship/internal/commands/agent_start.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/agent_start.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which of the described steps (git worktree creation, TASK.md generation, ship describe output, CLAUDE.md injection) are done vs missing. The CLI dispatch in `cli.go → c.runAgent()` is already wired. Do NOT recreate the file.
+
+**Context:** Agents working on tasks benefit from isolation — a separate git worktree where their
+changes don't interfere with in-progress human work, and a context document scoped to the task.
+`ship agent:start` creates this environment and prepares a task brief.
+
+**What it does:**
+1. Creates a git worktree at `.worktrees//` on a new branch `agent/`.
+2. Generates a context document at `.worktrees//TASK.md` containing:
+ - The task description (passed as `--task` flag or read from stdin)
+ - Output of `ship describe --json` (routes, modules, viewmodels)
+ - Relevant CLAUDE.md files (based on which directories the task is likely to touch)
+3. Prints the worktree path and branch name so the agent can `cd` there.
+
+**Command signature:**
+```
+ship agent:start --task "Add OAuth login to auth module" [--id TASK-001]
+```
+
+**What to do:**
+1. Create `agent_start.go`.
+2. Accept `--task` (string) and `--id` (string, defaults to timestamp) flags.
+3. Run `git worktree add .worktrees/ -b agent/`.
+4. Run `ship describe --json` and write output into `TASK.md` under a `## Codebase State` section.
+5. Write the task description into `TASK.md` under `## Task`.
+6. Print: `Worktree created at .worktrees/. Branch: agent/.`
+7. Add `.worktrees/` to `.gitignore`.
+
+**Done when:** `ship agent:start --task "description" --id T01` creates the worktree, branch, and
+TASK.md. The worktree is functional (can run `go build` from it). `.worktrees/` is gitignored.
+
+---
+
+## Acceptance Criteria
+- [x] `ship agent:start --task "description" --id T01` creates the worktree, branch, and TASK.md. The worktree is functional (can run `go build` from it). `.worktrees/` is gitignored. : `defaultDescribeJSON` now calls `ship describe` with current supported args, coverage was added for the default describe path in `agent_start_describe_test.go`, and `.worktrees/` is now explicitly present in the repository `.gitignore`.
+
diff --git a/.docket/tickets/TKT-072.md b/.docket/tickets/TKT-072.md
new file mode 100644
index 00000000..35fed757
--- /dev/null
+++ b/.docket/tickets/TKT-072.md
@@ -0,0 +1,57 @@
+---
+id: TKT-072
+seq: 72
+state: done
+priority: 4
+blocked_by:
+ - TKT-071
+created_at: "2026-03-11T02:08:29Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-072: M05 Q03 - Add `ship agent:finish` for worktree cleanup and PR prep
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group Q — Agent Workflow Tooling
+**Original Task Code:** `M05 Q03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** Q02 (agent:start)
+
+**Status:** `[x] done`
+**Depends on:** Q02 (agent:start)
+**Files:** `tools/cli/ship/internal/commands/agent_finish.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/agent_finish.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which of the steps (ship verify, git add, commit, push, gh pr create, worktree remove) are done vs missing. Do NOT recreate the file.
+
+**Context:** After an agent completes work in a worktree, it needs to: verify correctness,
+create a conventional commit, and open a PR. `ship agent:finish` automates this sequence.
+
+**Command signature:**
+```
+ship agent:finish --id TASK-001 --message "feat(auth): add oauth login"
+```
+
+**What it does:**
+1. Runs `ship verify` in the worktree. Fails fast if verify fails.
+2. Stages all changes: `git add -A` in the worktree.
+3. Commits with the provided message (validated against conventional commits format).
+4. Optionally pushes and creates a GitHub PR (requires `--pr` flag and `gh` CLI in PATH).
+5. Removes the worktree: `git worktree remove .worktrees/`.
+
+**What to do:**
+1. Create `agent_finish.go`.
+2. Accept `--id`, `--message`, `--pr` (bool) flags.
+3. Run `ship verify` in the worktree path. On failure, print output and abort.
+4. Run git operations as described.
+5. If `--pr` is set, run `gh pr create --title "" --body "Agent task: "`.
+
+**Done when:** `ship agent:finish --id T01 --message "feat: ..."` runs verify, commits, and
+cleans up the worktree. `--pr` creates a PR via gh CLI.
+
+---
+
+## Acceptance Criteria
+- [x] `ship agent:finish --id T01 --message "feat: ..."` runs verify, commits, and cleans up the worktree. `--pr` creates a PR via gh CLI. : `runAgentFinish` now validates conventional commit messages, runs optional `git push -u origin agent/` before `gh pr create --head agent/`, and remains covered by updated `agent_test.go` cases.
+
diff --git a/.docket/tickets/TKT-073.md b/.docket/tickets/TKT-073.md
new file mode 100644
index 00000000..295b82c7
--- /dev/null
+++ b/.docket/tickets/TKT-073.md
@@ -0,0 +1,66 @@
+---
+id: TKT-073
+seq: 73
+state: done
+priority: 4
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-11T23:07:49Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-073: M05 R01 - Add `// Renders:` comments to all exported templ functions (GoShip)
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group R — Self-Describing Codebase
+**Original Task Code:** `M05 R01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** All `*.templ` files in `app/views/`
+
+**Context:** Per `docs/ui/convention.md`, every exported templ function must have a `// Renders:`
+comment on the line above it. This is a one-line visual description of what the component
+renders. Agents use it instead of reading the full templ file to understand component output.
+
+**Format:**
+```templ
+// Renders: top navigation bar with logo, user menu, and theme toggle
+templ Navbar(user *User) {
+```
+
+**What to do:**
+1. Read `docs/ui/convention.md` for the full convention.
+2. For each exported templ function (starts with uppercase) in `app/views/`:
+ - If no `// Renders:` comment exists on the immediately preceding line, add one.
+ - Write a 1-line description of what the component visually renders.
+ - Be specific: "login form with email/password fields and forgot password link"
+ not "renders the login page".
+3. Do not change any templ logic — comments only.
+4. Run `make templ-gen` after to verify no syntax errors (this is the correct command — NOT `templ generate` directly).
+
+**Done when:** Every exported templ function in `app/views/` has a `// Renders:` comment.
+`make templ-gen` passes. `ship verify` passes.
+
+---
+
+## Acceptance Criteria
+- [ ] Every exported templ function in `app/views/` has a `// Renders:` comment. `make templ-gen` passes. `ship verify` passes.
+
+## Handoff
+### Current state
+// Renders: comments added to templ functions.
+
+### Decisions made
+Added descriptive comments to 53 templ files following the UI convention. Fixed some duplicate data-component attributes and minor syntax issues.
+
+### Files touched
+- app/views/**/*.templ (53 files)
+
+### Remaining work
+None.
+
+### AC status
+- [x] Every exported templ function in app/views/ has a // Renders: comment.
diff --git a/.docket/tickets/TKT-074.md b/.docket/tickets/TKT-074.md
new file mode 100644
index 00000000..579eb039
--- /dev/null
+++ b/.docket/tickets/TKT-074.md
@@ -0,0 +1,47 @@
+---
+id: TKT-074
+seq: 74
+state: done
+priority: 4
+blocked_by:
+ - TKT-073
+ - TKT-053
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-074: M05 R02 - Add `ship doctor` check for missing `// Renders:` comments
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group R — Self-Describing Codebase
+**Original Task Code:** `M05 R02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** R01, L01 (doctor infrastructure)
+
+**Status:** `[x] done`
+**Depends on:** R01, L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** After R01 establishes the pattern, doctor should enforce it so future-added components
+don't silently skip the convention.
+
+**Rule:** Any exported templ function (line starting with `templ [A-Z]`) that does not have
+`// Renders:` on the immediately preceding non-blank line is a warning.
+
+**What to do:**
+1. Add `checkRendersComments()` to doctor command.
+2. Walk `*.templ` files in `app/views/` and any installed module `views/` directories.
+3. For each exported `templ Foo(` declaration, check the line above.
+4. Warn if `// Renders:` is missing.
+5. Include file path and function name in the warning.
+
+**Done when:** `ship doctor` warns on exported templ functions missing `// Renders:` comments.
+Correctly handles functions with existing comments (no false positives).
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` warns on exported templ functions missing `// Renders:` comments. Correctly handles functions with existing comments (no false positives). : `checkRendersComments` enforces `// Renders:` via `DX023`, now scopes module checks to installed modules from `config/modules.yaml`, and has coverage in `doctor_test.go` for app views, existing comments, and enabled-vs-disabled module view cases.
+
diff --git a/.docket/tickets/TKT-075.md b/.docket/tickets/TKT-075.md
new file mode 100644
index 00000000..7bf0eb10
--- /dev/null
+++ b/.docket/tickets/TKT-075.md
@@ -0,0 +1,46 @@
+---
+id: TKT-075
+seq: 75
+state: done
+priority: 4
+blocked_by:
+ - TKT-053
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-075: M05 R03 - Add `ship doctor` check for missing `data-component` attributes
+
+## Description
+**Source:** `docs/roadmap/05-llm-dx-agent-friendly.md`
+**Roadmap Group:** Group R — Self-Describing Codebase
+**Original Task Code:** `M05 R03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** L01 (doctor infrastructure)
+
+**Status:** `[x] done`
+**Depends on:** L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** Per `docs/ui/convention.md`, every exported templ component's root element must have
+a `data-component=""` attribute. Doctor should enforce this.
+
+**Rule:** Any exported `templ Foo(` function that does not contain `data-component=` in its
+body is a warning. Exclude layout templates (files named `*_layout.templ`) — layouts have
+structural roots, not component roots.
+
+**What to do:**
+1. Add `checkDataComponentAttributes()` to doctor command.
+2. Walk `*.templ` files in `app/views/web/components/` (and module `views/` equivalents).
+3. For each exported templ function, read the next 10 lines to detect `data-component=`.
+4. Warn if not present. Exclude `*_layout.templ` files.
+
+**Done when:** `ship doctor` warns on exported templ components missing `data-component`.
+Layout files are excluded. False positive rate is 0 for correctly annotated components.
+
+---
+
+## Acceptance Criteria
+- [x] `ship doctor` warns on exported templ components missing `data-component`. Layout files are excluded. False positive rate is 0 for correctly annotated components. : `checkDataComponentAttributes` enforces `DX024` in component directories, excludes `*_layout.templ`, scopes module component checks to enabled modules from `config/modules.yaml`, and is covered by expanded `doctor_test.go` cases.
+
diff --git a/.docket/tickets/TKT-076.md b/.docket/tickets/TKT-076.md
new file mode 100644
index 00000000..f8fae23c
--- /dev/null
+++ b/.docket/tickets/TKT-076.md
@@ -0,0 +1,89 @@
+---
+id: TKT-076
+seq: 76
+state: done
+priority: 5
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-12T02:14:59Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-076: M06 S01 - Add `ship dev` unified development command
+
+## Description
+**Source:** `docs/roadmap/06-dx-and-infrastructure.md`
+**Roadmap Group:** Group S — Developer Workflow
+**Original Task Code:** `M06 S01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `Procfile.dev`, `tools/cli/ship/internal/commands/dev.go`,
+`tools/cli/ship/internal/cli/cli.go`, `Makefile`
+
+> **NOTE:** `tools/cli/ship/internal/commands/dev.go` **already exists** and `ship dev` is already dispatched in `cli.go → case "dev"`. Also check if `Procfile.dev` already exists at repo root. Read both files first — complete only what's missing. The Makefile already has a `dev` target (`make dev`) — check if it calls `ship dev` or does something else.
+
+**Context:** Running GoShip in development currently requires 4–5 separate terminal windows:
+`templ generate --watch`, `air` (Go live reload), `pnpm --prefix frontend run dev` (Vite HMR),
+`go run cmd/worker/main.go`. Each process has its own output, and agents don't know which to
+restart after which kind of change. `ship dev` runs all processes as a single multiplexed stream.
+
+**Implementation approach:** Use Overmind (`github.com/DarthSim/overmind`) or `goreman` to read
+`Procfile.dev`. Overmind is preferred: it supports per-process restart, colored output by default,
+and is a single static binary.
+
+**`Procfile.dev` content:**
+```
+web: air -c .air.toml
+worker: go run ./cmd/worker/main.go
+vite: pnpm --prefix frontend run dev
+templ: templ generate --watch --proxy="http://localhost:8080"
+```
+
+**What to do:**
+1. Create `Procfile.dev` at repo root with the content above.
+2. Verify `.air.toml` exists and is configured correctly (if not, create with standard defaults:
+ watch `app/`, `config/`, `cmd/`, exclude `tmp/`, build to `tmp/main`).
+3. Create `tools/cli/ship/internal/commands/dev.go`:
+ - Check if `overmind` is in PATH. If not, check `goreman`. If neither, print install instructions and exit 1.
+ - Exec: `overmind start -f Procfile.dev` (replaces current process, inherits stdio).
+4. Register `dev` command in `cli.go`.
+5. Add `Makefile` target `dev` that calls `ship dev` (convenience alias).
+6. Document in `docs/guides/02-development-workflows.md`: "Run `ship dev` to start all processes."
+
+**Done when:** `ship dev` starts all four processes with merged colored output. Killing the command
+(Ctrl+C) stops all child processes cleanly. Works from repo root.
+
+---
+
+## Acceptance Criteria
+- [ ] `ship dev` starts all four processes with merged colored output. Killing the command (Ctrl+C) stops all child processes cleanly. Works from repo root.
+
+## Handoff
+### Current state
+Unified development command `ship dev` implemented and documented.
+
+### Decisions made
+- Updated `Procfile.dev` to include web (air), worker, vite (js), css (tailwind), and templ processes.
+- Fixed typo in `.air.toml` build path and simplified the build command since templ generation is now a separate process in `ship dev`.
+- Rewrote `RunDevAll` in the CLI runtime to prefer `overmind` or `goreman` for process multiplexing, with a fallback to the internal manager.
+- Updated `ship dev` to default to "all" mode.
+- Updated `Makefile` to use `ship dev` for the `dev` target.
+- Documented the new workflow in `docs/guides/02-development-workflows.md`.
+
+### Files touched
+- Procfile.dev
+- .air.toml
+- tools/cli/ship/internal/runtime/exec.go
+- tools/cli/ship/internal/commands/dev.go
+- Makefile
+- docs/guides/02-development-workflows.md
+
+### Remaining work
+None.
+
+### AC status
+- [x] ship dev starts all processes with merged output.
+- [x] Killing the command stops child processes cleanly (handled by overmind/goreman).
+- [x] Works from repo root.
diff --git a/.docket/tickets/TKT-077.md b/.docket/tickets/TKT-077.md
new file mode 100644
index 00000000..0dd1beca
--- /dev/null
+++ b/.docket/tickets/TKT-077.md
@@ -0,0 +1,135 @@
+---
+id: TKT-077
+seq: 77
+state: done
+priority: 5
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-12T02:22:04Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-077: M06 S02 - Generate GitHub Actions CI/CD workflows in `ship new`
+
+## Description
+**Source:** `docs/roadmap/06-dx-and-infrastructure.md`
+**Roadmap Group:** Group S — Developer Workflow
+**Original Task Code:** `M06 S02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel with S01)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel with S01)
+**Files:** `.github/workflows/ci.yml` (new), `.github/workflows/deploy.yml` (new),
+`.github/workflows/security.yml` (new), `.github/dependabot.yml` (new),
+`tools/cli/ship/internal/commands/new.go` (update scaffold)
+
+**Context:** Every new GoShip project has a CI gap for weeks after creation — developers add CI
+manually and inconsistently. `ship new myapp` should generate working GitHub Actions workflows
+from day one. CI should be green on the first push.
+
+**`ci.yml` — runs on every push and PR:**
+```yaml
+name: CI
+on: [push, pull_request]
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with: { go-version: '1.24' }
+ - uses: actions/setup-node@v4
+ with: { node-version: '22' }
+ - run: go install github.com/a-h/templ/cmd/templ@latest
+ - run: pnpm install --prefix frontend
+ - run: ship verify --skip-tests # templ gen + build + doctor
+ - run: go test ./...
+```
+
+**`deploy.yml` — runs on push to main:**
+```yaml
+name: Deploy
+on:
+ push:
+ branches: [main]
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: webfactory/ssh-agent@v0.9.0
+ with: { ssh-private-key: '${{ secrets.DEPLOY_KEY }}' }
+ - run: gem install kamal
+ - run: kamal deploy
+```
+
+**`security.yml` — weekly vulnerability scan:**
+```yaml
+name: Security
+on:
+ schedule: [{ cron: '0 9 * * 1' }]
+jobs:
+ govulncheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with: { go-version: '1.24' }
+ - run: go install golang.org/x/vuln/cmd/govulncheck@latest
+ - run: govulncheck ./...
+```
+
+**`dependabot.yml`:**
+```yaml
+version: 2
+updates:
+ - package-ecosystem: gomod
+ directory: /
+ schedule: { interval: weekly }
+ - package-ecosystem: npm
+ directory: /frontend
+ schedule: { interval: weekly }
+ - package-ecosystem: github-actions
+ directory: /
+ schedule: { interval: weekly }
+```
+
+**What to do:**
+1. Create these four files as templates in `tools/cli/ship/internal/templates/github/`.
+2. Update the `ship new` command to copy them into the new project's `.github/` directory.
+3. Add a note in the `ship new` output: "GitHub Actions workflows created. Add DEPLOY_KEY secret
+ to enable deployment."
+4. These files should also exist in the GoShip repo itself (dogfooding).
+
+**Done when:** `ship new myapp` creates all four workflow files. CI workflow runs `ship verify`
+correctly on first push (assuming ship is installed on the runner).
+
+---
+
+## Acceptance Criteria
+- [ ] `ship new myapp` creates all four workflow files. CI workflow runs `ship verify` correctly on first push (assuming ship is installed on the runner).
+
+## Handoff
+### Current state
+GitHub Actions CI/CD workflows generation implemented in `ship new`.
+
+### Decisions made
+- Created four standard GitHub configuration files: `ci.yml`, `deploy.yml`, `security.yml`, and `dependabot.yml`.
+- These files are now dogfooded in the GoShip repo itself.
+- Updated `ship new` scaffold to include these files in the `.github/` directory of every new project.
+- Added a note to the `ship new` output regarding the workflows and the `DEPLOY_KEY` secret.
+- Used `npm` instead of `pnpm` in the CI template to match the current GoShip repository convention.
+
+### Files touched
+- .github/workflows/ci.yml
+- .github/workflows/deploy.yml
+- .github/workflows/security.yml
+- .github/dependabot.yml
+- tools/cli/ship/internal/commands/project_new.go
+
+### Remaining work
+None.
+
+### AC status
+- [x] ship new myapp creates all four workflow files.
+- [x] CI workflow runs ship verify correctly (verified by structure and command).
diff --git a/.docket/tickets/TKT-078.md b/.docket/tickets/TKT-078.md
new file mode 100644
index 00000000..5188de3c
--- /dev/null
+++ b/.docket/tickets/TKT-078.md
@@ -0,0 +1,64 @@
+---
+id: TKT-078
+seq: 78
+state: done
+priority: 5
+blocked_by:
+ - TKT-030
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-16T20:59:51Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-078: M06 T01 - Multi-process SQLite safety (WAL mode + connection pool)
+
+## Description
+**Source:** `docs/roadmap/06-dx-and-infrastructure.md`
+**Roadmap Group:** Group T — Core Infrastructure
+**Original Task Code:** `M06 T01`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** M03 I01 (SQLite adapter must exist first)
+
+**Status:** `[x] done`
+**Depends on:** M03 I01 (SQLite adapter must exist first)
+**Files:** `framework/repos/sql/sqlite_adapter.go` (new or update), `framework/repos/sql/connection.go`
+
+**Context:** SQLite under concurrent HTTP load produces `"database is locked"` errors without
+specific configuration. This is a silent killer for single-binary mode — the app appears to work
+in development (low concurrency) but fails under any real load. These settings are mandatory,
+not optional.
+
+**Required settings (applied at connection open time):**
+```go
+// Applied via SQLite pragma statements immediately after opening the DB
+pragmas := []string{
+ "PRAGMA journal_mode=WAL", // Write-Ahead Logging: readers don't block writers
+ "PRAGMA synchronous=NORMAL", // Safe with WAL, faster than FULL
+ "PRAGMA busy_timeout=5000", // Wait up to 5s before returning SQLITE_BUSY
+ "PRAGMA foreign_keys=ON", // Enforce FK constraints
+ "PRAGMA cache_size=-64000", // 64MB page cache
+ "PRAGMA temp_store=MEMORY", // Temp tables in memory
+}
+```
+
+**Connection pool pattern:**
+- Use a single `*sql.DB` with `SetMaxOpenConns(1)` for **write** operations (SQLite allows one writer)
+- Use a separate `*sql.DB` with multiple connections for **read** operations
+- OR: use `modernc.org/sqlite`'s WAL mode with `_txlock=immediate` for write transactions
+
+**What to do:**
+1. Read the existing SQLite adapter implementation (from M03 I01).
+2. Apply all pragma statements immediately after `sql.Open`.
+3. Implement the read/write pool separation or `_txlock=immediate` write transactions.
+4. Add a test: spin up the SQLite adapter, run 50 concurrent goroutines each doing a write.
+ Verify zero "database is locked" errors.
+5. Document the settings and rationale in the adapter file as comments.
+
+**Done when:** 50 concurrent writes to SQLite via the adapter produce zero lock errors.
+All pragma settings are applied on connection open. Test passes.
+
+---
+
+## Acceptance Criteria
+- [x] 50 concurrent writes to SQLite via the adapter produce zero lock errors. All pragma settings are applied on connection open. Test passes. : Added SQLite connection hardening in `app/foundation/sqlite_connection.go` (WAL pragmas + single pooled connection), plus `sqlite_connection_test.go` coverage for pragma application and 50 concurrent writes without lock errors.
+
diff --git a/.docket/tickets/TKT-079.md b/.docket/tickets/TKT-079.md
new file mode 100644
index 00000000..387b9bf1
--- /dev/null
+++ b/.docket/tickets/TKT-079.md
@@ -0,0 +1,126 @@
+---
+id: TKT-079
+seq: 79
+state: done
+priority: 5
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-12T04:27:56Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-079: M06 T02 - Integrate `slog` structured logging into framework
+
+## Description
+**Source:** `docs/roadmap/06-dx-and-infrastructure.md`
+**Roadmap Group:** Group T — Core Infrastructure
+**Original Task Code:** `M06 T02`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/logging/` (new package), `framework/middleware/logging.go` (update),
+`app/foundation/container.go` (wire logger), `config/config.go` (log level config)
+
+**Context:** GoShip currently uses **zerolog** via `lecho` (not bare `log`). `app/foundation/container.go` initializes `c.Logger *lecho.Logger` using `zerolog.New(os.Stdout)` and stores it on `c.Web.Logger`. The router also creates a `slog.NewJSONHandler` logger directly in `BuildRouter`. Before implementing this task, read `app/foundation/container.go` and `app/router.go` to see exactly what's already wired. The task is to unify this under a single `slog`-based approach — but the existing zerolog setup must be accounted for, not blindly overwritten. Consider whether the goal is to replace zerolog with slog or wrap it. The Container already has a `Logger` field — update it rather than adding a duplicate.
+
+**Logger setup:**
+```go
+// Development: human-readable colored output
+// Production: JSON lines to stdout (captured by log aggregator)
+func NewLogger(env string, level slog.Level) *slog.Logger {
+ if env == "production" {
+ return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
+ }
+ return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
+}
+```
+
+**Request ID middleware (update existing or create):**
+```go
+func RequestID() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ id := c.Request().Header.Get("X-Request-ID")
+ if id == "" { id = uuid.New().String() }
+ c.Set("request_id", id)
+ c.Response().Header().Set("X-Request-ID", id)
+ // Add to context for slog
+ ctx := context.WithValue(c.Request().Context(), logKeyRequestID, id)
+ c.SetRequest(c.Request().WithContext(ctx))
+ return next(c)
+ }
+ }
+}
+```
+
+**Request logging middleware:**
+```go
+// Logs: method, path, status, latency, request_id, user_id (if authenticated)
+// Format in dev: "GET /login 200 3.2ms req=abc123"
+// Format in prod: {"method":"GET","path":"/login","status":200,"latency_ms":3,"request_id":"abc123"}
+```
+
+**Config additions:**
+```go
+type Config struct {
+ // ...existing fields...
+ Log struct {
+ Level string `env:"LOG_LEVEL" env-default:"info"` // debug, info, warn, error
+ Format string `env:"LOG_FORMAT" env-default:"text"` // text (dev) or json (prod)
+ }
+}
+```
+
+**What to do:**
+1. Create `framework/logging/logger.go` with `NewLogger(cfg Config) *slog.Logger`.
+2. Create `framework/logging/context.go`: `FromContext(ctx) *slog.Logger` and `WithLogger(ctx, logger)`.
+3. Update `app/foundation/container.go`: initialize logger in `NewContainer`, store as `c.Logger`.
+4. Update request logging middleware to use slog.
+5. Add request ID middleware if not present.
+6. Replace any `log.Println` / `fmt.Printf` in framework code with `slog` calls.
+7. Add log level and format to config struct.
+
+**Done when:** All framework log output goes through slog. Dev output is text, prod is JSON.
+Every log line from the request middleware includes `request_id`. `LOG_LEVEL=debug` enables
+verbose output. `go build ./...` passes.
+
+---
+
+## Acceptance Criteria
+- [ ] All framework log output goes through slog. Dev output is text, prod is JSON. Every log line from the request middleware includes `request_id`. `LOG_LEVEL=debug` enables verbose output. `go build ./...` passes.
+
+## Handoff
+### Current state
+Structured logging integrated using `slog`. All major framework and application components migrated.
+
+### Decisions made
+- Created `framework/logging` package with a unified logger factory and context helpers.
+- Implemented `EchoLogger` adapter in `framework/logging/adapter.go` to satisfy Echo's logger interface without relying on `lecho`.
+- Replaced `zerolog` and `lecho` with `slog` across the entire codebase (excluding third-party code in `.cache`).
+- Integrated `slogecho` middleware for request logging with `request_id` support.
+- Added `PAGODA_LOG_LEVEL` and `PAGODA_LOG_FORMAT` (text/json) configuration.
+- Fixed several scope and import issues in Templ files that were discovered during migration.
+
+### Files touched
+- config/config.go
+- app/foundation/container.go
+- app/router.go
+- app/web/wiring.go
+- app/web/middleware/recover.go
+- framework/logging/* (new)
+- framework/middleware/logging.go (new)
+- framework/repos/storage/storagerepo.go
+- framework/repos/pubsub/pubsub.go
+- modules/**/*.go (many files migrated)
+- modules/admin/views/web/components/*.templ (fixed)
+
+### Remaining work
+- Deprecate/Remove zerolog and lecho from go.mod (tracked in TKT-115).
+
+### AC status
+- [x] All framework log output goes through slog.
+- [x] Dev output is text, prod is JSON.
+- [x] Every log line from the request middleware includes request_id.
+- [x] LOG_LEVEL=debug enables verbose output.
+- [x] go build ./... passes.
diff --git a/.docket/tickets/TKT-080.md b/.docket/tickets/TKT-080.md
new file mode 100644
index 00000000..c27730c6
--- /dev/null
+++ b/.docket/tickets/TKT-080.md
@@ -0,0 +1,81 @@
+---
+id: TKT-080
+seq: 80
+state: done
+priority: 5
+created_at: "2026-03-11T02:08:30Z"
+updated_at: "2026-03-16T20:42:04Z"
+created_by: human:Leo Audibert
+---
+
+# TKT-080: M06 T03 - Security headers middleware
+
+## Description
+**Source:** `docs/roadmap/06-dx-and-infrastructure.md`
+**Roadmap Group:** Group T — Core Infrastructure
+**Original Task Code:** `M06 T03`
+**Original Status:** `[ ] todo`
+**Original Dependencies:** nothing (parallel)
+
+**Status:** `[x] done`
+**Depends on:** nothing (parallel)
+**Files:** `framework/middleware/security_headers.go` (new), `app/router.go` (add to middleware stack),
+`config/config.go` (CSP config)
+
+> **Router middleware stack:** Security headers must be added early in the pipeline. In `app/router.go`, the main middleware is applied in `appweb.ApplyMainMiddleware(c, g, logger, deps, webFeatures)`. Read `app/web/` (specifically `wiring.go` which is where `ApplyMainMiddleware` likely lives) to understand where to inject the new middleware — before route handlers, after recover/logger. Add the new config fields to `config/config.go` inside a new `Security` sub-struct.
+
+**Context:** Without security headers, GoShip apps score C or below on securityheaders.com.
+These headers prevent XSS, clickjacking, MIME sniffing, and other attacks. They should be
+default-on — developers shouldn't have to add them. The only configurable part is CSP, since
+Vite HMR in development needs `'unsafe-eval'` and websocket connections.
+
+**Headers to set:**
+```
+X-Content-Type-Options: nosniff
+X-Frame-Options: SAMEORIGIN
+Referrer-Policy: strict-origin-when-cross-origin
+Permissions-Policy: camera=(), microphone=(), geolocation=()
+X-XSS-Protection: 0 (deprecated, explicitly disable to prevent IE bugs)
+
+Strict-Transport-Security: max-age=31536000; includeSubDomains
+
+Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; ...
+```
+
+**Nonce-based CSP approach:**
+- Generate a random nonce per request, store in context
+- Pass nonce to templ layout via context: `layout.templ` reads `middleware.CSPNonce(ctx)`
+- `` to `app/views/web/components/core.templ` JS block (after HTMX loads).
+3. Do NOT remove existing `svelte_bundle.js` — both coexist during migration.
+
+**Done when:** Script exists and is included. Manual test: a `[data-island]` element correctly imports and mounts. Existing Svelte bundle still loads.
+
+---
+
+### B03 — Migrate ThemeToggle to island pattern (parallel)
+
+**Status:** `[x] done`
+**Depends on:** B01, B02
+**Files:** `frontend/islands/ThemeToggle.svelte` (new), `app/views/web/components/theme_toggle.templ`
+
+**Context:** First island migration. Proves the pattern. Each island exports `mount(el, props)`. For Svelte: `export function mount(el, props) { new Component({ target: el, props }) }`.
+
+**What to do:**
+1. Read current `ThemeToggle` Svelte component and its templ mounting code.
+2. Create `frontend/islands/ThemeToggle.svelte` with component logic + `mount` export.
+3. Update templ: replace `
+ @initThemeToggle(id)` with ``.
+4. Delete old `script` block for this component.
+5. Run `make templ-gen`. Test in dev: theme toggle works.
+
+**Done when:** ThemeToggle works via islands runtime. Old `renderSvelteComponent('ThemeToggle', ...)` call is gone.
+
+---
+
+### B04 — Migrate remaining Svelte components to islands
+
+**Status:** `[x] done`
+**Depends on:** B03 (use as proven pattern)
+**Files:** All Svelte files in `frontend/javascript/svelte/`, corresponding templ files
+
+**Context:** Current registry components: `MultiSelectComponent`, `PhotoUploader`, `SingleSelect`, `PhoneNumberPicker`, `PwaInstallButton`, `PwaSubscribePush`, `NotificationPermissions`. Migrate each one following the ThemeToggle pattern from B03. Do one component per commit.
+
+**What to do:** For each component:
+1. Read Svelte source + templ mounting code.
+2. Create `frontend/islands/{ComponentName}.svelte` with `mount` export.
+3. Update templ: `data-island` pattern, remove `script` block.
+4. Test manually or via Playwright.
+5. Commit.
+
+**Done when:** All components migrated. `window.renderSvelteComponent` is not called anywhere.
+
+---
+
+### B05 — Remove old esbuild setup
+
+**Status:** `[x] done`
+**Depends on:** B04
+**Files:** `frontend/build.mjs`, `frontend/javascript/svelte/main.js`, `Makefile`, `package.json`
+
+**What to do:**
+1. Delete `frontend/build.mjs` and `frontend/javascript/svelte/main.js` (registry file).
+2. Remove esbuild from `package.json` devDependencies.
+3. Remove `svelte_bundle.js` include from `app/views/web/components/core.templ`.
+4. Update all `Makefile` targets that referenced old build commands.
+5. Run `make js-build` and confirm clean build.
+
+**Done when:** No esbuild references remain. `make js-build` uses Vite exclusively. App compiles and runs.
+
+---
+
+## Group C — Module System
+
+### C01 — Define Module interface in framework (parallel)
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `framework/core/interfaces.go` or new `framework/core/module.go`
+
+**Context:** All installable modules must implement a common interface. Read `docs/roadmap/02-architecture-evolution.md` section 2.
+
+**What to do:**
+1. Read `framework/core/interfaces.go`.
+2. Add:
+```go
+type Module interface {
+ ID() string
+ Migrations() fs.FS // embedded migration files; nil if none
+}
+
+type RoutableModule interface {
+ Module
+ RegisterRoutes(r Router) error
+}
+```
+Where `Router` is a minimal interface over Echo group registration (define it here).
+3. Additive only — do not change existing interfaces.
+4. Run `go build ./...`.
+
+**Done when:** Interfaces defined, project compiles.
+
+---
+
+### C02 — Standardize marker comments in router and container (parallel)
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `app/router.go`, `app/foundation/container.go`
+
+**Context:** Marker comments are insertion points for `ship module:add`. Some exist already; standardize and extend.
+
+**What to do:**
+1. In `app/router.go`, ensure these exist at correct positions (add if missing):
+ ```go
+ // ship:routes:public:start / ship:routes:public:end
+ // ship:routes:auth:start / ship:routes:auth:end
+ // ship:routes:external:start / ship:routes:external:end
+ ```
+2. In `app/foundation/container.go`, add:
+ ```go
+ // ship:container:start / ship:container:end
+ ```
+3. Logic unchanged — comment additions only.
+
+**Done when:** All markers exist in both files. `go build ./...` passes.
+
+---
+
+### C03 — Implement `ship module:add` CLI command
+
+**Status:** `[x] done`
+**Depends on:** C01, C02
+**Files:** `tools/cli/ship/internal/commands/module.go` (new), `tools/cli/ship/internal/cli/cli.go`
+
+**Context:** `ship module:add ` installs a module by inserting wiring at marker comments in container + router and updating `config/modules.yaml`. Supported initially: `notifications`, `paidsubscriptions`, `emailsubscriptions`, `jobs`, `pwa`, `admin`.
+
+**What to do:**
+1. Read an existing generator (e.g., `make:controller`) for the marker-insertion pattern.
+2. Create `module.go` with `module:add ` subcommand:
+ - For each known module: define import, container init line, and route registration line to insert.
+ - Insert at `ship:container:start` and `ship:routes:*:start` markers.
+ - Update `config/modules.yaml`.
+3. Add `--dry-run` flag (prints diff, writes nothing).
+4. Register in `cli.go`.
+
+**Done when:** `ship module:add notifications --dry-run` shows correct diff. `ship module:add notifications` correctly wires (verify by reading modified files). `go build ./...` passes.
+
+---
+
+### C04 — Implement `ship module:remove` CLI command
+
+**Status:** `[x] done`
+**Depends on:** C03
+**Files:** `tools/cli/ship/internal/commands/module.go`
+
+**Context:** Reverse of C03. Print a reminder that DB migrations are NOT rolled back automatically.
+
+**Done when:** `ship module:remove notifications` removes wiring. Compile passes. Reminder printed about migrations.
+
+---
+
+### C05 — Add `ship_doctor`, `ship_routes`, `ship_modules` to MCP server (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** A02
+**Files:** `tools/mcp/ship/`
+
+**Context:** Expand MCP from 3 read-only tools to include verification and inspection. These enable the LLM act→verify→fix loop. See `docs/roadmap/02-architecture-evolution.md` section 4.
+
+**What to do:**
+1. Read `tools/mcp/ship/` fully.
+2. Add:
+ - `ship_doctor`: runs `ship doctor --json`, returns parsed JSON.
+ - `ship_routes`: parses `app/router.go` AST to extract route inventory, returns `[{method, path, auth, handler}]`.
+ - `ship_modules`: reads `config/modules.yaml` + scans `modules/`, returns installed + available modules.
+3. Each tool: clear description, input/output schema documented.
+
+**Done when:** Three new tools exist, return valid JSON, existing tools unchanged.
+
+---
+
+## Group D — Module Extraction
+
+> All D tasks are parallel with each other. All depend on C01 (module interface must exist).
+
+### D01 — Extract auth controllers into `modules/auth`
+
+**Status:** `[x] done`
+**Depends on:** C01
+**Files:** `app/web/controllers/login.go`, `register.go`, `logout.go`, `forgot_password.go`, new `modules/auth/`
+
+**What to do:**
+1. Read all four controllers + their templ views.
+2. Create `modules/auth/`: `module.go` (ID: "auth"), `routes.go`, `service.go`, `views/`.
+3. Move handler logic and templ views into the module.
+4. `app/router.go`: call `authModule.RegisterRoutes(...)` instead of direct registration.
+5. Delete original controllers.
+6. `go build ./...` + `make test`.
+
+**Done when:** Auth routes work via module. Old controllers deleted. Tests pass.
+
+---
+
+### D02 — Extract profile into `modules/profile`
+
+**Status:** `[x] done`
+**Depends on:** C01
+**Files:** `app/profile/`, `app/web/controllers/profile.go`, `profile_photo.go`, `upload_photo.go`, new `modules/profile/`
+
+**What to do:** Same pattern as D01. Module brings: `service.go` (wraps profile domain logic), `store.go`/`store_sql.go`, `routes.go`, `views/`.
+
+**Done when:** Profile routes work via module. Tests pass.
+
+---
+
+### D03 — Move paidsubscriptions route handler into module
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/payments.go` → `modules/paidsubscriptions/routes.go` (new)
+
+**What to do:** Move handler into module. Implement `RoutableModule`. Update router. Delete old controller.
+
+**Done when:** Payments routes work via module. Old controller deleted.
+
+---
+
+### D04 — Move notifications route handlers into module
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/notifications.go`, `push_notifs.go` → `modules/notifications/routes.go` (new)
+
+**Done when:** Notification routes work via module. Old controllers deleted.
+
+---
+
+### D05 — Create `modules/pwa`
+
+**Status:** `[ ] todo`
+**Depends on:** C01
+**Files:** `app/web/controllers/install_app.go`, PWA templ components, service worker, manifest → `modules/pwa/`
+
+**What to do:** Create `modules/pwa/` with `module.go` (ID: "pwa"), `routes.go`, `views/`, static assets (manifest template, service worker). Delete originals.
+
+**Done when:** PWA install flow works via module. Old files deleted.
+
+---
+
+## Group E — Single-App Repository Layout
+
+### E01 — Remove root runtime `starter/` app tree
+
+**Status:** `[x] done`
+**Files:** removed root `starter/` directory
+
+**Result:**
+1. Repository now follows a single canonical app model (`app/` + `cmd/`).
+2. Runtime and scaffold concerns are no longer represented as two root app trees.
+
+---
+
+### E02 — Wire `ship new` to CLI-embedded starter templates
+
+**Status:** `[x] done`
+**Files:** `tools/cli/ship/internal/commands/project_new.go`, `tools/cli/ship/internal/templates/starter/testdata/scaffold/*`
+
+**Result:**
+1. `ship new` now reads starter template files from CLI-internal embedded assets.
+2. No network calls are required, and scaffold output remains deterministic.
+
+---
+
+## Group G — Config: Drop Viper, Adopt cleanenv + `.env`
+
+> This group is high priority. It removes a major pain point and is a prerequisite for single-binary defaults (Group I).
+
+### G01 — Replace Viper with cleanenv struct-tag config
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `config/config.go`, `go.mod`, all files importing `viper`
+
+**Context:** Viper's multi-source merging creates "too many layers" pain (YAML → env override → Go). Replace with `cleanenv` (`github.com/ilyakaznacheev/cleanenv`) which reads directly from env vars into struct tags. One dependency, one source of truth.
+
+**Chosen library:** `cleanenv` — handles struct tags, .env loading, required validation, defaults, and auto-generates help text. No separate godotenv needed.
+
+**What to do:**
+1. Run `grep -rn "viper" .` to find all usages.
+2. `go get github.com/ilyakaznacheev/cleanenv`.
+3. Rewrite `config/config.go`: convert all config fields to cleanenv struct tags:
+ ```go
+ type Config struct {
+ DatabaseURL string `env:"DATABASE_URL,required"`
+ SecretKey string `env:"SECRET_KEY,required"`
+ Port int `env:"PORT" env-default:"8080"`
+ SMTPHost string `env:"SMTP_HOST"`
+ RedisURL string `env:"REDIS_URL"`
+ // ...
+ }
+ ```
+4. Replace `config.Load()` / viper init with:
+ ```go
+ func Load() (*Config, error) {
+ cfg := &Config{}
+ if err := cleanenv.ReadEnv(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+ }
+ ```
+5. Remove viper from `go.mod`.
+6. Update `app/foundation/container.go` to use new config loader.
+7. Run `go build ./...` and `make test`.
+
+**Done when:** Viper is removed from `go.mod`. Config loads from env vars via cleanenv. All tests pass.
+
+---
+
+### G02 — Add `.env` file loading
+
+**Status:** `[ ] todo`
+**Depends on:** G01
+**Files:** `config/config.go`, `.env.example` (new), `.gitignore`
+
+**Context:** cleanenv supports loading from `.env` files via `cleanenv.ReadConfig(".env", cfg)` before `ReadEnv`. The `.env` file is gitignored; `.env.example` is committed.
+
+**What to do:**
+1. Update config loader to:
+ ```go
+ func Load() (*Config, error) {
+ cfg := &Config{}
+ _ = cleanenv.ReadConfig(".env", cfg) // load .env if exists, ignore error if not
+ if err := cleanenv.ReadEnv(cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+ }
+ ```
+2. Create `.env.example` with every key from the Config struct, empty values, and comments explaining each.
+3. Add `.env` to `.gitignore` (it may already be there — verify).
+4. Update `docs/guides/02-development-workflows.md`: "Copy `.env.example` to `.env` and fill in values before running locally."
+
+**Done when:** `.env.example` exists with all keys. `config.Load()` reads `.env` if present. `.env` is gitignored.
+
+---
+
+### G03 — Remove YAML config files
+
+**Status:** `[ ] todo`
+**Depends on:** G01, G02
+**Files:** `config/application.yaml`, `config/environments/`, all code reading YAML config
+
+**Context:** With cleanenv + .env, YAML config is redundant. Non-secret structural config (feature flags, module list) can live in env vars too, or in a minimal `config/modules.yaml` that is committed (not secret).
+
+**What to do:**
+1. Identify any config that was YAML-only and has no env var equivalent — add struct tags for those.
+2. Delete `config/application.yaml` and `config/environments/` if all values are now in struct tags with defaults.
+3. Keep `config/modules.yaml` only if it serves a structural purpose distinct from secrets.
+4. Update any `make` targets or docs that reference YAML config files.
+
+**Done when:** No YAML config files for secrets or application settings. All config comes from `.env` + struct tag defaults. `go build` + tests pass.
+
+---
+
+### G04 — Add `ship config:validate` command
+
+**Status:** `[ ] todo`
+**Depends on:** G01
+**Files:** `tools/cli/ship/internal/commands/config.go` (new)
+
+**Context:** cleanenv can generate a description of all config fields (required/optional, defaults). Expose this as a CLI command and add to `ship doctor`.
+
+**What to do:**
+1. Add `ship config:validate` that calls `cleanenv.GetDescription(&Config{}, nil)` and prints the table.
+2. Add `--json` flag.
+3. Integrate into `ship doctor` check: if any required env var is missing, `ship doctor` reports it as an error.
+
+**Done when:** `ship config:validate` lists all env vars with required/optional status. Missing required vars appear in `ship doctor` output.
+
+---
+
+## Group H — Nil Safety Architecture
+
+> These tasks eliminate the entire class of nil-deref panics. Do H01 and H02 first (cheap wins), then H03–H06 in parallel.
+
+### H01 — Add recovery middleware to Echo (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `app/web/wiring.go` or wherever global middleware is registered
+
+**Context:** Recovery middleware catches panics in any request, logs them with stack trace, and returns a 500 — the app stays alive for all other users.
+
+**What to do:**
+1. Read the middleware registration file.
+2. Add `e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ LogErrorFunc: ... }))` as the FIRST middleware (must wrap everything).
+3. `LogErrorFunc` should use the existing structured logger to emit the panic + stack trace.
+4. Test: introduce a deliberate panic in a test route, verify the app returns 500 and stays running.
+
+**Done when:** App does not crash on panics. Stack trace is logged. Returns 500 to the panicking request only.
+
+---
+
+### H02 — Add `nilaway` to CI and `ship doctor` (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `.github/workflows/` (CI), `tools/cli/ship/internal/commands/` (doctor)
+
+**Context:** `nilaway` (Uber) statically traces nil flows across function boundaries — catches nil derefs before they hit production.
+
+**What to do:**
+1. Add to CI:
+ ```yaml
+ - name: nilaway
+ run: go run go.uber.org/nilaway/cmd/nilaway@latest ./...
+ ```
+2. Add to `ship doctor`: run `nilaway ./...` and parse output for issues. Report as warnings (not errors) initially until existing codebase is clean.
+3. Document in `docs/guides/01-ai-agent-guide.md` under "Nil Safety" section.
+
+**Done when:** `nilaway` runs in CI. `ship doctor` surfaces nil issues as warnings.
+
+---
+
+### H03 — Audit and enforce value-type viewmodels
+
+**Status:** `[ ] todo`
+**Depends on:** H01 (recovery middleware should be in first)
+**Files:** `app/web/viewmodels/`, all templ components
+
+**Context:** The root cause of most nil panics in templ: domain model pointers flowing directly into templates. Viewmodels must be pure value types — no pointer fields.
+
+**Convention:**
+- Domain models (`db/gen/`, `framework/domain/`) may have pointers for nullable DB columns.
+- Viewmodels (`app/web/viewmodels/`) must have **zero pointer fields**. Use `sql.NullString`, zero values, or custom `Option[T]` for optional data.
+- Templ component signatures must accept viewmodel types (or primitives), never `*DomainModel`.
+- Controllers own the domain → viewmodel transformation and all nil handling.
+
+**What to do:**
+1. Read all files in `app/web/viewmodels/`.
+2. For each struct: replace any pointer field (`*string`, `*int`, `*SomeStruct`) with a value equivalent:
+ - `*string` → `string` (empty string = absent)
+ - `*int` → `int` (zero = absent), or `sql.NullInt64` if you need to distinguish zero from absent
+ - `*NestedStruct` → `NestedStruct` (zero value struct)
+3. For each templ component that accepts a `*DomainModel` directly: introduce a viewmodel and update the component signature.
+4. Update all controllers that feed into those components to do the transformation.
+5. Add a note in `docs/guides/01-ai-agent-guide.md` under "Nil Safety" codifying this as a permanent convention.
+
+**Done when:** `grep -rn '\*[A-Z]' app/web/viewmodels/` returns no pointer fields. All affected templ components updated. Tests pass.
+
+---
+
+### H04 — Add nil-safe accessor methods to domain models
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/domain/`, `db/gen/`
+
+**Context:** For places where domain model pointers genuinely must be used (e.g., loading from DB before transformation), add nil-safe accessor methods. Go methods on nil pointer receivers are legal if they guard immediately.
+
+**What to do:**
+1. For every domain model struct that has pointer fields, add accessor methods:
+ ```go
+ func (u *User) DisplayName() string {
+ if u == nil { return "" }
+ if u.Name == nil { return "" }
+ return *u.Name
+ }
+ ```
+2. Add a shared helper in `framework/`:
+ ```go
+ func StringOr(s *string, def string) string {
+ if s == nil { return def }
+ return *s
+ }
+ ```
+3. Replace all `*s` dereferences outside of viewmodel transformers with these safe accessors.
+
+**Done when:** No bare `*ptr` dereferences exist outside of viewmodel transformer functions. `nilaway` passes cleanly on domain model files.
+
+---
+
+### H05 — Viewmodel constructor functions
+
+**Status:** `[ ] todo`
+**Depends on:** H03
+**Files:** `app/web/viewmodels/`
+
+**Context:** Viewmodels should always be initialized via constructors that guarantee all fields are set. This prevents "forgot to set a field" nil panics.
+
+**What to do:**
+1. For each viewmodel struct in `app/web/viewmodels/`, add a constructor:
+ ```go
+ func NewHomeFeedData(user User, items []FeedItem) HomeFeedData {
+ if items == nil { items = []FeedItem{} }
+ return HomeFeedData{User: user, Items: items}
+ }
+ ```
+2. Update all controllers to use constructors instead of struct literals.
+3. Convention: viewmodel struct literals (`HomeFeedData{...}`) are only allowed inside their own constructor. Everywhere else must use `NewHomeFeedData(...)`.
+
+**Done when:** Every viewmodel has a constructor. Controllers use constructors. `go build` + tests pass.
+
+---
+
+### H06 — Route smoke tests for nil deref
+
+**Status:** `[ ] todo`
+**Depends on:** H03, H05
+**Files:** `app/web/controllers/*_test.go`
+
+**Context:** Each route test with zero-value / minimal data is a nil deref smoke test. If a template tries to dereference a nil, the test catches it before production.
+
+**What to do:**
+1. For every controller that does not already have a route test: add a minimal test that calls the route with zero-value data and asserts HTTP 200.
+2. For existing tests: verify they pass zero-value viewmodels (not maximal/happy-path data only).
+3. Follow the existing goquery test pattern in `app/web/controllers/*_test.go`.
+
+**Done when:** Every public-facing route has at least one route test with minimal data. All tests pass.
+
+---
+
+## Group I — Single Binary Mode
+
+> These four tasks unlock zero-dependency deployment. Do them together as a unit.
+
+### I01 — Add SQLite DB adapter (CGO-free)
+
+**Status:** `[ ] todo`
+**Depends on:** G01 (config must be cleanenv-based to add `DB_DRIVER` env var cleanly)
+**Files:** `app/foundation/container.go`, `go.mod`, new `framework/repos/db/sqlite.go`
+
+**Context:** Use `modernc.org/sqlite` (pure Go, CGO-free — cross-compilation works) NOT `go-sqlite3` (requires CGO). Goose supports SQLite dialect. Bob supports SQLite.
+
+**What to do:**
+1. `go get modernc.org/sqlite`.
+2. Add `DB_DRIVER` env var to Config struct (values: `postgres`, `sqlite`; default: `sqlite` for new projects, existing config keeps `postgres`).
+3. In `app/foundation/container.go` DB init: switch on `c.Config.DBDriver`:
+ - `sqlite`: open `modernc.org/sqlite` driver, connect to `./.local/db/app.db` (path configurable via `DB_PATH` env var).
+ - `postgres`: existing Postgres connection (unchanged).
+4. Ensure Goose migration runner uses the correct dialect.
+5. Ensure Bob query generation works against SQLite (may need a separate bobgen config).
+6. Test: `DB_DRIVER=sqlite make dev` starts app with SQLite.
+
+**Done when:** App boots with `DB_DRIVER=sqlite`. Migrations run. Basic CRUD works. No CGO required.
+
+---
+
+### I02 — Add Backlite as SQLite-backed jobs driver
+
+**Status:** `[ ] todo`
+**Depends on:** I01 (needs SQLite DB to be working)
+**Files:** `modules/jobs/drivers/backlite/` (new), `config/config.go`, `app/foundation/container.go`
+
+**Context:** Backlite (`github.com/mikestefanello/backlite`) uses SQLite as a job queue — same DB file, no Redis needed. Implements the existing `core.Jobs` interface.
+
+**What to do:**
+1. `go get github.com/mikestefanello/backlite`.
+2. Create `modules/jobs/drivers/backlite/driver.go` implementing `core.Jobs` using Backlite's client.
+3. Add `JOBS_DRIVER` env var to Config (values: `backlite`, `asynq`; default: `backlite`).
+4. In `app/foundation/container.go` jobs init: switch on `JOBS_DRIVER`:
+ - `backlite`: init Backlite client with the existing SQLite DB connection.
+ - `asynq`: existing Asynq setup (unchanged).
+5. Start Backlite dispatcher in `cmd/web/main.go` when jobs driver is Backlite (runs in-process, no separate worker needed).
+6. Test: `JOBS_DRIVER=backlite make dev` — enqueue a test job, verify it executes.
+
+**Done when:** Jobs work with `JOBS_DRIVER=backlite`. No Redis required. Backlite dispatcher runs in-process with the web server.
+
+---
+
+### I03 — Add Otter as in-memory cache adapter
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel with I01)
+**Files:** `app/foundation/container.go`, new `framework/repos/cache/otter.go`, `go.mod`
+
+**Context:** Otter (`github.com/maypok86/otter`) is a lockless in-memory cache (S3-FIFO eviction, very high throughput). Implements the existing `core.Cache` interface. Valid only for single-process deployment; use Redis for multi-process.
+
+**What to do:**
+1. `go get github.com/maypok86/otter`.
+2. Create `framework/repos/cache/otter.go` implementing `core.Cache` with Otter as the backend. Support key/group/tag/expiration semantics matching the existing interface. Add the chainable builder API (see M04 section 1.3).
+3. Add `CACHE_DRIVER` env var to Config (values: `otter`, `redis`; default: `otter`).
+4. In container cache init: switch on `CACHE_DRIVER`.
+5. Test: cache set/get/flush works with `CACHE_DRIVER=otter`.
+
+**Done when:** `CACHE_DRIVER=otter` works. No Redis required for cache. Chainable builder API exposed.
+
+---
+
+### I04 — Wire single-binary mode as default + update docs
+
+**Status:** `[ ] todo`
+**Depends on:** I01, I02, I03
+**Files:** `.env.example`, `Makefile`, `README.md`, `docs/guides/02-development-workflows.md`
+
+**Context:** Make single-binary mode the default for new projects. `make run` should work with zero Docker.
+
+**What to do:**
+1. Set defaults in Config struct: `DB_DRIVER=sqlite`, `CACHE_DRIVER=otter`, `JOBS_DRIVER=backlite`.
+2. Update `.env.example` to reflect these defaults.
+3. Add `make run` target: no Docker, no infra, just `go run ./cmd/web`. Succeeds with single-binary defaults.
+4. Update `README.md` Requirements section: remove Docker as hard requirement ("Docker required for Postgres/Redis; not needed for single-binary SQLite mode").
+5. Update `docs/guides/02-development-workflows.md`: document single-binary vs standard modes.
+
+**Done when:** `cp .env.example .env && make run` starts a working app with no Docker. Docs reflect two modes.
+
+---
+
+### I05 — In-memory test database (zero Docker for tests)
+
+**Status:** `[ ] todo`
+**Depends on:** I01
+**Files:** `config/config.go`, `app/foundation/container.go`, all test files using DB
+
+**Context:** When `APP_ENV=test`, the container should auto-connect to an in-memory SQLite DB and run migrations. Tests run instantly with no Docker. Integration tests (testing Postgres-specific behavior) remain Docker-based but are clearly separated.
+
+**What to do:**
+1. Add `APP_ENV` env var to Config (values: `development`, `test`, `production`).
+2. In container DB init: if `APP_ENV=test`, use SQLite in-memory (`file::memory:?cache=shared&mode=memory`), run migrations.
+3. Add `config.SwitchEnvironment(config.EnvTest)` helper (set `APP_ENV=test` before container init).
+4. In all `TestMain` functions: call `config.SwitchEnvironment(config.EnvTest)` before `services.NewContainer()`.
+5. Tag existing Docker-dependent tests as `//go:build integration` so `make test` skips them; `make test-integration` includes them.
+
+**Done when:** `make test` passes with no Docker running. In-memory DB is used. Integration tests still work with Docker via `make test-integration`.
+
+---
+
+## Group J — Admin Panel Module
+
+### J01 — Define AdminField and AdminResource type system (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `modules/admin/types.go` (new)
+
+**Context:** The admin panel is reflection-based + Bob-backed. No Ent required. Modules register Go structs; the admin module uses `reflect` to discover fields, types, and tags, then describes them as `AdminField` slices. Templ components receive these slices and render the appropriate UI. Read `docs/roadmap/04-pagoda-and-dx-improvements.md` section 1.4.
+
+**What to do:**
+Create `modules/admin/types.go` with:
+```go
+type FieldType string
+const (
+ FieldTypeString FieldType = "string"
+ FieldTypeInt FieldType = "int"
+ FieldTypeBool FieldType = "bool"
+ FieldTypeTime FieldType = "time"
+ FieldTypeText FieldType = "text" // multiline
+ FieldTypeEmail FieldType = "email"
+ FieldTypePassword FieldType = "password" // omit from list, hide in form
+ FieldTypeReadOnly FieldType = "readonly"
+)
+
+type AdminField struct {
+ Name string
+ Label string // human-readable, derived from field name
+ Type FieldType
+ Value any // current value for forms
+ Required bool
+ Sensitive bool // omit from list view
+}
+
+type AdminResource struct {
+ Name string // e.g., "Post"
+ PluralName string // e.g., "Posts"
+ TableName string // DB table name
+ Fields []AdminField
+ IDField string // which field is the PK
+}
+
+type AdminRow map[string]any // one row from DB list
+```
+
+**Done when:** Types file compiles. No other code changes yet.
+
+---
+
+### J02 — Implement reflection-based resource registration
+
+**Status:** `[ ] todo`
+**Depends on:** J01
+**Files:** `modules/admin/registry.go` (new)
+
+**Context:** `admin.Register[T]()` uses Go generics + reflection to inspect the struct type `T` and produce an `AdminResource` describing it.
+
+**What to do:**
+Create `modules/admin/registry.go`:
+```go
+var registry = map[string]AdminResource{}
+
+type ResourceConfig struct {
+ TableName string
+ ListFields []string // which fields appear in list view; empty = all non-sensitive
+ ReadOnly []string // fields shown but not editable
+ Sensitive []string // fields omitted from list, input type=password in form
+}
+
+func Register[T any](cfg ResourceConfig) {
+ t := reflect.TypeOf(*new(T))
+ // introspect t.Fields()
+ // derive FieldType from field Kind + tags
+ // build AdminResource and store in registry
+}
+```
+
+Field type derivation rules:
+- `string` → `FieldTypeString` (or `FieldTypeEmail` if tag `admin:"email"`, `FieldTypeText` if tag `admin:"text"`)
+- `bool` → `FieldTypeBool`
+- `int`, `int64` etc → `FieldTypeInt`
+- `time.Time` → `FieldTypeTime`
+- Field in `Sensitive` list → `FieldTypePassword`
+- Field in `ReadOnly` list → `FieldTypeReadOnly`
+
+**Done when:** `admin.Register[Post](cfg)` populates registry with correct `AdminResource`. Verified by unit test.
+
+---
+
+### J03 — Implement Bob-backed CRUD operations for admin
+
+**Status:** `[ ] todo`
+**Depends on:** J02, I01 (SQLite must work if testing with SQLite)
+**Files:** `modules/admin/store.go` (new)
+
+**Context:** The admin module must list, get, create, update, and delete records for any registered resource using Bob for type-safe SQL. Since the resource type is dynamic, use raw SQL with `database/sql` fallback for admin operations (Bob is used for app code; admin is introspection territory).
+
+**What to do:**
+1. Implement:
+ ```go
+ func List(ctx context.Context, db *sql.DB, res AdminResource, page, perPage int) ([]AdminRow, int, error)
+ func Get(ctx context.Context, db *sql.DB, res AdminResource, id any) (AdminRow, error)
+ func Create(ctx context.Context, db *sql.DB, res AdminResource, values map[string]any) error
+ func Update(ctx context.Context, db *sql.DB, res AdminResource, id any, values map[string]any) error
+ func Delete(ctx context.Context, db *sql.DB, res AdminResource, id any) error
+ ```
+2. Use parameterized queries (`?` for SQLite, `$1` for Postgres) — detect dialect from driver name.
+3. `List` returns rows as `[]AdminRow` (map[string]any) and total count for pagination.
+
+**Done when:** All 5 operations work against a test SQLite DB. Unit tests cover each.
+
+---
+
+### J04 — Build templ components for admin UI
+
+**Status:** `[ ] todo`
+**Depends on:** J01
+**Files:** `modules/admin/views/web/` (new templ files)
+
+**Context:** Templ components are **data-driven** — they receive `AdminResource` and `[]AdminField` at runtime and render the appropriate UI. The dynamic behavior is in the *data*, not in runtime template generation. A `switch` on `AdminField.Type` renders the correct input. This is fully compatible with templ's compiled approach.
+
+**What to do:**
+Create these templ components:
+
+1. `admin_layout.templ` — admin shell: sidebar with resource links, main content area.
+ ```templ
+ // Renders: full-page admin shell with left sidebar listing all registered resources and top bar with "Admin" title
+ templ AdminLayout(resources []AdminResource, content templ.Component) { ... }
+ ```
+
+2. `admin_list.templ` — list table for a resource.
+ ```templ
+ // Renders: paginated table of resource rows with column headers, edit/delete links per row, and an "Add new" button
+ templ AdminList(res AdminResource, rows []AdminRow, pager Pager) { ... }
+ ```
+
+3. `admin_form.templ` — create/edit form.
+ ```templ
+ // Renders: create/edit form with one input per AdminField, type-appropriate input widget per field type
+ templ AdminForm(res AdminResource, values map[string]any, errs map[string]string, csrfToken string) { ... }
+ ```
+
+4. `admin_field_input.templ` — single field input, switches on FieldType.
+ ```templ
+ // Renders: appropriate HTML input for the given field type (text, checkbox, number, datetime-local, textarea, password)
+ templ AdminFieldInput(field AdminField) {
+ switch field.Type {
+ case FieldTypeString:
+ case FieldTypeBool:
+ case FieldTypeInt:
+ case FieldTypeTime:
+ case FieldTypeText:
+ case FieldTypePassword:
+ case FieldTypeReadOnly:
+ }
+ }
+ ```
+
+5. `admin_delete_confirm.templ` — SweetAlert2 delete confirmation, or inline form.
+
+Run `make templ-gen` after.
+
+**Done when:** All 5 templ files exist and compile. `make templ-gen` succeeds.
+
+---
+
+### J05 — Wire admin routes
+
+**Status:** `[ ] todo`
+**Depends on:** J02, J03, J04
+**Files:** `modules/admin/routes.go` (new), `modules/admin/module.go` (new)
+
+**Context:** Admin routes are automatically generated for every registered resource. Protected by `middleware.RequireAdmin`.
+
+**What to do:**
+1. Create `modules/admin/module.go`:
+ ```go
+ func New() *AdminModule { ... }
+ func (m *AdminModule) ID() string { return "admin" }
+ func (m *AdminModule) Migrations() fs.FS { return nil }
+ func (m *AdminModule) RegisterRoutes(r Router) error { ... }
+ ```
+2. Create `modules/admin/routes.go`. For each registered resource, register:
+ ```
+ GET /admin/{resource} → List handler
+ GET /admin/{resource}/new → New form
+ POST /admin/{resource} → Create handler
+ GET /admin/{resource}/{id} → Edit form
+ PUT /admin/{resource}/{id} → Update handler
+ DELETE /admin/{resource}/{id} → Delete handler
+ ```
+3. All admin routes wrapped in `middleware.RequireAdmin`.
+4. Add link to admin in main nav (conditionally, if user is admin).
+
+**Done when:** Visiting `/admin/posts` (assuming Post is registered) renders the list. CRUD works end-to-end. Non-admin users get 403.
+
+---
+
+### J06 — Embed Backlite queue monitor in admin panel (parallel after J05, I02)
+
+**Status:** `[ ] todo`
+**Depends on:** J05, I02
+**Files:** `modules/admin/routes.go`
+
+**Context:** Backlite provides an HTTP handler for monitoring queues. Embed it at `/admin/queues`.
+
+**What to do:**
+1. Read Backlite docs for the embedded monitor handler.
+2. Mount Backlite's handler at `/admin/queues` in admin routes.
+3. Add "Queue Monitor" link to admin sidebar.
+
+**Done when:** `/admin/queues` shows task queue monitor when `JOBS_DRIVER=backlite`.
+
+---
+
+## Group K — DX Improvements
+
+### K01 — Chainable redirect helper (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `framework/redirect/redirect.go` (new)
+
+**Context:** Replace manual redirect calls with a chainable builder. Automatically handles HTMX redirects (`HX-Redirect` header) for boosted requests.
+
+**What to do:**
+```go
+// Usage:
+return redirect.New(ctx).Route("user_profile").Params(userID).Query(q).Go()
+
+// Implementation:
+type Redirect struct { ctx echo.Context; route string; params []any; query url.Values }
+func New(ctx echo.Context) *Redirect
+func (r *Redirect) Route(name string) *Redirect
+func (r *Redirect) Params(params ...any) *Redirect
+func (r *Redirect) Query(q url.Values) *Redirect
+func (r *Redirect) Go() error // detects HX-Request header, sets HX-Redirect if HTMX
+```
+
+**Done when:** `redirect.New(ctx).Route("home_feed").Go()` works in a controller. HTMX requests get `HX-Redirect` header. Non-HTMX requests get 302.
+
+---
+
+### K02 — Pagination utility (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `framework/pager/pager.go` (new), new templ component in `app/views/web/components/pager.templ`
+
+**Context:** Standardize cursor/offset pagination. Controller gets a `Pager`, passes it to viewmodel, templ component renders prev/next links.
+
+**What to do:**
+1. Create `framework/pager/pager.go`:
+ ```go
+ type Pager struct { Page, PerPage, Total int }
+ func New(ctx echo.Context, perPage int) Pager // reads ?page= from query
+ func (p Pager) Offset() int
+ func (p Pager) Limit() int
+ func (p Pager) HasNext() bool
+ func (p Pager) HasPrev() bool
+ func (p Pager) TotalPages() int
+ ```
+2. Create `app/views/web/components/pager.templ`:
+ ```templ
+ // Renders: prev/next pagination bar with page number and total pages indicator
+ templ Pagination(p pager.Pager, baseURL string) { ... }
+ ```
+
+**Done when:** Controller can call `pager.New(ctx, 20)`, pass pager to viewmodel, and render `Pagination` component. Unit tests for offset/limit/HasNext/HasPrev.
+
+---
+
+### K03 — `ship routes` command (parallel)
+
+**Status:** `[x] done`
+**Depends on:** nothing
+**Files:** `tools/cli/ship/internal/commands/routes.go` (new)
+
+**Context:** Print a table of all registered routes. Inspect `app/router.go` via AST parsing. Also expose as MCP tool.
+
+**What to do:**
+1. Parse `app/router.go` AST to extract route registrations (method, path, handler, auth level).
+2. Print as table:
+ ```
+ METHOD PATH AUTH HANDLER
+ GET / public landing.Get
+ POST /user/register public register.Post
+ GET /auth/homeFeed auth home_feed.Get
+ ```
+3. Add `--json` flag.
+4. Integrate as `ship_routes` MCP tool (see C05).
+
+**Done when:** `ship routes` prints route table. `ship routes --json` outputs JSON array.
+
+---
+
+### K04 — `ship db:console` command (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** G01 (needs cleanenv config to read DB URL)
+**Files:** `tools/cli/ship/internal/commands/db.go`
+
+**Context:** Opens a raw DB shell. Reads active DB config and spawns `psql`, `mysql`, or `sqlite3` with the correct connection string.
+
+**What to do:**
+1. Read active `DB_DRIVER` from config.
+2. Spawn the appropriate shell with the connection string from config.
+3. Pass through stdin/stdout/stderr to the terminal.
+
+**Done when:** `ship db:console` drops into an interactive DB shell.
+
+---
+
+### K05 — Built-in rate limiter middleware (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** I03 (Otter for in-memory rate limit state; Redis if scaled)
+**Files:** `app/web/middleware/rate_limit.go` (new), `framework/repos/ratelimit/` (new)
+
+**Context:** Per-IP and per-user rate limiting with configurable limits per route group.
+
+**What to do:**
+1. Create `framework/repos/ratelimit/ratelimit.go` with an interface backed by Otter (in-memory) or Redis.
+2. Create `app/web/middleware/rate_limit.go` Echo middleware factory:
+ ```go
+ func RateLimit(store ratelimit.Store, max int, window time.Duration) echo.MiddlewareFunc
+ ```
+3. Apply to auth routes (e.g., 10 req/min on `/user/login`).
+4. Returns 429 with `Retry-After` header on exceed.
+
+**Done when:** Auth routes return 429 after exceeding the limit. Test covers this.
+
+---
+
+### K06 — Afero file system abstraction (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** G01 (needs `STORAGE_DRIVER` env var)
+**Files:** `framework/repos/storage/`, `app/foundation/container.go`
+
+**Context:** Replace MinIO-only storage with afero abstraction. `STORAGE_DRIVER=local` for dev/single-binary; `STORAGE_DRIVER=minio` for production.
+
+**What to do:**
+1. `go get github.com/spf13/afero`.
+2. Add `STORAGE_DRIVER` env var (values: `local`, `minio`).
+3. Wrap afero behind the existing `framework/core` storage interface (or create one).
+4. `local`: afero `OsFs` rooted at `./uploads` (path configurable).
+5. Tests: automatically use afero `MemMapFs` when `APP_ENV=test`.
+6. Keep MinIO backend for production compatibility.
+
+**Done when:** File uploads work with `STORAGE_DRIVER=local`. Tests use in-memory FS.
+
+---
+
+## Group F — Documentation
+
+### F01 — Fix README inconsistencies (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+
+**What to do:**
+1. Read `README.md`.
+2. Fix `pkg/` → `framework/` in the Repository Shape section.
+3. Fix `pkg/repos/storage/storagerepo.go` reference to correct path.
+4. Update Requirements: remove Docker as hard requirement; note it's only needed for Postgres/Redis mode.
+5. Add brief description of single-binary mode once Group I tasks are done, or add a TODO note.
+
+**Done when:** README has no stale `pkg/` references. Docker requirement is accurately described.
+
+---
+
+### F02 — Fix architecture doc: decouple from Asynq (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (fix the doc now; implementation follows in Group I/C)
+
+**What to do:**
+1. Read `docs/architecture/01-architecture.md`.
+2. Update Worker Runtime Flow section: replace hardcoded Asynq description with "jobs adapter — currently Asynq (Redis-backed); Backlite (SQLite-backed) supported for single-binary mode".
+3. Update "Asynq handles background jobs" line at bottom to reflect adapter abstraction.
+
+**Done when:** Architecture doc does not assume Asynq specifically. References adapter pattern.
+
+---
+
+### F03 — Update AI agent guide: add nil safety convention (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+
+**What to do:**
+Add a "Nil Safety" section to `docs/guides/01-ai-agent-guide.md`:
+- Viewmodels must have zero pointer fields (value types only).
+- Templ components accept viewmodel types, never `*DomainModel`.
+- Controllers own domain → viewmodel transformation and all nil handling.
+- `nilaway` runs in CI — new code must pass it.
+- Recovery middleware is registered globally — panics return 500 but app stays up.
+
+**Done when:** Section exists in the guide.
+
+---
+
+### F04 — Update docs index with all new roadmap docs (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+
+**What to do:** Read `docs/00-index.md`. Verify M01–M04 are all listed. Add any missing entries.
+
+**Done when:** Index references all four roadmap documents.
+
+---
+
+### F05 — Update workflows doc: config and single binary mode (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** G01, G02, I04
+
+**What to do:**
+1. Read `docs/guides/02-development-workflows.md`.
+2. Add "Configuration" section: "Copy `.env.example` to `.env`. All config comes from env vars. No YAML for secrets."
+3. Add "Single Binary Mode" section: "Set `DB_DRIVER=sqlite`, `CACHE_DRIVER=otter`, `JOBS_DRIVER=backlite` in `.env`. Run `make run`. No Docker needed."
+4. Update Services and Infra section to clarify Redis/Postgres are optional.
+
+**Done when:** Workflows doc accurately describes both single-binary and standard modes.
+
+---
+
+### F06 — Update scope analysis doc to reflect evolving architecture (parallel)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+
+**What to do:**
+1. Read `docs/architecture/03-project-scope-analysis.md`.
+2. Remove Viper reference (line ~121).
+3. Update background task section to mention Backlite as an option.
+4. Add entry for admin module once J01–J05 are planned.
+
+**Done when:** Scope analysis doc has no Viper references. Reflects adapter-based jobs and planned admin module.
+
+---
+
+## Completion Tracker
+
+```
+Group A — Critical Fixes
+[ ] A01 Container init bug
+[ ] A02 ship doctor --json
+
+Group B — JS Islands
+[x] B01 Vite config
+[x] B02 Islands runtime
+[x] B03 ThemeToggle migrated
+[x] B04 All components migrated
+[x] B05 Old esbuild removed
+
+Group C — Module System
+[ ] C01 Module interface
+[ ] C02 Marker comments
+[ ] C03 ship module:add
+[ ] C04 ship module:remove
+[ ] C05 MCP tools
+
+Group D — Module Extraction (parallel after C01)
+[x] D01 modules/auth
+[x] D02 modules/profile
+[ ] D03 modules/paidsubscriptions routes
+[ ] D04 modules/notifications routes
+[ ] D05 modules/pwa
+
+Group E — App Split
+[ ] E01 starter/ skeleton
+[ ] E02 ship new uses starter/
+
+Group F — Documentation (mostly parallel)
+[ ] F01 README fix
+[ ] F02 Architecture doc fix
+[ ] F03 Agent guide: nil safety
+[ ] F04 Docs index
+[ ] F05 Workflows: config + single binary
+[ ] F06 Scope analysis cleanup
+
+Group G — Config: cleanenv + .env
+[ ] G01 Replace Viper with cleanenv
+[ ] G02 .env file loading
+[ ] G03 Remove YAML config files
+[ ] G04 ship config:validate command
+
+Group H — Nil Safety
+[ ] H01 Recovery middleware
+[ ] H02 nilaway in CI + ship doctor
+[ ] H03 Audit viewmodels (no pointer fields)
+[ ] H04 Nil-safe domain model accessors
+[ ] H05 Viewmodel constructors
+[ ] H06 Route smoke tests
+
+Group I — Single Binary Mode
+[ ] I01 SQLite DB adapter (modernc, CGO-free)
+[ ] I02 Backlite jobs driver
+[ ] I03 Otter cache adapter
+[ ] I04 Single-binary default + docs
+[ ] I05 In-memory test DB
+
+Group J — Admin Panel
+[ ] J01 AdminField/AdminResource types
+[ ] J02 Reflection-based registration
+[ ] J03 Bob-backed CRUD operations
+[ ] J04 Templ components (list, form, field input)
+[ ] J05 Wire admin routes
+[ ] J06 Backlite queue monitor in admin
+
+Group K — DX Improvements (all parallel)
+[ ] K01 Chainable redirect helper
+[ ] K02 Pagination utility
+[ ] K03 ship routes command
+[ ] K04 ship db:console command
+[ ] K05 Rate limiter middleware
+[ ] K06 Afero file system abstraction
+```
+
+---
+
+## Recommended Execution Order
+
+Tasks are ordered by dependency. Tasks with no shared dependencies can run in parallel.
+
+**Layer 1 — No dependencies, start immediately (all parallel):**
+A01, A02, H01, H02, G01, F01, F02, F03, F04, C01, C02, J01, K01, K02, K03, K04
+
+**Layer 2 — Depends on Layer 1 tasks (parallel within layer):**
+- G02 → needs G01
+- G03 → needs G01, G02
+- G04 → needs G01
+- H03 → needs H01 (recovery in place first)
+- H04 → no dependency (can move to Layer 1)
+- H05 → needs H03
+- I01 → needs G01
+- I03 → no hard dependency (can move to Layer 1)
+- B01, B02 → no dependency (can move to Layer 1)
+- J02 → needs J01
+- F05 → needs G01, G02, I04
+- F06 → no dependency (can move to Layer 1)
+
+**Layer 3 — Depends on Layer 2:**
+- I02 → needs I01 (SQLite adapter)
+- I04 → needs I01, I02, I03
+- I05 → needs I01
+- H06 → needs H03, H05
+- B03 → needs B01, B02
+- C03 → needs C01, C02
+- J03 → needs J02
+- J04 → needs J01
+- K05 → needs I03 (Otter)
+- K06 → needs G01
+
+**Layer 4 — Depends on Layer 3:**
+- B04 → needs B03 (proven pattern)
+- C04 → needs C03
+- C05 → needs A02
+- D01–D05 → needs C01 (module interface)
+- J05 → needs J02, J03, J04
+- E01 → needs D01, D02
+
+**Layer 5 — Final cleanup:**
+- B05 → needs B04 (all components migrated)
+- J06 → needs J05, I02
+- E02 → needs E01
+
+---
+FILE: docs/roadmap/04-pagoda-and-dx-improvements.md
+---
+# Pagoda Inspirations & DX Improvements
+
+**Reference:** `docs/roadmap/02-architecture-evolution.md` for broader context.
+**Last updated:** 2026-03-08
+
+This document captures ideas sourced from two places:
+1. [Pagoda](https://github.com/mikestefanello/pagoda) — the Go starter kit GoShip originally branched from, which has diverged significantly but still has patterns worth adopting.
+2. Rails / Laravel — the gold standards for web framework DX.
+
+Each item includes a clear rationale, the pagoda/framework precedent, and whether it's a small lift or architectural change.
+
+---
+
+## Part 1 — What to Pull from Pagoda
+
+### 1.1 Single-Binary Deployment Mode
+
+**The gap:** GoShip requires Postgres + Redis + Docker just to run locally. Pagoda runs as a single binary with no external dependencies — SQLite for data, Backlite (SQLite-backed queue) for jobs, Otter (in-memory) for cache.
+
+**Why it matters:** For indie devs, internal tools, self-hosted apps, and the GoShip starter itself — requiring a full stack is a huge barrier. `ship new myapp && make run` should produce a working app with zero external dependencies.
+
+**The architecture:** GoShip's adapter interfaces (`core.Store`, `core.Cache`, `core.Jobs`) were designed for exactly this. They just need SQLite/Otter/Backlite adapters.
+
+**Three deployment modes to support:**
+
+| Mode | DB | Cache | Jobs | Use case |
+|---|---|---|---|---|
+| **Single binary** | SQLite (`modernc.org/sqlite`) | Otter (in-memory) | Backlite (SQLite queue) | Starter, indie, self-hosted |
+| **Standard** | Postgres | Redis | Asynq (Redis) | Production SaaS |
+| **Scaled** | Postgres | Redis | Asynq + separate worker process | High-traffic, horizontal scale |
+
+Config selects the mode. `ship new` defaults to single-binary. Upgrading to standard is `ship adapter:set db=postgres cache=redis jobs=asynq`.
+
+**Important:** Use `modernc.org/sqlite` (CGO-free, pure Go) not `go-sqlite3` (requires CGO). CGO breaks cross-compilation and complicates single-binary distribution.
+
+**Pagoda precedent:** Full single-binary since v1. Stores data locally on disk. `make run` is the only command needed.
+
+---
+
+### 1.2 Backlite — SQLite-Backed Job Queue
+
+**The gap:** GoShip's `jobs` module uses Asynq which requires Redis. In single-binary mode there is no Redis.
+
+**What Backlite provides:**
+- SQLite as the job queue backend (same DB, no extra infra)
+- Worker pool with configurable goroutine count
+- Delayed jobs (execute after duration or at specific time)
+- Retry with configurable attempts
+- Task monitoring UI (embeds into admin panel via HTTP handler)
+- Automatic schema install on startup
+
+**Fit:** Add as a new driver under `modules/jobs/drivers/backlite/`. The existing `core.Jobs` interface defines the contract — Backlite implements it.
+
+**Pagoda precedent:** Written by pagoda's author specifically for this use case. Actively maintained.
+
+**Config selection:**
+```yaml
+jobs:
+ driver: backlite # or: asynq
+ backlite:
+ goroutines: 10
+ release_after: 30s
+ cleanup_interval: 1h
+```
+
+---
+
+### 1.3 Otter — In-Memory Cache Adapter
+
+**The gap:** GoShip's cache adapter requires Redis. In single-binary mode there is no Redis.
+
+**What Otter provides:**
+- Lockless in-memory cache using S3-FIFO eviction
+- Very high throughput (benchmarks beat Ristretto)
+- No external dependencies
+- Works perfectly for single-process deployment
+
+**Fit:** Add as a new `CoreCache` adapter. The existing `core.Cache` interface defines the contract.
+
+**Limitation:** In-memory cache does not share state across multiple processes. Only valid for single-binary mode. When scaling to multiple web processes, swap to Redis adapter.
+
+**Pagoda precedent:** Pagoda's default cache since they dropped Redis. Has `CacheStore` interface to swap Redis back in when needed.
+
+**Important note on pagoda's cache API:**
+Pagoda exposes a chainable builder API over the cache:
+```go
+c.Cache.Set().Key("k").Tags("tag1").Expiration(time.Hour).Data(myData).Save(ctx)
+c.Cache.Get().Group("g").Key("k").Fetch(ctx)
+c.Cache.Flush().Tags("tag1", "tag2").Execute(ctx)
+```
+This is significantly more ergonomic than a raw `Get(key)/Set(key, val, ttl)` interface. GoShip should adopt this API shape for the `core.Cache` interface.
+
+---
+
+### 1.4 Admin Panel (Auto-Generated from Ent Schema)
+
+**The gap:** GoShip has no admin panel. Pagoda auto-generates one from the Ent schema using Ent's extension API — every entity type gets list/create/edit/delete UI automatically. It also embeds the Backlite queue monitor in the admin.
+
+**Why it matters for DX:** This is a major productivity win. Every internal tool, SaaS, or early-stage product needs to manage data. Without an admin panel, every team builds bespoke tooling. Rails has ActiveAdmin; Laravel has Filament. Go has nothing mainstream — this could be GoShip's differentiator.
+
+**How pagoda does it:**
+1. A custom Ent extension (`ent/admin/extension.go`) generates flat structs and handler code for each entity type during `make ent-gen`.
+2. A single `admin.go` handler serves all entity routes dynamically.
+3. The UI uses gomponents (pagoda's rendering engine) to build forms dynamically from the Ent graph data structure.
+
+**How it works without Ent (GoShip uses Bob):**
+Pagoda's approach requires Ent's schema graph. GoShip uses Bob. Instead, use Go reflection + generics:
+
+```go
+// Register any Go struct type with the admin module
+admin.Register[Post](admin.Config{
+ TableName: "posts",
+ ListFields: []string{"title", "published_at"},
+ Sensitive: []string{"internal_notes"},
+})
+```
+
+`admin.Register[T]()` uses `reflect.TypeOf(*new(T))` at runtime to enumerate exported fields, derive their types, and build a slice of `AdminField` descriptors. The admin module then drives all CRUD via raw SQL through `database/sql` (not Bob's codegen, since the resource type is dynamic).
+
+**The UI — templ components, data-driven:**
+GoShip uses templ, not gomponents. Templ is compiled, so templates cannot be generated at runtime — but they CAN be fully dynamic through data. The admin templ components receive `[]AdminField` at runtime and `switch` on field type to render the appropriate input:
+
+```templ
+templ AdminFieldInput(field AdminField) {
+ switch field.Type {
+ case "string":
+ case "bool":
+ case "int":
+ case "time":
+ case "text":
+ }
+}
+```
+
+The dynamic behavior is in the **data** (field descriptors derived from reflection), not the template. This works perfectly with templ's compiled model.
+
+**No Ent. No Ent extension. No Ent for admin-only.** Pure reflection + Bob runtime queries + templ components.
+
+---
+
+### 1.5 Afero — File System Abstraction
+
+**The gap:** GoShip's file storage uses MinIO (S3-compatible), which requires infrastructure even locally.
+
+**What afero provides:** A `fs.FS`-compatible abstraction with backends for local OS, GCS, SFTP, in-memory (for tests), and more. Swap backends without changing application code.
+
+**Fit:** Add as an alternative to MinIO for local development and single-binary mode. In-memory backend for tests means no MinIO in CI.
+
+**Pagoda precedent:** Default file system is local OS. Tests use in-memory automatically.
+
+**Config selection:**
+```yaml
+storage:
+ driver: local # or: minio, gcs
+ local:
+ path: ./uploads
+```
+
+---
+
+### 1.6 Chainable Redirect Helper
+
+**The gap:** Redirects in GoShip are manual `c.Redirect(http.StatusFound, url)` calls with manual URL construction.
+
+**Pagoda's pattern:**
+```go
+return redirect.New(ctx).
+ Route("user_profile").
+ Params(userID).
+ Query(queryParams).
+ Go()
+```
+Automatically handles HTMX redirects (sets `HX-Redirect` header for boosted requests). Type-safe route names. Chainable.
+
+**Fit:** Small addition to `framework/htmx` or a new `framework/redirect` package. High DX value for low effort.
+
+---
+
+### 1.7 In-Memory Test Database
+
+**The gap:** GoShip's integration tests require Docker + Postgres. This slows CI and makes tests harder to run locally.
+
+**Pagoda's pattern:** When `config.EnvTest` is set, the container auto-connects to an in-memory SQLite database and runs migrations. Tests start instantly.
+
+**GoShip adaptation:**
+- `config.SwitchEnvironment(config.EnvTest)` sets env before container init.
+- Container init: if env is test, use SQLite in-memory for DB, in-memory for cache, sync (no-op) for jobs.
+- No Docker required for unit or route-level tests.
+- Integration tests (testing actual Postgres behavior) remain Docker-based but are clearly separated.
+
+**Pagoda precedent:** Enabled fast, zero-infrastructure test runs. A game-changer for iteration speed.
+
+---
+
+## Part 2 — Rails / Laravel DX Ideas
+
+### 2.1 `ship console` — Interactive Database Session
+
+**Rails:** `rails console` opens an IRB session connected to the live DB.
+**Laravel:** `php artisan tinker` opens a PsySH REPL.
+
+**Go reality:** This doesn't translate. Go compiles to machine code — there's no interpreter that can load your live app's types, run queries, and inspect results the way Ruby/PHP can. REPLs like `gore` or `yaegi` exist but they can't import your compiled app and have very limited stdlib/package support. Not worth pursuing.
+
+**Practical Go-native alternative:** `ship db:console` (see 2.4) drops you into a raw DB shell. For structured data inspection, the admin panel (1.4) covers most real use cases. For one-off scripts, `cmd/scripts/` convention with access to the container is the Go way.
+
+---
+
+### 2.2 `ship routes` — Route Table
+
+**Rails:** `rails routes` prints a table of all routes (verb, path, name, handler).
+**Laravel:** `php artisan route:list`.
+
+**GoShip proposal:** `ship routes` parses `app/router.go` (or inspects the running Echo instance) and prints:
+
+```
+METHOD PATH AUTH HANDLER
+GET / public landing.Get
+POST /user/register public register.Post
+GET /auth/homeFeed auth home_feed.Get
+POST /auth/payments/checkout auth payments.CreateCheckoutSession
+...
+```
+
+**Value:** LLMs and devs can audit routes without reading the router file. Also useful as an MCP tool.
+
+**Implementation:** Parse router.go at compile time (AST) or add Echo's `Routes()` method output to the CLI at runtime.
+
+---
+
+### 2.3 Auto-CRUD Admin Panel (see 1.4)
+
+**Rails:** ActiveAdmin, Administrate.
+**Laravel:** Filament, Nova.
+
+Already covered in section 1.4 — the pagoda implementation is the direct model.
+
+---
+
+### 2.4 `ship db:console` — Direct DB Shell
+
+**Rails:** `rails db` opens a psql/mysql/sqlite3 shell connected to the configured database.
+**Laravel:** `php artisan db`.
+
+**GoShip proposal:** `ship db:console` reads the active DB config and spawns `psql`, `mysql`, or `sqlite3` with the correct connection string.
+
+**Value:** Fast data inspection without remembering connection strings.
+
+---
+
+### 2.5 Built-in Rate Limiter
+
+**Rails:** `throttle` via rack-attack.
+**Laravel:** `throttle` middleware.
+
+**GoShip proposal:** A configurable rate-limiting middleware in the framework. Echo has `echo.IPRateLimit()` but it's minimal. A proper implementation:
+- Per-IP and per-user limits
+- Configurable per route group
+- Backed by in-memory (Otter) for single binary, Redis for scaled
+- Returns 429 with `Retry-After` header
+
+---
+
+### 2.6 DB-Backed Sessions (Optional)
+
+**Rails:** `ActiveRecord::SessionStore`.
+**GoShip current:** Cookie-only sessions (Gorilla sessions).
+
+**Problem:** Cookie sessions work fine for single-server. Scaling to multiple web processes with cookie sessions requires sticky sessions at the load balancer.
+
+**Proposal:** Add a DB-backed session store implementation as an option. The Gorilla sessions ecosystem has existing implementations for Postgres and SQLite. Config-selectable:
+```yaml
+session:
+ store: cookie # or: db
+```
+
+---
+
+### 2.7 First-Class `.env` Support
+
+**Rails/Laravel:** `.env` files for local secrets via dotenv.
+
+**GoShip current:** YAML config with environment variable overrides via viper. Works, but the ergonomics for secrets (DB password, Stripe key) is cumbersome.
+
+**Proposal:** Load `.env` at startup before viper config resolution. `.env` variables map to the same `GOSHIP_*` prefixed env var names already supported. `.env.example` committed; `.env` gitignored.
+
+**Value:** Standard pattern every developer expects. Makes `ship new myapp` produce a project that works immediately after editing `.env`.
+
+**Chosen library: `cleanenv` (`github.com/ilyakaznacheev/cleanenv`)**
+
+Wins over `envconfig` (Kelsey Hightower):
+- Built-in `.env` file loading (no separate godotenv needed)
+- Auto-generates help/usage text for `ship config:validate`
+- `required` and `env-default` tags built-in
+- One dependency replaces Viper + godotenv
+
+Config struct pattern:
+```go
+type Config struct {
+ DatabaseURL string `env:"DATABASE_URL,required"`
+ SecretKey string `env:"SECRET_KEY,required"`
+ Port int `env:"PORT" env-default:"8080"`
+ RedisURL string `env:"REDIS_URL"`
+}
+
+func Load() (*Config, error) {
+ cfg := &Config{}
+ _ = cleanenv.ReadConfig(".env", cfg) // load .env if present, ignore if absent
+ return cfg, cleanenv.ReadEnv(cfg) // overlay actual env vars
+}
+```
+
+---
+
+### 2.8 Pagination as First-Class
+
+**Pagoda has it:** A `Pager` utility for cursor/offset pagination with page size, current page, total pages, and a `HasPages()` check.
+
+**GoShip status:** Manual pagination in each controller.
+
+**Proposal:** Add a `framework/pager` package:
+```go
+p := pager.New(ctx, 20) // 20 per page
+results, err := db.Query().Limit(p.Limit()).Offset(p.Offset())...
+page.Pager = p
+```
+Templ component renders prev/next links automatically from `page.Pager`.
+
+---
+
+## Part 3 — Priority Matrix
+
+| Item | Value | Effort | Priority |
+|---|---|---|---|
+| **1.2 Backlite driver** | Very High | Medium | P0 |
+| **1.3 Otter cache adapter** | Very High | Low | P0 |
+| **1.1 SQLite DB adapter** | Very High | Medium | P0 |
+| **1.7 In-memory test DB** | High | Low | P0 |
+| **1.6 Chainable redirect** | Medium | Low | P1 |
+| **2.2 `ship routes`** | High | Low | P1 |
+| **2.7 `.env` support** | High | Low | P1 |
+| **2.8 Pagination utility** | Medium | Low | P1 |
+| **1.4 Admin panel** | Very High | High | P2 |
+| **1.5 Afero file system** | Medium | Medium | P2 |
+| **2.4 `ship db:console`** | Medium | Low | P2 |
+| **2.5 Rate limiter** | Medium | Medium | P2 |
+| **2.6 DB sessions** | Low | Medium | P3 |
+| **2.1 `ship console`** | N/A | N/A | ❌ Not viable in Go |
+
+**P0 = unlocks single-binary deployment. Do these together as a unit.**
+
+---
+
+## Part 4 — Single Binary Release Checklist
+
+For GoShip to support `ship new myapp && make run` with zero external dependencies, the following must all be done:
+
+```
+[ ] SQLite adapter for core.Store (modernc.org/sqlite, CGO-free)
+[ ] Backlite driver for modules/jobs
+[ ] Otter adapter for core.Cache
+[ ] In-memory test DB (SQLite in EnvTest)
+[ ] Goose SQLite dialect support verified
+[ ] config/application.yaml: default to single-binary mode
+[ ] ship new: scaffold with single-binary defaults
+[ ] Makefile: make run works without docker-compose
+[ ] docs: "single binary" getting-started guide
+```
+
+When all boxes are checked, GoShip can legitimately claim: **one binary, zero dependencies, production-ready**.
+
+---
+
+## Part 4 — Nil Safety: Eliminating Nil Deref Panics
+
+Go + templ nil dereference panics are the most common runtime crash class. The fix is architectural, not defensive.
+
+### Root causes
+1. Domain model pointers (`*User`, `*string`) flowing directly into templ components
+2. Uninitialized nested structs in viewmodels
+3. Optional DB columns as `*string` instead of `sql.NullString`
+
+### The architecture fix: value-type viewmodels
+
+Create a hard boundary between domain models and viewmodels:
+
+- **Domain models** (`db/gen/`, `framework/domain/`) — pointers allowed for nullable DB columns
+- **Viewmodels** (`app/web/viewmodels/`) — **zero pointer fields**. All value types, fully initialized
+- **Templ components** — accept viewmodel types or primitives only. Never `*DomainModel`
+- **Controllers** — own the domain → viewmodel transformation. All nil handling happens here
+
+```go
+// Domain model — pointer fields OK
+type User struct { Name *string }
+
+// Viewmodel — value type only
+type UserCardVM struct { DisplayName string } // empty string = absent, never nil
+
+// Controller transforms
+func toUserCardVM(u *User) UserCardVM {
+ return UserCardVM{DisplayName: stringOr(u.Name, "")}
+}
+```
+
+### Nil-safe domain accessors
+
+```go
+func (u *User) DisplayName() string {
+ if u == nil || u.Name == nil { return "" }
+ return *u.Name
+}
+```
+
+Go methods on nil pointer receivers are legal if they nil-guard at entry.
+
+### Enforcement
+
+- **`nilaway`** (Uber) in CI — statically traces nil flows across function boundaries
+- **`middleware.Recover()`** as first middleware — panics return 500, app stays alive
+- **Route smoke tests** with zero-value data — nil deref shows up in test, not production
+- **Viewmodel constructors** — `NewUserCardVM(u *User) UserCardVM` guarantees all fields set
+
+### Priority
+
+Add to `app/web/viewmodels/` convention: no pointer fields, ever. This is a permanent architectural rule, enforced by nilaway in CI.
+
+---
+FILE: docs/roadmap/05-llm-dx-agent-friendly.md
+---
+# GoShip — LLM-Forward DX & Agent-Friendly Codebase
+
+**Status:** Active — tasks pickup-ready for any LLM agent
+**Last updated:** 2026-03-08
+**Priority:** Convention-over-configuration is the top priority. All tasks serve that goal.
+
+**Reference docs (read before picking up any task):**
+- `docs/roadmap/02-architecture-evolution.md` — architecture evolution (islands, modules, MCP)
+- `docs/roadmap/03-atomic-tasks.md` — the main implementation task list (M03)
+- `docs/guides/01-ai-agent-guide.md` — conventions, safe change workflow
+- `docs/ui/convention.md` — data-component / data-slot / Renders: comment rules
+
+**Task format:** Each task is self-contained — full context, exact files to touch, and a "done when"
+acceptance criterion. A task is complete only when its criterion is met. Mark `[x]` before starting
+any task that depends on it.
+
+**Parallelism:** Tasks within a group marked `(parallel)` have no inter-dependencies.
+
+**Stack context:** Go 1.24, Echo v4, Templ, HTMX, Alpine.js, Bob ORM, cleanenv config,
+ship CLI (`tools/cli/ship/`), MCP server (`tools/mcp/ship/`), Vite islands frontend.
+
+---
+
+## Key File Map (read before touching any task)
+
+| Concern | File / Fact |
+|---------|-------------|
+| Doctor logic (checks) | `tools/cli/ship/internal/policies/doctor.go` → `RunDoctorChecks(root string) []DoctorIssue` |
+| Doctor CLI wiring | `tools/cli/ship/internal/cli/cli.go` → `c.runDoctor()` dispatches to `cmd.RunDoctor(args, deps)` |
+| DoctorIssue struct | `{ Code, Message, Fix, File, Severity string }` |
+| Ship CLI dispatch | `tools/cli/ship/internal/cli/cli.go` → `func (c CLI) Run(args []string) int` with `switch args[0]` |
+| Ship command pattern | `func RunXxx(args []string, d XxxDeps) int` in `tools/cli/ship/internal/commands/*.go`; deps injected via struct |
+| Ship command example | `tools/cli/ship/internal/commands/infra.go` — read this as the canonical simple command template |
+| MCP server entrypoint | `tools/mcp/ship/cmd/ship-mcp/main.go` → calls `server.Run(ctx, stdin, stdout, stderr, docsRoot)` |
+| MCP tools registration | `tools/mcp/ship/internal/server/tools.go` — read this file to see how tools are registered and how to add new ones |
+| MCP server logic | `tools/mcp/ship/internal/server/server.go` |
+| App router | `app/router.go` — route markers: `// ship:routes:public:start/end`, `// ship:routes:auth:start/end`, `// ship:routes:external:start/end` |
+| Container | `app/foundation/container.go` — container marker: `// ship:container:start` / `// ship:container:end` at line ~95 |
+| Core interfaces | `framework/core/interfaces.go` — defines `Cache`, `Jobs`, `PubSub`, `Mailer`, `BlobStorage`, `Module`, `RoutableModule` |
+| App controllers | `app/web/controllers/` (30 files) |
+| App views | `app/views/` |
+| App viewmodels | `app/web/viewmodels/` |
+| Config struct | `config/config.go` → `type Config struct { HTTP, App, Runtime, Adapters, Cache, Database, Mail, … }` |
+| Templ generate | `make templ-gen` |
+| Test commands | `make test` (unit, no Docker), `make test-integration` (Docker required), `make e2e` (Playwright) |
+| Already implemented | `tools/cli/ship/internal/commands/describe.go`, `verify.go`, `agent_start.go`, `agent_finish.go`, `dev.go` all **exist** — check actual content before re-implementing |
+
+---
+
+## Why Convention-Over-Configuration?
+
+LLMs make fewer errors when there is exactly one correct place to put each kind of thing.
+When a codebase has multiple valid patterns, the agent must guess — and guesses compound.
+
+**The Rails insight applied to GoShip:**
+- One place for controllers: `app/web/controllers/`
+- One place for route registration: `app/router.go` (with ship:routes markers)
+- One place for DB queries: `db/queries/` (SQL files → generated by bobgen-sql)
+- One place for config: struct tags in `config/config.go`
+- One place for migrations: `db/migrations/`
+
+`ship doctor` is the enforcer. Every convention that can be checked programmatically must be.
+Every violation must be a structured error that an agent can read and fix.
+
+---
+
+## Group L — Convention Enforcement (Start Here)
+
+> These tasks give `ship doctor` and related tools the ability to enforce conventions
+> programmatically. This is the foundation that makes all other LLM-DX work valuable.
+
+### L01 — Enforce canonical file placement in `ship doctor`
+
+**Status:** `[ ] todo`
+**Depends on:** M03 A02 (ship doctor --json flag)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+> **IMPORTANT:** The doctor command logic lives in `tools/cli/ship/internal/policies/doctor.go` (1,897 lines), NOT in `commands/`. The CLI wiring is in `tools/cli/ship/internal/cli/cli.go → c.runDoctor()`. The main check function is `RunDoctorChecks(root string) []DoctorIssue`. Each check appends to a `[]DoctorIssue` slice. The `DoctorIssue` struct has fields `{ Code, Message, Fix, File, Severity string }`. Read `policies/doctor.go` in full before editing — many checks already exist and the pattern is established.
+
+**Context:** GoShip has one canonical path for every concern. Agents violate these when they
+have no enforcement. `ship doctor` must catch placement violations and report them as structured
+errors. The `--json` output format is: `{"type", "file", "detail", "severity"}` — match existing issue format in `policies/doctor.go`.
+
+**Rules to enforce:**
+1. No `*.go` file defining an HTTP handler func outside `app/web/controllers/`
+ (detect: func signature `func(*echo.Context) error` outside that dir)
+2. No route registration (`e.GET`, `e.POST`, `e.PUT`, `e.DELETE`, `e.PATCH`) outside `app/router.go`
+3. No SQL queries (raw `db.Query`, `db.Exec` without Bob) outside `db/queries/` or `*_store.go` files
+4. No migration files outside `db/migrations/`
+5. No config struct definitions outside `config/config.go`
+
+**Exact pattern to follow** (copy this for every new check — do NOT deviate):
+```go
+// Add this function anywhere in policies/doctor.go
+func checkHandlerPlacement(root string) []DoctorIssue {
+ issues := make([]DoctorIssue, 0)
+ // walk files, detect violation, then:
+ issues = append(issues, DoctorIssue{
+ Code: "DX020", // use the next available DX0XX code not already in the file
+ Message: "HTTP handler defined outside app/web/controllers/",
+ Fix: "move the handler to app/web/controllers/",
+ File: "path/to/offending/file.go", // the specific file that violated the rule
+ Severity: "error", // "error" blocks the build; "warning" just warns
+ })
+ return issues
+}
+
+// Then in RunDoctorChecks (around line 169), add ONE line to call it:
+issues = append(issues, checkHandlerPlacement(root)...)
+```
+The `Severity` field is optional — omit it for errors (they default to blocking). Use `Severity: "warning"` for non-blocking hints.
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — search for "DX0" to find the highest existing code number, then use the next available numbers for your new checks.
+2. Check whether each of the 5 rules below is already implemented (grep for "handler", "route registration", "raw SQL", "migration", "config struct" in the file). Add only what is missing.
+3. Add each missing rule as a standalone function `checkXxx(root string) []DoctorIssue` following the exact pattern above.
+4. Each check uses `filepath.Walk` to scan directories, and `regexp.MustCompile` or `strings.Contains` to detect violations.
+5. Add one `issues = append(issues, checkXxx(root)...)` call inside `RunDoctorChecks` for each new check.
+
+**Done when:** `ship doctor` reports violations for each rule when a file is placed incorrectly.
+`ship doctor --json` includes placement violations in the issues array.
+
+---
+
+### L02 — Enforce file size conventions in `ship doctor`
+
+**Status:** `[ ] todo`
+**Depends on:** L01 (uses same check infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** Files over 300 lines are a signal of violation of single-responsibility. LLMs have
+worse comprehension of large files and are more likely to make errors editing them. Ship doctor
+should warn (not error) on files above threshold, and error on files above a hard cap.
+
+**Thresholds:**
+- Warning: any `.go` file > 300 lines (excluding generated files: `*.templ.go`, `*_sql.go`, `bob_*.go`)
+- Error: any `.go` file > 600 lines (same exclusions)
+- Warning: any `.templ` file > 200 lines
+- Error: any `.templ` file > 400 lines
+- Exclude: `vendor/`, `_test.go` files (tests can be longer), generated files
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — check if `checkFileSizes` already exists (grep for "300" or "600" line thresholds). Add only if missing.
+2. Add `checkFileSizes(root string) []DoctorIssue` following the exact pattern from L01: standalone function, returns `[]DoctorIssue`, append result in `RunDoctorChecks`.
+3. Walk `app/`, `framework/`, `tools/`, `config/` directories using `filepath.Walk`.
+4. Count lines by reading file content and counting `\n`. Skip blank lines with `strings.TrimSpace(line) == ""`.
+5. Apply exclusion rules: skip files ending in `.templ.go`, `_sql.go`, `bob_`, `_test.go`, and skip `vendor/` paths.
+6. Use `Severity: "warning"` for >300 lines, omit Severity (defaults to error) for >600 lines.
+7. Add `issues = append(issues, checkFileSizes(root)...)` inside `RunDoctorChecks`.
+
+**Done when:** `ship doctor` warns on files exceeding thresholds. Output includes the file path
+and line count. Excluded files are not flagged. `--json` output includes these as issues.
+
+---
+
+### L03 — Enforce marker comment integrity in `ship doctor`
+
+**Status:** `[ ] todo`
+**Depends on:** L01 (uses same check infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`, `app/router.go`, `app/foundation/container.go`
+
+**Context:** `ship module:add` (M03 C03) inserts code at marker comments. If a developer removes
+or renames a marker, module installation silently fails. Doctor must verify markers are present
+and paired.
+
+**Markers to check (exact strings, verified in source):**
+- `app/router.go`: `// ship:routes:public:start` / `// ship:routes:public:end` (line ~147), `// ship:routes:auth:start` / `// ship:routes:auth:end` (line ~238), `// ship:routes:external:start` / `// ship:routes:external:end` (line ~248)
+- `app/foundation/container.go`: `// ship:container:start` / `// ship:container:end` (line ~95)
+
+**What to do:**
+1. Read `tools/cli/ship/internal/policies/doctor.go` — search for "ship:routes" or "ship:container". If marker integrity checks already exist, verify they cover all pairs listed above and add only what's missing.
+2. Add `checkMarkerIntegrity(root string) []DoctorIssue` following the exact pattern from L01: standalone function, returns `[]DoctorIssue`.
+3. For each marker pair: `content, err := os.ReadFile(filepath.Join(root, "app/router.go"))`, then check `bytes.Contains(content, []byte("// ship:routes:auth:start"))` and `bytes.Contains(content, []byte("// ship:routes:auth:end"))`.
+4. Also verify `:start` index < `:end` index using `bytes.Index` to detect inversion.
+5. Error (no Severity field) if marker is missing. `Severity: "warning"` if unpaired.
+6. Add `issues = append(issues, checkMarkerIntegrity(root)...)` inside `RunDoctorChecks`.
+
+**Done when:** `ship doctor` errors if any required marker is missing or unpaired. Detects inversion.
+
+---
+
+### L04 — Add `ship verify` as the single done-check command
+
+**Status:** `[ ] todo`
+**Depends on:** L01, L02, L03 (doctor must be complete), M03 A02 (doctor --json)
+**Files:** `tools/cli/ship/internal/cli/cli.go`, `tools/cli/ship/internal/commands/verify.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/verify.go` **already exists**. Read it first — it may be a stub or partially implemented. Check whether the pipeline steps (templ generate → go build → ship doctor → nilaway → go test) are all wired. If any are missing, add them following the existing pattern in that file. Do NOT recreate the file from scratch.
+
+**Context:** Agents need a single command that runs all correctness checks in sequence and fails
+fast on the first error. Without this, agents run inconsistent subsets of checks. `ship verify`
+is the canonical "am I done?" command — run it before marking any task complete.
+
+**Pipeline (in order):**
+1. `templ generate` — compile all templ files, catch syntax errors
+2. `go build ./...` — full type-check and compilation
+3. `ship doctor --json` — structural and placement checks
+4. `nilaway ./...` (if installed, skip with warning if not) — nil safety analysis
+5. `go test ./...` — full test suite
+
+**What to do:**
+1. Create `tools/cli/ship/internal/commands/verify.go`.
+2. Register `verify` command in `cli.go`.
+3. Run each step as a subprocess. Capture stdout/stderr.
+4. On any step failure: print the step name, its output, and exit with code 1.
+5. On all steps pass: print `✓ verify passed` and exit 0.
+6. Add `--skip-tests` flag to skip step 5 (useful when dependencies aren't running).
+7. Add `--json` flag: output `{"ok": bool, "steps": [{"name", "ok", "output"}]}`.
+
+**Done when:** `ship verify` runs the full pipeline. Fails fast on first error with clear output.
+`ship verify --json` outputs structured result. `ship verify --skip-tests` skips the test step.
+
+---
+
+### L05 — Add `ship describe --json` machine-readable codebase map
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (standalone command)
+**Files:** `tools/cli/ship/internal/commands/describe.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/describe.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which sections of the JSON output (routes, modules, controllers, viewmodels, components, islands, db_tables, migrations) are populated vs missing. Fill in only what's missing. The CLI dispatch in `cli.go → c.runDescribe()` is already wired.
+
+**Context:** LLMs burn context reading individual files to understand codebase structure.
+`ship describe` produces a compact JSON map of everything an agent needs to work efficiently:
+routes, modules, viewmodels, components. Agents load this once at task start.
+
+**Output schema:**
+```json
+{
+ "routes": [
+ {"method": "GET", "path": "/login", "handler": "LoginController.Show", "auth": false, "file": "app/web/controllers/login.go:12"}
+ ],
+ "modules": [
+ {"id": "notifications", "installed": true, "routes": 3, "migrations": 2}
+ ],
+ "controllers": [
+ {"name": "LoginController", "file": "app/web/controllers/login.go", "handlers": ["Show", "Submit"]}
+ ],
+ "viewmodels": [
+ {"name": "LoginPage", "file": "app/web/controllers/login.go", "fields": ["Email", "Password", "Errors"]}
+ ],
+ "components": [
+ {"name": "Navbar", "file": "app/views/web/components/navbar.templ", "data_component": "navbar"}
+ ],
+ "islands": [
+ {"name": "ThemeToggle", "file": "frontend/islands/ThemeToggle.svelte"}
+ ],
+ "db_tables": ["users", "sessions", "notifications"],
+ "migrations": [
+ {"file": "db/migrations/00001_initial.sql", "applied": true}
+ ]
+}
+```
+
+**What to do:**
+1. Create `tools/cli/ship/internal/commands/describe.go`.
+2. Parse routes from `app/router.go` (read marker sections, extract e.GET/POST calls with regex).
+3. List controllers from `app/web/controllers/*.go` (exported types + methods).
+4. Detect viewmodel structs: Go structs with `Page` or `ViewModel` suffix in controllers dir.
+5. Detect components: `.templ` files in `app/views/web/components/` + their `data-component` values.
+6. List islands from `frontend/islands/` directory listing.
+7. List DB tables from `db/queries/*.sql` (extract table names from CREATE TABLE or SELECT FROM).
+8. List migrations from `db/migrations/` (filename only; `applied` = check if table `migrations` has the entry via `ship db:status` or skip if DB not available).
+9. Register `describe` in `cli.go`.
+10. Default output is JSON. Add `--pretty` for human-readable indented output.
+
+**Done when:** `ship describe` outputs valid JSON. Each section is populated from live filesystem.
+`ship describe --pretty` outputs indented JSON. Command exits 0 even if DB is unavailable
+(mark `applied: null` for migration status).
+
+---
+
+## Group M — MCP Tool Expansion
+
+> Agents interact with GoShip via the MCP server. Each tool here enables a new autonomous action.
+> Read `tools/mcp/ship/` before picking up any task in this group.
+
+### M01 — Add `ship_doctor` MCP tool
+
+**Status:** `[ ] todo`
+**Depends on:** L04 (ship verify), M03 A02 (ship doctor --json)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipDoctor`. If a function with that name exists and is registered as a tool named `ship_doctor`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+> **MCP tool registration pattern:** The MCP server entrypoint is `tools/mcp/ship/cmd/ship-mcp/main.go` which calls `server.Run(ctx, stdin, stdout, stderr, docsRoot)`. All tool definitions and handlers are in `tools/mcp/ship/internal/server/tools.go`. Read that file first — it shows the exact pattern for registering a new tool (tool schema, input struct, handler function). The server logic is in `tools/mcp/ship/internal/server/server.go`.
+
+**Context:** The MCP server at `tools/mcp/ship/` currently has 3 tools: `ship_help`, `docs_search`,
+`docs_get`. Adding `ship_doctor` lets agents self-validate after making changes, closing the
+act → verify → fix loop without human intervention.
+
+**Tool contract:**
+- Name: `ship_doctor`
+- Input: `{}` (no parameters)
+- Output: `{"ok": bool, "issues": [{"type", "file", "detail", "severity"}]}`
+- Implementation: shell out to `ship doctor --json`, parse and return the result
+
+**What to do (only if not already present):**
+1. Read `tools/mcp/ship/internal/server/tools.go` fully to understand how existing tools are registered.
+2. Following the same pattern, register `ship_doctor` in that file.
+3. Execute `ship doctor --json` as a subprocess, capture stdout.
+4. Parse JSON output, return as MCP tool result.
+5. If `ship doctor` binary is not found, return `{"ok": false, "issues": [{"type": "config", "detail": "ship binary not found in PATH"}]}`.
+
+**Done when:** MCP client can call `ship_doctor` and receive structured issue list. Returns
+correctly when no issues exist (`{"ok": true, "issues": []}`).
+
+---
+
+### M02 — Add `ship_routes` MCP tool
+
+**Status:** `[ ] todo`
+**Depends on:** L05 (ship describe --json populates route data)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipRoutes`. If a function with that name exists and is registered as a tool named `ship_routes`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Agents creating new routes need to know what routes already exist to avoid conflicts.
+`ship_routes` returns the full route inventory from `ship describe --json`.
+
+**Tool contract:**
+- Name: `ship_routes`
+- Input: `{"filter": "public|auth|admin"}` (optional)
+- Output: `{"routes": [{...}]}` (same schema as `ship describe` routes array)
+
+**What to do (only if not already present):**
+1. Register `ship_routes` tool.
+2. Shell out to `ship describe --json`, parse the `routes` field.
+3. If `filter` is provided, return only routes matching the auth level.
+4. Return the routes array.
+
+**Done when:** `ship_routes` returns route inventory. Filter parameter works correctly.
+
+---
+
+### M03 — Add `ship_modules` MCP tool
+
+**Status:** `[ ] todo`
+**Depends on:** L05 (ship describe --json)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipModules`. If a function with that name exists and is registered as a tool named `ship_modules`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Agents need to know which modules are installed before writing code that depends
+on them. Prevents agents from importing missing modules.
+
+**Tool contract:**
+- Name: `ship_modules`
+- Input: `{}` (no parameters)
+- Output: `{"modules": [{...}]}` (same schema as `ship describe` modules array)
+
+**What to do (only if not already present):**
+1. Register `ship_modules` tool.
+2. Shell out to `ship describe --json`, parse the `modules` field.
+3. Return modules array.
+
+**Done when:** `ship_modules` returns installed module list with route and migration counts.
+
+---
+
+### M04 — Add `ship_scaffold` MCP tool
+
+**Status:** `[ ] todo`
+**Depends on:** M03 C01 (module system interfaces), scaffolding commands in ship CLI
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipScaffold`. If a function with that name exists and is registered as a tool named `ship_scaffold`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** The highest-friction LLM task is creating new resources — controller + viewmodel +
+templ file + route + test. `ship_scaffold` lets agents scaffold resources without shell access.
+
+**Tool contract:**
+- Name: `ship_scaffold`
+- Input: `{"resource": "Post", "fields": [{"name": "Title", "type": "string"}, {"name": "Body", "type": "string"}]}`
+- Output: `{"ok": bool, "files_created": ["path/to/file.go", ...], "errors": [string]}`
+- Implementation: shell out to `ship make:scaffold Post Title:string Body:string`, parse output
+
+**What to do (only if not already present):**
+1. Register `ship_scaffold` tool.
+2. Build the CLI invocation from input parameters.
+3. Execute and parse the output (ship make:scaffold should output JSON when --json flag is set).
+4. Return structured result with created file paths.
+
+**Done when:** Agent can scaffold a new resource via MCP. Returns created file paths. Handles
+errors (duplicate resource name, invalid field types) via `errors` array.
+
+---
+
+### M05 — Add `ship_verify` MCP tool
+
+**Status:** `[ ] todo`
+**Depends on:** L04 (ship verify command)
+**Files:** `tools/mcp/ship/internal/server/tools.go`
+
+> **VERIFY FIRST — may already be implemented.** Open `tools/mcp/ship/internal/server/tools.go` and search for `callShipVerify`. If a function with that name exists and is registered as a tool named `ship_verify`, this task is **done** — mark it `[x]`. Only implement if the function is missing.
+
+**Context:** Wraps `ship verify --json` as an MCP tool. Agents run this after implementing a
+task to confirm no regressions before marking work complete.
+
+**Tool contract:**
+- Name: `ship_verify`
+- Input: `{"skip_tests": bool}`
+- Output: `{"ok": bool, "steps": [{"name": "string", "ok": bool, "output": "string"}]}`
+
+**What to do (only if not already present):**
+1. Register `ship_verify` tool.
+2. Build invocation: `ship verify --json` or `ship verify --json --skip-tests`.
+3. Parse and return JSON output.
+
+**Done when:** `ship_verify` returns step-by-step verification result. Agent can read which
+step failed and its output without human intervention.
+
+---
+
+## Group N — Hierarchical CLAUDE.md Context Files
+
+> Scoped context files allow agents to load only the context relevant to their current task.
+> This reduces token usage and improves accuracy for module-level work.
+
+### N01 — Create framework CLAUDE.md
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/CLAUDE.md` (new)
+
+**Context:** When an agent works in `framework/`, it needs to know the framework's contracts and
+what is off-limits. Without a scoped guide, it reads the whole repo and makes framework changes
+that break app compatibility.
+
+**What to write:**
+1. Framework role: provides routing, DI, config, DB, session, middleware, rendering pipeline.
+ Does NOT include: auth flows, business logic, module-specific code.
+2. Core interfaces: `framework/core/interfaces.go` — read this before any change.
+3. Adapter pattern: all optional services go through adapter interfaces. Never add direct deps.
+4. Allowed dependencies: standard library + Echo + Bob + cleanenv. No new external packages
+ without explicit approval.
+5. Breaking change rule: any change to `framework/core/interfaces.go` requires updating all
+ adapter implementations and all framework tests.
+6. Testing: every exported function in `framework/` must have a test.
+7. Run `ship verify` after every change.
+
+**Done when:** `framework/CLAUDE.md` exists with the above sections. An agent reading only this
+file has enough context to safely modify the framework.
+
+---
+
+### N02 — Create module-level CLAUDE.md template
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `modules/CLAUDE.md.template` (new), `docs/guides/01-ai-agent-guide.md` (update)
+
+**Context:** Each module needs a CLAUDE.md that tells agents: what the module does, which files
+to touch, which interfaces it implements, and which other modules it depends on. This template
+is used when `ship module:add` scaffolds a new module (M03 C03).
+
+**Template content:**
+```markdown
+# Module:
+
+## What This Module Does
+
+
+## Files
+- `module.go` — module ID, config schema, interface implementation
+- `service.go` — business logic (exported API)
+- `store.go` — storage interface
+- `store_sql.go` — SQL implementation using Bob
+- `routes.go` — route registration (implement RoutableModule)
+- `views/` — templ templates
+- `db/migrations/` — SQL migration files
+
+## Interfaces Implemented
+- `core.Module` (module.go)
+- `core.RoutableModule` (routes.go) — if this module has HTTP routes
+
+## Dependencies
+- Other modules this module imports:
+- Framework packages used:
+
+## Conventions
+- All HTTP handlers are in `routes.go` — nowhere else
+- All business logic is in `service.go` — controllers never call store directly
+- All DB access goes through the store interface — never raw SQL in service.go
+- Viewmodels are value types (no pointer fields)
+- Run `ship verify` after every change
+```
+
+**What to do:**
+1. Create `modules/CLAUDE.md.template` with the above content.
+2. Update `docs/guides/01-ai-agent-guide.md` to mention that each module has a CLAUDE.md
+ and that agents should read it before modifying a module.
+
+**Done when:** Template exists. Agent guide references it. The template is referenced in `ship module:add` implementation (M03 C03).
+
+---
+
+### N03 — Create app-layer CLAUDE.md
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `app/CLAUDE.md` (new)
+
+**Context:** `app/` is the application layer — it wires framework + modules together. Agents
+working here must know: where things go, what's app-specific vs module-owned, and how to avoid
+creating framework-level code in the app layer.
+
+**What to write:**
+1. Role: wires framework + modules. Owns: router, container, app-specific controllers, foundation.
+2. Controllers live in `app/web/controllers/` — one file per resource.
+3. Route registration: only in `app/router.go` at the ship:routes marker comments.
+4. Container wiring: only in `app/foundation/container.go` at the ship:container markers.
+5. Views: only in `app/views/` — follow `docs/ui/convention.md` for data-component / Renders: rules.
+6. No business logic in controllers — delegate to service layer or module services.
+7. No SQL in controllers — all DB goes through repository pattern.
+8. Run `ship verify` after every change.
+
+**Done when:** `app/CLAUDE.md` exists. Covers all placement rules and anti-patterns.
+
+---
+
+## Group O — Route Contracts as First-Class Specs
+
+> Route contracts define the typed shape of every request and response. They act as a spec
+> that agents implement against, and as validation that the implementation matches the intent.
+
+### O01 — Define route contract types for all existing routes
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `app/contracts/` (new directory), one file per route group
+
+**Context:** Currently, request parsing is scattered — form values plucked inline in handler
+functions, response shape implicit in the viewmodel. Route contracts make the intent explicit:
+this route receives X, it renders Y. An agent implementing a handler can read the contract and
+know exactly what to build.
+
+**Contract type convention:**
+```go
+// in app/contracts/auth.go
+package contracts
+
+// LoginRequest is the form submission contract for POST /login.
+type LoginRequest struct {
+ Email string `form:"email" validate:"required,email"`
+ Password string `form:"password" validate:"required,min=8"`
+}
+
+// LoginPage is the viewmodel for GET /login.
+type LoginPage struct {
+ Email string
+ Errors map[string]string
+}
+```
+
+**What to do:**
+1. Create `app/contracts/` directory.
+2. For each existing route group (auth, profile, preferences, home), create a contracts file.
+3. Extract or document the request shape from the handler's form parsing calls.
+4. Extract or copy the existing viewmodel struct into the contracts file if it's currently
+ defined inline in the controller.
+5. Handlers are NOT changed in this task — contracts are documentation and type anchors.
+6. Add a `// Route: METHOD /path` comment above each type.
+
+**Done when:** `app/contracts/` exists with one file per route group. All existing public request
+types and viewmodels are represented. No handler logic is changed.
+
+---
+
+### O02 — Enforce contract usage in `ship doctor`
+
+**Status:** `[ ] todo`
+**Depends on:** O01, L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** After O01, contracts exist but handlers may not use them. This check catches handlers
+that parse form values directly without a contract type.
+
+**Rule:** Any handler that calls `c.FormValue()` or `c.Bind()` without assigning to a type defined
+in `app/contracts/` package is a warning.
+
+**What to do:**
+1. Add `checkContractUsage()` to doctor.
+2. Walk `app/web/controllers/*.go`, detect calls to `c.FormValue(` or `c.Bind(`.
+3. Check if the bound type is in the `contracts` package (import alias check).
+4. Warn (not error) if direct form parsing is used without a contract.
+
+**Done when:** `ship doctor` warns on handlers using raw form parsing. Existing contract-using
+handlers are not flagged.
+
+---
+
+## Group P — Test-First Scaffolding
+
+> The highest-value change to scaffolding: generate a failing test first, then the implementation.
+> This forces a clear spec before code, and gives agents a green-light signal to stop.
+
+### P01 — Add `--test-first` flag to `ship make:scaffold`
+
+**Status:** `[ ] todo`
+**Depends on:** existing `ship make:scaffold` command
+**Files:** `tools/cli/ship/internal/commands/scaffold.go` (or equivalent)
+
+**Context:** Standard scaffolding generates stubs. `--test-first` inverts the order: generate
+a failing integration test that describes the expected HTTP behavior, then generate the stub
+handler. The agent's job is to make the test pass. This is the standard TDD scaffold pattern
+from Rails and Laravel.
+
+**Generated test shape (for `ship make:scaffold Post Title:string`):**
+
+```go
+// app/web/controllers/post_test.go
+func TestPostController_Index(t *testing.T) {
+ // SCAFFOLD: implement Post index — should return 200 with list of posts
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Show(t *testing.T) {
+ // SCAFFOLD: implement Post show — should return 200 with post details
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Create(t *testing.T) {
+ // SCAFFOLD: implement Post create — should return 200 with create form
+ t.Skip("scaffold: implement me")
+}
+
+func TestPostController_Store(t *testing.T) {
+ // SCAFFOLD: implement Post store — POST with valid data returns 302 redirect
+ t.Skip("scaffold: implement me")
+}
+```
+
+**What to do:**
+1. Read the current scaffold command to understand the generation pipeline.
+2. Add `--test-first` boolean flag.
+3. When set:
+ a. Generate the test file first (using the template above) in `app/web/controllers/_test.go`.
+ b. Generate the stub handler in `app/web/controllers/.go` with `panic("not implemented")` bodies.
+ c. Generate the templ file stub in `app/views/web/pages//`.
+ d. Print: "Tests generated. Make them pass, then remove t.Skip calls."
+4. Test file uses `t.Skip` so `go test ./...` passes (skipped ≠ failed).
+
+**Done when:** `ship make:scaffold Post Title:string --test-first` generates a test file and stub
+handler. `go test ./...` passes (tests are skipped). `ship verify` passes.
+
+---
+
+### P02 — Add scaffold test to `ship verify` pre-completion check
+
+**Status:** `[ ] todo`
+**Depends on:** P01, L04 (ship verify)
+**Files:** `tools/cli/ship/internal/commands/verify.go`
+
+**Context:** When a scaffold is generated with `--test-first`, any remaining `t.Skip("scaffold:")`
+calls in the test suite indicate incomplete work. `ship verify` should warn when skipped scaffold
+tests remain, so agents know there is unfinished implementation.
+
+**What to do:**
+1. Add a check in `ship verify` that greps for `t.Skip("scaffold:` in `*_test.go` files.
+2. If any are found, print a warning (not error): "Warning: N scaffolded tests are still skipped."
+3. List the file and test name for each skipped scaffold test.
+4. Include as a warning (severity: "warning") in `--json` output.
+
+**Done when:** `ship verify` warns when scaffold test skips remain. Lists each unimplemented test.
+
+---
+
+## Group Q — Agent Workflow Tooling
+
+> Commands that improve how agents start, isolate, and complete work.
+
+### Q01 — Add conventional commits enforcement to pre-commit hook
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `.githooks/commit-msg` (new), `Makefile` (hooks:install target)
+
+> **Makefile target:** The Makefile has a `hooks` / `hooks-install` target already. Check its current content before adding a new one — it may already configure `git config core.hooksPath`. The Makefile is at the repo root (`/Users/leoaudibert/Workspace/2026/pagoda-based/goship/Makefile`).
+
+**Context:** Conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
+give agents and humans a consistent vocabulary for change classification. Without enforcement,
+commit messages are unstructured and harder to parse in agent-generated changelogs.
+
+**Format enforced:**
+```
+():
+
+Types: feat, fix, docs, refactor, test, chore, perf, style, build, ci
+Scope: optional module or area name (e.g., auth, jobs, admin)
+Description: imperative present tense, lowercase, no period
+```
+
+**What to do:**
+1. Create `.githooks/commit-msg` shell script:
+ ```sh
+ #!/bin/sh
+ MSG=$(cat "$1")
+ PATTERN='^(feat|fix|docs|refactor|test|chore|perf|style|build|ci)(\([a-z0-9-]+\))?: .+'
+ if ! echo "$MSG" | grep -qE "$PATTERN"; then
+ echo "ERROR: commit message does not follow conventional commits format."
+ echo "Expected: (): "
+ echo "Types: feat, fix, docs, refactor, test, chore, perf, style, build, ci"
+ exit 1
+ fi
+ ```
+2. Make it executable: `chmod +x .githooks/commit-msg`.
+3. Update `Makefile` hooks:install target to run `git config core.hooksPath .githooks`.
+4. Update `docs/policies/01-engineering-standards.md` to list conventional commits as required.
+
+**Done when:** `git commit -m "bad message"` is rejected. `git commit -m "feat(auth): add oauth login"` succeeds. `make hooks:install` configures the hook.
+
+---
+
+### Q02 — Add `ship agent:start` for isolated worktree workflow
+
+**Status:** `[ ] todo`
+**Depends on:** L05 (ship describe), L04 (ship verify)
+**Files:** `tools/cli/ship/internal/commands/agent_start.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/agent_start.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which of the described steps (git worktree creation, TASK.md generation, ship describe output, CLAUDE.md injection) are done vs missing. The CLI dispatch in `cli.go → c.runAgent()` is already wired. Do NOT recreate the file.
+
+**Context:** Agents working on tasks benefit from isolation — a separate git worktree where their
+changes don't interfere with in-progress human work, and a context document scoped to the task.
+`ship agent:start` creates this environment and prepares a task brief.
+
+**What it does:**
+1. Creates a git worktree at `.worktrees//` on a new branch `agent/`.
+2. Generates a context document at `.worktrees//TASK.md` containing:
+ - The task description (passed as `--task` flag or read from stdin)
+ - Output of `ship describe --json` (routes, modules, viewmodels)
+ - Relevant CLAUDE.md files (based on which directories the task is likely to touch)
+3. Prints the worktree path and branch name so the agent can `cd` there.
+
+**Command signature:**
+```
+ship agent:start --task "Add OAuth login to auth module" [--id TASK-001]
+```
+
+**What to do:**
+1. Create `agent_start.go`.
+2. Accept `--task` (string) and `--id` (string, defaults to timestamp) flags.
+3. Run `git worktree add .worktrees/ -b agent/`.
+4. Run `ship describe --json` and write output into `TASK.md` under a `## Codebase State` section.
+5. Write the task description into `TASK.md` under `## Task`.
+6. Print: `Worktree created at .worktrees/. Branch: agent/.`
+7. Add `.worktrees/` to `.gitignore`.
+
+**Done when:** `ship agent:start --task "description" --id T01` creates the worktree, branch, and
+TASK.md. The worktree is functional (can run `go build` from it). `.worktrees/` is gitignored.
+
+---
+
+### Q03 — Add `ship agent:finish` for worktree cleanup and PR prep
+
+**Status:** `[ ] todo`
+**Depends on:** Q02 (agent:start)
+**Files:** `tools/cli/ship/internal/commands/agent_finish.go`, `tools/cli/ship/internal/cli/cli.go`
+
+> **NOTE:** `tools/cli/ship/internal/commands/agent_finish.go` **already exists**. Read it first — it may be a stub or partially implemented. Check which of the steps (ship verify, git add, commit, push, gh pr create, worktree remove) are done vs missing. Do NOT recreate the file.
+
+**Context:** After an agent completes work in a worktree, it needs to: verify correctness,
+create a conventional commit, and open a PR. `ship agent:finish` automates this sequence.
+
+**Command signature:**
+```
+ship agent:finish --id TASK-001 --message "feat(auth): add oauth login"
+```
+
+**What it does:**
+1. Runs `ship verify` in the worktree. Fails fast if verify fails.
+2. Stages all changes: `git add -A` in the worktree.
+3. Commits with the provided message (validated against conventional commits format).
+4. Optionally pushes and creates a GitHub PR (requires `--pr` flag and `gh` CLI in PATH).
+5. Removes the worktree: `git worktree remove .worktrees/`.
+
+**What to do:**
+1. Create `agent_finish.go`.
+2. Accept `--id`, `--message`, `--pr` (bool) flags.
+3. Run `ship verify` in the worktree path. On failure, print output and abort.
+4. Run git operations as described.
+5. If `--pr` is set, run `gh pr create --title "" --body "Agent task: "`.
+
+**Done when:** `ship agent:finish --id T01 --message "feat: ..."` runs verify, commits, and
+cleans up the worktree. `--pr` creates a PR via gh CLI.
+
+---
+
+## Group R — Self-Describing Codebase
+
+> Make the codebase explain itself. Agents should be able to understand the current state
+> from structured data, not from reading dozens of source files.
+
+### R01 — Add `// Renders:` comments to all exported templ functions (GoShip)
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** All `*.templ` files in `app/views/`
+
+**Context:** Per `docs/ui/convention.md`, every exported templ function must have a `// Renders:`
+comment on the line above it. This is a one-line visual description of what the component
+renders. Agents use it instead of reading the full templ file to understand component output.
+
+**Format:**
+```templ
+// Renders: top navigation bar with logo, user menu, and theme toggle
+templ Navbar(user *User) {
+```
+
+**What to do:**
+1. Read `docs/ui/convention.md` for the full convention.
+2. For each exported templ function (starts with uppercase) in `app/views/`:
+ - If no `// Renders:` comment exists on the immediately preceding line, add one.
+ - Write a 1-line description of what the component visually renders.
+ - Be specific: "login form with email/password fields and forgot password link"
+ not "renders the login page".
+3. Do not change any templ logic — comments only.
+4. Run `make templ-gen` after to verify no syntax errors (this is the correct command — NOT `templ generate` directly).
+
+**Done when:** Every exported templ function in `app/views/` has a `// Renders:` comment.
+`make templ-gen` passes. `ship verify` passes.
+
+---
+
+### R02 — Add `ship doctor` check for missing `// Renders:` comments
+
+**Status:** `[ ] todo`
+**Depends on:** R01, L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** After R01 establishes the pattern, doctor should enforce it so future-added components
+don't silently skip the convention.
+
+**Rule:** Any exported templ function (line starting with `templ [A-Z]`) that does not have
+`// Renders:` on the immediately preceding non-blank line is a warning.
+
+**What to do:**
+1. Add `checkRendersComments()` to doctor command.
+2. Walk `*.templ` files in `app/views/` and any installed module `views/` directories.
+3. For each exported `templ Foo(` declaration, check the line above.
+4. Warn if `// Renders:` is missing.
+5. Include file path and function name in the warning.
+
+**Done when:** `ship doctor` warns on exported templ functions missing `// Renders:` comments.
+Correctly handles functions with existing comments (no false positives).
+
+---
+
+### R03 — Add `ship doctor` check for missing `data-component` attributes
+
+**Status:** `[ ] todo`
+**Depends on:** L01 (doctor infrastructure)
+**Files:** `tools/cli/ship/internal/policies/doctor.go`
+
+**Context:** Per `docs/ui/convention.md`, every exported templ component's root element must have
+a `data-component=""` attribute. Doctor should enforce this.
+
+**Rule:** Any exported `templ Foo(` function that does not contain `data-component=` in its
+body is a warning. Exclude layout templates (files named `*_layout.templ`) — layouts have
+structural roots, not component roots.
+
+**What to do:**
+1. Add `checkDataComponentAttributes()` to doctor command.
+2. Walk `*.templ` files in `app/views/web/components/` (and module `views/` equivalents).
+3. For each exported templ function, read the next 10 lines to detect `data-component=`.
+4. Warn if not present. Exclude `*_layout.templ` files.
+
+**Done when:** `ship doctor` warns on exported templ components missing `data-component`.
+Layout files are excluded. False positive rate is 0 for correctly annotated components.
+
+---
+
+## Execution Order
+
+Tasks can be picked up in dependency order. A task is ready when all its dependencies are marked `[x]`.
+
+**Layer 0 (no dependencies — start any of these in parallel):**
+- L01, L02 (need L01 check infra), L03
+- N01, N02, N03
+- O01
+- Q01
+- R01
+
+**Layer 1 (depends on Layer 0):**
+- L04 (needs L01+L02+L03)
+- L05
+- O02 (needs O01, L01)
+- R02 (needs R01, L01)
+- R03 (needs L01)
+
+**Layer 2 (depends on Layer 1):**
+- M01 (needs L04)
+- M02, M03 (need L05)
+- P01 (needs existing scaffold command)
+- Q02 (needs L05, L04)
+
+**Layer 3 (depends on Layer 2):**
+- M04 (needs M03 C01 from main task list)
+- M05 (needs L04)
+- P02 (needs P01, L04)
+- Q03 (needs Q02)
+
+**Layer 4:**
+- All MCP tools complete → agent self-correction loop is fully operational
+
+---
+
+## Acceptance: The Full Agent Loop
+
+When all tasks in this document are complete, the following workflow is fully operational:
+
+```
+1. Human creates task: ship agent:start --task "Add rate limiting to auth routes" --id T042
+2. Agent reads TASK.md (task brief + codebase map)
+3. Agent reads app/CLAUDE.md and app/web/controllers/CLAUDE.md
+4. Agent calls ship_routes MCP tool → knows existing auth routes
+5. Agent calls ship_modules MCP tool → knows installed modules
+6. Agent implements the change
+7. Agent calls ship_verify MCP tool → gets pass/fail per step
+8. Agent fixes issues flagged by ship_doctor (inside ship_verify)
+9. Agent runs ship agent:finish --id T042 --message "feat(auth): add rate limiting"
+10. PR is created. Human reviews diff only — no debugging needed.
+```
+
+This is the act → verify → fix loop with zero human intervention between steps 2–9.
+
+---
+FILE: docs/roadmap/06-dx-and-infrastructure.md
+---
+# GoShip — DX & Core Infrastructure
+
+**Status:** Active planning — tasks pickup-ready for any LLM agent
+**Last updated:** 2026-03-08
+
+**Reference docs (read before picking up any task):**
+- `docs/roadmap/02-architecture-evolution.md` — architecture overview
+- `docs/roadmap/03-atomic-tasks.md` — M03 task list (groups A–K)
+- `docs/roadmap/05-llm-dx-agent-friendly.md` — M05 task list (groups L–R)
+- `docs/guides/01-ai-agent-guide.md` — conventions, safe change workflow
+
+**Stack context:** Go 1.24, Echo v4, Templ, HTMX, Bob ORM, cleanenv config,
+ship CLI (`tools/cli/ship/`), Vite frontend, Overmind for process management.
+
+**Task format:** Self-contained. Full context, exact files, "done when" criterion.
+Mark `[x]` before starting any dependent task.
+
+**Group prefix:** S–V (continues from M05's L–R).
+
+---
+
+## Key File Map (read before touching any task)
+
+| Concern | File / Fact |
+|---------|-------------|
+| Ship CLI command pattern | `func RunXxx(args []string, d XxxDeps) int` — see `tools/cli/ship/internal/commands/infra.go` as the canonical simple example |
+| Ship CLI dispatch | `tools/cli/ship/internal/cli/cli.go` → `switch args[0]` and `runNamespaced` |
+| Commands directory | `tools/cli/ship/internal/commands/` — contains 26 files. Check here before creating new files. |
+| Already implemented commands | `dev.go`, `describe.go`, `verify.go`, `agent_start.go`, `agent_finish.go` all exist — **read before recreating** |
+| Container | `app/foundation/container.go` — `NewContainer()` wires all services; marker `// ship:container:start` / `// ship:container:end` at line ~95 |
+| Container fields | `Container.Database *sql.DB`, `Container.Cache *CacheClient`, `Container.Mail *mailer.MailClient`, `Container.CoreJobs core.Jobs`, `Container.CorePubSub core.PubSub` |
+| Core interfaces | `framework/core/interfaces.go` — `core.Mailer` (and `core.MailMessage`) already defined here |
+| Config struct | `config/config.go` → `type Config struct { HTTP, App, Runtime, Adapters, Database, Cache, Mail, … }` — add new fields here |
+| App router | `app/router.go` — add middleware to the global stack via `appweb.ApplyMainMiddleware` |
+| Framework middleware | `app/web/middleware/` — existing middleware lives here |
+| App controllers | `app/web/controllers/` |
+| App views | `app/views/` |
+| Logging stack | `framework/logging` is canonical: `slog` logger + Echo adapter (`app/foundation/container.go` wires `c.Logger`/`c.Web.Logger`). |
+| Templ generate | `make templ-gen` |
+| Test commands | `make test` (unit, no Docker), `make test-integration` (Docker), `make e2e` (Playwright) |
+| Procfile.dev | Check if it already exists at repo root before creating |
+| GitHub Actions | Check if `.github/workflows/` already exists before creating |
+
+---
+
+## Group S — Developer Workflow
+
+### S01 — Add `ship dev` unified development command
+
+**Status:** `[ ] todo`
+**Depends on:** nothing
+**Files:** `Procfile.dev`, `tools/cli/ship/internal/commands/dev.go`,
+`tools/cli/ship/internal/cli/cli.go`, `Makefile`
+
+> **NOTE:** `tools/cli/ship/internal/commands/dev.go` **already exists** and `ship dev` is already dispatched in `cli.go → case "dev"`. Also check if `Procfile.dev` already exists at repo root. Read both files first — complete only what's missing. The Makefile already has a `dev` target (`make dev`) — check if it calls `ship dev` or does something else.
+
+**Context:** Running GoShip in development currently requires 4–5 separate terminal windows:
+`templ generate --watch`, `air` (Go live reload), `pnpm --prefix frontend run dev` (Vite HMR),
+`go run cmd/worker/main.go`. Each process has its own output, and agents don't know which to
+restart after which kind of change. `ship dev` runs all processes as a single multiplexed stream.
+
+**Implementation approach:** Use Overmind (`github.com/DarthSim/overmind`) or `goreman` to read
+`Procfile.dev`. Overmind is preferred: it supports per-process restart, colored output by default,
+and is a single static binary.
+
+**`Procfile.dev` content:**
+```
+web: air -c .air.toml
+worker: go run ./cmd/worker/main.go
+vite: pnpm --prefix frontend run dev
+templ: templ generate --watch --proxy="http://localhost:8080"
+```
+
+**What to do:**
+1. Create `Procfile.dev` at repo root with the content above.
+2. Verify `.air.toml` exists and is configured correctly (if not, create with standard defaults:
+ watch `app/`, `config/`, `cmd/`, exclude `tmp/`, build to `tmp/main`).
+3. Create `tools/cli/ship/internal/commands/dev.go`:
+ - Check if `overmind` is in PATH. If not, check `goreman`. If neither, print install instructions and exit 1.
+ - Exec: `overmind start -f Procfile.dev` (replaces current process, inherits stdio).
+4. Register `dev` command in `cli.go`.
+5. Add `Makefile` target `dev` that calls `ship dev` (convenience alias).
+6. Document in `docs/guides/02-development-workflows.md`: "Run `ship dev` to start all processes."
+
+**Done when:** `ship dev` starts all four processes with merged colored output. Killing the command
+(Ctrl+C) stops all child processes cleanly. Works from repo root.
+
+---
+
+### S02 — Generate GitHub Actions CI/CD workflows in `ship new`
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel with S01)
+**Files:** `.github/workflows/ci.yml` (new), `.github/workflows/deploy.yml` (new),
+`.github/workflows/security.yml` (new), `.github/dependabot.yml` (new),
+`tools/cli/ship/internal/commands/new.go` (update scaffold)
+
+**Context:** Every new GoShip project has a CI gap for weeks after creation — developers add CI
+manually and inconsistently. `ship new myapp` should generate working GitHub Actions workflows
+from day one. CI should be green on the first push.
+
+**`ci.yml` — runs on every push and PR:**
+```yaml
+name: CI
+on: [push, pull_request]
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with: { go-version: '1.24' }
+ - uses: actions/setup-node@v4
+ with: { node-version: '22' }
+ - run: go install github.com/a-h/templ/cmd/templ@latest
+ - run: pnpm install --prefix frontend
+ - run: ship verify --skip-tests # templ gen + build + doctor
+ - run: go test ./...
+```
+
+**`deploy.yml` — runs on push to main:**
+```yaml
+name: Deploy
+on:
+ push:
+ branches: [main]
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: webfactory/ssh-agent@v0.9.0
+ with: { ssh-private-key: '${{ secrets.DEPLOY_KEY }}' }
+ - run: gem install kamal
+ - run: kamal deploy
+```
+
+**`security.yml` — weekly vulnerability scan:**
+```yaml
+name: Security
+on:
+ schedule: [{ cron: '0 9 * * 1' }]
+jobs:
+ govulncheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with: { go-version: '1.24' }
+ - run: go install golang.org/x/vuln/cmd/govulncheck@latest
+ - run: govulncheck ./...
+```
+
+**`dependabot.yml`:**
+```yaml
+version: 2
+updates:
+ - package-ecosystem: gomod
+ directory: /
+ schedule: { interval: weekly }
+ - package-ecosystem: npm
+ directory: /frontend
+ schedule: { interval: weekly }
+ - package-ecosystem: github-actions
+ directory: /
+ schedule: { interval: weekly }
+```
+
+**What to do:**
+1. Create these four files as templates in `tools/cli/ship/internal/templates/github/`.
+2. Update the `ship new` command to copy them into the new project's `.github/` directory.
+3. Add a note in the `ship new` output: "GitHub Actions workflows created. Add DEPLOY_KEY secret
+ to enable deployment."
+4. These files should also exist in the GoShip repo itself (dogfooding).
+
+**Done when:** `ship new myapp` creates all four workflow files. CI workflow runs `ship verify`
+correctly on first push (assuming ship is installed on the runner).
+
+---
+
+## Group T — Core Infrastructure
+
+### T01 — Multi-process SQLite safety (WAL mode + connection pool)
+
+**Status:** `[ ] todo`
+**Depends on:** M03 I01 (SQLite adapter must exist first)
+**Files:** `framework/repos/sql/sqlite_adapter.go` (new or update), `framework/repos/sql/connection.go`
+
+**Context:** SQLite under concurrent HTTP load produces `"database is locked"` errors without
+specific configuration. This is a silent killer for single-binary mode — the app appears to work
+in development (low concurrency) but fails under any real load. These settings are mandatory,
+not optional.
+
+**Required settings (applied at connection open time):**
+```go
+// Applied via SQLite pragma statements immediately after opening the DB
+pragmas := []string{
+ "PRAGMA journal_mode=WAL", // Write-Ahead Logging: readers don't block writers
+ "PRAGMA synchronous=NORMAL", // Safe with WAL, faster than FULL
+ "PRAGMA busy_timeout=5000", // Wait up to 5s before returning SQLITE_BUSY
+ "PRAGMA foreign_keys=ON", // Enforce FK constraints
+ "PRAGMA cache_size=-64000", // 64MB page cache
+ "PRAGMA temp_store=MEMORY", // Temp tables in memory
+}
+```
+
+**Connection pool pattern:**
+- Use a single `*sql.DB` with `SetMaxOpenConns(1)` for **write** operations (SQLite allows one writer)
+- Use a separate `*sql.DB` with multiple connections for **read** operations
+- OR: use `modernc.org/sqlite`'s WAL mode with `_txlock=immediate` for write transactions
+
+**What to do:**
+1. Read the existing SQLite adapter implementation (from M03 I01).
+2. Apply all pragma statements immediately after `sql.Open`.
+3. Implement the read/write pool separation or `_txlock=immediate` write transactions.
+4. Add a test: spin up the SQLite adapter, run 50 concurrent goroutines each doing a write.
+ Verify zero "database is locked" errors.
+5. Document the settings and rationale in the adapter file as comments.
+
+**Done when:** 50 concurrent writes to SQLite via the adapter produce zero lock errors.
+All pragma settings are applied on connection open. Test passes.
+
+---
+
+### T02 — Integrate `slog` structured logging into framework
+
+**Status:** `[x] done`
+**Depends on:** nothing (parallel)
+**Files:** `framework/logging/` (new package), `framework/middleware/logging.go` (update),
+`app/foundation/container.go` (wire logger), `config/config.go` (log level config)
+
+**Context:** Completed: GoShip now uses `framework/logging` + `slog` as the primary structured logging path. Legacy `lecho` and direct `zerolog` dependencies were removed from app/runtime imports.
+
+**Logger setup:**
+```go
+// Development: human-readable colored output
+// Production: JSON lines to stdout (captured by log aggregator)
+func NewLogger(env string, level slog.Level) *slog.Logger {
+ if env == "production" {
+ return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
+ }
+ return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
+}
+```
+
+**Request ID middleware (update existing or create):**
+```go
+func RequestID() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ id := c.Request().Header.Get("X-Request-ID")
+ if id == "" { id = uuid.New().String() }
+ c.Set("request_id", id)
+ c.Response().Header().Set("X-Request-ID", id)
+ // Add to context for slog
+ ctx := context.WithValue(c.Request().Context(), logKeyRequestID, id)
+ c.SetRequest(c.Request().WithContext(ctx))
+ return next(c)
+ }
+ }
+}
+```
+
+**Request logging middleware:**
+```go
+// Logs: method, path, status, latency, request_id, user_id (if authenticated)
+// Format in dev: "GET /login 200 3.2ms req=abc123"
+// Format in prod: {"method":"GET","path":"/login","status":200,"latency_ms":3,"request_id":"abc123"}
+```
+
+**Config additions:**
+```go
+type Config struct {
+ // ...existing fields...
+ Log struct {
+ Level string `env:"LOG_LEVEL" env-default:"info"` // debug, info, warn, error
+ Format string `env:"LOG_FORMAT" env-default:"text"` // text (dev) or json (prod)
+ }
+}
+```
+
+**What to do:**
+1. Create `framework/logging/logger.go` with `NewLogger(cfg Config) *slog.Logger`.
+2. Create `framework/logging/context.go`: `FromContext(ctx) *slog.Logger` and `WithLogger(ctx, logger)`.
+3. Update `app/foundation/container.go`: initialize logger in `NewContainer`, store as `c.Logger`.
+4. Update request logging middleware to use slog.
+5. Add request ID middleware if not present.
+6. Replace any `log.Println` / `fmt.Printf` in framework code with `slog` calls.
+7. Add log level and format to config struct.
+
+**Done when:** All framework log output goes through slog. Dev output is text, prod is JSON.
+Every log line from the request middleware includes `request_id`. `LOG_LEVEL=debug` enables
+verbose output. `go build ./...` passes.
+
+---
+
+### T03 — Security headers middleware
+
+**Status:** `[ ] todo`
+**Depends on:** nothing (parallel)
+**Files:** `framework/middleware/security_headers.go` (new), `app/router.go` (add to middleware stack),
+`config/config.go` (CSP config)
+
+> **Router middleware stack:** Security headers must be added early in the pipeline. In `app/router.go`, the main middleware is applied in `appweb.ApplyMainMiddleware(c, g, logger, deps, webFeatures)`. Read `app/web/` (specifically `wiring.go` which is where `ApplyMainMiddleware` likely lives) to understand where to inject the new middleware — before route handlers, after recover/logger. Add the new config fields to `config/config.go` inside a new `Security` sub-struct.
+
+**Context:** Without security headers, GoShip apps score C or below on securityheaders.com.
+These headers prevent XSS, clickjacking, MIME sniffing, and other attacks. They should be
+default-on — developers shouldn't have to add them. The only configurable part is CSP, since
+Vite HMR in development needs `'unsafe-eval'` and websocket connections.
+
+**Headers to set:**
+```
+X-Content-Type-Options: nosniff
+X-Frame-Options: SAMEORIGIN
+Referrer-Policy: strict-origin-when-cross-origin
+Permissions-Policy: camera=(), microphone=(), geolocation=()
+X-XSS-Protection: 0 (deprecated, explicitly disable to prevent IE bugs)
+
+# In production:
+Strict-Transport-Security: max-age=31536000; includeSubDomains
+
+# CSP — configurable, with safe defaults:
+Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; ...
+```
+
+**Nonce-based CSP approach:**
+- Generate a random nonce per request, store in context
+- Pass nonce to templ layout via context: `layout.templ` reads `middleware.CSPNonce(ctx)`
+- `\n\n\n","\n\n\n","\n\n\n","\n\n\n","\n\n{#if loading}\n \n \n \n{:else if permissionGranted}\n \n \n \n{:else}\n \n \n \n{/if}\n","\n\n\n","\n\n\n","\n\n\n\n{#if loading}\n \n \n \n{:else if permissionGranted && isSubscribedForPermission}\n \n \n \n{:else}\n \n \n \n{/if}\n","\n\n\n\n{#if loading}\n \n \n \n{:else if isSubscribedForPermission}\n \n \n \n{:else}\n \n \n \n{/if}\n","\n\n\n\n