---
phase: 07-deploy-v1
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/embed.go
  - backend/internal/db/migrate.go
  - backend/internal/web/handlers.go
  - backend/internal/web/handlers_test.go
  - backend/internal/web/router.go
  - backend/cmd/web/main.go
autonomous: true
requirements:
  - DEPLOY-03
  - DEPLOY-04

must_haves:
  truths:
    - "D-12: GET /healthz returns 200 with no DB connection (pure liveness)"
    - "D-13: GET /readyz returns 200 when DB is reachable and 503 when DB is down"
    - "D-09: Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
    - "D-10: goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
    - "Full Go test suite passes: go test ./..."
  artifacts:
    - path: "backend/embed.go"
      provides: "go:embed declarations for static/ and migrations/ directories"
      contains: "//go:embed"
    - path: "backend/internal/db/migrate.go"
      provides: "RunMigrations function using pgx/v5/stdlib bridge and goose.Up()"
      exports: ["RunMigrations"]
    - path: "backend/internal/web/handlers.go"
      provides: "split HealthzHandler (no pinger) and new ReadyzHandler (pinger)"
      contains: "ReadyzHandler"
    - path: "backend/internal/web/router.go"
      provides: "NewRouter with fs.FS param, /readyz route, /healthz liveness route"
      contains: "ReadyzHandler"
  key_links:
    - from: "backend/cmd/web/main.go"
      to: "backend/internal/db/migrate.go"
      via: "db.RunMigrations(ctx, pool, assets.Migrations)"
      pattern: "RunMigrations"
    - from: "backend/cmd/web/main.go"
      to: "backend/embed.go"
      via: "assets.Static passed to web.NewRouter (import assets \"backend\")"
      pattern: "assets\\.Static"
    - from: "backend/internal/web/router.go"
      to: "ReadyzHandler"
      via: "r.Get(\"/readyz\", ReadyzHandler(pinger))"
      pattern: "readyz"
---

## Phase Goal

**As a** developer, **I want to** ship the Go backend in a self-contained binary with embedded assets, embedded migrations, and correct health endpoints, **so that** the Docker image has zero runtime file dependencies and production deployments run migrations automatically.

<objective>
Deliver all Go code changes required before the Dockerfile can be built successfully:

1. Create `backend/embed.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
2. Create `backend/internal/db/migrate.go` with `RunMigrations(ctx, pool, migrationsFS)` using the pgx/v5/stdlib bridge to goose.Up() (D-10, DEPLOY-03).
3. Refactor `backend/internal/web/handlers.go`: rename existing `HealthzHandler` → `ReadyzHandler`; add new no-pinger `HealthzHandler` (D-12, D-13, DEPLOY-04).
4. Update `backend/internal/web/router.go`: change `staticDir string` → `staticFS fs.FS`, register `/readyz` route with `ReadyzHandler`, keep `/healthz` with new liveness `HealthzHandler` (D-09, D-12, D-13).
5. Update `backend/cmd/web/main.go`: call `db.RunMigrations(ctx, pool, assets.Migrations)` after pool creation and pass `assets.Static` to `NewRouter` (D-10).
6. Update `backend/internal/web/handlers_test.go`: refactor `TestHealthz_OK` (no pinger), delete `TestHealthz_Down`, add `TestReadyz_OK` and `TestReadyz_Down`, fix all `NewRouter` call sites to pass `os.DirFS("./static")` (DEPLOY-04, Pitfall 8 in RESEARCH).

Purpose: Without this plan, `docker build` fails (assets not embedded, *_templ.go generated at build time but go:embed missing). Tests also fail after handler refactor if not updated together.

Output: All Go source changes committed; `cd backend && go test ./...` is green.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/07-deploy-v1/07-PATTERNS.md
</context>

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From backend/internal/web/router.go (current signature — will be changed):
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
```

From backend/internal/web/handlers.go (current — will be refactored):
```go
// Current (becoming ReadyzHandler):
func HealthzHandler(pinger Pinger) http.HandlerFunc
type Pinger interface { Ping(ctx context.Context) error }
```

From backend/internal/web/handlers_test.go (tests that call NewRouter):
```go
// Line 69, 84, 107 — all pass "./static" as second arg today:
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
```

From backend/cmd/web/main.go (current — will be modified):
```go
pool, err := db.NewPool(ctx, dsn)         // line 64
q := sqlc.New(pool)                        // line 71 — goose.Up() goes between these
router := web.NewRouter(pool, "./static", deps, ...) // line 116 — changes to assets.Static
```

From backend/cmd/worker/main.go (rivermigrate pattern to mirror for goose.Up()):
```go
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
for _, v := range res.Versions {
    slog.Info("river migration applied", "version", v.Version)
}
```
</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split</name>
  <files>
    backend/embed.go
    backend/internal/db/migrate.go
    backend/internal/web/handlers.go
    backend/internal/web/handlers_test.go
  </files>
  <read_first>
    - backend/internal/web/handlers.go (current HealthzHandler to rename, import list)
    - backend/internal/web/handlers_test.go (TestHealthz_OK and TestHealthz_Down to refactor; stubPinger already defined)
    - backend/cmd/worker/main.go (rivermigrate startup pattern to mirror for goose.Up() in migrate.go)
    - backend/internal/db/ (existing package files to understand package name and any existing migrate.go)
    - backend/migrations/ (directory list — all .sql files; embed.FS will embed these)
    - backend/static/ (directory list — confirm no files starting with . or _; if found, use //go:embed all:static)
    - backend/go.mod (confirm github.com/pressly/goose/v3 and github.com/jackc/pgx/v5 versions)
  </read_first>
  <behavior>
    - TestHealthz_OK: `HealthzHandler()` (no args) returns 200 with body containing `"status":"ok"` but NOT `"db":"ok"`
    - TestHealthz_Down: deleted (new HealthzHandler has no failure mode)
    - TestReadyz_OK: `ReadyzHandler(stubPinger{err: nil})` returns 200 with body containing both `"status":"ok"` and `"db":"ok"`
    - TestReadyz_Down: `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` returns 503 with body containing `"status":"degraded"` and `"db":"down"`
  </behavior>
  <action>
    **Verified approach (no ambiguity — read RESEARCH.md Open Questions (RESOLVED) before starting):**

    Create `backend/embed.go` in `package assets`. This file lives at the module root (`backend/`), making `static/` and `migrations/` siblings — so `//go:embed` directives resolve without any `..` paths. Module is `module backend` (verified in `go.mod`). No other .go files exist at `backend/` root, so no package name conflict. Declare two exported vars:
    - `//go:embed all:static` → `var Static embed.FS` (use `all:` prefix because `backend/static/.gitkeep` exists)
    - `//go:embed migrations` → `var Migrations embed.FS` (no hidden files in migrations/)
    Import in `cmd/web/main.go` as `import assets "backend"` — aliasing the import path to the declared package name `assets`.

    Create `backend/internal/db/migrate.go` in `package db`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer sqlDB.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(sqlDB, "migrations")` and returns the error. Required imports: `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"embed"`, `"context"`. Do NOT set a custom table name — default `goose_db_version` is correct.

    In `backend/internal/web/handlers.go`: rename existing `HealthzHandler(pinger Pinger) http.HandlerFunc` → `ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s context timeout, `db.Ping()`, 503 + `{"status":"degraded","db":"down"}` on error, 200 + `{"status":"ok","db":"ok"}` on success). Add new `HealthzHandler() http.HandlerFunc` that returns 200 + `{"status":"ok"}` immediately with no DB ping and no pinger argument.

    In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args and assert 200 + body contains `"status":"ok"` but NOT `"db":"ok"`; delete `TestHealthz_Down`; add `TestReadyz_OK` calling `ReadyzHandler(stubPinger{err: nil})` → 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` calling `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` → 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct is unchanged.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/... -run "TestHealthz|TestReadyz" -v -count=1</automated>
  </verify>
  <done>
    TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. `backend/embed.go` exists in `package assets` with `//go:embed all:static` and `//go:embed migrations`. backend/internal/db/migrate.go exports RunMigrations.
  </done>
</task>

<task type="auto">
  <name>Task 2: Wire goose.Up() and embed.FS into cmd/web/main.go and fix NewRouter signature + all test call sites</name>
  <files>
    backend/internal/web/router.go
    backend/cmd/web/main.go
    backend/internal/web/handlers_test.go
  </files>
  <read_first>
    - backend/internal/web/router.go (full file — change staticDir string → staticFS fs.FS, add /readyz route, update /healthz)
    - backend/cmd/web/main.go (full file — add RunMigrations call after pool creation, change NewRouter call to pass assets.Static)
    - backend/internal/web/handlers_test.go (all NewRouter call sites that pass "./static" — need os.DirFS("./static"))
    - backend/embed.go (created in Task 1 — verify package name and exported var names)
    - backend/internal/db/migrate.go (created in Task 1 — verify RunMigrations signature)
    - backend/go.mod (confirm module name for import path: "backend/assets" or what the actual package path is)
  </read_first>
  <action>
    In `backend/internal/web/router.go`: change the `NewRouter` function signature — replace the `staticDir string` parameter with `staticFS fs.FS` (add `"io/fs"` to imports, remove no-longer-needed static path logic). Replace the static file serving lines (currently `fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))` and `r.Get("/static/*", fs.ServeHTTP)`) with the embed.FS pattern: `sub, err := fs.Sub(staticFS, "static"); if err != nil { panic(...) }; fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub))); r.Get("/static/*", fileServer.ServeHTTP)`. Change the `/healthz` route from `r.Get("/healthz", HealthzHandler(pinger))` to `r.Get("/healthz", HealthzHandler())` (liveness, no pinger, per D-12). Add `r.Get("/readyz", ReadyzHandler(pinger))` immediately after (per D-13). Variable name conflict: the local variable `fs` used for the file server will conflict with the `"io/fs"` package import alias. Rename the local variable (e.g., `fileHandler`) to avoid the shadowing.

    In `backend/cmd/web/main.go`: add `import assets "backend"` — this aliases the import path `"backend"` (module root files) to the local identifier `assets`, which matches the `package assets` declaration in `backend/embed.go`. This is valid Go aliased import syntax.

    After `pool, err := db.NewPool(ctx, dsn)` and its nil-check block, before `q := sqlc.New(pool)`: add a call to `db.RunMigrations(ctx, pool, assets.Migrations)`. Use the same `slog.Error(...) + os.Exit(1)` error-handling pattern already used for `db.NewPool`. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.

    In `backend/internal/web/handlers_test.go`: find all call sites that pass `"./static"` as the second argument to `NewRouter` (lines 69, 84, 107 based on file inspection) and change them to `os.DirFS("./static")`. Add `"os"` to the test imports if not already present.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./cmd/web/... && go test ./... -count=1</automated>
  </verify>
  <done>
    `go build ./cmd/web/...` succeeds. `go test ./...` is green (all tests pass including updated TestHealthz_OK, new TestReadyz_OK/Down, and all router-level tests with os.DirFS). NewRouter signature uses fs.FS for static assets. /readyz route is registered. cmd/web/main.go calls db.RunMigrations before constructing the router. No import errors or compilation failures.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| External → /healthz and /readyz | These routes are public (no auth required). Attackers can probe them. |
| Binary → embedded filesystem | go:embed bakes assets into binary at build time; no runtime file access possible. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | Information Disclosure | /healthz and /readyz response bodies | mitigate | Return only `{"status":"ok"}` or `{"status":"degraded","db":"down"}` — no version strings, no stack traces, no DSN fragments |
| T-07-02 | Information Disclosure | goose_db_version table default name | accept | Table name is internal schema metadata; not exposed via any API endpoint; low risk |
| T-07-03 | Denial of Service | goose.Up() blocks startup until complete | accept | Migrations are idempotent and fast after initial apply; startup delay is bounded; acceptable trade-off for D-10 |
| T-07-04 | Tampering | go:embed includes files at build time | accept | Embedded FS is read-only at runtime; cannot be tampered with without rebuilding the binary |
</threat_model>

<verification>
Full suite after both tasks:

```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1
```

All tests green. No compilation errors. Verify embed works:

```
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./cmd/web/... && ls -la cmd/web/web 2>/dev/null || go build -o /tmp/xtablo-web ./cmd/web && ls -la /tmp/xtablo-web
```
</verification>

<success_criteria>
1. `cd backend && go test ./... -count=1` exits 0 with all tests passing
2. `cd backend && go build ./cmd/web/...` exits 0 (binary compiles with embedded assets and migrations)
3. `grep -r "ReadyzHandler" backend/internal/web/router.go` returns a match
4. `grep -r "RunMigrations" backend/cmd/web/main.go` returns a match
5. `backend/embed.go` exists in `package assets`, contains `//go:embed all:static` and `//go:embed migrations`
6. `backend/internal/db/migrate.go` exists and exports `RunMigrations`
7. TestHealthz_OK passes without a pinger argument; TestHealthz_Down is deleted; TestReadyz_OK and TestReadyz_Down exist and pass
</success_criteria>

<output>
After completion, create `.planning/phases/07-deploy-v1/07-01-SUMMARY.md`
</output>
