---
phase: 03
slug: tablos-crud
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-05-14
---

# Phase 03 — Validation Strategy

> Per-phase validation contract for feedback sampling during execution.

---

## Test Infrastructure

| Property | Value |
|----------|-------|
| **Framework** | Go standard `testing` package + `net/http/httptest` |
| **Config file** | none — `go test ./...` from `backend/` |
| **Quick run command** | `go test ./internal/web/... -run TestTablo` |
| **Full suite command** | `just test` (runs `just generate` first, then `go test ./...`) |
| **Estimated runtime** | ~30 seconds |

---

## Sampling Rate

- **After every task commit:** Run `go test ./internal/web/... -run TestTablo`
- **After every plan wave:** Run `just test`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 30 seconds

---

## Per-Task Verification Map

| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 03-list | 01 | 1 | TABLO-01 | T-ownership | Authed user sees only their tablos | integration | `go test ./internal/web/... -run TestTabloList` | ❌ W0 | ⬜ pending |
| 03-list-empty | 01 | 1 | TABLO-01 | — | Empty state renders CTA | integration | `go test ./internal/web/... -run TestTabloList_Empty` | ❌ W0 | ⬜ pending |
| 03-create | 01 | 1 | TABLO-02 | T-csrf | CSRF token validated on POST | integration | `go test ./internal/web/... -run TestTabloCreate` | ❌ W0 | ⬜ pending |
| 03-create-validation | 01 | 1 | TABLO-02 | T-input | Empty title returns 422 + errors | integration | `go test ./internal/web/... -run TestTabloCreate_Validation` | ❌ W0 | ⬜ pending |
| 03-detail-owner | 02 | 1 | TABLO-03 | T-ownership | Owner sees detail page | integration | `go test ./internal/web/... -run TestTabloDetail_Owner` | ❌ W0 | ⬜ pending |
| 03-detail-nonowner | 02 | 1 | TABLO-03 | T-ownership | Non-owner gets 404 (not 403) | integration | `go test ./internal/web/... -run TestTabloDetail_NonOwner` | ❌ W0 | ⬜ pending |
| 03-detail-invalid | 02 | 1 | TABLO-03 | T-input | Invalid UUID gets 404 | integration | `go test ./internal/web/... -run TestTabloDetail_InvalidID` | ❌ W0 | ⬜ pending |
| 03-edit | 02 | 2 | TABLO-04 | T-csrf | CSRF validated; returns display fragment | integration | `go test ./internal/web/... -run TestTabloUpdate` | ❌ W0 | ⬜ pending |
| 03-delete-confirm | 02 | 2 | TABLO-05 | — | Returns confirm fragment | integration | `go test ./internal/web/... -run TestTabloDeleteConfirm` | ❌ W0 | ⬜ pending |
| 03-delete | 02 | 2 | TABLO-05 | T-csrf | CSRF validated; row removed | integration | `go test ./internal/web/... -run TestTabloDelete` | ❌ W0 | ⬜ pending |
| 03-htmx-fragment | 01 | 1 | TABLO-06 | — | HX-Request=true → fragment, not full page | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending |
| 03-htmx-degrade | 01 | 1 | TABLO-06 | — | Non-HTMX POST → 303 redirect | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending |

*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*

---

## Wave 0 Requirements

- [ ] `backend/internal/web/handlers_tablos_test.go` — integration tests for all TABLO-01..06 paths (follows `handlers_auth_test.go` pattern with real Postgres via `setupTestDB`)
- [ ] `backend/internal/db/queries/tablos.sql` — must exist and `just generate` must complete before tests compile
- [ ] `backend/migrations/0003_tablos.sql` — goose picks this up automatically during `setupTestDB` (`goose.Up`)

---

## Security Threat Model

### Applicable ASVS Categories (L1)

| ASVS Category | Applies | Control |
|---------------|---------|---------|
| V2 Authentication | yes (inherited) | gorilla/csrf + session cookie from Phase 2 |
| V3 Session Management | yes (inherited) | auth.ResolveSession + RequireAuth from Phase 2 |
| V4 Access Control | **yes — new** | Ownership check in every handler; 404 for non-owner (D-04) |
| V5 Input Validation | **yes — new** | title required + max 255; color max 32 chars |
| V6 Cryptography | no new requirements | UUID PKs are `gen_random_uuid()` (crypto-random) |

### Threats

| Threat | STRIDE | Mitigation |
|--------|--------|------------|
| Unauthorized tablo read/write | Elevation of privilege | `auth.Authed(ctx)` + `tablo.UserID != user.ID → http.NotFound` in every handler |
| CSRF on state-changing forms | Tampering | `@ui.CSRFField(csrfToken)` in every form; gorilla/csrf rejects missing token |
| Over-long title causes DB error | DoS/Data | `len(title) > 255` → 422 before DB write |
| UUID injection (non-UUID in {id}) | Tampering | `uuid.Parse(id)` returns error → 404 immediately |
