diff --git a/.planning/STATE.md b/.planning/STATE.md
index 875dc05..a8ba398 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,7 +3,7 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: unknown
-last_updated: "2026-05-14T15:18:59.172Z"
+last_updated: "2026-05-14T15:41:00.506Z"
progress:
total_phases: 7
completed_phases: 0
diff --git a/.planning/phases/01-foundation/01-01-PLAN.md b/.planning/phases/01-foundation/01-01-PLAN.md
index 68df887..22d21a7 100644
--- a/.planning/phases/01-foundation/01-01-PLAN.md
+++ b/.planning/phases/01-foundation/01-01-PLAN.md
@@ -35,10 +35,12 @@ tags:
must_haves:
truths:
- - "Running `just bootstrap` from a fresh clone installs goose, templ, sqlc, air CLIs and downloads the Tailwind standalone binary + htmx.min.js into backend/bin and backend/static"
+ - "Running `just bootstrap` from a fresh clone installs goose, templ, sqlc, air CLIs and bootstrap-downloads the Tailwind standalone binary + htmx.min.js into backend/bin and backend/static (these assets are NOT committed; they are gitignored and reproduced by `just bootstrap`)"
- "Running `just db-up` (or `podman compose up -d postgres`) starts a healthy Postgres 16 container reachable at localhost:5432"
- "Running `just migrate up` against the running Postgres applies migration 0001_init.sql cleanly and `just migrate status` shows it applied"
- - "Running `just --list` shows recipes for at least: bootstrap, dev, db-up, db-down, migrate, generate, styles-watch, test, lint, build"
+ - "Running `just --list` shows recipes for at least: bootstrap, dev, db-up, db-down, migrate, generate, styles-watch, test, lint, build, clean"
+ - "Phase-1 caveat: the `generate`, `test`, `build`, and `dev` recipes are scaffolded in Plan 01-01 but only become runnable after Plans 01-02 and 01-03 land their consumers (templ files, ui/*.css, cmd/web/main.go, cmd/worker/main.go). The justfile parses and `just --list` enumerates them in Plan 01-01; their end-to-end execution is covered by Plan 01-03's checkpoint."
+ - "CDN policy (clarified): no runtime CDN references in served HTML/CSS/JS or in any committed source file consumed at request time; bootstrap-time download URLs (Tailwind GitHub release, HTMX from unpkg) appear ONLY inside `backend/justfile` and are the single authoritative source for the pinned asset versions."
- "Tailwind input CSS declares @source globs that include backend/templates/**/*.templ and backend/internal/web/**/*.{templ,go} so JIT does not purge classes used only in templ files"
- ".env.example documents DATABASE_URL, PORT, ENV and .env is gitignored"
- "D-01: Two-binary cmd/web + cmd/worker layout with shared internal/ packages"
@@ -164,17 +166,22 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
- .planning/phases/01-foundation/01-RESEARCH.md (Standard Stack section — pinned versions are authoritative)
- .planning/phases/01-foundation/SKELETON.md (locked versions table)
- From repo root, create `backend/`, then `cd backend && go mod init backend`. Pin the following runtime deps with `go get` at the exact versions from RESEARCH.md Standard Stack: `github.com/go-chi/chi/v5@v5.2.5`, `github.com/a-h/templ@v0.3.1020`, `github.com/jackc/pgx/v5@v5.9.2`, `github.com/pressly/goose/v3@v3.27.1`, `github.com/google/uuid@v1.6.0`. Do NOT use any version other than those listed. Run `go mod tidy` so `go.sum` is populated. The Go directive should match the developer's installed Go (1.22 minimum; existing `go-backend/go.mod` uses 1.26 — use the same `go` line if available, otherwise `1.22`). Do not import or fetch `air`, `sqlc`, `templ` CLI, `goose` CLI as Go module deps in `go.mod` — those are installed via `go install` from the `just bootstrap` recipe (Task 6).
+ From repo root, create `backend/`, then `cd backend && go mod init backend`. Pin the following runtime deps with `go get` at the exact versions from RESEARCH.md Standard Stack: `github.com/go-chi/chi/v5@v5.2.5`, `github.com/a-h/templ@v0.3.1020`, `github.com/jackc/pgx/v5@v5.9.2`, `github.com/pressly/goose/v3@v3.27.1`, `github.com/google/uuid@v1.6.0`. Do NOT use any version other than those listed.
+
+**Do NOT run `go mod tidy` in this task.** Because no Go source file imports these packages yet in Plan 01-01, `go mod tidy` would aggressively strip the `require` lines we just pinned (Codex review concern #1). `go get` writes the pins into `go.mod` and `go.sum` directly; that is sufficient for Plan 01-01's purposes. `go mod tidy` becomes safe to run only after Plan 01-03 lands the consumer code in `internal/db/pool.go`, `internal/web/{router,handlers,middleware,slog}.go`, and `cmd/{web,worker}/main.go` — Plan 01-03's `just generate` / `just build` workflow will execute it implicitly, and at that point all five deps have real importers so `tidy` is a no-op on the require list.
+
+The Go directive should match the developer's installed Go (1.22 minimum; existing `go-backend/go.mod` uses 1.26 — use the same `go` line if available, otherwise `1.22`). Do not import or fetch `air`, `sqlc`, `templ` CLI, `goose` CLI as Go module deps in `go.mod` — those are installed via `go install` from the `just bootstrap` recipe (Task 5).
- cd backend && go mod verify && grep -q 'github.com/go-chi/chi/v5 v5.2.5' go.mod && grep -q 'github.com/a-h/templ v0.3.1020' go.mod && grep -q 'github.com/jackc/pgx/v5 v5.9.2' go.mod && grep -q 'github.com/pressly/goose/v3 v3.27.1' go.mod && grep -q 'github.com/google/uuid v1.6.0' go.mod
+ cd backend && test -f go.mod && test -f go.sum && grep -q '^module backend$' go.mod && grep -q 'github.com/go-chi/chi/v5 v5.2.5' go.mod && grep -q 'github.com/a-h/templ v0.3.1020' go.mod && grep -q 'github.com/jackc/pgx/v5 v5.9.2' go.mod && grep -q 'github.com/pressly/goose/v3 v3.27.1' go.mod && grep -q 'github.com/google/uuid v1.6.0' go.mod
- `backend/go.mod` declares `module backend`
- - All five runtime deps pinned at the versions above (verified by grep, not just presence)
- - `go mod verify` exits 0
- - `go.sum` is populated and committed
+ - All five runtime deps pinned at the exact versions above (verified by grep on the literal `name version` line)
+ - `go.sum` exists and is non-empty (populated by `go get`)
+ - `go mod tidy` is NOT run in this task (Codex concern #1: would strip the require lines because no Go source imports them yet)
+ - `go mod verify` is NOT asserted here because it can interact badly with un-imported requires on some Go versions; Plan 01-03 reasserts it after real importers land
- `backend/go.mod` and `backend/go.sum` exist, module is `backend`, all five deps pinned at locked versions, `go mod verify` passes.
+ `backend/go.mod` and `backend/go.sum` exist, module is `backend`, all five deps pinned at locked versions; tidying deferred to Plan 01-03 where real imports exist.
@@ -204,7 +211,7 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
- .planning/phases/01-foundation/01-RESEARCH.md (Code Examples section — compose.yaml and .env.example are verbatim references; D-15, D-20)
- go-backend/compose.yaml (existing healthy reference; strip dev seed mounts as the research note instructs)
- Write `backend/compose.yaml` based on the verbatim block in RESEARCH.md Code Examples. Required: service name `postgres`, image `postgres:16-alpine`, container name `xtablo-backend-postgres`, env `POSTGRES_DB=xtablo`, `POSTGRES_USER=xtablo`, `POSTGRES_PASSWORD=xtablo`, port mapping `5432:5432`, named volume `postgres_data`, `pg_isready -U xtablo -d xtablo` healthcheck (interval 5s, timeout 5s, retries 10), `restart: unless-stopped`. Do NOT include the seed-mount volume from `go-backend/compose.yaml` — Phase 1 has nothing to seed. Write `backend/.env.example` with exactly the three keys locked in D-15: `DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable`, `PORT=8080`, `ENV=development`. Add a brief comment line above each key. Write `backend/.gitignore` covering: `bin/` (Tailwind binary + go-installed CLIs may live here), `tmp/` (air rebuild output), `.env`, `.env.local`, `static/tailwind.css` (generated), `static/htmx.min.js` (vendored on bootstrap, not committed), `*_templ.go` (templ-generated; per RESEARCH Pitfall 1 these are never committed), `internal/db/sqlc/*.go` (generated, except keep `.gitkeep`). Write `backend/migrations/0001_init.sql` exactly per RESEARCH.md Code Examples — goose annotations `-- +goose Up` and `-- +goose Down`, each section containing `SELECT 1;`. The file MUST start with `-- +goose Up` on the first non-comment line (goose parser requirement).
+ Write `backend/compose.yaml` based on the verbatim block in RESEARCH.md Code Examples. Required: service name `postgres`, image `postgres:16-alpine`, container name `xtablo-backend-postgres`, env `POSTGRES_DB=xtablo`, `POSTGRES_USER=xtablo`, `POSTGRES_PASSWORD=xtablo`, port mapping `5432:5432`, named volume `postgres_data`, `pg_isready -U xtablo -d xtablo` healthcheck (interval 5s, timeout 5s, retries 10), `restart: unless-stopped`. Do NOT include the seed-mount volume from `go-backend/compose.yaml` — Phase 1 has nothing to seed. Write `backend/.env.example` with exactly the three keys locked in D-15: `DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable`, `PORT=8080`, `ENV=development`. Add a brief comment line above each key. Write `backend/.gitignore` covering: `bin/` (Tailwind binary + go-installed CLIs may live here), `tmp/` (air rebuild output), `.env`, `.env.local`, `static/tailwind.css` (generated), `static/htmx.min.js` (bootstrap-downloaded by `just bootstrap`, never committed), `*_templ.go` (templ-generated; per RESEARCH Pitfall 1 these are never committed), `internal/db/sqlc/*.go` (generated, except keep `.gitkeep`). Write `backend/migrations/0001_init.sql` exactly per RESEARCH.md Code Examples — goose annotations `-- +goose Up` and `-- +goose Down`, each section containing `SELECT 1;`. The file MUST start with `-- +goose Up` on the first non-comment line (goose parser requirement).cd backend && grep -q 'postgres:16-alpine' compose.yaml && grep -q 'pg_isready' compose.yaml && grep -q '^DATABASE_URL=' .env.example && grep -q '^PORT=' .env.example && grep -q '^ENV=' .env.example && grep -q '^\.env$' .gitignore && grep -q 'tailwind.css' .gitignore && grep -q 'htmx.min.js' .gitignore && grep -q '_templ\.go' .gitignore && grep -q '\-\- +goose Up' migrations/0001_init.sql && grep -q '\-\- +goose Down' migrations/0001_init.sql
@@ -251,7 +258,7 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
Write `backend/justfile` modeled on the RESEARCH Code Examples block. Required at minimum, these recipes (names exact):
- `default` → `@just --list`
- - `bootstrap` → mkdir bin, then `go install` the four CLI tools at the exact RESEARCH-pinned versions: `github.com/pressly/goose/v3/cmd/goose@v3.27.1`, `github.com/a-h/templ/cmd/templ@v0.3.1020`, `github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`, `github.com/air-verse/air@v1.65.1`. Then `curl -sSL` the Tailwind v4 standalone binary into `./bin/tailwindcss` (use the platform-detection pattern `tailwindcss-$(uname -s | tr A-Z a-z)-$(uname -m)`) and `chmod +x`. Then `curl -sSL` the HTMX v2.x `htmx.min.js` from `unpkg.com/htmx.org@2/dist/htmx.min.js` into `./static/htmx.min.js` (vendored once at bootstrap; subsequently committed-via-gitignore semantics — per CONTEXT D-10 we vendor, never CDN). Pin the Tailwind version in a recipe-local variable at the top of the justfile (e.g., `tailwind_version := "v4.0.0"` — planner: use the latest 4.x stable available at bootstrap time; document the version in a comment).
+ - `bootstrap` → mkdir bin, then `go install` the four CLI tools at the exact RESEARCH-pinned versions: `github.com/pressly/goose/v3/cmd/goose@v3.27.1`, `github.com/a-h/templ/cmd/templ@v0.3.1020`, `github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`, `github.com/air-verse/air@v1.65.1`. Then bootstrap-download the Tailwind v4 standalone binary into `./bin/tailwindcss` using an **explicit OS/arch mapping** (Codex concern #2 — the `uname -s`/`uname -m` outputs do NOT match GitHub release asset naming; `darwin` vs `macos`, `x86_64` vs `x64`, `aarch64`/`arm64` vs `arm64`). The recipe must compute the asset name with a shell case on `uname -s` and `uname -m` resolving to one of: `tailwindcss-macos-x64`, `tailwindcss-macos-arm64`, `tailwindcss-linux-x64`, `tailwindcss-linux-arm64` (verify these against `https://github.com/tailwindlabs/tailwindcss/releases/latest` at implementation time — the four-name set above is current as of Tailwind v4.0). Use a recipe-local `tailwind_version` variable (e.g., `tailwind_version := "v4.0.0"` — use the latest 4.x stable available at bootstrap time and document in a comment). After download, `chmod +x bin/tailwindcss`. Then bootstrap-download the HTMX v2.x `htmx.min.js` from `unpkg.com/htmx.org@2/dist/htmx.min.js` into `./static/htmx.min.js` — this asset is bootstrap-downloaded (not committed; `.gitignore` excludes it), and the justfile is the single authoritative source for the HTMX version. Per CONTEXT D-10 the runtime page MUST NOT reference any CDN; the bootstrap-time `unpkg.com` URL inside the justfile is the explicit allowed exception.
- `db-up` → `podman compose up -d postgres`
- `db-down` → `podman compose down`
- `migrate cmd="status"` → invokes `goose` with `GOOSE_DRIVER=postgres`, `GOOSE_DBSTRING` set to the dev DSN, `GOOSE_MIGRATION_DIR=migrations`, passing `{{cmd}}` (so `just migrate up`, `just migrate down`, `just migrate status` all work)
@@ -261,17 +268,20 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
- `test` → runs `just generate` then `go test ./...`
- `lint` → runs `go vet ./...` and `gofmt -l .` (failing if any file would be reformatted)
- `build` → runs `just generate`, then `go build -o bin/web ./cmd/web`, then `go build -o bin/worker ./cmd/worker`
+ - `clean` → removes bootstrap-downloaded and generated artifacts: `rm -rf bin/ tmp/ static/htmx.min.js static/tailwind.css` and `find . -name '*_templ.go' -delete`. Does NOT remove the Postgres volume — call `just db-down` first if a full reset is needed. (Codex concern #10 — README Method B references `just clean`; provide a real recipe.)
Document at top of justfile in a comment block: project name, podman/docker portability note (CONTEXT D-11 — `compose.yaml` works under either; README will spell out the alternative `docker compose` invocation), and the two-terminal `dev` + `styles-watch` workflow. Do NOT add a recipe to download `goose`/`templ`/`sqlc`/`air` outside of `bootstrap` — keep one install path.
Note: `build` will fail until Plan 03 ships `cmd/web/main.go` and `cmd/worker/main.go`. That is expected and acceptable for Plan 01's verification — we only assert the justfile parses and `just --list` enumerates the required recipes.
- cd backend && just --list 2>/dev/null | grep -E '^\s+(bootstrap|db-up|db-down|migrate|generate|styles-watch|dev|test|lint|build)\b' | wc -l | awk '$1>=9{exit 0} {exit 1}'
+ cd backend && just --list 2>/dev/null | grep -E '^\s+(bootstrap|db-up|db-down|migrate|generate|styles-watch|dev|test|lint|build|clean)\b' | wc -l | awk '$1>=10{exit 0} {exit 1}'
- - `just --list` enumerates at least: bootstrap, db-up, db-down, migrate, generate, styles-watch, dev, test, lint, build
+ - `just --list` enumerates at least: bootstrap, db-up, db-down, migrate, generate, styles-watch, dev, test, lint, build, clean
- `bootstrap` recipe installs goose v3.27.1, templ v0.3.1020, sqlc v1.31.1, air v1.65.1 at exact pinned versions (verifiable by grep on the justfile)
- - `bootstrap` recipe downloads htmx.min.js into static/ from unpkg.com (NOT a CDN reference at runtime — D-10)
+ - `bootstrap` recipe resolves the Tailwind asset name via an explicit OS/arch case — `darwin→macos`, `linux→linux`, `x86_64→x64`, `arm64`/`aarch64→arm64` — and downloads one of the four documented asset names (Codex concern #2). Grep test: `grep -E 'tailwindcss-(macos|linux)-(x64|arm64)' backend/justfile` returns ≥ 1 match.
+ - `bootstrap` recipe bootstrap-downloads htmx.min.js into static/ from unpkg.com (this is a bootstrap-time exception to D-10's no-CDN rule; D-10 forbids runtime CDN references — the served HTML/CSS/JS must reference only `/static/htmx.min.js`)
+ - `clean` recipe exists and removes `bin/`, `tmp/`, `static/htmx.min.js`, `static/tailwind.css`, and `*_templ.go` files
- `migrate` recipe sets GOOSE_DRIVER, GOOSE_DBSTRING, GOOSE_MIGRATION_DIR
- `dev` recipe runs `db-up`, `generate`, then `air`
- No pnpm / npm / node references anywhere in the justfile (D-12)
@@ -316,7 +326,7 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
-| T-01-01 | T (Tampering) | `static/htmx.min.js` vendored via curl from unpkg | accept | Phase 1 scope: vendoring from a public CDN is an explicit decision (D-10 forbids runtime CDN, not bootstrap-time fetch). Subresource integrity hashes deferred to Phase 7 along with deploy hardening. Rationale: low-value local-dev attack surface; CSS/HTMX assets do not handle secrets in Phase 1. |
+| T-01-01 | T (Tampering) | `static/htmx.min.js` bootstrap-downloaded via curl from unpkg | accept | Phase 1 scope: vendoring from a public CDN is an explicit decision (D-10 forbids runtime CDN, not bootstrap-time fetch). Subresource integrity hashes deferred to Phase 7 along with deploy hardening. Rationale: low-value local-dev attack surface; CSS/HTMX assets do not handle secrets in Phase 1. |
| T-01-02 | T (Tampering) | `bin/tailwindcss` binary downloaded via curl | accept | Same rationale as T-01-01. Tailwind is local-dev tooling only; not shipped in any production image in Phase 1. Hash pinning revisited at Phase 7 (deploy) where reproducible CI bootstrap matters. |
| T-01-03 | I (Information disclosure) | `.env` containing dev DSN | mitigate | `.gitignore` excludes `.env`; only `.env.example` (with placeholder/dev-only credentials) is committed. Dev DSN uses the literal `xtablo:xtablo` credential set, which is harmless for local-only bound containers. |
| T-01-04 | I (Information disclosure) | `goose_db_version` table | accept | Standard goose metadata table; contains only migration version numbers and timestamps. No user data. |
@@ -338,7 +348,9 @@ Output: `backend/` exists with a valid Go module, all directory placeholders, wo
4. `just db-up` starts a healthy postgres:16-alpine container reachable at localhost:5432
5. `just migrate up` applies the no-op bootstrap migration; `just migrate status` shows it as Applied
6. No Node/npm/pnpm artifacts anywhere in `backend/` (D-12)
-7. No CDN references in any committed file (D-10)
+7. No **runtime** CDN references in any served HTML/CSS/JS (D-10 clarified): bootstrap-time download URLs inside `backend/justfile` (Tailwind GitHub release URL, HTMX unpkg URL) are the explicit allowed exception; templates and committed CSS reference only `/static/*` paths
+8. Tailwind asset name is resolved via explicit OS/arch mapping (`darwin→macos`, `x86_64→x64`, `arm64`/`aarch64→arm64`) — never via raw `uname` interpolation (Codex concern #2)
+9. `go mod tidy` is NOT executed in Plan 01-01 (Codex concern #1) — pinning is via `go get` only; tidy lands implicitly in Plan 01-03 when consumers import the deps
@@ -401,12 +418,13 @@ templ Badge(props BadgeProps)
- `cd backend && go build ./internal/web/ui/` succeeds
-- `cd backend && templ generate && go test ./internal/web/ui/ -count=1` exits 0 (all ui smoke tests GREEN)
-- `cd backend && go test ./internal/web/` exits NON-ZERO with `undefined: NewRouter` (or similar) — confirms RED gate is set for Plan 03
-- `cd backend && go test ./internal/db/` exits 0 (TestPool_Connects skips cleanly) OR exits non-zero with `undefined: db.NewPool` — either is acceptable here since Plan 03 lands `pool.go`
+- `cd backend && templ generate && go test ./internal/web/ui/... -count=1` exits 0 (all ui smoke tests GREEN — package-scoped)
+- `cd backend && go test ./... -count=1` exits 0 — the red-gated test files are excluded from default compilation (Codex concern #3); no leak into `go test ./...`
+- `cd backend && go test -tags=red_gate ./internal/web/... ./internal/db/... -count=1` exits NON-ZERO with `undefined: web.NewRouter` / `undefined: db.NewPool` — confirms the RED gate is set for Plan 01-03
- All ten files under `internal/web/ui/` exist
-- All three test files exist and contain the required `Test*` functions
+- Both red-gated test files exist, start with `//go:build red_gate`, and contain the required `Test*` functions
- `internal/web/ui/ui_test.go` covers Button (default+attrs), Card, Badge (info+success+zero-value)
+- `button.css` declares non-nested `.ui-button-solid-default-md:hover` and `:focus-visible` selectors (Codex concern #7)
@@ -415,7 +433,8 @@ templ Badge(props BadgeProps)
3. UI package CSS files are pickable by Tailwind input CSS imports (already wired in Plan 01)
4. `internal/web/handlers_test.go` declares the exact test names from VALIDATION.md and targets the exact handler signatures Plan 03 will implement
5. `internal/db/pool_test.go` declares `TestPool_Connects` with a graceful skip when `DATABASE_URL` is unset
-6. RED gate established: `go test ./internal/web/` fails because production handlers don't exist yet — this is intentional and is the target Plan 03 fills in
+6. RED gate established via `//go:build red_gate` (Codex concern #3): `go test -tags=red_gate ./internal/web/... ./internal/db/...` fails with `undefined: web.NewRouter` / `db.NewPool` — this is intentional and is the target Plan 01-03 fills in. Default `go test ./...` succeeds because the gated files are excluded.
+7. Component CSS uses non-nested selectors only (Codex concern #7): `.ui-button-solid-default-md:hover` and `:focus-visible` are top-level rules, not `&:hover` nested inside the base selector