---
phase: 03-tablos-crud
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/migrations/0003_tablos.sql
  - backend/internal/db/queries/tablos.sql
  - backend/internal/db/sqlc/tablos.sql.go
  - backend/internal/db/sqlc/models.go
  - backend/internal/web/handlers_tablos_test.go
  - backend/internal/web/ui/button.css
autonomous: true
requirements:
  - TABLO-01
  - TABLO-02
  - TABLO-03
  - TABLO-04
  - TABLO-05
  - TABLO-06
tags:
  - go
  - postgres
  - sqlc
  - goose
  - integration-tests

must_haves:
  truths:
    - "Migration 0003_tablos creates tablos table with all columns from D-02"
    - "sqlc generates Tablo struct with pgtype.Text for nullable description and color"
    - "handlers_tablos_test.go compiles and runs (all tests fail/skip because handlers do not exist yet)"
    - "Button CSS gains .ui-button-solid-danger-md and .ui-button-soft-neutral-md rules"
  artifacts:
    - path: "backend/migrations/0003_tablos.sql"
      provides: "tablos table + tablos_user_id_idx"
      contains: "CREATE TABLE tablos"
    - path: "backend/internal/db/queries/tablos.sql"
      provides: "5 sqlc queries: ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo"
      contains: "name: ListTablosByUser"
    - path: "backend/internal/db/sqlc/tablos.sql.go"
      provides: "Generated sqlc query bindings"
      contains: "ListTablosByUser"
    - path: "backend/internal/web/handlers_tablos_test.go"
      provides: "Integration test scaffold for all TABLO-01..06 paths"
      contains: "func TestTabloList"
    - path: "backend/internal/web/ui/button.css"
      provides: "Danger and neutral-soft button variants"
      contains: "ui-button-solid-danger-md"
  key_links:
    - from: "backend/migrations/0003_tablos.sql"
      to: "users(id)"
      via: "REFERENCES users(id) ON DELETE CASCADE"
      pattern: "REFERENCES users\\(id\\) ON DELETE CASCADE"
    - from: "backend/internal/db/queries/tablos.sql"
      to: "backend/internal/db/sqlc/tablos.sql.go"
      via: "sqlc generate"
      pattern: "ListTablosByUser"
---

<objective>
Lay the data + test foundation for Phase 3 Tablos CRUD: the goose migration, sqlc query file, generated bindings, and the Wave 0 integration test scaffold that downstream plans will turn green. Add the two new button CSS variants so subsequent template work can render the delete/cancel buttons without inline styles.

Purpose: Establish the schema, type-safe DB layer, and failing-test baseline before any handler code is written. Plans 02 and 03 turn this red into green.

Output:
- 0003_tablos.sql migration
- tablos.sql query file + generated tablos.sql.go + extended models.go
- handlers_tablos_test.go covering all TABLO-01..06 paths (RED, compiles, fails because handlers/routes do not yet exist)
- button.css with danger + neutral-soft variants
</objective>

## Phase Goal

The ROADMAP `**Goal:**` line is task-shaped ("A logged-in user can list, create, view, edit, and delete..."), not a user story. Recording derived user story for MVP_MODE; if mismatch matters, run `/gsd mvp-phase 03` to formalise.

**As a** signed-in Xtablo user, **I want to** create, view, edit, and delete my own tablos through HTMX-driven flows, **so that** I can organise my work without leaving the dashboard or seeing full-page reloads.

<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>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-tablos-crud/03-CONTEXT.md
@.planning/phases/03-tablos-crud/03-RESEARCH.md
@.planning/phases/03-tablos-crud/03-PATTERNS.md
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
@.planning/phases/03-tablos-crud/03-VALIDATION.md
@backend/migrations/0002_auth.sql
@backend/internal/db/queries/users.sql
@backend/internal/db/sqlc/models.go
@backend/internal/web/handlers_auth_test.go
@backend/internal/web/testdb_test.go
@backend/internal/web/ui/button.css
@backend/internal/web/ui/variants.go

<interfaces>
<!-- Contracts that downstream plans (02, 03) will implement against. -->

Expected sqlc-generated Tablo struct (in backend/internal/db/sqlc/models.go):
- ID          uuid.UUID
- UserID      uuid.UUID
- Title       string
- Description pgtype.Text   (Valid + String)
- Color       pgtype.Text
- CreatedAt   pgtype.Timestamptz
- UpdatedAt   pgtype.Timestamptz

Expected sqlc-generated Queries methods (backend/internal/db/sqlc/tablos.sql.go):
- ListTablosByUser(ctx, userID uuid.UUID) ([]Tablo, error)
- GetTabloByID(ctx, id uuid.UUID) (Tablo, error)
- InsertTablo(ctx, InsertTabloParams) (Tablo, error)   -- params: UserID, Title, Description (pgtype.Text), Color (pgtype.Text)
- UpdateTablo(ctx, UpdateTabloParams) (Tablo, error)   -- params: ID, Title, Description; sets updated_at = now()
- DeleteTablo(ctx, id uuid.UUID) error

Existing reusable infra (do NOT reimplement):
- backend/internal/web/testdb_test.go: setupTestDB(t) — boots a per-test schema using goose.Up against `backend/migrations`, returns *pgxpool.Pool. Phase 3 migrations are picked up automatically by goose.
- backend/internal/auth: RequireAuth middleware, Authed(ctx), Store
- backend/internal/web/handlers_auth_test.go: pattern for newAuthedRequest helper, cookie+session+csrf wiring
- backend/internal/web/ui/variants.go already declares ButtonVariantDanger, ButtonVariantNeutral, ButtonToneSoft — only the CSS classes are missing.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Migration 0003_tablos.sql + sqlc query file + regenerate sqlc</name>
  <files>backend/migrations/0003_tablos.sql, backend/internal/db/queries/tablos.sql, backend/internal/db/sqlc/tablos.sql.go, backend/internal/db/sqlc/models.go</files>
  <read_first>
    backend/migrations/0002_auth.sql
    backend/internal/db/queries/users.sql
    backend/internal/db/sqlc/models.go
    backend/internal/db/sqlc/users.sql.go
    backend/sqlc.yaml
    backend/justfile
    .planning/phases/03-tablos-crud/03-CONTEXT.md (D-01, D-02, D-03)
    .planning/phases/03-tablos-crud/03-RESEARCH.md (Migration + sqlc Queries sections)
    .planning/phases/03-tablos-crud/03-PATTERNS.md (analog migration + queries blocks)
  </read_first>
  <behavior>
    - Migration up creates tablos table with columns: id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text NULL, color text NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now().
    - Index tablos_user_id_idx on tablos(user_id).
    - Migration down: DROP TABLE IF EXISTS tablos.
    - sqlc generates Tablo struct matching the interfaces block above (pgtype.Text for description/color, pgtype.Timestamptz for created_at/updated_at).
    - UpdateTablo SQL must SET updated_at = now() explicitly (Pitfall 7).
  </behavior>
  <action>
    Create backend/migrations/0003_tablos.sql following the 0002_auth.sql header style ("-- migrations/0003_tablos.sql\n-- Phase 3: Tablos CRUD") with goose Up/Down sections. Schema per D-02 (CONTEXT.md): id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text, color text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(); CREATE INDEX tablos_user_id_idx ON tablos(user_id); Down drops tablos.

    Create backend/internal/db/queries/tablos.sql with exactly five named queries matching the RESEARCH.md "sqlc Queries" code block:
    - ListTablosByUser :many   (WHERE user_id = $1 ORDER BY created_at DESC)
    - GetTabloByID :one        (WHERE id = $1)
    - InsertTablo :one         (INSERT user_id,title,description,color RETURNING all columns)
    - UpdateTablo :one         (SET title=$2, description=$3, updated_at=now() WHERE id=$1 RETURNING all columns) — do NOT include color (per RESEARCH "color not editable in Phase 3")
    - DeleteTablo :exec        (DELETE FROM tablos WHERE id = $1)

    Use explicit column lists in SELECTs (no SELECT *), positional $N parameters, and the same -- name: header style as users.sql.

    Run `just generate` (or `just sqlc` if the justfile separates them) to produce backend/internal/db/sqlc/tablos.sql.go and update backend/internal/db/sqlc/models.go. Do not hand-edit the generated files.
  </action>
  <verify>
    <automated>cd backend && just generate && grep -q "CREATE TABLE tablos" migrations/0003_tablos.sql && grep -q "tablos_user_id_idx" migrations/0003_tablos.sql && grep -q "name: ListTablosByUser" internal/db/queries/tablos.sql && grep -q "name: UpdateTablo" internal/db/queries/tablos.sql && grep -q "updated_at = now()" internal/db/queries/tablos.sql && grep -q "func (q \\*Queries) ListTablosByUser" internal/db/sqlc/tablos.sql.go && grep -q "type Tablo struct" internal/db/sqlc/models.go && go build ./...</automated>
  </verify>
  <acceptance_criteria>
    - File backend/migrations/0003_tablos.sql exists and contains "CREATE TABLE tablos", "REFERENCES users(id) ON DELETE CASCADE", "tablos_user_id_idx", "-- +goose Up", "-- +goose Down".
    - File backend/internal/db/queries/tablos.sql exists and contains exactly the 5 query names (ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo).
    - tablos.sql contains "ORDER BY created_at DESC" (D-03) and "updated_at = now()" (Pitfall 7) in UpdateTablo.
    - tablos.sql UpdateTablo does NOT include color (Phase 3 scope per RESEARCH §sqlc Queries note).
    - backend/internal/db/sqlc/tablos.sql.go exists with exported methods ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo on *Queries.
    - backend/internal/db/sqlc/models.go contains `type Tablo struct` with Description and Color fields typed as pgtype.Text.
    - `go build ./...` from backend/ exits 0.
  </acceptance_criteria>
  <done>Migration committed, query file committed, sqlc-generated files committed, build green.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: handlers_tablos_test.go — RED integration test scaffold for TABLO-01..06</name>
  <files>backend/internal/web/handlers_tablos_test.go</files>
  <read_first>
    backend/internal/web/handlers_auth_test.go
    backend/internal/web/testdb_test.go
    backend/internal/web/router.go
    backend/internal/web/csrf_test.go
    backend/internal/db/sqlc/tablos.sql.go (from Task 1)
    backend/internal/db/sqlc/models.go (Tablo struct shape)
    .planning/phases/03-tablos-crud/03-VALIDATION.md (per-task verification map)
    .planning/phases/03-tablos-crud/03-RESEARCH.md (Phase Requirements → Test Map)
  </read_first>
  <behavior>
    File must compile and `go test` must execute every named test. All tests are expected to FAIL or panic for now (handlers/routes do not exist) — that is the RED state Plans 02 and 03 will fix. Each test must exist as a real Go function (no t.Skip stubs) so the failing assertions document the contract.

    Tests required (function names exact, per 03-VALIDATION.md):
    - TestTabloList — authed GET / shows current user's tablos, newest-first; only the calling user's rows present.
    - TestTabloList_Empty — authed GET / with no tablos renders empty-state copy "No tablos yet" + "Create your first tablo to get started.".
    - TestTabloCreate — authed POST /tablos with HX-Request:true and valid title/description/color: returns 200, body contains the new title; HX-Retarget header == "#tablos-list", HX-Reswap header == "afterbegin"; response body contains an OOB element `id="create-form-slot"` with hx-swap-oob="true"; DB row exists with user_id = caller. Sub-asserts cover TABLO-06: non-HTMX POST returns 303 to "/".
    - TestTabloCreate_Validation — POST /tablos with empty title returns 422; response body contains "Title is required.".
    - TestTabloDetail_Owner — GET /tablos/{id} as owner returns 200 with title rendered.
    - TestTabloDetail_NonOwner — GET /tablos/{id} as a different authed user returns 404 (not 403).
    - TestTabloDetail_InvalidID — GET /tablos/not-a-uuid returns 404.
    - TestTabloUpdate — authed POST /tablos/{id} with new title/description: returns 200, body contains updated title; DB row title/updated_at refreshed.
    - TestTabloDeleteConfirm — GET /tablos/{id}/delete-confirm returns 200 with body containing "Delete tablo?" and "Yes, delete".
    - TestTabloDelete — authed POST /tablos/{id}/delete: DB row deleted; HTMX request gets 200 + HX-Redirect:/ header (or HX-Retarget #tablos-list on dashboard delete); non-HTMX gets 303 to /.
  </behavior>
  <action>
    Create backend/internal/web/handlers_tablos_test.go in package web. Reuse setupTestDB (from testdb_test.go) and the CSRF/cookie helpers exactly as handlers_auth_test.go does — use a duplicated helper if needed (per STATE.md decision: "setupTestDB duplicated into web package test file"). Drive requests through NewRouter so the full middleware chain (RequestID, ResolveSession, csrf.Protect, RequireAuth) runs.

    For each test:
    1. Call setupTestDB(t) to get *pgxpool.Pool. Build *sqlc.Queries.
    2. Insert two test users via Queries.InsertUser (reusing handlers_auth_test.go pattern).
    3. Build TablosDeps{Queries: q} and pass into web.NewRouter using the NEW signature: NewRouter(pinger, "./static", AuthDeps{...}, web.TablosDeps{Queries: q}, csrfKey, "test", "localhost"). Plans 02/03 must add TablosDeps to web package; if symbol does not yet exist when test compiles, expect a compile error — that is part of the RED state. To keep the test FILE compilable now while still being RED at runtime, declare a local stub: `var _ = web.TablosDeps{}` referencing the type Plan 02 introduces. If that prevents compilation, gate the test scaffold with the same TablosDeps stub introduced in Plan 02 Task 0 (interface skeleton).
    4. Acquire a session by POSTing /signup with HX-Request:false (same flow as handlers_auth_test.go), capture cookie + CSRF token via subsequent GET /.
    5. Issue the test's HTTP request via httptest.NewRecorder + router.ServeHTTP.
    6. Assert status, headers (HX-Retarget, HX-Reswap, HX-Redirect), and body substrings per the behavior block.
    7. Verify DB state by calling Queries.GetTabloByID / ListTablosByUser as appropriate.

    Use t.Run subtests where one Test* name covers multiple assertions (e.g. TestTabloCreate covers HTMX-path + non-HTMX-path).

    DO NOT implement handlers in this task — only the test scaffold. Tests are expected to fail until Plans 02 and 03 land.
  </action>
  <verify>
    <automated>cd backend && just generate && go test ./internal/web/... -run TestTablo -count=1 2>&1 | tee /tmp/test_out.log; grep -E "TestTabloList|TestTabloCreate|TestTabloDetail|TestTabloUpdate|TestTabloDelete" /tmp/test_out.log | grep -cE "(FAIL|RUN)" | xargs -I{} test {} -ge 10 && echo "scaffold present"</automated>
  </verify>
  <acceptance_criteria>
    - File backend/internal/web/handlers_tablos_test.go exists in package web.
    - grep -c '^func Test' backend/internal/web/handlers_tablos_test.go returns >= 10 (covers the 10 named tests above).
    - Each of these substrings appears in the file: "TestTabloList(", "TestTabloList_Empty(", "TestTabloCreate(", "TestTabloCreate_Validation(", "TestTabloDetail_Owner(", "TestTabloDetail_NonOwner(", "TestTabloDetail_InvalidID(", "TestTabloUpdate(", "TestTabloDeleteConfirm(", "TestTabloDelete(".
    - File references HX-Retarget and HX-Reswap as expected response headers in TestTabloCreate.
    - File references http.StatusSeeOther (303) and HX-Redirect for the post-delete navigation assertion.
    - File contains an ownership-cross-user assertion (creates user A's tablo, requests it as user B, expects 404) in TestTabloDetail_NonOwner.
    - Test file's intended state is RED — Plan 01 acceptance is that the file compiles after Plan 02 introduces the TablosDeps stub. To unblock Plan 01 alone, if web.TablosDeps does not yet exist, this task additionally creates a one-line stub `type TablosDeps struct { Queries *sqlc.Queries }` at the top of handlers_tablos.go (new empty file) so the test file compiles.
    - `go vet ./internal/web/...` exits 0.
  </acceptance_criteria>
  <done>Test scaffold compiles; running `go test ./internal/web/... -run TestTablo` lists all 10 tests as FAIL (no implementation yet).</done>
</task>

<task type="auto">
  <name>Task 3: Add ui-button-solid-danger-md and ui-button-soft-neutral-md CSS rules</name>
  <files>backend/internal/web/ui/button.css</files>
  <read_first>
    backend/internal/web/ui/button.css
    backend/internal/web/ui/variants.go
    .planning/phases/03-tablos-crud/03-UI-SPEC.md (New Button Variants Required + Color sections)
    .planning/phases/03-tablos-crud/03-PATTERNS.md (button.css analog block)
  </read_first>
  <action>
    Append two new top-level rule sets to backend/internal/web/ui/button.css following the existing convention (NO CSS nesting — all pseudo-class selectors as separate top-level rules; class name format `.ui-button-{tone}-{variant}-{size}`).

    Class `.ui-button-solid-danger-md`: background-color #b91c1c (red-700), color #ffffff, padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 600, display inline-flex, align-items center, min-height 44px (WCAG 2.5.5 per UI-SPEC). Hover rule: background-color #991b1b. Focus-visible rule: outline 2px solid #b91c1c, outline-offset 2px.

    Class `.ui-button-soft-neutral-md`: background-color #f1f5f9 (slate-100), color #334155 (slate-700), padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 400, border 1px solid #e2e8f0, display inline-flex, align-items center, min-height 44px. Hover rule: background-color #e2e8f0. Focus-visible rule: outline 2px solid #64748b, outline-offset 2px.

    Run `just generate` to rebuild static/tailwind.css (Pitfall 4: imported CSS passes through).
  </action>
  <verify>
    <automated>cd backend && grep -c "^\\.ui-button-solid-danger-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -c "^\\.ui-button-soft-neutral-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -q "#b91c1c" internal/web/ui/button.css && grep -q "#f1f5f9" internal/web/ui/button.css && grep -q "min-height: 44px" internal/web/ui/button.css && just generate && grep -q "ui-button-solid-danger-md" static/tailwind.css</automated>
  </verify>
  <acceptance_criteria>
    - button.css contains top-level selectors `.ui-button-solid-danger-md`, `.ui-button-solid-danger-md:hover`, `.ui-button-solid-danger-md:focus-visible`, `.ui-button-soft-neutral-md`, `.ui-button-soft-neutral-md:hover`, `.ui-button-soft-neutral-md:focus-visible` (each as its own top-level rule — no `&` nesting).
    - Hex codes #b91c1c, #991b1b, #f1f5f9, #e2e8f0, #334155, #64748b present.
    - `min-height: 44px` appears for both new classes.
    - After `just generate`, backend/static/tailwind.css contains the strings `ui-button-solid-danger-md` and `ui-button-soft-neutral-md`.
    - `go build ./...` from backend/ exits 0 (sanity — no Go changes here but verifying nothing breaks).
  </acceptance_criteria>
  <done>CSS classes present, Tailwind regenerated, no other files modified.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Browser → web server | Untrusted form input crosses into POST /tablos handlers (Plans 02/03); Phase 1 sets the test surface but introduces no new boundary. |
| web server → Postgres | SQL parameters from sqlc bindings are parameterised ($N) — no string concat. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information disclosure | tablos table FK to users | mitigate | ON DELETE CASCADE on user_id ensures orphan-free deletes; tablos_user_id_idx supports per-user query bounded by user_id (no cross-tenant scans). |
| T-03-02 | Tampering | sqlc query layer | mitigate | All five queries use positional $N parameters (no string interpolation); sqlc generates type-safe Go bindings consumed by Plans 02/03. |
| T-03-03 | DoS | tablos.title column | accept (mitigated by Plans 02/03) | Schema accepts arbitrary-length text; per-request length validation (<=255) happens in Plans 02/03 handlers. Documented here so reviewers know length-cap is layered above the DB. |
| T-03-04 | Repudiation | tablos audit trail | accept | No audit log table in v1 per ROADMAP scope; created_at/updated_at provide minimum forensic signal. |
| T-03-05 | Tampering | Integration test fixtures | mitigate | handlers_tablos_test.go uses setupTestDB per-test schema isolation (D-09 carryover from Phase 2) — no test pollutes another's data. |
</threat_model>

<verification>
- `cd backend && just generate` runs without error.
- `cd backend && go build ./...` exits 0.
- `cd backend && go test ./internal/web/... -run TestTablo` lists all 10 TABLO tests, all FAIL (RED state expected).
- `grep -c "^func Test" backend/internal/web/handlers_tablos_test.go` >= 10.
- `psql` against the local compose DB shows tablos table with the 7 columns from D-02 after `just migrate up`.
- New button CSS classes appear in backend/static/tailwind.css.
</verification>

<success_criteria>
1. backend/migrations/0003_tablos.sql exists and applies cleanly to a fresh DB via goose.
2. backend/internal/db/queries/tablos.sql defines the 5 queries; sqlc regeneration succeeds.
3. backend/internal/db/sqlc/tablos.sql.go and updated models.go are committed (generated, not hand-written).
4. backend/internal/web/handlers_tablos_test.go compiles, declares all 10 named test functions, and currently fails at runtime (handlers missing — Plans 02/03 turn it green).
5. backend/internal/web/ui/button.css declares the two new variants; Tailwind rebuild emits them into static/tailwind.css.
6. No regression: existing `just test` suite (Phase 1/2 tests) still passes.
</success_criteria>

<output>
After completion, create `.planning/phases/03-tablos-crud/03-01-SUMMARY.md` covering: migration shape, query list, generated bindings overview, button CSS additions, and a count of failing TABLO tests (expected: 10/10 fail before Plans 02/03).
</output>
